Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 103 additions & 4 deletions codepet/Managers/CompanyStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 38 additions & 2 deletions codepetTests/CompanyStoreChatRunTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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 {
Expand Down
53 changes: 49 additions & 4 deletions codepetTests/VirtualCompanyInterviewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,17 +180,62 @@ 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)
XCTAssertFalse(s.chatMessages.contains { $0.text == greeting.text },
"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()))
Expand Down