@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.
Files changed (186) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-CbSY9HfS.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/{index-C0U2kwuB.js → index-TU1k6ov6.js} +5 -5
  29. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  30. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  31. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  32. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  33. package/dist/client/assets/{linear-DLR6miy3.js → linear-ByKHtsIx.js} +1 -1
  34. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-Dl_f2KLc.js} +4 -4
  35. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  36. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  37. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  38. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  39. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  40. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  41. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  42. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  43. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  44. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  45. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  46. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  47. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  48. package/dist/client/index.html +1 -1
  49. package/package.json +1 -1
  50. package/src/server/agent-coordinator-types.ts +158 -0
  51. package/src/server/agent-coordinator.ts +919 -0
  52. package/src/server/agent-deps-builders.ts +425 -0
  53. package/src/server/agent.test.ts +22 -6
  54. package/src/server/agent.ts +35 -4987
  55. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  56. package/src/server/claude-autocontinue-commands.ts +256 -0
  57. package/src/server/claude-cancel-handler.test.ts +472 -0
  58. package/src/server/claude-cancel-handler.ts +241 -0
  59. package/src/server/claude-chat-management.test.ts +395 -0
  60. package/src/server/claude-chat-management.ts +251 -0
  61. package/src/server/claude-harness-stream.test.ts +273 -0
  62. package/src/server/claude-harness-stream.ts +158 -0
  63. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  64. package/src/server/claude-loop-orch-commands.ts +466 -0
  65. package/src/server/claude-message-normalizer.test.ts +213 -0
  66. package/src/server/claude-message-normalizer.ts +436 -0
  67. package/src/server/claude-prompt-helpers.test.ts +248 -0
  68. package/src/server/claude-prompt-helpers.ts +139 -0
  69. package/src/server/claude-sdk-queue.test.ts +169 -0
  70. package/src/server/claude-sdk-queue.ts +82 -0
  71. package/src/server/claude-send-command.test.ts +414 -0
  72. package/src/server/claude-send-command.ts +420 -0
  73. package/src/server/claude-session-config-helpers.test.ts +279 -0
  74. package/src/server/claude-session-config-helpers.ts +151 -0
  75. package/src/server/claude-session-config.test.ts +250 -0
  76. package/src/server/claude-session-config.ts +159 -0
  77. package/src/server/claude-session-error-handler.test.ts +498 -0
  78. package/src/server/claude-session-error-handler.ts +361 -0
  79. package/src/server/claude-session-lifecycle.test.ts +669 -0
  80. package/src/server/claude-session-lifecycle.ts +283 -0
  81. package/src/server/claude-session-rebuild.test.ts +224 -0
  82. package/src/server/claude-session-rebuild.ts +101 -0
  83. package/src/server/claude-session-runner.test.ts +523 -0
  84. package/src/server/claude-session-runner.ts +521 -0
  85. package/src/server/claude-session-spawner.test.ts +401 -0
  86. package/src/server/claude-session-spawner.ts +401 -0
  87. package/src/server/claude-session-start.test.ts +205 -0
  88. package/src/server/claude-session-start.ts +277 -0
  89. package/src/server/claude-session-state-queries.test.ts +344 -0
  90. package/src/server/claude-session-state-queries.ts +160 -0
  91. package/src/server/claude-session-state.ts +104 -0
  92. package/src/server/claude-slash-commands.test.ts +277 -0
  93. package/src/server/claude-slash-commands.ts +234 -0
  94. package/src/server/claude-spawn-helpers.test.ts +221 -0
  95. package/src/server/claude-spawn-helpers.ts +179 -0
  96. package/src/server/claude-steer-log.test.ts +243 -0
  97. package/src/server/claude-steer-log.ts +58 -0
  98. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  99. package/src/server/claude-subagent-tool-response.ts +165 -0
  100. package/src/server/claude-subagent-wiring.test.ts +263 -0
  101. package/src/server/claude-subagent-wiring.ts +324 -0
  102. package/src/server/claude-tool-respond.test.ts +321 -0
  103. package/src/server/claude-tool-respond.ts +142 -0
  104. package/src/server/claude-turn-runner.test.ts +310 -0
  105. package/src/server/claude-turn-runner.ts +228 -0
  106. package/src/server/claude-turn-starter.test.ts +266 -0
  107. package/src/server/claude-turn-starter.ts +566 -0
  108. package/src/server/claude-usage-math.test.ts +169 -0
  109. package/src/server/claude-usage-math.ts +132 -0
  110. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  111. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  112. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  113. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  114. package/src/server/diff-store-errors.ts +85 -0
  115. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  116. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  117. package/src/server/diff-store-io.adapter.ts +27 -0
  118. package/src/server/diff-store-parse.test.ts +212 -0
  119. package/src/server/diff-store-parse.ts +122 -0
  120. package/src/server/diff-store-state.ts +82 -0
  121. package/src/server/diff-store.ts +76 -1854
  122. package/src/server/event-store-apply.test.ts +280 -0
  123. package/src/server/event-store-apply.ts +132 -0
  124. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  125. package/src/server/event-store-chat-lifecycle.ts +441 -0
  126. package/src/server/event-store-entity-write.ts +356 -0
  127. package/src/server/event-store-helpers.test.ts +230 -0
  128. package/src/server/event-store-helpers.ts +235 -0
  129. package/src/server/event-store-init.ts +276 -0
  130. package/src/server/event-store-messages.adapter.test.ts +365 -0
  131. package/src/server/event-store-messages.adapter.ts +213 -0
  132. package/src/server/event-store-orch-subscription.ts +124 -0
  133. package/src/server/event-store-orch.test.ts +462 -0
  134. package/src/server/event-store-orch.ts +312 -0
  135. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  136. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  137. package/src/server/event-store-snapshot.test.ts +597 -0
  138. package/src/server/event-store-snapshot.ts +658 -0
  139. package/src/server/event-store-subagent.test.ts +408 -0
  140. package/src/server/event-store-subagent.ts +198 -0
  141. package/src/server/event-store-tool-requests.test.ts +210 -0
  142. package/src/server/event-store-tool-requests.ts +131 -0
  143. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  144. package/src/server/event-store-write-ops.test.ts +554 -0
  145. package/src/server/event-store-write-ops.ts +529 -0
  146. package/src/server/event-store.test.ts +35 -0
  147. package/src/server/event-store.ts +251 -2285
  148. package/src/server/harness-types.ts +15 -1
  149. package/src/server/oauth-errors.ts +19 -0
  150. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  151. package/src/server/ws-router-agent-ctrl.ts +137 -0
  152. package/src/server/ws-router-broadcast.ts +488 -0
  153. package/src/server/ws-router-chat.test.ts +461 -0
  154. package/src/server/ws-router-chat.ts +279 -0
  155. package/src/server/ws-router-defaults.test.ts +283 -0
  156. package/src/server/ws-router-defaults.ts +434 -0
  157. package/src/server/ws-router-diff.test.ts +167 -0
  158. package/src/server/ws-router-diff.ts +250 -0
  159. package/src/server/ws-router-envelope.ts +329 -0
  160. package/src/server/ws-router-misc.test.ts +422 -0
  161. package/src/server/ws-router-misc.ts +265 -0
  162. package/src/server/ws-router-orch.test.ts +190 -0
  163. package/src/server/ws-router-orch.ts +93 -0
  164. package/src/server/ws-router-project.test.ts +403 -0
  165. package/src/server/ws-router-project.ts +256 -0
  166. package/src/server/ws-router-push.test.ts +193 -0
  167. package/src/server/ws-router-push.ts +120 -0
  168. package/src/server/ws-router-settings.test.ts +173 -0
  169. package/src/server/ws-router-settings.ts +416 -0
  170. package/src/server/ws-router-skills.test.ts +194 -0
  171. package/src/server/ws-router-skills.ts +204 -0
  172. package/src/server/ws-router-utils.ts +224 -0
  173. package/src/server/ws-router.ts +339 -2254
  174. package/src/shared/app-settings-types.ts +365 -0
  175. package/src/shared/core-types.ts +27 -0
  176. package/src/shared/git-diff-types.ts +165 -0
  177. package/src/shared/mcp-types.ts +105 -0
  178. package/src/shared/provider-model-types.ts +437 -0
  179. package/src/shared/subagent-types.ts +198 -0
  180. package/src/shared/tool-call-types.ts +314 -0
  181. package/src/shared/transcript-types.ts +270 -0
  182. package/src/shared/types.ts +25 -1711
  183. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  184. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  185. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  186. 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
+ }