From 0ec6b75cf76bd80c0efdcce35b54d97530ee6ed4 Mon Sep 17 00:00:00 2001 From: dominich Date: Tue, 4 Aug 2026 15:25:40 +0700 Subject: [PATCH 1/2] fix(vc): the interview now says what the two answers changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the app, which nothing else on this branch could do. The founder answered "how long does your money last" and "any constraint the room must not propose", and then the conversation simply stopped. No acknowledgement, no visible change. Their words: it feels like it did nothing. That reading was fair. The first-run interview ends by seeding byte's greeting; this one deliberately suppresses that — and nothing was put in its place. Neither answer is displayed anywhere in the app, so a closing line is the only evidence they landed at all. The close quotes both answers back rather than thanking the founder abstractly, because the point is to show the specifics were heard, and it names what they change: the room drops proposals that eat too much of the runway, and stops suggesting what has already been ruled out. A founder has no way to infer that runway is what makes a three-week proposal unacceptable. Skipping both is handled separately and honestly — it says recommendations stay more general, rather than claiming an effect it did not get. One existing test asserted "no greeting" by checking the last message's role was .me, which conflated "no welcome" with "no companion message at all". Narrowed to assert what it means: that this particular greeting text is absent. Its comment already recorded being rewritten once before for the same reason. VirtualCompanyInterviewTests 19/19 (2 new), CompanyStoreChatTests 24/24, CompanyStoreOnboardingTests 11/11, EnrichInterviewTests 8/8, CompanyStoreVirtualCompanyTests 15/15, build green. Co-Authored-By: Claude Opus 5 (1M context) --- codepet/Managers/CompanyStore.swift | 47 +++++++++++++++- .../VirtualCompanyInterviewTests.swift | 53 +++++++++++++++++-- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/codepet/Managers/CompanyStore.swift b/codepet/Managers/CompanyStore.swift index b9df804..b45ef89 100644 --- a/codepet/Managers/CompanyStore.swift +++ b/codepet/Managers/CompanyStore.swift @@ -325,10 +325,55 @@ final class CompanyStore: ObservableObject { interviewState = nil // Only the first-run interview earns the greeting. Welcoming the founder // right after a mid-session runway question would read as amnesia. - if st.seedGreetingWhenDone { seedFirstRunGreeting(language: language) } + if st.seedGreetingWhenDone { + seedFirstRunGreeting(language: language) + } else { + seedVirtualCompanyInterviewClose(language: language) + } } } + /// Closes the Virtual Company's runway/constraints interview. + /// + /// Without this the founder answered two questions and the conversation simply + /// stopped — nothing said, nothing visibly changed. It read as "that did + /// nothing", which was fair: neither answer appears anywhere in the UI, so a + /// closing line is the only evidence they landed at all. + /// + /// It quotes both answers back rather than thanking them abstractly, because + /// the point is to show the room heard the specifics, and it names what the + /// answers change — a founder has no way to know that runway is what makes a + /// three-week proposal unacceptable. + private func seedVirtualCompanyInterviewClose(language: AppLanguage) { + let runway = (company.brief.runway ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let limits = (company.brief.constraints ?? "") + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + // Both skipped: say so honestly instead of claiming an effect that is not + // there. The room will keep saying what it does not know. + guard !runway.isEmpty || !limits.isEmpty else { + chatMessages.append(CopilotMessage(role: .companion, text: language == .vi + ? "Không sao — mình vẫn họp được, chỉ là các khuyến nghị sẽ chung chung hơn vì phòng họp chưa biết runway và ràng buộc của bạn." + : "No problem — the room can still meet, but its recommendations stay more general while it doesn't know your runway or your constraints.")) + return + } + + var recorded: [String] = [] + if !runway.isEmpty { recorded.append((language == .vi ? "runway: " : "runway: ") + runway) } + if !limits.isEmpty { recorded.append(limits.joined(separator: " · ")) } + let echo = recorded.joined(separator: " · ") + + let effect = language == .vi + ? "Từ giờ phòng họp cân cả hai khi ra khuyến nghị: nó sẽ loại những đề xuất ăn quá nhiều thời gian bạn còn, và không đề xuất thứ bạn đã gạt." + : "From now on the room weighs both when it recommends: it drops proposals that eat too much of the time you have left, and stops suggesting what you have already ruled out." + + chatMessages.append(CopilotMessage( + role: .companion, + text: (language == .vi ? "Ghi lại rồi — " : "On record — ") + echo + ". " + effect)) + } + /// Skip: stamp with the current (empty) brief so they aren't re-blocked. Called /// directly from the view (no prior await); capture the token at entry and re-check /// after the save await. diff --git a/codepetTests/VirtualCompanyInterviewTests.swift b/codepetTests/VirtualCompanyInterviewTests.swift index ee8b673..1241f77 100644 --- a/codepetTests/VirtualCompanyInterviewTests.swift +++ b/codepetTests/VirtualCompanyInterviewTests.swift @@ -180,10 +180,11 @@ final class VirtualCompanyInterviewTests: XCTestCase { XCTAssertEqual(s.company.brief.runway, "6 months") XCTAssertEqual(s.company.brief.constraints, "No hiring this quarter") XCTAssertEqual(probe.briefs.last?.constraints, "No hiring this quarter") - // The greeting would be a companion message appended after the founder's last - // answer. Asserted by identity rather than by a message count, which moved - // under this test once the room started appending its own message. - XCTAssertEqual(s.chatMessages.last?.role, .me) + // Asserted by identity, not by role or by a message count: both moved under + // this test once the room started appending its own message, and again once + // the interview earned a closing line. What must stay true is narrower than + // "no companion message follows" — it is that THIS particular message, the + // first-run welcome, is not among them. let greeting = FirstRunGreetingBuilder.build(brief: s.company.brief, nextStep: RoadmapEngine.nextStep(s.company.tasks), language: .en) @@ -191,6 +192,50 @@ final class VirtualCompanyInterviewTests: XCTestCase { "the queue emptying must not welcome the founder like a new user") } + func testTheInterviewClosesByQuotingTheAnswersBackAndNamingTheirEffect() async { + // The founder answered two questions and the conversation simply stopped — + // nothing said, nothing visibly changed. Neither answer appears anywhere in + // the UI, so this closing line is the only evidence they landed. + let probe = SaveProbe() + let s = store(probe, vcRunner: runnerYielding(briefedRunEvents())) + await s.hydrate(companyId: "u") + await s.sendChat("free with ads or $9.99 once?", language: .en) + + await s.answerInterview(messageId: s.chatMessages.last!.id, gap: .runway, + answer: "6 months", language: .en) + await s.answerInterview(messageId: s.chatMessages.last!.id, gap: .constraints, + answer: "No hiring this quarter", language: .en) + + let close = try? XCTUnwrap(s.chatMessages.last) + XCTAssertEqual(close?.role, .companion) + // Quotes both answers back, so the founder sees the specifics were heard. + XCTAssertTrue(close?.text.contains("6 months") == true) + XCTAssertTrue(close?.text.contains("No hiring this quarter") == true) + // And names what they change — runway is what makes a three-week proposal + // unacceptable, and a founder has no way to infer that. + XCTAssertTrue(close?.text.contains("recommend") == true, + "the close must say what the answers change, not just thank them") + } + + func testSkippingBothQuestionsSaysSoRatherThanClaimingAnEffect() async { + let probe = SaveProbe() + let s = store(probe, vcRunner: runnerYielding(briefedRunEvents())) + await s.hydrate(companyId: "u") + await s.sendChat("free with ads or $9.99 once?", language: .en) + + await s.answerInterview(messageId: s.chatMessages.last!.id, gap: .runway, + answer: nil, language: .en) + await s.answerInterview(messageId: s.chatMessages.last!.id, gap: .constraints, + answer: nil, language: .en) + + let close = s.chatMessages.last + XCTAssertEqual(close?.role, .companion) + XCTAssertTrue(close?.text.contains("more general") == true, + "a skipped interview must not claim an effect it did not get") + XCTAssertNil(s.company.brief.runway) + XCTAssertNil(s.company.brief.constraints) + } + func testStoreAsksAtMostOnceAcrossTwoRuns() async { let probe = SaveProbe() let s = store(probe, vcRunner: runnerYielding(briefedRunEvents())) From 413f702a8081c5f9ceae13821c17b22b07db7bb9 Mon Sep 17 00:00:00 2001 From: dominich Date: Tue, 4 Aug 2026 15:37:38 +0700 Subject: [PATCH 2/2] fix(chat): byte no longer promises a task run and then goes quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in the app. byte replied with a run_task_id and no text, so the store wrote its lead-in — "on it, putting that together now" — and then nothing ever arrived. No draft, no error, no explanation. Forever. `handleRunTaskId` had three silent early returns, and the lead-in is written BEFORE any of them run: guard let runId, let task = company.tasks.first(where: { $0.id == runId }), RoadmapEngine.status(for: task, in: company.tasks) == .codepetCanDo else { return } So byte promised and a guard quietly withdrew it. Worth noting `produceDraftInline` already had an honest failure path — its own comment promises "an honest couldn't generate bubble" — but this returned before reaching it. Every refusal knows exactly why, and `TaskStatus` distinguishes five cases, three of which are the founder's own move rather than a failure. So each one says so, and names the task: the founder did not choose it, byte did, so "that one" is not enough to act on. - needsApproval → a draft is already waiting; redoing it would overwrite that - done → already finished; open it if you want it redone - blocked → waiting on another task, or its phase hasn't opened - needsYou → yours to do, and byte offers to walk you through it - task missing → the id no longer matches the roadmap; ask which one A nil run_task_id — the overwhelmingly common case — still says nothing. Two existing tests asserted `chatMessages.count == 2` with the comment "me + lead-in only": they encoded the silence itself. Updated to assert the reason is given, plus a new test covering all four refusal reasons and that each names its task. CompanyStoreChatRunTests 15/15, CompanyStoreChatTests 24/24, CompanyStoreRunTaskTests 17/17, CompanyStoreVirtualCompanyTests 15/15, VirtualCompanyInterviewTests 19/19, ChatTailActionTests 5/5, build green. Co-Authored-By: Claude Opus 5 (1M context) --- codepet/Managers/CompanyStore.swift | 60 +++++++++++++++++++-- codepetTests/CompanyStoreChatRunTests.swift | 40 +++++++++++++- 2 files changed, 95 insertions(+), 5 deletions(-) diff --git a/codepet/Managers/CompanyStore.swift b/codepet/Managers/CompanyStore.swift index b45ef89..90b79b0 100644 --- a/codepet/Managers/CompanyStore.swift +++ b/codepet/Managers/CompanyStore.swift @@ -1016,12 +1016,66 @@ final class CompanyStore: ObservableObject { /// after the `taskRunner` await so an account switch mid-run can't append /// this account's draft into a different (already-hydrated) account's chat. private func handleRunTaskId(_ runId: String?, cid: String?, language: AppLanguage) async { - guard let runId, - let task = company.tasks.first(where: { $0.id == runId }), - RoadmapEngine.status(for: task, in: company.tasks) == .codepetCanDo else { return } + // No run was requested — the overwhelmingly common case. Say nothing. + guard let runId else { return } + + // Everything below is the case where byte ALREADY promised. `sendMessage` + // writes "on it, putting that together now" before this runs, so returning + // silently here left the founder watching a promise nobody kept — observed + // in the app: lead-in, then nothing, forever. Each refusal knows exactly + // why, and three of the five reasons are the founder's own move rather than + // a failure, so every one of them is worth saying out loud. + guard let task = company.tasks.first(where: { $0.id == runId }) else { + appendRunRefusal(language == .vi + ? "Mình vừa nói sẽ làm, nhưng không tìm thấy việc đó trong lộ trình nữa — có thể nó đã bị đổi. Bạn nói lại là việc nào nhé." + : "I said I'd get on it, but I can't find that task in the roadmap any more — it may have changed. Tell me which one and I'll pick it up.") + return + } + + let status = RoadmapEngine.status(for: task, in: company.tasks) + guard status == .codepetCanDo else { + appendRunRefusal(Self.runRefusalCopy(status, task: task, language: language)) + return + } _ = await produceDraftInline(for: task, cid: cid, language: language) } + private func appendRunRefusal(_ text: String) { + chatMessages.append(CopilotMessage(role: .companion, text: text)) + } + + /// Why byte cannot run this task, in the founder's terms. Deliberately names the + /// task, because the founder did not choose it — byte did — so "that one" is not + /// enough to act on. + private static func runRefusalCopy(_ status: TaskStatus, + task: RoadmapTask, + language: AppLanguage) -> String { + let vi = language == .vi + switch status { + case .needsApproval: + return vi + ? "\"\(task.title)\" đã có bản nháp đang chờ bạn duyệt — mình không làm lại để khỏi ghi đè. Duyệt hoặc yêu cầu sửa bản đó trước nhé." + : "\"\(task.title)\" already has a draft waiting for your approval — I won't redo it and overwrite that. Approve or revise the existing one first." + case .done: + return vi + ? "\"\(task.title)\" đã xong rồi — mình không chạy lại. Nếu muốn làm lại từ đầu thì mở việc đó ra rồi nói mình." + : "\"\(task.title)\" is already done — I won't run it again. Open it and tell me if you want it redone from scratch." + case .blocked: + return vi + ? "\"\(task.title)\" chưa tới lượt — nó còn chờ một việc khác xong, hoặc giai đoạn của nó chưa mở." + : "\"\(task.title)\" isn't ready yet — it's still waiting on another task, or its phase hasn't opened." + case .needsYou: + return vi + ? "\"\(task.title)\" là việc chỉ bạn làm được, mình không thay bạn làm được việc đó. Cần thì mình hướng dẫn từng bước." + : "\"\(task.title)\" is yours to do — I can't do that one for you. I can walk you through it step by step if that helps." + case .codepetCanDo: + // Unreachable: the caller only lands here when the status is not runnable. + return vi + ? "Mình chưa chạy được \"\(task.title)\" lúc này." + : "I couldn't start \"\(task.title)\" just now." + } + } + /// The single inline-run path shared by EVERY chat run (typed "run" command /// AND the greeting's "Do it with me"), so "how the agent works" shows the same /// everywhere: a transient producing placeholder drives the execute-log — a diff --git a/codepetTests/CompanyStoreChatRunTests.swift b/codepetTests/CompanyStoreChatRunTests.swift index d205800..539dc2a 100644 --- a/codepetTests/CompanyStoreChatRunTests.swift +++ b/codepetTests/CompanyStoreChatRunTests.swift @@ -52,9 +52,44 @@ final class CompanyStoreChatRunTests: XCTestCase { runner: { _ in RunTaskResponse(kind: "doc", title: "x", body: "# y") }) await s.hydrate(companyId: "u") await s.sendChat("hi", language: .en) - XCTAssertEqual(s.chatMessages.count, 2) // me + lead-in only XCTAssertNil(s.chatMessages.last?.draft) + // byte had already promised in the lead-in, so silence left the founder + // watching a promise nobody kept. It now says why instead. + XCTAssertEqual(s.chatMessages.count, 3) // me + lead-in + why + XCTAssertTrue(s.chatMessages.last?.text.contains("can't find that task") == true) } + + func testARefusalNamesTheTaskAndTheReason() async { + // Each refusal reason is a different founder action, so each has to be + // distinguishable — "couldn't run it" would be useless for all four. + let cases: [(RoadmapTask, String)] = [ + (RoadmapTask(id: "t1", title: "Survey users", detail: "", phase: .find, + who: .does, drafted: true), "waiting for your approval"), + (RoadmapTask(id: "t1", title: "Survey users", detail: "", phase: .find, + who: .does, done: true), "already done"), + (RoadmapTask(id: "t1", title: "Survey users", detail: "", phase: .find, + who: .you), "yours to do") + ] + for (task, expected) in cases { + let s = CompanyStore(loader: { _ in + CompanyState(brief: CompanyBrief(), departments: [], library: [], stage: .idea, + companionId: "byte", onboardedAt: Date(), tasks: [task]) + }, saver: { _, _ in true }, tasksSaver: { _, _ in true }, + chatSender: { _ in CompanyChatReply(text: "hm", runTaskId: "t1") }, + chatStreamer: Self.failingStreamer, + taskRunner: { _ in XCTFail("an unrunnable task must not reach the runner"); return nil }, + decisionExtractor: { _, _ in [] }) + await s.hydrate(companyId: "u") + await s.sendChat("hi", language: .en) + + XCTAssertNil(s.chatMessages.last?.draft) + let text = s.chatMessages.last?.text ?? "" + XCTAssertTrue(text.contains(expected), "expected \(expected) — got: \(text)") + XCTAssertTrue(text.contains("Survey users"), + "the founder did not pick this task, byte did — naming it is what makes the refusal actionable") + } + } + func testChatRunFailureHonestBubble() async { let s = store(reply: CompanyChatReply(text: "On it", runTaskId: "t1"), runner: { _ in nil }) @@ -206,8 +241,9 @@ final class CompanyStoreChatRunTests: XCTestCase { decisionExtractor: { _, _ in [] }) await s.hydrate(companyId: "u") await s.sendChat("hi", language: .en) - XCTAssertEqual(s.chatMessages.count, 2) // me + lead-in only XCTAssertNil(s.chatMessages.last?.draft) + XCTAssertEqual(s.chatMessages.count, 3) // me + lead-in + why + XCTAssertTrue(s.chatMessages.last?.text.contains("can't find that task") == true) } func testStreamingDoneWithNilRunTaskIdIsNoOp() async {