@cuongtran001/kanna 1.3.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-CAysfmX5.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.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-YgpMurXf.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-CbSY9HfS.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/{index-C0U2kwuB.js → index-TU1k6ov6.js} +5 -5
- package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DLR6miy3.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.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-ByWRdpQX.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +1 -1
- 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.test.ts +22 -6
- package/src/server/agent.ts +35 -4987
- 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 -2285
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -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/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 +25 -1711
- package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the extracted turn runner (claude-turn-runner.ts).
|
|
3
|
+
* Covers the key branches of runTurn without touching agent.ts internals.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect, mock } from "bun:test"
|
|
6
|
+
import { runTurn, type RunTurnDeps } from "./claude-turn-runner"
|
|
7
|
+
import type { ActiveTurn } from "./claude-session-state"
|
|
8
|
+
import type { HarnessTurn, HarnessEvent } from "./harness-types"
|
|
9
|
+
import type { TranscriptEntry } from "../shared/types"
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function makeFakeTurn(events: HarnessEvent[] = []): HarnessTurn {
|
|
16
|
+
return {
|
|
17
|
+
provider: "codex",
|
|
18
|
+
stream: {
|
|
19
|
+
async *[Symbol.asyncIterator]() {
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
yield event
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
interrupt: async () => {},
|
|
26
|
+
close: mock(() => {}),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeErrorTurn(error: Error): HarnessTurn {
|
|
31
|
+
return {
|
|
32
|
+
provider: "codex",
|
|
33
|
+
stream: {
|
|
34
|
+
[Symbol.asyncIterator]() {
|
|
35
|
+
return {
|
|
36
|
+
next(): Promise<IteratorResult<HarnessEvent>> {
|
|
37
|
+
return Promise.reject(error)
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
interrupt: async () => {},
|
|
43
|
+
close: mock(() => {}),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeResultEntry(isError = false): TranscriptEntry {
|
|
48
|
+
return {
|
|
49
|
+
_id: "entry-1",
|
|
50
|
+
createdAt: Date.now(),
|
|
51
|
+
kind: "result",
|
|
52
|
+
subtype: isError ? "error" : "success",
|
|
53
|
+
isError,
|
|
54
|
+
durationMs: 100,
|
|
55
|
+
result: isError ? "Something went wrong" : "Done",
|
|
56
|
+
} as TranscriptEntry
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function makeActiveTurn(overrides: Partial<ActiveTurn> = {}, turn?: HarnessTurn): ActiveTurn {
|
|
60
|
+
return {
|
|
61
|
+
chatId: "chat-1",
|
|
62
|
+
provider: "codex",
|
|
63
|
+
turn: turn ?? makeFakeTurn(),
|
|
64
|
+
model: "gpt-4o",
|
|
65
|
+
planMode: false,
|
|
66
|
+
status: "running",
|
|
67
|
+
pendingTool: null,
|
|
68
|
+
postToolFollowUp: null,
|
|
69
|
+
hasFinalResult: false,
|
|
70
|
+
cancelRequested: false,
|
|
71
|
+
cancelRecorded: false,
|
|
72
|
+
waitStartedAt: null,
|
|
73
|
+
userMessageId: null,
|
|
74
|
+
...overrides,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeDeps(overrides: Partial<RunTurnDeps> = {}): RunTurnDeps {
|
|
79
|
+
const activeTurns = new Map<string, ActiveTurn>()
|
|
80
|
+
const drainingStreams = new Map<string, { turn: HarnessTurn }>()
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
store: {
|
|
84
|
+
setSessionTokenForProvider: mock(async () => {}),
|
|
85
|
+
getChat: mock(() => null),
|
|
86
|
+
setPendingForkSessionToken: mock(async () => {}),
|
|
87
|
+
appendMessage: mock(async () => {}),
|
|
88
|
+
recordTurnFailed: mock(async () => {}),
|
|
89
|
+
recordTurnFinished: mock(async () => {}),
|
|
90
|
+
recordTurnCancelled: mock(async () => {}),
|
|
91
|
+
} as unknown as RunTurnDeps["store"],
|
|
92
|
+
activeTurns,
|
|
93
|
+
drainingStreams,
|
|
94
|
+
oauthPool: { release: mock(() => {}) },
|
|
95
|
+
codexLimitDetector: { detect: mock(() => null) } as unknown as RunTurnDeps["codexLimitDetector"],
|
|
96
|
+
handleLimitError: mock(async () => false),
|
|
97
|
+
emitStateChange: mock(() => {}),
|
|
98
|
+
clearDrainingStream: mock(() => {}),
|
|
99
|
+
startTurnForChat: mock(async () => {}),
|
|
100
|
+
maybeStartNextQueuedMessage: mock(async () => {}),
|
|
101
|
+
...overrides,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Tests
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
describe("runTurn", () => {
|
|
110
|
+
test("calls recordTurnFinished on successful result entry", async () => {
|
|
111
|
+
const turn = makeFakeTurn([{ type: "transcript", entry: makeResultEntry(false) }])
|
|
112
|
+
const active = makeActiveTurn({}, turn)
|
|
113
|
+
const deps = makeDeps()
|
|
114
|
+
|
|
115
|
+
await runTurn(deps, active)
|
|
116
|
+
|
|
117
|
+
expect(deps.store.recordTurnFinished).toHaveBeenCalledWith("chat-1")
|
|
118
|
+
expect(deps.store.recordTurnFailed).not.toHaveBeenCalled()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("calls recordTurnFailed on error result entry", async () => {
|
|
122
|
+
const turn = makeFakeTurn([{ type: "transcript", entry: makeResultEntry(true) }])
|
|
123
|
+
const active = makeActiveTurn({}, turn)
|
|
124
|
+
const deps = makeDeps()
|
|
125
|
+
|
|
126
|
+
await runTurn(deps, active)
|
|
127
|
+
|
|
128
|
+
expect(deps.store.recordTurnFailed).toHaveBeenCalledWith("chat-1", "Something went wrong")
|
|
129
|
+
expect(deps.store.recordTurnFinished).not.toHaveBeenCalled()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("sets hasFinalResult=true and moves to drainingStreams on result", async () => {
|
|
133
|
+
const turn = makeFakeTurn([{ type: "transcript", entry: makeResultEntry(false) }])
|
|
134
|
+
const active = makeActiveTurn({}, turn)
|
|
135
|
+
const deps = makeDeps()
|
|
136
|
+
deps.activeTurns.set("chat-1", active)
|
|
137
|
+
|
|
138
|
+
await runTurn(deps, active)
|
|
139
|
+
|
|
140
|
+
expect(active.hasFinalResult).toBe(true)
|
|
141
|
+
expect(deps.activeTurns.has("chat-1")).toBe(false)
|
|
142
|
+
expect(deps.drainingStreams.has("chat-1")).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("records turn cancelled when cancelRequested=true and cancelRecorded=false", async () => {
|
|
146
|
+
const turn = makeFakeTurn([])
|
|
147
|
+
const active = makeActiveTurn({ cancelRequested: true, cancelRecorded: false }, turn)
|
|
148
|
+
const deps = makeDeps()
|
|
149
|
+
|
|
150
|
+
await runTurn(deps, active)
|
|
151
|
+
|
|
152
|
+
expect(deps.store.recordTurnCancelled).toHaveBeenCalledWith("chat-1")
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("does not record cancelled when cancelRecorded=true", async () => {
|
|
156
|
+
const turn = makeFakeTurn([])
|
|
157
|
+
const active = makeActiveTurn({ cancelRequested: true, cancelRecorded: true }, turn)
|
|
158
|
+
const deps = makeDeps()
|
|
159
|
+
|
|
160
|
+
await runTurn(deps, active)
|
|
161
|
+
|
|
162
|
+
expect(deps.store.recordTurnCancelled).not.toHaveBeenCalled()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test("releases oauthPool token and emits state change in finally", async () => {
|
|
166
|
+
const turn = makeFakeTurn([])
|
|
167
|
+
const active = makeActiveTurn({}, turn)
|
|
168
|
+
const deps = makeDeps()
|
|
169
|
+
|
|
170
|
+
await runTurn(deps, active)
|
|
171
|
+
|
|
172
|
+
expect(deps.oauthPool?.release).toHaveBeenCalledWith("chat-1")
|
|
173
|
+
expect(deps.emitStateChange).toHaveBeenCalledWith("chat-1")
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("calls maybeStartNextQueuedMessage after successful turn", async () => {
|
|
177
|
+
const turn = makeFakeTurn([{ type: "transcript", entry: makeResultEntry(false) }])
|
|
178
|
+
const active = makeActiveTurn({ postToolFollowUp: null }, turn)
|
|
179
|
+
const deps = makeDeps()
|
|
180
|
+
|
|
181
|
+
await runTurn(deps, active)
|
|
182
|
+
|
|
183
|
+
expect(deps.maybeStartNextQueuedMessage).toHaveBeenCalledWith("chat-1")
|
|
184
|
+
expect(deps.startTurnForChat).not.toHaveBeenCalled()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test("calls startTurnForChat when postToolFollowUp is set", async () => {
|
|
188
|
+
const turn = makeFakeTurn([])
|
|
189
|
+
const active = makeActiveTurn({
|
|
190
|
+
postToolFollowUp: { content: "follow-up content", planMode: false },
|
|
191
|
+
}, turn)
|
|
192
|
+
const deps = makeDeps()
|
|
193
|
+
|
|
194
|
+
await runTurn(deps, active)
|
|
195
|
+
|
|
196
|
+
expect(deps.startTurnForChat).toHaveBeenCalledWith(
|
|
197
|
+
expect.objectContaining({
|
|
198
|
+
chatId: "chat-1",
|
|
199
|
+
content: "follow-up content",
|
|
200
|
+
appendUserPrompt: false,
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
expect(deps.maybeStartNextQueuedMessage).not.toHaveBeenCalled()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test("does not call maybeStartNextQueuedMessage when cancelRequested=true", async () => {
|
|
207
|
+
const turn = makeFakeTurn([])
|
|
208
|
+
const active = makeActiveTurn({ cancelRequested: true, cancelRecorded: true }, turn)
|
|
209
|
+
const deps = makeDeps()
|
|
210
|
+
|
|
211
|
+
await runTurn(deps, active)
|
|
212
|
+
|
|
213
|
+
expect(deps.maybeStartNextQueuedMessage).not.toHaveBeenCalled()
|
|
214
|
+
expect(deps.startTurnForChat).not.toHaveBeenCalled()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test("handles limit error: appends error entry and records failed", async () => {
|
|
218
|
+
const errorTurn = makeErrorTurn(new Error("rate limit hit"))
|
|
219
|
+
const active = makeActiveTurn({}, errorTurn)
|
|
220
|
+
const deps = makeDeps({
|
|
221
|
+
handleLimitError: mock(async () => false), // not a limit error
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
await runTurn(deps, active)
|
|
225
|
+
|
|
226
|
+
expect(deps.store.appendMessage).toHaveBeenCalledWith(
|
|
227
|
+
"chat-1",
|
|
228
|
+
expect.objectContaining({ kind: "result", isError: true })
|
|
229
|
+
)
|
|
230
|
+
expect(deps.store.recordTurnFailed).toHaveBeenCalledWith("chat-1", "rate limit hit")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test("handles limit error: records rate_limit when handleLimitError returns true", async () => {
|
|
234
|
+
const errorTurn = makeErrorTurn(new Error("quota exceeded"))
|
|
235
|
+
const active = makeActiveTurn({}, errorTurn)
|
|
236
|
+
const deps = makeDeps({
|
|
237
|
+
handleLimitError: mock(async () => true), // handled as limit
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
await runTurn(deps, active)
|
|
241
|
+
|
|
242
|
+
expect(deps.store.recordTurnFailed).toHaveBeenCalledWith("chat-1", "rate_limit")
|
|
243
|
+
expect(deps.store.appendMessage).not.toHaveBeenCalled()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
test("skips error handling when cancelRequested=true during stream error", async () => {
|
|
247
|
+
const errorTurn = makeErrorTurn(new Error("cancelled mid-stream"))
|
|
248
|
+
const active = makeActiveTurn({ cancelRequested: true, cancelRecorded: true }, errorTurn)
|
|
249
|
+
const deps = makeDeps()
|
|
250
|
+
|
|
251
|
+
await runTurn(deps, active)
|
|
252
|
+
|
|
253
|
+
expect(deps.store.recordTurnFailed).not.toHaveBeenCalled()
|
|
254
|
+
expect(deps.handleLimitError).not.toHaveBeenCalled()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test("processes session_token event and updates store", async () => {
|
|
258
|
+
const chatRecord = {
|
|
259
|
+
pendingForkSessionToken: { provider: "claude" as const, token: "old-token" },
|
|
260
|
+
}
|
|
261
|
+
const turn = makeFakeTurn([
|
|
262
|
+
{ type: "session_token" as const, sessionToken: "new-token" },
|
|
263
|
+
])
|
|
264
|
+
const active = makeActiveTurn({}, turn)
|
|
265
|
+
const deps = makeDeps({
|
|
266
|
+
store: {
|
|
267
|
+
setSessionTokenForProvider: mock(async () => {}),
|
|
268
|
+
getChat: mock(() => chatRecord),
|
|
269
|
+
setPendingForkSessionToken: mock(async () => {}),
|
|
270
|
+
appendMessage: mock(async () => {}),
|
|
271
|
+
recordTurnFailed: mock(async () => {}),
|
|
272
|
+
recordTurnFinished: mock(async () => {}),
|
|
273
|
+
recordTurnCancelled: mock(async () => {}),
|
|
274
|
+
} as unknown as RunTurnDeps["store"],
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
await runTurn(deps, active)
|
|
278
|
+
|
|
279
|
+
expect(deps.store.setSessionTokenForProvider).toHaveBeenCalledWith(
|
|
280
|
+
"chat-1", "codex", "new-token"
|
|
281
|
+
)
|
|
282
|
+
// pendingForkSessionToken.token differs from event.sessionToken → cleared
|
|
283
|
+
expect(deps.store.setPendingForkSessionToken).toHaveBeenCalledWith("chat-1", null)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test("closes the turn in finally regardless of outcome", async () => {
|
|
287
|
+
const turn = makeFakeTurn([])
|
|
288
|
+
const active = makeActiveTurn({}, turn)
|
|
289
|
+
const deps = makeDeps()
|
|
290
|
+
|
|
291
|
+
await runTurn(deps, active)
|
|
292
|
+
|
|
293
|
+
expect(turn.close).toHaveBeenCalled()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test("sets active.status to running on system_init entry", async () => {
|
|
297
|
+
const systemInitEntry: TranscriptEntry = {
|
|
298
|
+
_id: "entry-sys",
|
|
299
|
+
createdAt: Date.now(),
|
|
300
|
+
kind: "system_init",
|
|
301
|
+
} as TranscriptEntry
|
|
302
|
+
const turn = makeFakeTurn([{ type: "transcript" as const, entry: systemInitEntry }])
|
|
303
|
+
const active = makeActiveTurn({ status: "starting" as never }, turn)
|
|
304
|
+
const deps = makeDeps()
|
|
305
|
+
|
|
306
|
+
await runTurn(deps, active)
|
|
307
|
+
|
|
308
|
+
expect(active.status).toBe("running")
|
|
309
|
+
})
|
|
310
|
+
})
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone turn-runner — extracted from AgentCoordinator.runTurn.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Stream events from an in-flight HarnessTurn and write them to the store.
|
|
6
|
+
* - Handle cancellation, errors, and limit detection.
|
|
7
|
+
* - On completion: release OAuth pool token, emit state change, and kick off
|
|
8
|
+
* the next queued message or postToolFollowUp turn.
|
|
9
|
+
*
|
|
10
|
+
* Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
|
|
11
|
+
* calls, no Bun primitives). Every effectful operation is injected through
|
|
12
|
+
* RunTurnDeps so the module remains testable without a real coordinator.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgentProvider, TranscriptEntry } from "../shared/types"
|
|
16
|
+
import type { AnyValue } from "../shared/errors"
|
|
17
|
+
import type { HarnessTurn } from "./harness-types"
|
|
18
|
+
import type { ActiveTurn } from "./claude-session-state"
|
|
19
|
+
import type { LimitDetector } from "./auto-continue/limit-detector"
|
|
20
|
+
import type { StartTurnForChatArgs } from "./claude-turn-starter"
|
|
21
|
+
import { timestamped } from "./claude-message-normalizer"
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Structural sub-interfaces — only the operations this module calls.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Subset of EventStore used by runTurn. */
|
|
28
|
+
interface RunTurnStore {
|
|
29
|
+
setSessionTokenForProvider(
|
|
30
|
+
chatId: string,
|
|
31
|
+
provider: AgentProvider,
|
|
32
|
+
sessionToken: string | null,
|
|
33
|
+
): Promise<void>
|
|
34
|
+
getChat(chatId: string): { pendingForkSessionToken?: { provider: AgentProvider; token: string } | null } | null | undefined
|
|
35
|
+
setPendingForkSessionToken(
|
|
36
|
+
chatId: string,
|
|
37
|
+
value: { provider: AgentProvider; token: string } | null,
|
|
38
|
+
): Promise<void>
|
|
39
|
+
appendMessage(chatId: string, entry: TranscriptEntry): Promise<void>
|
|
40
|
+
recordTurnFailed(chatId: string, error: string): Promise<void>
|
|
41
|
+
recordTurnFinished(chatId: string): Promise<void>
|
|
42
|
+
recordTurnCancelled(chatId: string): Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Subset of OAuthTokenPool used by runTurn. */
|
|
46
|
+
interface RunTurnOAuthPool {
|
|
47
|
+
release(chatId: string): void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Dependency bundle
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* All AgentCoordinator fields and callbacks accessed by runTurn.
|
|
56
|
+
* Passed as a single deps object so the function stays testable without a
|
|
57
|
+
* real coordinator.
|
|
58
|
+
*/
|
|
59
|
+
export interface RunTurnDeps {
|
|
60
|
+
/** Structural subset of EventStore — no concrete import. */
|
|
61
|
+
store: RunTurnStore
|
|
62
|
+
/** Map of chatId → in-flight turn; mutated to reflect turn lifecycle. */
|
|
63
|
+
activeTurns: Map<string, ActiveTurn>
|
|
64
|
+
/** Map of chatId → draining stream; mutated when a result arrives early. */
|
|
65
|
+
drainingStreams: Map<string, { turn: HarnessTurn }>
|
|
66
|
+
/** OAuth pool for releasing the per-chat token reservation on completion. */
|
|
67
|
+
oauthPool: RunTurnOAuthPool | null
|
|
68
|
+
/** Detector for codex-side rate-limit / limit errors. */
|
|
69
|
+
codexLimitDetector: LimitDetector
|
|
70
|
+
/** Delegate to the coordinator's handleLimitError (already has detector bound via args). */
|
|
71
|
+
handleLimitError: (chatId: string, detector: LimitDetector, error: AnyValue) => Promise<boolean>
|
|
72
|
+
/** Notify the WebSocket layer that UI state changed. */
|
|
73
|
+
emitStateChange: (chatId: string) => void
|
|
74
|
+
/** Remove the draining-stream entry for a chat (coordinator keeps the map). */
|
|
75
|
+
clearDrainingStream: (chatId: string) => void
|
|
76
|
+
/** Spawn a follow-up turn for a chat (postToolFollowUp path). */
|
|
77
|
+
startTurnForChat: (args: StartTurnForChatArgs) => Promise<void>
|
|
78
|
+
/** Process the next queued message for a chat after the current turn ends. */
|
|
79
|
+
maybeStartNextQueuedMessage: (chatId: string) => Promise<boolean | void>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Standalone function
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drive a single agentic turn to completion.
|
|
88
|
+
*
|
|
89
|
+
* Reads from `active.turn.stream`, writes transcript entries to the store,
|
|
90
|
+
* and handles the full lifecycle: result detection, cancel recording, OAuth
|
|
91
|
+
* token release, and post-turn scheduling (postToolFollowUp or queue drain).
|
|
92
|
+
*/
|
|
93
|
+
export async function runTurn(deps: RunTurnDeps, active: ActiveTurn): Promise<void> {
|
|
94
|
+
try {
|
|
95
|
+
for await (const event of active.turn.stream) {
|
|
96
|
+
// Once cancelled, stop processing further stream events.
|
|
97
|
+
// cancel() already removed us from activeTurns and notified the UI.
|
|
98
|
+
if (active.cancelRequested) break
|
|
99
|
+
|
|
100
|
+
if (event.type === "session_token" && event.sessionToken) {
|
|
101
|
+
await deps.store.setSessionTokenForProvider(active.chatId, active.provider, event.sessionToken)
|
|
102
|
+
const chat = deps.store.getChat(active.chatId)
|
|
103
|
+
if (
|
|
104
|
+
chat?.pendingForkSessionToken
|
|
105
|
+
&& event.sessionToken !== chat.pendingForkSessionToken.token
|
|
106
|
+
) {
|
|
107
|
+
await deps.store.setPendingForkSessionToken(active.chatId, null)
|
|
108
|
+
}
|
|
109
|
+
deps.emitStateChange(active.chatId)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!event.entry) continue
|
|
114
|
+
await deps.store.appendMessage(active.chatId, event.entry)
|
|
115
|
+
|
|
116
|
+
if (event.entry.kind === "system_init") {
|
|
117
|
+
active.status = "running"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.entry.kind === "result") {
|
|
121
|
+
active.hasFinalResult = true
|
|
122
|
+
if (event.entry.isError) {
|
|
123
|
+
await deps.store.recordTurnFailed(active.chatId, event.entry.result || "Turn failed")
|
|
124
|
+
} else if (!active.cancelRequested) {
|
|
125
|
+
await deps.store.recordTurnFinished(active.chatId)
|
|
126
|
+
}
|
|
127
|
+
// Remove from activeTurns as soon as the result arrives so the UI
|
|
128
|
+
// transitions to idle immediately. The stream may still be open
|
|
129
|
+
// (e.g. background tasks), but the user should be able to send
|
|
130
|
+
// new messages without having to hit stop first.
|
|
131
|
+
deps.activeTurns.delete(active.chatId)
|
|
132
|
+
deps.drainingStreams.set(active.chatId, { turn: active.turn })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
deps.emitStateChange(active.chatId)
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (!active.cancelRequested) {
|
|
139
|
+
const handled = await deps.handleLimitError(active.chatId, deps.codexLimitDetector, error)
|
|
140
|
+
if (!handled) {
|
|
141
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
142
|
+
await deps.store.appendMessage(
|
|
143
|
+
active.chatId,
|
|
144
|
+
timestamped({
|
|
145
|
+
kind: "result",
|
|
146
|
+
subtype: "error",
|
|
147
|
+
isError: true,
|
|
148
|
+
durationMs: 0,
|
|
149
|
+
result: message,
|
|
150
|
+
})
|
|
151
|
+
)
|
|
152
|
+
await deps.store.recordTurnFailed(active.chatId, message)
|
|
153
|
+
} else {
|
|
154
|
+
await deps.store.recordTurnFailed(active.chatId, "rate_limit")
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
if (active.cancelRequested && !active.cancelRecorded) {
|
|
159
|
+
await deps.store.recordTurnCancelled(active.chatId)
|
|
160
|
+
}
|
|
161
|
+
active.turn.close()
|
|
162
|
+
// Only remove if we're still the active turn for this chat.
|
|
163
|
+
// We may have already been removed by result handling or cancel(),
|
|
164
|
+
// and a new turn may have started for the same chatId.
|
|
165
|
+
if (deps.activeTurns.get(active.chatId) === active) {
|
|
166
|
+
deps.activeTurns.delete(active.chatId)
|
|
167
|
+
}
|
|
168
|
+
// Stream has fully ended — no longer draining.
|
|
169
|
+
deps.clearDrainingStream(active.chatId)
|
|
170
|
+
// Turn-scoped reservation: release so another chat can claim this
|
|
171
|
+
// token while this chat is idle. The rotation race between concurrent
|
|
172
|
+
// in-flight turns is still serialized — both startClaudeTurn and the
|
|
173
|
+
// pickActive() inside markLimited/markError run atomically in the JS
|
|
174
|
+
// event loop, and a token marked limited/errored already drops its
|
|
175
|
+
// reservation. The next turn for this chat reuses its existing claude
|
|
176
|
+
// session (no re-pick) or pickActive again if it needs a fresh one.
|
|
177
|
+
deps.oauthPool?.release(active.chatId)
|
|
178
|
+
deps.emitStateChange(active.chatId)
|
|
179
|
+
|
|
180
|
+
if (active.postToolFollowUp && !active.cancelRequested) {
|
|
181
|
+
try {
|
|
182
|
+
await deps.startTurnForChat({
|
|
183
|
+
chatId: active.chatId,
|
|
184
|
+
provider: active.provider,
|
|
185
|
+
content: active.postToolFollowUp.content,
|
|
186
|
+
attachments: [],
|
|
187
|
+
model: active.model,
|
|
188
|
+
effort: active.effort,
|
|
189
|
+
serviceTier: active.serviceTier,
|
|
190
|
+
planMode: active.postToolFollowUp.planMode,
|
|
191
|
+
appendUserPrompt: false,
|
|
192
|
+
})
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
195
|
+
await deps.store.appendMessage(
|
|
196
|
+
active.chatId,
|
|
197
|
+
timestamped({
|
|
198
|
+
kind: "result",
|
|
199
|
+
subtype: "error",
|
|
200
|
+
isError: true,
|
|
201
|
+
durationMs: 0,
|
|
202
|
+
result: message,
|
|
203
|
+
})
|
|
204
|
+
)
|
|
205
|
+
await deps.store.recordTurnFailed(active.chatId, message)
|
|
206
|
+
deps.emitStateChange(active.chatId)
|
|
207
|
+
}
|
|
208
|
+
} else if (!active.cancelRequested) {
|
|
209
|
+
try {
|
|
210
|
+
await deps.maybeStartNextQueuedMessage(active.chatId)
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
213
|
+
await deps.store.appendMessage(
|
|
214
|
+
active.chatId,
|
|
215
|
+
timestamped({
|
|
216
|
+
kind: "result",
|
|
217
|
+
subtype: "error",
|
|
218
|
+
isError: true,
|
|
219
|
+
durationMs: 0,
|
|
220
|
+
result: message,
|
|
221
|
+
})
|
|
222
|
+
)
|
|
223
|
+
await deps.store.recordTurnFailed(active.chatId, message)
|
|
224
|
+
deps.emitStateChange(active.chatId)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|