@cuongtran001/kanna 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/index-CLsOWXff.css +32 -0
- package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
- package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +158 -0
- package/src/server/agent-coordinator.ts +919 -0
- package/src/server/agent-deps-builders.ts +425 -0
- package/src/server/agent.oauth-rotation.test.ts +101 -0
- package/src/server/agent.test.ts +24 -8
- package/src/server/agent.ts +35 -4963
- package/src/server/auto-continue/read-model.test.ts +2 -2
- package/src/server/auto-continue/read-model.ts +4 -1
- package/src/server/claude-autocontinue-commands.test.ts +494 -0
- package/src/server/claude-autocontinue-commands.ts +256 -0
- package/src/server/claude-cancel-handler.test.ts +472 -0
- package/src/server/claude-cancel-handler.ts +241 -0
- package/src/server/claude-chat-management.test.ts +395 -0
- package/src/server/claude-chat-management.ts +251 -0
- package/src/server/claude-harness-stream.test.ts +273 -0
- package/src/server/claude-harness-stream.ts +158 -0
- package/src/server/claude-loop-orch-commands.test.ts +579 -0
- package/src/server/claude-loop-orch-commands.ts +466 -0
- package/src/server/claude-message-normalizer.test.ts +213 -0
- package/src/server/claude-message-normalizer.ts +436 -0
- package/src/server/claude-prompt-helpers.test.ts +248 -0
- package/src/server/claude-prompt-helpers.ts +139 -0
- package/src/server/claude-sdk-queue.test.ts +169 -0
- package/src/server/claude-sdk-queue.ts +82 -0
- package/src/server/claude-send-command.test.ts +414 -0
- package/src/server/claude-send-command.ts +420 -0
- package/src/server/claude-session-config-helpers.test.ts +279 -0
- package/src/server/claude-session-config-helpers.ts +151 -0
- package/src/server/claude-session-config.test.ts +250 -0
- package/src/server/claude-session-config.ts +159 -0
- package/src/server/claude-session-error-handler.test.ts +498 -0
- package/src/server/claude-session-error-handler.ts +361 -0
- package/src/server/claude-session-lifecycle.test.ts +669 -0
- package/src/server/claude-session-lifecycle.ts +283 -0
- package/src/server/claude-session-rebuild.test.ts +224 -0
- package/src/server/claude-session-rebuild.ts +101 -0
- package/src/server/claude-session-runner.test.ts +523 -0
- package/src/server/claude-session-runner.ts +521 -0
- package/src/server/claude-session-spawner.test.ts +401 -0
- package/src/server/claude-session-spawner.ts +401 -0
- package/src/server/claude-session-start.test.ts +205 -0
- package/src/server/claude-session-start.ts +277 -0
- package/src/server/claude-session-state-queries.test.ts +344 -0
- package/src/server/claude-session-state-queries.ts +160 -0
- package/src/server/claude-session-state.ts +104 -0
- package/src/server/claude-slash-commands.test.ts +277 -0
- package/src/server/claude-slash-commands.ts +234 -0
- package/src/server/claude-spawn-helpers.test.ts +221 -0
- package/src/server/claude-spawn-helpers.ts +179 -0
- package/src/server/claude-steer-log.test.ts +243 -0
- package/src/server/claude-steer-log.ts +58 -0
- package/src/server/claude-subagent-tool-response.test.ts +213 -0
- package/src/server/claude-subagent-tool-response.ts +165 -0
- package/src/server/claude-subagent-wiring.test.ts +263 -0
- package/src/server/claude-subagent-wiring.ts +324 -0
- package/src/server/claude-tool-respond.test.ts +321 -0
- package/src/server/claude-tool-respond.ts +142 -0
- package/src/server/claude-turn-runner.test.ts +310 -0
- package/src/server/claude-turn-runner.ts +228 -0
- package/src/server/claude-turn-starter.test.ts +266 -0
- package/src/server/claude-turn-starter.ts +566 -0
- package/src/server/claude-usage-math.test.ts +169 -0
- package/src/server/claude-usage-math.ts +132 -0
- package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
- package/src/server/diff-store-branch-ops.adapter.ts +602 -0
- package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
- package/src/server/diff-store-commit-ops.adapter.ts +258 -0
- package/src/server/diff-store-errors.ts +85 -0
- package/src/server/diff-store-file-ops.adapter.ts +212 -0
- package/src/server/diff-store-git-branch.adapter.ts +686 -0
- package/src/server/diff-store-io.adapter.ts +27 -0
- package/src/server/diff-store-parse.test.ts +212 -0
- package/src/server/diff-store-parse.ts +122 -0
- package/src/server/diff-store-state.ts +82 -0
- package/src/server/diff-store.ts +76 -1854
- package/src/server/event-store-apply.test.ts +280 -0
- package/src/server/event-store-apply.ts +132 -0
- package/src/server/event-store-chat-lifecycle.test.ts +408 -0
- package/src/server/event-store-chat-lifecycle.ts +441 -0
- package/src/server/event-store-entity-write.ts +356 -0
- package/src/server/event-store-helpers.test.ts +230 -0
- package/src/server/event-store-helpers.ts +235 -0
- package/src/server/event-store-init.ts +276 -0
- package/src/server/event-store-messages.adapter.test.ts +365 -0
- package/src/server/event-store-messages.adapter.ts +213 -0
- package/src/server/event-store-orch-subscription.ts +124 -0
- package/src/server/event-store-orch.test.ts +462 -0
- package/src/server/event-store-orch.ts +312 -0
- package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
- package/src/server/event-store-peripheral-events.adapter.ts +105 -0
- package/src/server/event-store-snapshot.test.ts +597 -0
- package/src/server/event-store-snapshot.ts +658 -0
- package/src/server/event-store-subagent.test.ts +408 -0
- package/src/server/event-store-subagent.ts +198 -0
- package/src/server/event-store-tool-requests.test.ts +210 -0
- package/src/server/event-store-tool-requests.ts +131 -0
- package/src/server/event-store-transcript-write.adapter.ts +285 -0
- package/src/server/event-store-write-ops.test.ts +554 -0
- package/src/server/event-store-write-ops.ts +529 -0
- package/src/server/event-store.test.ts +35 -0
- package/src/server/event-store.ts +251 -2284
- package/src/server/events.ts +3 -0
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/read-models.ts +26 -1
- package/src/server/subagent-orchestrator.ts +3 -0
- package/src/server/ws-router-agent-ctrl.test.ts +187 -0
- package/src/server/ws-router-agent-ctrl.ts +137 -0
- package/src/server/ws-router-broadcast.ts +488 -0
- package/src/server/ws-router-chat.test.ts +461 -0
- package/src/server/ws-router-chat.ts +279 -0
- package/src/server/ws-router-defaults.test.ts +283 -0
- package/src/server/ws-router-defaults.ts +434 -0
- package/src/server/ws-router-diff.test.ts +167 -0
- package/src/server/ws-router-diff.ts +250 -0
- package/src/server/ws-router-envelope.ts +329 -0
- package/src/server/ws-router-misc.test.ts +422 -0
- package/src/server/ws-router-misc.ts +265 -0
- package/src/server/ws-router-orch.test.ts +190 -0
- package/src/server/ws-router-orch.ts +93 -0
- package/src/server/ws-router-project.test.ts +403 -0
- package/src/server/ws-router-project.ts +256 -0
- package/src/server/ws-router-push.test.ts +193 -0
- package/src/server/ws-router-push.ts +120 -0
- package/src/server/ws-router-settings.test.ts +173 -0
- package/src/server/ws-router-settings.ts +416 -0
- package/src/server/ws-router-skills.test.ts +194 -0
- package/src/server/ws-router-skills.ts +204 -0
- package/src/server/ws-router-utils.ts +224 -0
- package/src/server/ws-router.ts +339 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/core-types.ts +27 -0
- package/src/shared/git-diff-types.ts +165 -0
- package/src/shared/loop-progress.test.ts +106 -0
- package/src/shared/loop-progress.ts +88 -0
- package/src/shared/mcp-types.ts +105 -0
- package/src/shared/provider-model-types.ts +437 -0
- package/src/shared/subagent-types.ts +198 -0
- package/src/shared/tool-call-types.ts +314 -0
- package/src/shared/transcript-types.ts +270 -0
- package/src/shared/types.ts +27 -1668
- package/dist/client/assets/channel-BMkhjiLI.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
- package/dist/client/assets/index-H6C-f2Vp.css +0 -32
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone cancel handler for AgentCoordinator.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent.ts so the chat-cancellation logic lives in its
|
|
5
|
+
* own testable module. The coordinator delegates to `cancelChat` by
|
|
6
|
+
* passing an object literal that satisfies `CancelHandlerDeps`.
|
|
7
|
+
*
|
|
8
|
+
* Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
|
|
9
|
+
* calls, no Bun primitives). Every effectful operation is injected through
|
|
10
|
+
* the deps interface.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ClaudeDriverPreference, TranscriptEntry } from "../shared/types"
|
|
14
|
+
import type { ActiveTurn, ClaudeSessionState } from "./claude-session-state"
|
|
15
|
+
import type { HarnessTurn } from "./harness-types"
|
|
16
|
+
import { logClaudeSteer } from "./claude-steer-log"
|
|
17
|
+
import { discardedToolResult } from "./claude-sdk-queue"
|
|
18
|
+
import { timestamped } from "./claude-message-normalizer"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Structural sub-interfaces — only the slices this module calls.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Subset of the draining-streams map used by the cancel handler. */
|
|
25
|
+
interface DrainingStreamsMap {
|
|
26
|
+
get(chatId: string): { turn: HarnessTurn } | undefined
|
|
27
|
+
delete(chatId: string): boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Subset of EventStore used by the cancel handler. */
|
|
31
|
+
interface CancelStore {
|
|
32
|
+
appendMessage(chatId: string, entry: TranscriptEntry): Promise<void>
|
|
33
|
+
recordTurnCancelled(chatId: string): Promise<void>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Subset of the activeTurns map used by the cancel handler. */
|
|
37
|
+
interface ActiveTurnsMap {
|
|
38
|
+
get(chatId: string): ActiveTurn | undefined
|
|
39
|
+
delete(chatId: string): boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Subset of the claudeSessions map used by the cancel handler. */
|
|
43
|
+
interface ClaudeSessionsMap {
|
|
44
|
+
get(chatId: string): ClaudeSessionState | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Dependency bundle injected by AgentCoordinator
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export interface CancelHandlerDeps {
|
|
52
|
+
/** The draining-streams map. The handler reads and deletes from it. */
|
|
53
|
+
drainingStreams: DrainingStreamsMap
|
|
54
|
+
|
|
55
|
+
/** Reject all pending `canUseTool` Promises waiting on user input for this chat. */
|
|
56
|
+
rejectPendingResolversForChat(chatId: string): void
|
|
57
|
+
|
|
58
|
+
/** Signal the subagent orchestrator to cancel all runs for this chat. */
|
|
59
|
+
cancelChatInOrchestrator(chatId: string): void
|
|
60
|
+
|
|
61
|
+
/** The active-turns map. The handler reads and deletes from it. */
|
|
62
|
+
activeTurns: ActiveTurnsMap
|
|
63
|
+
|
|
64
|
+
/** Store — for appending transcript entries and recording the cancelled turn. */
|
|
65
|
+
store: CancelStore
|
|
66
|
+
|
|
67
|
+
/** The claude-sessions map. Read-only from the handler's perspective. */
|
|
68
|
+
claudeSessions: ClaudeSessionsMap
|
|
69
|
+
|
|
70
|
+
/** Emit a state-change event for a chat. */
|
|
71
|
+
emitStateChange(chatId: string): void
|
|
72
|
+
|
|
73
|
+
/** Return the currently resolved Claude driver preference. */
|
|
74
|
+
resolveClaudeDriverPreference(): ClaudeDriverPreference
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Tear down a Claude session and (by default) release any OAuth-pool
|
|
78
|
+
* reservation for this chat. Delegates to closeClaudeSession in
|
|
79
|
+
* claude-session-lifecycle.ts.
|
|
80
|
+
*/
|
|
81
|
+
closeClaudeSession(chatId: string, session: ClaudeSessionState): void
|
|
82
|
+
|
|
83
|
+
/** Dequeue and start the next message waiting in the chat queue, if any. */
|
|
84
|
+
maybeStartNextQueuedMessage(chatId: string): Promise<void>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Exported standalone function
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cancel an active turn for a chat.
|
|
93
|
+
*
|
|
94
|
+
* 1. Closes any draining stream for the chat.
|
|
95
|
+
* 2. Rejects pending `canUseTool` Promises and signals the subagent orchestrator.
|
|
96
|
+
* 3. If there is an active turn:
|
|
97
|
+
* a. Guards against concurrent cancel calls.
|
|
98
|
+
* b. Discards any pending tool request and appends a `tool_result` entry.
|
|
99
|
+
* c. Appends an `interrupted` transcript entry and records `turn_cancelled`.
|
|
100
|
+
* d. Removes the turn from `activeTurns` so the UI reflects cancellation.
|
|
101
|
+
* e. Drains the cancelled prompt's seq from the Claude session's pending queue.
|
|
102
|
+
* f. Emits a state-change event.
|
|
103
|
+
* g. Interrupts and closes the underlying stream (best-effort, 5 s timeout).
|
|
104
|
+
* h. For PTY driver: drops the dead Claude session from the map.
|
|
105
|
+
* i. Optionally drains the message queue (unless `skipQueueDrain` is set).
|
|
106
|
+
*
|
|
107
|
+
* @param deps Injected dependencies — all coordinator state arrives through here.
|
|
108
|
+
* @param chatId The chat to cancel.
|
|
109
|
+
* @param options
|
|
110
|
+
* - `hideInterrupted` If true, the `interrupted` transcript entry is hidden.
|
|
111
|
+
* - `skipQueueDrain` If true, skip the `maybeStartNextQueuedMessage` call.
|
|
112
|
+
* Used by callers (e.g. `steer`) that handle dequeue themselves.
|
|
113
|
+
*/
|
|
114
|
+
export async function cancelChat(
|
|
115
|
+
deps: CancelHandlerDeps,
|
|
116
|
+
chatId: string,
|
|
117
|
+
options?: { hideInterrupted?: boolean; skipQueueDrain?: boolean },
|
|
118
|
+
): Promise<void> {
|
|
119
|
+
// Also clean up any draining stream for this chat.
|
|
120
|
+
const draining = deps.drainingStreams.get(chatId)
|
|
121
|
+
if (draining) {
|
|
122
|
+
draining.turn.close()
|
|
123
|
+
deps.drainingStreams.delete(chatId)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Reject any subagent canUseTool Promises waiting on a user response in
|
|
127
|
+
// this chat, and signal the orchestrator. Both happen unconditionally —
|
|
128
|
+
// a chat may have no active main-turn (e.g. just an @mention with the
|
|
129
|
+
// main turn already ended) while subagents are still running. Without
|
|
130
|
+
// this, the SDK's canUseTool callback hangs forever, wedging the
|
|
131
|
+
// subagent session and leaking the resolver entry.
|
|
132
|
+
deps.rejectPendingResolversForChat(chatId)
|
|
133
|
+
deps.cancelChatInOrchestrator(chatId)
|
|
134
|
+
|
|
135
|
+
const active = deps.activeTurns.get(chatId)
|
|
136
|
+
if (!active) return
|
|
137
|
+
|
|
138
|
+
logClaudeSteer("cancel_requested", {
|
|
139
|
+
chatId,
|
|
140
|
+
provider: active.provider,
|
|
141
|
+
activePromptSeq: active.claudePromptSeq ?? null,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Guard against concurrent cancel() calls — only the first one does work.
|
|
145
|
+
if (active.cancelRequested) return
|
|
146
|
+
active.cancelRequested = true
|
|
147
|
+
|
|
148
|
+
const pendingTool = active.pendingTool
|
|
149
|
+
active.pendingTool = null
|
|
150
|
+
|
|
151
|
+
if (pendingTool) {
|
|
152
|
+
const result = discardedToolResult(pendingTool.tool)
|
|
153
|
+
await deps.store.appendMessage(
|
|
154
|
+
chatId,
|
|
155
|
+
timestamped({
|
|
156
|
+
kind: "tool_result",
|
|
157
|
+
toolId: pendingTool.toolUseId,
|
|
158
|
+
content: result,
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
if (active.provider === "codex" && pendingTool.tool.toolKind === "exit_plan_mode") {
|
|
162
|
+
pendingTool.resolve(result)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await deps.store.appendMessage(
|
|
167
|
+
chatId,
|
|
168
|
+
timestamped({ kind: "interrupted", hidden: options?.hideInterrupted }),
|
|
169
|
+
)
|
|
170
|
+
await deps.store.recordTurnCancelled(chatId)
|
|
171
|
+
active.cancelRecorded = true
|
|
172
|
+
active.hasFinalResult = true
|
|
173
|
+
|
|
174
|
+
// Remove from activeTurns immediately so the UI reflects the cancellation
|
|
175
|
+
// right away, rather than waiting for interrupt() which may hang.
|
|
176
|
+
deps.activeTurns.delete(chatId)
|
|
177
|
+
|
|
178
|
+
// Drain the cancelled prompt's seq from the Claude session's pending
|
|
179
|
+
// queue. The SDK does not always echo a `result.subtype=cancelled` for
|
|
180
|
+
// an interrupted prompt — when the stream just ends, the seq would
|
|
181
|
+
// otherwise linger and cause a FIFO mismatch when the next turn's
|
|
182
|
+
// result arrives, leaving the chat stuck in "running".
|
|
183
|
+
if (active.provider === "claude" && active.claudePromptSeq != null) {
|
|
184
|
+
const session = deps.claudeSessions.get(chatId)
|
|
185
|
+
if (session) {
|
|
186
|
+
const idx = session.pendingPromptSeqs.indexOf(active.claudePromptSeq)
|
|
187
|
+
if (idx >= 0) session.pendingPromptSeqs.splice(idx, 1)
|
|
188
|
+
// The SDK driver's `interrupt()` emits a tail `result` with
|
|
189
|
+
// subtype `error_during_execution` (empty text) after the splice
|
|
190
|
+
// above. Mark it pending so runClaudeSession suppresses that one
|
|
191
|
+
// result instead of rendering "An unknown error occurred." The
|
|
192
|
+
// `interrupted` entry above is the user-visible cancellation.
|
|
193
|
+
session.cancelledResultPending += 1
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
deps.emitStateChange(chatId)
|
|
198
|
+
logClaudeSteer("cancel_active_turn_deleted", {
|
|
199
|
+
chatId,
|
|
200
|
+
provider: active.provider,
|
|
201
|
+
activePromptSeq: active.claudePromptSeq ?? null,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Now attempt to interrupt/close the underlying stream in the background.
|
|
205
|
+
// This is best-effort — the turn is already removed from active state above,
|
|
206
|
+
// and runTurn()'s finally block will also call close().
|
|
207
|
+
try {
|
|
208
|
+
await Promise.race([
|
|
209
|
+
active.turn.interrupt(),
|
|
210
|
+
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
211
|
+
])
|
|
212
|
+
} catch {
|
|
213
|
+
// interrupt() failed — force close
|
|
214
|
+
}
|
|
215
|
+
active.turn.close()
|
|
216
|
+
|
|
217
|
+
// For Claude under the PTY driver, `active.turn` is a ghost facade over
|
|
218
|
+
// the long-lived `claudeSessions` entry and its `close()` is a no-op.
|
|
219
|
+
// The PTY driver's `interrupt()` sends SIGINT which terminates the CLI,
|
|
220
|
+
// so the underlying session is dead — drop it from the map so the next
|
|
221
|
+
// turn respawns a fresh `claude --resume <sessionToken>` (preserves
|
|
222
|
+
// transcript context). For the SDK driver, `interrupt()` is honored
|
|
223
|
+
// in-band without killing the worker, so reuse is still valid.
|
|
224
|
+
if (active.provider === "claude" && deps.resolveClaudeDriverPreference() === "pty") {
|
|
225
|
+
const session = deps.claudeSessions.get(chatId)
|
|
226
|
+
if (session) {
|
|
227
|
+
deps.closeClaudeSession(chatId, session)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Drain the queue. A queued message must auto-start after cancel; the
|
|
232
|
+
// result-success branch in runClaudeSession is the only other place this
|
|
233
|
+
// is called, and it can never fire for a cancelled turn (active has been
|
|
234
|
+
// deleted above before the result event arrives).
|
|
235
|
+
//
|
|
236
|
+
// `skipQueueDrain` is passed by callers that handle dequeue themselves
|
|
237
|
+
// (e.g. `steer`, which dequeues the head message with the steer wrapper).
|
|
238
|
+
if (!options?.skipQueueDrain) {
|
|
239
|
+
await deps.maybeStartNextQueuedMessage(chatId)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the extracted chat management functions.
|
|
3
|
+
* All deps are injected stubs — no IO, no Claude harness.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, mock } from "bun:test"
|
|
7
|
+
import {
|
|
8
|
+
stopDraining,
|
|
9
|
+
closeChat,
|
|
10
|
+
steer,
|
|
11
|
+
dequeue,
|
|
12
|
+
forkChat,
|
|
13
|
+
generateTitleInBackground,
|
|
14
|
+
type ChatManagementDeps,
|
|
15
|
+
} from "./claude-chat-management"
|
|
16
|
+
import type { QueuedChatMessage } from "../shared/types"
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Minimal stub helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
function makeQueuedMessage(overrides?: Partial<QueuedChatMessage>): QueuedChatMessage {
|
|
23
|
+
return {
|
|
24
|
+
id: "msg-1",
|
|
25
|
+
content: "hello",
|
|
26
|
+
attachments: [],
|
|
27
|
+
createdAt: 0,
|
|
28
|
+
provider: "claude",
|
|
29
|
+
model: "claude-opus-4-5",
|
|
30
|
+
planMode: false,
|
|
31
|
+
...overrides,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeDeps(overrides?: Partial<ChatManagementDeps>): ChatManagementDeps {
|
|
36
|
+
return {
|
|
37
|
+
activeTurns: {
|
|
38
|
+
has: mock(() => false),
|
|
39
|
+
get: mock(() => undefined),
|
|
40
|
+
},
|
|
41
|
+
drainingStreams: {
|
|
42
|
+
get: mock(() => undefined),
|
|
43
|
+
has: mock(() => false),
|
|
44
|
+
delete: mock(() => false),
|
|
45
|
+
},
|
|
46
|
+
claudeSessions: {
|
|
47
|
+
get: mock(() => undefined),
|
|
48
|
+
},
|
|
49
|
+
autoResumeByChat: {
|
|
50
|
+
delete: mock(() => false),
|
|
51
|
+
},
|
|
52
|
+
store: {
|
|
53
|
+
getQueuedMessage: mock((_chatId: string, _id: string) => null),
|
|
54
|
+
removeQueuedMessage: mock(async () => {}),
|
|
55
|
+
requireChat: mock((_chatId: string) => ({
|
|
56
|
+
title: "Chat title",
|
|
57
|
+
provider: "claude" as const,
|
|
58
|
+
sessionTokensByProvider: { claude: "tok-123" },
|
|
59
|
+
pendingForkSessionToken: null,
|
|
60
|
+
})),
|
|
61
|
+
forkChat: mock(async (_chatId: string) => ({ id: "forked-id" })),
|
|
62
|
+
renameChat: mock(async () => {}),
|
|
63
|
+
},
|
|
64
|
+
analytics: {
|
|
65
|
+
track: mock(() => {}),
|
|
66
|
+
},
|
|
67
|
+
cancel: mock(async () => {}),
|
|
68
|
+
closeClaudeSession: mock(() => {}),
|
|
69
|
+
emitStateChange: mock(() => {}),
|
|
70
|
+
generateTitle: mock(async () => ({ title: "New Title", usedFallback: false, failureMessage: null })),
|
|
71
|
+
reportBackgroundError: null,
|
|
72
|
+
dequeueAndStartQueuedMessage: mock(async () => {}),
|
|
73
|
+
...overrides,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// stopDraining
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
describe("stopDraining", () => {
|
|
82
|
+
it("is a no-op when no draining stream exists", async () => {
|
|
83
|
+
const deps = makeDeps()
|
|
84
|
+
await stopDraining(deps, "chat-1")
|
|
85
|
+
expect(deps.emitStateChange).not.toHaveBeenCalled()
|
|
86
|
+
expect(deps.drainingStreams.delete).not.toHaveBeenCalled()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("closes the turn, removes the stream, and emits state change", async () => {
|
|
90
|
+
const close = mock(() => {})
|
|
91
|
+
const deps = makeDeps({
|
|
92
|
+
drainingStreams: {
|
|
93
|
+
get: mock(() => ({ turn: { close } })),
|
|
94
|
+
has: mock(() => true),
|
|
95
|
+
delete: mock(() => true),
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
await stopDraining(deps, "chat-1")
|
|
99
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
100
|
+
expect(deps.drainingStreams.delete).toHaveBeenCalledWith("chat-1")
|
|
101
|
+
expect(deps.emitStateChange).toHaveBeenCalledWith("chat-1")
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// closeChat
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe("closeChat", () => {
|
|
110
|
+
it("stops draining, clears auto-resume, and emits state change", async () => {
|
|
111
|
+
const deps = makeDeps()
|
|
112
|
+
await closeChat(deps, "chat-1")
|
|
113
|
+
expect(deps.autoResumeByChat.delete).toHaveBeenCalledWith("chat-1")
|
|
114
|
+
expect(deps.emitStateChange).toHaveBeenCalledWith("chat-1")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("closes the claude session when one exists", async () => {
|
|
118
|
+
const fakeSession = { sessionId: "s1" } as never
|
|
119
|
+
const deps = makeDeps({
|
|
120
|
+
claudeSessions: { get: mock(() => fakeSession) },
|
|
121
|
+
})
|
|
122
|
+
await closeChat(deps, "chat-1")
|
|
123
|
+
expect(deps.closeClaudeSession).toHaveBeenCalledWith("chat-1", fakeSession)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("skips closeClaudeSession when no session exists", async () => {
|
|
127
|
+
const deps = makeDeps()
|
|
128
|
+
await closeChat(deps, "chat-1")
|
|
129
|
+
expect(deps.closeClaudeSession).not.toHaveBeenCalled()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// steer
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe("steer", () => {
|
|
138
|
+
it("throws when queued message not found", async () => {
|
|
139
|
+
const deps = makeDeps()
|
|
140
|
+
await expect(
|
|
141
|
+
steer(deps, { type: "message.steer", chatId: "c1", queuedMessageId: "qm-1" }),
|
|
142
|
+
).rejects.toThrow("Queued message not found")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it("cancels active turn then dequeues when chat is active", async () => {
|
|
146
|
+
const qm = makeQueuedMessage()
|
|
147
|
+
// steer() calls has() 4 times: (1) in the log object, (2) the real guard that
|
|
148
|
+
// triggers cancel, (3) in the after-cancel log, (4) the throw guard.
|
|
149
|
+
// We need calls 1+2 → true (chat is active), calls 3+4 → false (idle after cancel).
|
|
150
|
+
let hasCallCount = 0
|
|
151
|
+
const cancelFn = mock(async () => {})
|
|
152
|
+
const dequeueStartFn = mock(async () => {})
|
|
153
|
+
const deps: ChatManagementDeps = {
|
|
154
|
+
...makeDeps(),
|
|
155
|
+
activeTurns: {
|
|
156
|
+
has: (_chatId: string) => hasCallCount++ < 2, // first 2 calls → active; rest → idle
|
|
157
|
+
get: () => undefined,
|
|
158
|
+
},
|
|
159
|
+
store: {
|
|
160
|
+
...makeDeps().store,
|
|
161
|
+
getQueuedMessage: (_chatId: string, _id: string) => qm,
|
|
162
|
+
},
|
|
163
|
+
cancel: cancelFn,
|
|
164
|
+
dequeueAndStartQueuedMessage: dequeueStartFn,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await steer(deps, { type: "message.steer", chatId: "c1", queuedMessageId: "qm-1" })
|
|
168
|
+
expect(cancelFn).toHaveBeenCalledWith("c1", { hideInterrupted: true, skipQueueDrain: true })
|
|
169
|
+
expect(dequeueStartFn).toHaveBeenCalledWith("c1", qm, { steered: true })
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("throws when chat is still active after cancel", async () => {
|
|
173
|
+
const qm = makeQueuedMessage()
|
|
174
|
+
const deps: ChatManagementDeps = {
|
|
175
|
+
...makeDeps(),
|
|
176
|
+
activeTurns: {
|
|
177
|
+
has: () => true, // always active — cancel didn't help
|
|
178
|
+
get: () => undefined,
|
|
179
|
+
},
|
|
180
|
+
store: {
|
|
181
|
+
...makeDeps().store,
|
|
182
|
+
getQueuedMessage: (_chatId: string, _id: string) => qm,
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
await expect(
|
|
186
|
+
steer(deps, { type: "message.steer", chatId: "c1", queuedMessageId: "qm-1" }),
|
|
187
|
+
).rejects.toThrow("Chat is still running")
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// dequeue
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
describe("dequeue", () => {
|
|
196
|
+
it("throws when queued message not found", async () => {
|
|
197
|
+
const deps = makeDeps()
|
|
198
|
+
await expect(
|
|
199
|
+
dequeue(deps, { type: "message.dequeue", chatId: "c1", queuedMessageId: "qm-1" }),
|
|
200
|
+
).rejects.toThrow("Queued message not found")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("throws when proactive compact is running", async () => {
|
|
204
|
+
const qm = makeQueuedMessage()
|
|
205
|
+
const deps = makeDeps({
|
|
206
|
+
activeTurns: {
|
|
207
|
+
has: mock(() => true),
|
|
208
|
+
get: mock(() => ({ proactiveCompactInjection: true })),
|
|
209
|
+
},
|
|
210
|
+
store: {
|
|
211
|
+
getQueuedMessage: mock(() => qm),
|
|
212
|
+
removeQueuedMessage: mock(async () => {}),
|
|
213
|
+
requireChat: mock(() => ({ title: "", provider: null, sessionTokensByProvider: {}, pendingForkSessionToken: null })),
|
|
214
|
+
forkChat: mock(async () => ({ id: "" })),
|
|
215
|
+
renameChat: mock(async () => {}),
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
await expect(
|
|
219
|
+
dequeue(deps, { type: "message.dequeue", chatId: "c1", queuedMessageId: "qm-1" }),
|
|
220
|
+
).rejects.toThrow("Cannot remove queued message while compact is running")
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("removes the queued message when safe", async () => {
|
|
224
|
+
const qm = makeQueuedMessage()
|
|
225
|
+
const deps = makeDeps({
|
|
226
|
+
store: {
|
|
227
|
+
getQueuedMessage: mock(() => qm),
|
|
228
|
+
removeQueuedMessage: mock(async () => {}),
|
|
229
|
+
requireChat: mock(() => ({ title: "", provider: null, sessionTokensByProvider: {}, pendingForkSessionToken: null })),
|
|
230
|
+
forkChat: mock(async () => ({ id: "" })),
|
|
231
|
+
renameChat: mock(async () => {}),
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
await dequeue(deps, { type: "message.dequeue", chatId: "c1", queuedMessageId: "qm-1" })
|
|
235
|
+
expect(deps.store.removeQueuedMessage).toHaveBeenCalledWith("c1", "qm-1")
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// forkChat
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe("forkChat", () => {
|
|
244
|
+
it("throws when chat is active", async () => {
|
|
245
|
+
const deps = makeDeps({
|
|
246
|
+
activeTurns: { has: mock(() => true), get: mock(() => undefined) },
|
|
247
|
+
})
|
|
248
|
+
await expect(forkChat(deps, "c1")).rejects.toThrow("Chat must be idle before forking")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("throws when chat is draining", async () => {
|
|
252
|
+
const deps = makeDeps({
|
|
253
|
+
drainingStreams: { has: mock(() => true), get: mock(() => undefined), delete: mock(() => false) },
|
|
254
|
+
})
|
|
255
|
+
await expect(forkChat(deps, "c1")).rejects.toThrow("Chat must be idle before forking")
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it("throws when chat has no provider", async () => {
|
|
259
|
+
const deps = makeDeps({
|
|
260
|
+
store: {
|
|
261
|
+
getQueuedMessage: mock(() => null),
|
|
262
|
+
removeQueuedMessage: mock(async () => {}),
|
|
263
|
+
requireChat: mock(() => ({ title: "", provider: null, sessionTokensByProvider: {}, pendingForkSessionToken: null })),
|
|
264
|
+
forkChat: mock(async () => ({ id: "" })),
|
|
265
|
+
renameChat: mock(async () => {}),
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
await expect(forkChat(deps, "c1")).rejects.toThrow("Chat must have a provider before forking")
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it("throws when chat has no session token", async () => {
|
|
272
|
+
const deps = makeDeps({
|
|
273
|
+
store: {
|
|
274
|
+
getQueuedMessage: mock(() => null),
|
|
275
|
+
removeQueuedMessage: mock(async () => {}),
|
|
276
|
+
requireChat: mock(() => ({
|
|
277
|
+
title: "",
|
|
278
|
+
provider: "claude" as const,
|
|
279
|
+
sessionTokensByProvider: { claude: null },
|
|
280
|
+
pendingForkSessionToken: null,
|
|
281
|
+
})),
|
|
282
|
+
forkChat: mock(async () => ({ id: "" })),
|
|
283
|
+
renameChat: mock(async () => {}),
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
await expect(forkChat(deps, "c1")).rejects.toThrow("Chat has no session to fork")
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it("forks and tracks analytics on success", async () => {
|
|
290
|
+
const deps = makeDeps()
|
|
291
|
+
const result = await forkChat(deps, "c1")
|
|
292
|
+
expect(result).toEqual({ chatId: "forked-id" })
|
|
293
|
+
expect(deps.analytics.track).toHaveBeenCalledWith("chat_created")
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("accepts pendingForkSessionToken as the fork source", async () => {
|
|
297
|
+
const deps = makeDeps({
|
|
298
|
+
store: {
|
|
299
|
+
getQueuedMessage: mock(() => null),
|
|
300
|
+
removeQueuedMessage: mock(async () => {}),
|
|
301
|
+
requireChat: mock(() => ({
|
|
302
|
+
title: "",
|
|
303
|
+
provider: "claude" as const,
|
|
304
|
+
sessionTokensByProvider: { claude: null },
|
|
305
|
+
pendingForkSessionToken: { provider: "claude" as const, token: "pending-tok" },
|
|
306
|
+
})),
|
|
307
|
+
forkChat: mock(async () => ({ id: "forked-2" })),
|
|
308
|
+
renameChat: mock(async () => {}),
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
const result = await forkChat(deps, "c1")
|
|
312
|
+
expect(result).toEqual({ chatId: "forked-2" })
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// generateTitleInBackground
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
describe("generateTitleInBackground", () => {
|
|
321
|
+
it("renames chat when title generation succeeds and title still matches", async () => {
|
|
322
|
+
const deps = makeDeps({
|
|
323
|
+
generateTitle: mock(async () => ({ title: "AI Title", usedFallback: false, failureMessage: null })),
|
|
324
|
+
store: {
|
|
325
|
+
getQueuedMessage: mock(() => null),
|
|
326
|
+
removeQueuedMessage: mock(async () => {}),
|
|
327
|
+
requireChat: mock(() => ({
|
|
328
|
+
title: "original",
|
|
329
|
+
provider: null,
|
|
330
|
+
sessionTokensByProvider: {},
|
|
331
|
+
pendingForkSessionToken: null,
|
|
332
|
+
})),
|
|
333
|
+
forkChat: mock(async () => ({ id: "" })),
|
|
334
|
+
renameChat: mock(async () => {}),
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
await generateTitleInBackground(deps, "c1", "message content", "/path", "original")
|
|
338
|
+
expect(deps.store.renameChat).toHaveBeenCalledWith("c1", "AI Title")
|
|
339
|
+
expect(deps.emitStateChange).toHaveBeenCalledWith("c1")
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("skips rename when title has changed since the call was queued", async () => {
|
|
343
|
+
const deps = makeDeps({
|
|
344
|
+
generateTitle: mock(async () => ({ title: "AI Title", usedFallback: false, failureMessage: null })),
|
|
345
|
+
store: {
|
|
346
|
+
getQueuedMessage: mock(() => null),
|
|
347
|
+
removeQueuedMessage: mock(async () => {}),
|
|
348
|
+
requireChat: mock(() => ({
|
|
349
|
+
title: "already changed",
|
|
350
|
+
provider: null,
|
|
351
|
+
sessionTokensByProvider: {},
|
|
352
|
+
pendingForkSessionToken: null,
|
|
353
|
+
})),
|
|
354
|
+
forkChat: mock(async () => ({ id: "" })),
|
|
355
|
+
renameChat: mock(async () => {}),
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
await generateTitleInBackground(deps, "c1", "message content", "/path", "original")
|
|
359
|
+
expect(deps.store.renameChat).not.toHaveBeenCalled()
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it("skips rename when usedFallback is true", async () => {
|
|
363
|
+
const deps = makeDeps({
|
|
364
|
+
generateTitle: mock(async () => ({ title: "fallback", usedFallback: true, failureMessage: null })),
|
|
365
|
+
})
|
|
366
|
+
await generateTitleInBackground(deps, "c1", "content", "/path", "original")
|
|
367
|
+
expect(deps.store.renameChat).not.toHaveBeenCalled()
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it("reports error on failure without throwing", async () => {
|
|
371
|
+
const report = mock((_msg: string) => {})
|
|
372
|
+
const deps = makeDeps({
|
|
373
|
+
generateTitle: mock(async () => { throw new Error("API error") }),
|
|
374
|
+
reportBackgroundError: report,
|
|
375
|
+
})
|
|
376
|
+
await expect(
|
|
377
|
+
generateTitleInBackground(deps, "c1", "content", "/path", "original"),
|
|
378
|
+
).resolves.toBeUndefined()
|
|
379
|
+
expect(report).toHaveBeenCalledWith(
|
|
380
|
+
expect.stringContaining("[title-generation] chat c1 failed background title generation: API error"),
|
|
381
|
+
)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it("reports provider failure message without renaming", async () => {
|
|
385
|
+
const report = mock((_msg: string) => {})
|
|
386
|
+
const deps = makeDeps({
|
|
387
|
+
generateTitle: mock(async () => ({ title: "title", usedFallback: false, failureMessage: "quota exceeded" })),
|
|
388
|
+
reportBackgroundError: report,
|
|
389
|
+
})
|
|
390
|
+
await generateTitleInBackground(deps, "c1", "content", "/path", "original")
|
|
391
|
+
expect(report).toHaveBeenCalledWith(
|
|
392
|
+
expect.stringContaining("failed provider title generation: quota exceeded"),
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
})
|