diff --git a/codepet/Managers/CompanyStore.swift b/codepet/Managers/CompanyStore.swift index b9df804..90b79b0 100644 --- a/codepet/Managers/CompanyStore.swift +++ b/codepet/Managers/CompanyStore.swift @@ -325,8 +325,53 @@ 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 @@ -971,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 { 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()))