From 9392fcaba43ebb4a0ea7e72f238ad7e873da8d43 Mon Sep 17 00:00:00 2001 From: dominich Date: Fri, 9 Oct 2026 11:11:58 +0700 Subject: [PATCH 1/2] Ask knows what day it is Told 'runway is 8 months' on 9 Oct 2026, Finance dated the deadline 'June 2026': nothing in the Ask context said what today was. The context now opens with 'Today is 2026-10-09 (October 2026)', ISO for arithmetic and the month name so a deadline lands on a month the model can name. In the per-request context, not the cached system prompt, which must stay byte-identical across days for the prefix cache to hit. First in the context so the CONTEXT_CAP clip can never trim it. Store-level test pins that the request carries it (red with the store line removed). Co-Authored-By: Claude Opus 5.5 --- codepet/Managers/CompanyStore.swift | 2 +- codepet/Models/ChatContext.swift | 18 ++++++++++++++- codepetTests/ChatContextTodayTests.swift | 28 ++++++++++++++++++++++++ codepetTests/CompanyStoreChatTests.swift | 17 ++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 codepetTests/ChatContextTodayTests.swift diff --git a/codepet/Managers/CompanyStore.swift b/codepet/Managers/CompanyStore.swift index 630e4165..baabd6c3 100644 --- a/codepet/Managers/CompanyStore.swift +++ b/codepet/Managers/CompanyStore.swift @@ -2173,7 +2173,7 @@ final class CompanyStore: ObservableObject { // What the founder pinned on the `+` menu, rendered above the // ranker's guesses and excluded from them. Empty for every // caller that passes nothing. - pinned: pinned, departmentWork: deskWork), + pinned: pinned, departmentWork: deskWork, today: Date()), history: Array(history), userMessage: text, runnable: Array(runnable), openTasks: Array(openTasks), envSetup: envSetup, // nil at defaults, so an untouched settings panel adds nothing to the wire diff --git a/codepet/Models/ChatContext.swift b/codepet/Models/ChatContext.swift index 9e5a1176..b5ac5d41 100644 --- a/codepet/Models/ChatContext.swift +++ b/codepet/Models/ChatContext.swift @@ -142,6 +142,16 @@ enum ChatContext { /// Render a compact per-department status snapshot — one line per department that /// has at least one task assigned (fully-untouched departments are skipped), mirroring /// web's deptSummary (`- name (status, N to do): focus`). + /// "Today is 2026-10-09 (October 2026)." — ISO for arithmetic, the month name so "8 months + /// from now" lands on a month the model can name without converting. + static func composeToday(_ date: Date) -> String { + let iso = DateFormatter(), month = DateFormatter() + for f in [iso, month] { f.locale = Locale(identifier: "en_US_POSIX"); f.calendar = Calendar(identifier: .gregorian) } + iso.dateFormat = "yyyy-MM-dd" + month.dateFormat = "LLLL yyyy" + return "Today is \(iso.string(from: date)) (\(month.string(from: date))). Count any deadline or runway from this date." + } + /// How many of the department's own deliverables the desk excerpts, newest first. static let deskWorkCap = 4 /// How many of the department's own notes (decisions tagged with its `dept`) the desk lists. @@ -312,8 +322,14 @@ enum ChatContext { product: String? = nil, library: [Deliverable] = [], query: String? = nil, focusDepartment: Department? = nil, memoryEnabled: Bool = true, - pinned: [ContextPin] = [], departmentWork: [Deliverable] = []) -> String { + pinned: [ContextPin] = [], departmentWork: [Deliverable] = [], + today: Date? = nil) -> String { var parts: [String] = [] + // First, so the backend's CONTEXT_CAP clip can never trim it. 9 Oct: told "runway is + // 8 months" on 9 Oct 2026, Finance dated the deadline "June 2026" — nothing in the + // context said what today was. Here rather than in the cached system prompt, which + // must stay identical across days for the prefix cache to hit. + if let today { parts.append(composeToday(today)) } // Memory off composes nothing from `decisions` — the desk's notes included. // `decisions` is already the applicable set (`CompanyStore.applicableDecisions`), so the // desk can never show another project's note: it reads only this parameter. diff --git a/codepetTests/ChatContextTodayTests.swift b/codepetTests/ChatContextTodayTests.swift new file mode 100644 index 00000000..b9e65d57 --- /dev/null +++ b/codepetTests/ChatContextTodayTests.swift @@ -0,0 +1,28 @@ +// codepetTests/ChatContextTodayTests.swift +import XCTest +@testable import codepet + +/// 9 Oct: told "runway is 8 months" on 9 Oct 2026, Finance put the deadline at "June 2026". +/// Nothing in the Ask context said what today was, so every date the model computed floated. +final class ChatContextTodayTests: XCTestCase { + private func day(_ y: Int, _ m: Int, _ d: Int) -> Date { + var c = DateComponents(); c.year = y; c.month = m; c.day = d; c.hour = 12 + return Calendar(identifier: .gregorian).date(from: c)! + } + + func testTheContextSaysWhatDayItIs() { + let out = ChatContext.compose(brief: CompanyBrief(), tasks: [], today: day(2026, 10, 9)) + XCTAssertTrue(out.contains("Today is 2026-10-09 (October 2026)"), out) + } + + /// First, so a clip of the context (CONTEXT_CAP) can never trim it away. + func testTheDateLeadsTheContext() { + let out = ChatContext.compose(brief: CompanyBrief(projectName: "Codepet"), tasks: [], today: day(2026, 10, 9)) + XCTAssertTrue(out.hasPrefix("Today is 2026-10-09")) + } + + /// Callers that pass no date are unchanged. + func testNoDateWhenNoneIsGiven() { + XCTAssertFalse(ChatContext.compose(brief: CompanyBrief(), tasks: []).contains("Today is")) + } +} diff --git a/codepetTests/CompanyStoreChatTests.swift b/codepetTests/CompanyStoreChatTests.swift index 00959af3..f32da5d5 100644 --- a/codepetTests/CompanyStoreChatTests.swift +++ b/codepetTests/CompanyStoreChatTests.swift @@ -496,6 +496,23 @@ final class CompanyStoreChatTests: XCTestCase { XCTAssertNil(s.company.decisions.first { $0.topic == "goal" }?.dept) } + /// The request's context says what day it is — the compose tests alone pass even if the + /// store stops passing a date. 9 Oct: Finance dated an 8-month runway "June 2026". + func testTheChatRequestCarriesTodaysDate() async { + var context = "" + let streamer: (CompanyChatRequest) -> AsyncThrowingStream = { req in + context = req.context + return AsyncThrowingStream { c in + c.yield(.delta("hi")); c.yield(.done(model: "m", cacheHit: false, action: ChatDoneAction())); c.finish() + } + } + let s = CompanyStore(loader: { _ in .empty }, saver: { _, _ in true }, + chatSender: { _ in nil }, chatStreamer: streamer) + await s.hydrate(companyId: "u") + await s.sendChat("how long is our runway?", language: .en) + XCTAssertTrue(context.hasPrefix(ChatContext.composeToday(Date())), context) + } + /// `sendChat` populates `env_setup` from the company's currently-OFF toolkit /// items (mirrors the web's off-toolkit filter) — AND, since the /// environment-honesty pass, only from items that are actually built. From cc85b551b88ef644212eb82777eff30c0804c6e4 Mon Sep 17 00:00:00 2001 From: dominich Date: Fri, 9 Oct 2026 11:12:57 +0700 Subject: [PATCH 2/2] Pin the notebook's re-tag behaviour (review minors from #248) - An ordinary turn straight after a Finance turn records no department, and its re-record of the same topic takes it off Finance's desk. - A company-wide re-record clears the tag at merge level. - End to end: once Sales re-records Finance's price, Finance's desk no longer lists it and Sales' does. Characterisation tests: the behaviour was already right; these fail if it stops being so. Co-Authored-By: Claude Opus 5.5 --- codepetTests/ChatContextDepartmentDeskTests.swift | 12 ++++++++++++ codepetTests/CompanyStoreChatTests.swift | 4 ++++ codepetTests/DepartmentNotebookTests.swift | 9 +++++++++ 3 files changed, 25 insertions(+) diff --git a/codepetTests/ChatContextDepartmentDeskTests.swift b/codepetTests/ChatContextDepartmentDeskTests.swift index 17b11cf5..54794bf6 100644 --- a/codepetTests/ChatContextDepartmentDeskTests.swift +++ b/codepetTests/ChatContextDepartmentDeskTests.swift @@ -130,4 +130,16 @@ final class ChatContextDepartmentDeskTests: XCTestCase { let out = ChatContext.compose(brief: brief, tasks: [], decisions: ds, focusDepartment: finance) XCTAssertTrue(out.contains("Sales says $29")) } + + /// End to end through the merge: once Sales re-records Finance's price, Finance's desk no + /// longer lists it and Sales' does. + func testAReRecordedTopicMovesDesks() { + let fin = Decisions.mergeDecisions(existing: [], extracted: [ExtractedDecision(topic: "pricing", statement: "$19", source: nil)], + now: 1, dept: "fin") + let both = Decisions.mergeDecisions(existing: fin, extracted: [ExtractedDecision(topic: "pricing", statement: "$29", source: nil)], + now: 2, dept: "sales") + XCTAssertEqual(notes(in: ChatContext.compose(brief: brief, tasks: [], decisions: both, focusDepartment: finance)), []) + XCTAssertEqual(notes(in: ChatContext.compose(brief: brief, tasks: [], decisions: both, focusDepartment: sales), "Sales"), + ["- pricing: $29"]) + } } diff --git a/codepetTests/CompanyStoreChatTests.swift b/codepetTests/CompanyStoreChatTests.swift index f32da5d5..444ec666 100644 --- a/codepetTests/CompanyStoreChatTests.swift +++ b/codepetTests/CompanyStoreChatTests.swift @@ -492,6 +492,10 @@ final class CompanyStoreChatTests: XCTestCase { chatStreamer: Self.streamer(deltas: ["Noted"], remember: [fact]), decisionsSaver: { _, _ in true }) await s.hydrate(companyId: "u") + // A Finance turn first: the ordinary turn after it must not inherit the department — + // and re-recording the same topic without one moves it off Finance's desk. + await s.sendChat("ten paying founders is the goal", language: .en, department: DepartmentCatalog.find("fin")) + XCTAssertEqual(s.company.decisions.first { $0.topic == "goal" }?.dept, "fin") await s.sendChat("ten paying founders is the goal", language: .en) XCTAssertNil(s.company.decisions.first { $0.topic == "goal" }?.dept) } diff --git a/codepetTests/DepartmentNotebookTests.swift b/codepetTests/DepartmentNotebookTests.swift index 62a96db7..e17e4b44 100644 --- a/codepetTests/DepartmentNotebookTests.swift +++ b/codepetTests/DepartmentNotebookTests.swift @@ -39,4 +39,13 @@ final class DepartmentNotebookTests: XCTestCase { let out = String(data: try JSONEncoder().encode(d), encoding: .utf8)! XCTAssertFalse(out.contains("dept")) } + + /// A company-wide re-record (a room lock-in, an ordinary turn) takes the topic off the + /// department's desk: one truth per topic, and that truth is now nobody's in particular. + func testReRecordingWithoutADepartmentClearsTheTag() { + let a = Decisions.mergeDecisions(existing: [], extracted: [x("pricing", "$19")], now: 1, dept: "fin") + let b = Decisions.mergeDecisions(existing: a, extracted: [x("pricing", "$24")], now: 2) + XCTAssertEqual(b.count, 1) + XCTAssertNil(b.first?.dept) + } }