@cuongtran001/kanna 1.3.0 → 1.4.0
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-BHj370W7.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
- package/dist/client/assets/channel-BP9IMEvd.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
- package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
- package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
- package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
- package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
- package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
- package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.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.test.ts +22 -6
- package/src/server/agent.ts +35 -4987
- package/src/server/chat-op-log.test.ts +63 -0
- package/src/server/chat-op-log.ts +71 -0
- package/src/server/chat-ops-diff.test.ts +120 -0
- package/src/server/chat-ops-diff.ts +97 -0
- package/src/server/chat-ops-parity.test.ts +140 -0
- 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 +532 -0
- package/src/server/event-store-messages.adapter.ts +455 -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 +291 -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 +75 -0
- package/src/server/event-store.ts +257 -2285
- package/src/server/events.ts +1 -1
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/storage/backend.ts +7 -0
- package/src/server/storage/fs-storage.adapter.ts +21 -1
- package/src/server/storage/in-memory-storage.ts +12 -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 +560 -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 +360 -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 +238 -0
- package/src/server/ws-router.test.ts +90 -0
- package/src/server/ws-router.ts +340 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/chat-ops.test.ts +138 -0
- package/src/shared/chat-ops.ts +71 -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/protocol.ts +3 -2
- 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 +31 -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,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone loop-orchestration command handlers for AgentCoordinator.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent.ts so the 11 related private/public methods live in
|
|
5
|
+
* their own testable module. The coordinator delegates to these functions by
|
|
6
|
+
* passing an object literal that satisfies `LoopOrchCommandDeps`.
|
|
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 — including `ensureTrackingFile` which is the sole IO
|
|
11
|
+
* operation used by `setupLoop`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { TranscriptEntry } from "../shared/types"
|
|
15
|
+
import type { Subagent, AgentProvider } from "../shared/types"
|
|
16
|
+
import { AUTO_CONTINUE_EVENT_VERSION, type AutoContinueEvent } from "./auto-continue/events"
|
|
17
|
+
import { deriveChatSchedules, deriveLoopState, type LoopState } from "./auto-continue/read-model"
|
|
18
|
+
import { timestamped } from "./claude-message-normalizer"
|
|
19
|
+
import { buildTaskNotification } from "./claude-session-config"
|
|
20
|
+
import { validateLoopSetup, reconcileTrackingFile, type LoopSetupInput } from "./loop-template"
|
|
21
|
+
import type { EnsureTrackingFileArgs, EnsureTrackingFileResult } from "./loop-template-io.adapter"
|
|
22
|
+
import { validateOrchRun, toOrchRunDetail, type OrchRunContext } from "./orchestration-input"
|
|
23
|
+
import type { OrchRunDetail, OrchRunInput, OrchRunConfig, OrchTaskSpec, OrchRunSnapshot } from "../shared/orchestration-types"
|
|
24
|
+
import type { WorkerSpawnArgs, WorkerResult } from "./orchestration-queue"
|
|
25
|
+
import type { BackgroundRunOutcome, ProviderRunStart } from "./subagent-orchestrator"
|
|
26
|
+
import type { ClaudeSessionState } from "./claude-session-state"
|
|
27
|
+
import type { SetupLoopHandlerResult } from "./kanna-mcp"
|
|
28
|
+
import { log } from "../shared/log"
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Structural sub-interfaces — only the operations this module calls.
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/** Subset of EventStore used by these handlers. */
|
|
35
|
+
interface LoopOrchCommandStore {
|
|
36
|
+
getOrchRun(runId: string): OrchRunSnapshot | null
|
|
37
|
+
getChat(chatId: string): { id: string; projectId: string } | null
|
|
38
|
+
getProject(projectId: string): { localPath: string; id: string } | null
|
|
39
|
+
getAutoContinueEvents(chatId: string): AutoContinueEvent[]
|
|
40
|
+
setSessionTokenForProvider(chatId: string, provider: AgentProvider, token: string | null): Promise<void>
|
|
41
|
+
appendMessage(chatId: string, entry: TranscriptEntry): Promise<void>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Subset of OrchestrationQueue used by these handlers. */
|
|
45
|
+
interface LoopOrchCommandOrchQueue {
|
|
46
|
+
createRun(config: OrchRunConfig, tasks: OrchTaskSpec[]): Promise<string>
|
|
47
|
+
cancelRun(runId: string): Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Dependency bundle injected by AgentCoordinator
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export interface LoopOrchCommandDeps {
|
|
55
|
+
/** EventStore — subset used by these handlers. */
|
|
56
|
+
store: LoopOrchCommandStore
|
|
57
|
+
|
|
58
|
+
/** OrchestrationQueue — subset used by these handlers. */
|
|
59
|
+
orchestrationQueue: LoopOrchCommandOrchQueue
|
|
60
|
+
|
|
61
|
+
/** Live Claude sessions map owned by the coordinator (read-only). */
|
|
62
|
+
claudeSessions: Pick<Map<string, ClaudeSessionState>, "get">
|
|
63
|
+
|
|
64
|
+
/** Active turns map — `.has()` to check existence. */
|
|
65
|
+
activeTurns: Pick<Map<string, unknown>, "has">
|
|
66
|
+
|
|
67
|
+
/** Returns all configured subagents. */
|
|
68
|
+
getSubagents(): Subagent[]
|
|
69
|
+
|
|
70
|
+
/** Returns the current app settings snapshot for subagentRuntime config. */
|
|
71
|
+
getAppSettingsSnapshot(): {
|
|
72
|
+
subagentRuntime?: {
|
|
73
|
+
defaultOrchSubagentId?: string | null
|
|
74
|
+
defaultLoopSubagentId?: string | null
|
|
75
|
+
} | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Builds a provider-run start bundle for an orchestration worker subagent.
|
|
80
|
+
* Delegates to `AgentCoordinator.buildSubagentProviderRunForChat`.
|
|
81
|
+
*/
|
|
82
|
+
buildSubagentProviderRunForChat(args: {
|
|
83
|
+
subagent: Subagent
|
|
84
|
+
chatId: string
|
|
85
|
+
primer: string | null
|
|
86
|
+
userInstruction: string | null
|
|
87
|
+
runId: string
|
|
88
|
+
abortSignal: AbortSignal
|
|
89
|
+
depth: number
|
|
90
|
+
ancestorSubagentIds: string[]
|
|
91
|
+
parentUserMessageId: string
|
|
92
|
+
cwdOverride?: string
|
|
93
|
+
}): ProviderRunStart
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Tears down the given Claude session. No permit semantics here — caller
|
|
97
|
+
* must ensure the session is in a stable state before calling.
|
|
98
|
+
*/
|
|
99
|
+
closeClaudeSession(chatId: string, session: ClaudeSessionState): void
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Appends an AutoContinueEvent to the store, notifies the schedule manager,
|
|
103
|
+
* and emits a state-change event for the chat.
|
|
104
|
+
*/
|
|
105
|
+
emitAutoContinueEvent(event: AutoContinueEvent): Promise<void>
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* IO adapter: create or reconcile the loop tracking file on disk. Injected
|
|
109
|
+
* so this module stays IO-free. See `loop-template-io.adapter.ts`.
|
|
110
|
+
*/
|
|
111
|
+
ensureTrackingFile(args: EnsureTrackingFileArgs): Promise<EnsureTrackingFileResult>
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the current armed-loop state for a chat, or null if disarmed.
|
|
115
|
+
* Injected (rather than calling the module-level `isLoopArmed` fn) so
|
|
116
|
+
* AgentCoordinator can be monkey-patched in tests via
|
|
117
|
+
* `coordinator.isLoopArmed = () => ({...})`.
|
|
118
|
+
*/
|
|
119
|
+
isLoopArmed(chatId: string): LoopState | null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Exported standalone functions
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
// ── Orchestration commands ──────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Spawn a single orchestration phase worker. Looks up the run's persisted
|
|
130
|
+
* config for origin chat + subagent, then delegates to the coordinator's
|
|
131
|
+
* subagent provider-run machinery. Origin chat + subagent are read from the
|
|
132
|
+
* persisted run config so this resolves identically on a fresh run and after
|
|
133
|
+
* a restart.
|
|
134
|
+
*/
|
|
135
|
+
export async function buildOrchWorker(
|
|
136
|
+
deps: LoopOrchCommandDeps,
|
|
137
|
+
spawn: WorkerSpawnArgs,
|
|
138
|
+
): Promise<WorkerResult> {
|
|
139
|
+
const run = deps.store.getOrchRun(spawn.runId)
|
|
140
|
+
const chatId = run?.config.originChatId
|
|
141
|
+
const subagentId = run?.config.workerSubagentId
|
|
142
|
+
if (!chatId || !subagentId) {
|
|
143
|
+
return { kind: "failed", error: "orchestration run missing originChatId / workerSubagentId" }
|
|
144
|
+
}
|
|
145
|
+
const subagent = deps.getSubagents().find((s) => s.id === subagentId)
|
|
146
|
+
if (!subagent) return { kind: "failed", error: `orchestration worker subagent "${subagentId}" not found` }
|
|
147
|
+
if (!deps.store.getChat(chatId)) return { kind: "failed", error: `orchestration origin chat ${chatId} not found` }
|
|
148
|
+
|
|
149
|
+
const providerRun = deps.buildSubagentProviderRunForChat({
|
|
150
|
+
subagent,
|
|
151
|
+
chatId,
|
|
152
|
+
primer: null,
|
|
153
|
+
userInstruction: spawn.prompt,
|
|
154
|
+
runId: `${spawn.runId}:${spawn.workerId}`,
|
|
155
|
+
abortSignal: spawn.abortSignal,
|
|
156
|
+
depth: 0,
|
|
157
|
+
ancestorSubagentIds: [],
|
|
158
|
+
parentUserMessageId: spawn.runId,
|
|
159
|
+
cwdOverride: spawn.cwd,
|
|
160
|
+
})
|
|
161
|
+
try {
|
|
162
|
+
if (!(await providerRun.authReady())) {
|
|
163
|
+
return { kind: "failed", error: "orchestration worker auth not ready" }
|
|
164
|
+
}
|
|
165
|
+
const result = await providerRun.start(() => undefined, () => undefined)
|
|
166
|
+
return { kind: "completed", text: result.text }
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (spawn.abortSignal.aborted) return { kind: "failed", error: "aborted" }
|
|
169
|
+
return { kind: "failed", error: err instanceof Error ? err.message : String(err) }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Derive chat/project context for orchestration validation. Returns null when
|
|
175
|
+
* the chat or project is not found.
|
|
176
|
+
*/
|
|
177
|
+
export function buildOrchRunContext(
|
|
178
|
+
deps: LoopOrchCommandDeps,
|
|
179
|
+
chatId: string,
|
|
180
|
+
): OrchRunContext | null {
|
|
181
|
+
const chat = deps.store.getChat(chatId)
|
|
182
|
+
if (!chat) return null
|
|
183
|
+
const project = deps.store.getProject(chat.projectId)
|
|
184
|
+
if (!project) return null
|
|
185
|
+
return {
|
|
186
|
+
chatId,
|
|
187
|
+
repoRoot: project.localPath,
|
|
188
|
+
roster: deps.getSubagents().map((s) => ({ id: s.id, name: s.name })),
|
|
189
|
+
defaultOrchSubagentId: deps.getAppSettingsSnapshot().subagentRuntime?.defaultOrchSubagentId ?? null,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* User-callable entry point (MCP `orch_run` + ws `orch.run`). Validates the
|
|
195
|
+
* task list into the fixed linear config, then starts the run. Returns the
|
|
196
|
+
* runId or the flat validation error list — never a partial run.
|
|
197
|
+
*/
|
|
198
|
+
export async function runOrchestration(
|
|
199
|
+
deps: LoopOrchCommandDeps,
|
|
200
|
+
chatId: string,
|
|
201
|
+
input: OrchRunInput,
|
|
202
|
+
): Promise<{ ok: true; runId: string } | { ok: false; errors: string[] }> {
|
|
203
|
+
const context = buildOrchRunContext(deps, chatId)
|
|
204
|
+
if (!context) return { ok: false, errors: [`chat ${chatId} not found or has no project`] }
|
|
205
|
+
const validation = validateOrchRun(input, context)
|
|
206
|
+
if (!validation.ok) return { ok: false, errors: validation.errors }
|
|
207
|
+
const runId = await deps.orchestrationQueue.createRun(
|
|
208
|
+
validation.resolved.config,
|
|
209
|
+
validation.resolved.tasks,
|
|
210
|
+
)
|
|
211
|
+
return { ok: true, runId }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Cancel a run (MCP `orch_cancel_run` + ws `orch.cancelRun`). */
|
|
215
|
+
export async function cancelOrchRun(
|
|
216
|
+
deps: LoopOrchCommandDeps,
|
|
217
|
+
runId: string,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
await deps.orchestrationQueue.cancelRun(runId)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Canonical run detail DTO (MCP `orch_run_status` + ws `orch.getRun`). */
|
|
223
|
+
export function getOrchRunDetail(
|
|
224
|
+
deps: LoopOrchCommandDeps,
|
|
225
|
+
runId: string,
|
|
226
|
+
): OrchRunDetail | null {
|
|
227
|
+
const snapshot = deps.store.getOrchRun(runId)
|
|
228
|
+
return snapshot ? toOrchRunDetail(snapshot) : null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Loop + background delivery ──────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Wipe the main-agent's Claude session context (the /clear equivalent).
|
|
235
|
+
*
|
|
236
|
+
* Three things happen:
|
|
237
|
+
* - The persisted session token is set to null (so the next spawn starts fresh).
|
|
238
|
+
* - The live session's `suppressSessionTokenPersist` flag is set to true so the
|
|
239
|
+
* in-flight stream cannot overwrite the null we just wrote (avoids a
|
|
240
|
+
* 121 ms race on setup_loop /clear).
|
|
241
|
+
* - An idle warm SDK session is torn down so it cannot be reused in-band by
|
|
242
|
+
* the next turn (which would make the /clear a no-op).
|
|
243
|
+
*/
|
|
244
|
+
export async function clearClaudeSessionContext(
|
|
245
|
+
deps: LoopOrchCommandDeps,
|
|
246
|
+
chatId: string,
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
await deps.store.setSessionTokenForProvider(chatId, "claude", null)
|
|
249
|
+
const session = deps.claudeSessions.get(chatId)
|
|
250
|
+
if (!session) return
|
|
251
|
+
session.suppressSessionTokenPersist = true
|
|
252
|
+
if (!deps.activeTurns.has(chatId)) {
|
|
253
|
+
deps.closeClaudeSession(chatId, session)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Deliver a finished `run_in_background` subagent's result back into the
|
|
259
|
+
* main chat as a fresh turn AND clear the main-agent's Claude session so the
|
|
260
|
+
* next turn starts with a fresh context window. Wired as the orchestrator's
|
|
261
|
+
* `onBackgroundRunComplete` hook.
|
|
262
|
+
*
|
|
263
|
+
* Loop-orchestration invariant: main is stateless-in-context / stateful-in-file.
|
|
264
|
+
* PROGRESS.md is the durability contract; every delivery re-reads it. Subagent
|
|
265
|
+
* output is NOT carried forward as prompt content — the subagent is expected
|
|
266
|
+
* to have written its findings into PROGRESS.md before terminating.
|
|
267
|
+
*
|
|
268
|
+
* See adr-20260711-notification-driven-loop-orchestration.
|
|
269
|
+
*/
|
|
270
|
+
export async function deliverSubagentToMain(
|
|
271
|
+
deps: LoopOrchCommandDeps,
|
|
272
|
+
chatId: string,
|
|
273
|
+
runId: string,
|
|
274
|
+
outcome: BackgroundRunOutcome,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
if (!deps.store.getChat(chatId)) return
|
|
277
|
+
|
|
278
|
+
// Structured re-entry: the completion is delivered as the same
|
|
279
|
+
// <task-notification> XML Claude Code's own background agents use
|
|
280
|
+
// (LocalAgentTask), so the model parses task identity/status with the
|
|
281
|
+
// format it already knows from native training.
|
|
282
|
+
//
|
|
283
|
+
// When a loop is armed, the FULL loop discipline prompt follows the
|
|
284
|
+
// notification on every wake — not a generic "decide next action" string,
|
|
285
|
+
// which drifted into self-implementation (the 7.5h marathon-turn bug).
|
|
286
|
+
// Armed notifications carry NO <result> body: PROGRESS.md is the loop's
|
|
287
|
+
// only durability contract. Non-loop deliveries include the (truncated)
|
|
288
|
+
// result since ad-hoc background delegations have no tracking file.
|
|
289
|
+
const armed = isLoopArmed(deps, chatId)
|
|
290
|
+
const notification = buildTaskNotification(runId, outcome, { includeResult: !armed })
|
|
291
|
+
let prompt: string
|
|
292
|
+
if (armed) {
|
|
293
|
+
prompt = `${notification}\n\n${armed.prompt}`
|
|
294
|
+
} else if (outcome.status === "completed") {
|
|
295
|
+
prompt = `${notification}\n\nYour Claude context has been cleared. Read PROGRESS.md if present, then decide the next action.`
|
|
296
|
+
} else {
|
|
297
|
+
prompt = `${notification}\n\nYour Claude context has been cleared. Read PROGRESS.md if present; decide whether to retry, try another approach, or stop.`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// Wipe the main-agent's Claude session token so the next spawn starts
|
|
302
|
+
// fresh (the /clear equivalent). Codex path is unaffected.
|
|
303
|
+
await clearClaudeSessionContext(deps, chatId)
|
|
304
|
+
await deps.store.appendMessage(chatId, timestamped({ kind: "context_cleared" }))
|
|
305
|
+
|
|
306
|
+
const now = Date.now()
|
|
307
|
+
const scheduleId = crypto.randomUUID()
|
|
308
|
+
await deps.emitAutoContinueEvent({
|
|
309
|
+
v: AUTO_CONTINUE_EVENT_VERSION,
|
|
310
|
+
kind: "auto_continue_accepted",
|
|
311
|
+
timestamp: now,
|
|
312
|
+
chatId,
|
|
313
|
+
scheduleId,
|
|
314
|
+
scheduledAt: now,
|
|
315
|
+
tz: "system",
|
|
316
|
+
source: "subagent_background",
|
|
317
|
+
resetAt: now,
|
|
318
|
+
detectedAt: now,
|
|
319
|
+
prompt,
|
|
320
|
+
})
|
|
321
|
+
} catch (err) {
|
|
322
|
+
log.warn(`[kanna] deliverSubagentToMain failed`, { chatId, runId, err })
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Arm an autonomous loop on the main chat. Validates the loop spec, ensures
|
|
328
|
+
* the tracking file exists (writes a skeleton if absent), then /clears the
|
|
329
|
+
* main-agent Claude session and enqueues the templated recurring prompt so
|
|
330
|
+
* the next turn starts the loop. Backs `mcp__kanna__setup_loop`. See
|
|
331
|
+
* adr-20260711-setup-loop-template.
|
|
332
|
+
*/
|
|
333
|
+
export async function setupLoop(
|
|
334
|
+
deps: LoopOrchCommandDeps,
|
|
335
|
+
args: {
|
|
336
|
+
chatId: string
|
|
337
|
+
input: LoopSetupInput
|
|
338
|
+
},
|
|
339
|
+
): Promise<SetupLoopHandlerResult> {
|
|
340
|
+
const chat = deps.store.getChat(args.chatId)
|
|
341
|
+
if (!chat) return { ok: false, errors: [`chat ${args.chatId} not found`] }
|
|
342
|
+
const project = deps.store.getProject(chat.projectId)
|
|
343
|
+
if (!project) return { ok: false, errors: [`project ${chat.projectId} not found`] }
|
|
344
|
+
|
|
345
|
+
const validation = validateLoopSetup(args.input, project.localPath, {
|
|
346
|
+
roster: deps.getSubagents().map((s) => ({ id: s.id, name: s.name })),
|
|
347
|
+
defaultLoopSubagentId: deps.getAppSettingsSnapshot().subagentRuntime?.defaultLoopSubagentId ?? null,
|
|
348
|
+
})
|
|
349
|
+
if (!validation.ok) return { ok: false, errors: validation.errors }
|
|
350
|
+
|
|
351
|
+
const resolved = validation.resolved
|
|
352
|
+
let created: boolean
|
|
353
|
+
let reconciled: boolean
|
|
354
|
+
let reconcileActions: string[]
|
|
355
|
+
try {
|
|
356
|
+
const ensureResult = await deps.ensureTrackingFile({
|
|
357
|
+
absPath: resolved.trackingFileAbs,
|
|
358
|
+
skeleton: resolved.skeleton,
|
|
359
|
+
// Deterministic schema reconcile of an EXISTING tracking file: pure
|
|
360
|
+
// string transform — server-owned sections rewritten to the inputs,
|
|
361
|
+
// loop history preserved. No model judgement involved.
|
|
362
|
+
reconcile: (existing) =>
|
|
363
|
+
reconcileTrackingFile(existing, {
|
|
364
|
+
goal: resolved.goal,
|
|
365
|
+
verifyCommand: resolved.verifyCommand,
|
|
366
|
+
chunkHint: resolved.chunkHint,
|
|
367
|
+
}),
|
|
368
|
+
})
|
|
369
|
+
created = ensureResult.created
|
|
370
|
+
reconciled = ensureResult.reconciled
|
|
371
|
+
reconcileActions = ensureResult.actions
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
errors: [`ensureTrackingFile failed: ${err instanceof Error ? err.message : String(err)}`],
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
// Wipe main-agent Claude session so the next turn starts fresh with the
|
|
381
|
+
// rendered loop prompt. Codex untouched. setup_loop runs from INSIDE a
|
|
382
|
+
// live turn, so the suppression half of clearClaudeSessionContext is
|
|
383
|
+
// what keeps the wipe from being overwritten by the in-flight stream.
|
|
384
|
+
await clearClaudeSessionContext(deps, args.chatId)
|
|
385
|
+
await deps.store.appendMessage(args.chatId, timestamped({ kind: "context_cleared" }))
|
|
386
|
+
|
|
387
|
+
const now = Date.now()
|
|
388
|
+
// Arm the loop durably: every subsequent background-completion wake
|
|
389
|
+
// re-injects THIS prompt (not the generic one) and loop turns are
|
|
390
|
+
// tool-blocked. Superseded by a later setup_loop or cleared by stop_loop
|
|
391
|
+
// / a real user send. Replays from the auto-continue log on restart.
|
|
392
|
+
await deps.emitAutoContinueEvent({
|
|
393
|
+
v: AUTO_CONTINUE_EVENT_VERSION,
|
|
394
|
+
kind: "loop_armed",
|
|
395
|
+
timestamp: now,
|
|
396
|
+
chatId: args.chatId,
|
|
397
|
+
scheduleId: crypto.randomUUID(),
|
|
398
|
+
subagentId: resolved.subagentId,
|
|
399
|
+
prompt: resolved.prompt,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
const scheduleId = crypto.randomUUID()
|
|
403
|
+
await deps.emitAutoContinueEvent({
|
|
404
|
+
v: AUTO_CONTINUE_EVENT_VERSION,
|
|
405
|
+
kind: "auto_continue_accepted",
|
|
406
|
+
timestamp: now,
|
|
407
|
+
chatId: args.chatId,
|
|
408
|
+
scheduleId,
|
|
409
|
+
scheduledAt: now,
|
|
410
|
+
tz: "system",
|
|
411
|
+
source: "subagent_background",
|
|
412
|
+
resetAt: now,
|
|
413
|
+
detectedAt: now,
|
|
414
|
+
prompt: resolved.prompt,
|
|
415
|
+
})
|
|
416
|
+
} catch (err) {
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
errors: [`enqueue failed: ${err instanceof Error ? err.message : String(err)}`],
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
ok: true,
|
|
425
|
+
trackingFileRel: resolved.trackingFileRel,
|
|
426
|
+
created,
|
|
427
|
+
reconciled,
|
|
428
|
+
reconcileActions,
|
|
429
|
+
prompt: resolved.prompt,
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Current armed-loop state for a chat, or null. Pure replay of the auto-continue log. */
|
|
434
|
+
export function isLoopArmed(deps: LoopOrchCommandDeps, chatId: string): LoopState | null {
|
|
435
|
+
return deriveLoopState(deps.store.getAutoContinueEvents(chatId), chatId)
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Disarm an armed loop (restores tools + stops prompt re-injection). Backs
|
|
440
|
+
* the `stop_loop` MCP tool (called by the model on GOAL MET) and the
|
|
441
|
+
* user-send takeover path. No-op when no loop is armed.
|
|
442
|
+
*/
|
|
443
|
+
export async function stopLoop(
|
|
444
|
+
deps: LoopOrchCommandDeps,
|
|
445
|
+
chatId: string,
|
|
446
|
+
reason: "goal_met" | "user_send" | "chat_deleted",
|
|
447
|
+
): Promise<void> {
|
|
448
|
+
if (!isLoopArmed(deps, chatId)) return
|
|
449
|
+
await deps.emitAutoContinueEvent({
|
|
450
|
+
v: AUTO_CONTINUE_EVENT_VERSION,
|
|
451
|
+
kind: "loop_disarmed",
|
|
452
|
+
timestamp: Date.now(),
|
|
453
|
+
chatId,
|
|
454
|
+
scheduleId: crypto.randomUUID(),
|
|
455
|
+
reason,
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Returns live schedule IDs (proposed or scheduled) for the given chat. */
|
|
460
|
+
export function listLiveSchedules(deps: LoopOrchCommandDeps, chatId: string): string[] {
|
|
461
|
+
const { schedules } = deriveChatSchedules(deps.store.getAutoContinueEvents(chatId), chatId)
|
|
462
|
+
return Object.values(schedules)
|
|
463
|
+
.filter((s) => s.state === "proposed" || s.state === "scheduled")
|
|
464
|
+
.map((s) => s.scheduleId)
|
|
465
|
+
.sort()
|
|
466
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
getClaudeAssistantMessageUsageId,
|
|
4
|
+
normalizeClaudeStreamMessage,
|
|
5
|
+
normalizeToolContent,
|
|
6
|
+
type ClaudeRawSdkMessage,
|
|
7
|
+
} from "./claude-message-normalizer"
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// getClaudeAssistantMessageUsageId
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
describe("getClaudeAssistantMessageUsageId", () => {
|
|
14
|
+
test("returns uuid when present on message", () => {
|
|
15
|
+
const msg: ClaudeRawSdkMessage = { uuid: "msg-uuid-123", type: "assistant" }
|
|
16
|
+
expect(getClaudeAssistantMessageUsageId(msg)).toBe("msg-uuid-123")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("returns message.id when uuid is absent", () => {
|
|
20
|
+
const msg: ClaudeRawSdkMessage = {
|
|
21
|
+
type: "assistant",
|
|
22
|
+
message: { id: "inner-id-456", content: [] },
|
|
23
|
+
}
|
|
24
|
+
expect(getClaudeAssistantMessageUsageId(msg)).toBe("inner-id-456")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("returns null when neither uuid nor message.id are present", () => {
|
|
28
|
+
const msg: ClaudeRawSdkMessage = { type: "user" }
|
|
29
|
+
expect(getClaudeAssistantMessageUsageId(msg)).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("prefers message.id over uuid when both are present", () => {
|
|
33
|
+
const msg: ClaudeRawSdkMessage = {
|
|
34
|
+
uuid: "outer",
|
|
35
|
+
type: "assistant",
|
|
36
|
+
message: { id: "inner", content: [] },
|
|
37
|
+
}
|
|
38
|
+
// message.id is checked first
|
|
39
|
+
expect(getClaudeAssistantMessageUsageId(msg)).toBe("inner")
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// normalizeToolContent
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe("normalizeToolContent", () => {
|
|
48
|
+
test("returns null for null input", () => {
|
|
49
|
+
expect(normalizeToolContent(null)).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("returns null for undefined input", () => {
|
|
53
|
+
expect(normalizeToolContent(undefined)).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("returns string unchanged", () => {
|
|
57
|
+
expect(normalizeToolContent("hello")).toBe("hello")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("returns object unchanged", () => {
|
|
61
|
+
const obj = { a: 1 }
|
|
62
|
+
expect(normalizeToolContent(obj)).toEqual({ a: 1 })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("returns array unchanged", () => {
|
|
66
|
+
const arr = [1, 2, 3]
|
|
67
|
+
expect(normalizeToolContent(arr)).toEqual([1, 2, 3])
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// normalizeClaudeStreamMessage
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe("normalizeClaudeStreamMessage", () => {
|
|
76
|
+
test("returns [] for unrecognized message type", () => {
|
|
77
|
+
const msg: ClaudeRawSdkMessage = { type: "unknown_type" }
|
|
78
|
+
expect(normalizeClaudeStreamMessage(msg)).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("normalizes assistant text block to assistant_text entry", () => {
|
|
82
|
+
const msg: ClaudeRawSdkMessage = {
|
|
83
|
+
type: "assistant",
|
|
84
|
+
message: {
|
|
85
|
+
role: "assistant",
|
|
86
|
+
content: [{ type: "text", text: "Hello, world!" }],
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
90
|
+
expect(entries).toHaveLength(1)
|
|
91
|
+
expect(entries[0].kind).toBe("assistant_text")
|
|
92
|
+
expect((entries[0] as { text: string }).text).toBe("Hello, world!")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("normalizes assistant tool_use block to tool_call entry", () => {
|
|
96
|
+
const msg: ClaudeRawSdkMessage = {
|
|
97
|
+
type: "assistant",
|
|
98
|
+
message: {
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: [
|
|
101
|
+
{ type: "tool_use", name: "Bash", id: "tool-123", input: { command: "ls" } },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
106
|
+
expect(entries).toHaveLength(1)
|
|
107
|
+
expect(entries[0].kind).toBe("tool_call")
|
|
108
|
+
const tc = entries[0] as { tool: { toolName: string; toolId: string } }
|
|
109
|
+
expect(tc.tool.toolName).toBe("Bash")
|
|
110
|
+
expect(tc.tool.toolId).toBe("tool-123")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("normalizes user tool_result block to tool_result entry", () => {
|
|
114
|
+
const msg: ClaudeRawSdkMessage = {
|
|
115
|
+
type: "user",
|
|
116
|
+
message: {
|
|
117
|
+
role: "user",
|
|
118
|
+
content: [
|
|
119
|
+
{ type: "tool_result", tool_use_id: "tool-123", content: "output text", is_error: false },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
124
|
+
expect(entries).toHaveLength(1)
|
|
125
|
+
expect(entries[0].kind).toBe("tool_result")
|
|
126
|
+
const tr = entries[0] as { toolId: string; content: string; isError: boolean }
|
|
127
|
+
expect(tr.toolId).toBe("tool-123")
|
|
128
|
+
expect(tr.content).toBe("output text")
|
|
129
|
+
expect(tr.isError).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("drops synthetic non-error placeholder ('No response requested.')", () => {
|
|
133
|
+
const msg: ClaudeRawSdkMessage = {
|
|
134
|
+
type: "assistant",
|
|
135
|
+
isApiErrorMessage: false,
|
|
136
|
+
message: {
|
|
137
|
+
role: "assistant",
|
|
138
|
+
model: "<synthetic>",
|
|
139
|
+
content: [{ type: "text", text: "No response requested." }],
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
143
|
+
expect(entries).toHaveLength(0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("normalizes result message to result entry", () => {
|
|
147
|
+
const msg: ClaudeRawSdkMessage = {
|
|
148
|
+
type: "result",
|
|
149
|
+
subtype: "success",
|
|
150
|
+
is_error: false,
|
|
151
|
+
duration_ms: 1200,
|
|
152
|
+
result: "all good",
|
|
153
|
+
total_cost_usd: 0.005,
|
|
154
|
+
}
|
|
155
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
156
|
+
expect(entries).toHaveLength(1)
|
|
157
|
+
expect(entries[0].kind).toBe("result")
|
|
158
|
+
const r = entries[0] as { subtype: string; durationMs: number; result: string; costUsd: number }
|
|
159
|
+
expect(r.subtype).toBe("success")
|
|
160
|
+
expect(r.durationMs).toBe(1200)
|
|
161
|
+
expect(r.result).toBe("all good")
|
|
162
|
+
expect(r.costUsd).toBeCloseTo(0.005)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test("normalizes api_error assistant message to api_error entry", () => {
|
|
166
|
+
const msg: ClaudeRawSdkMessage = {
|
|
167
|
+
type: "assistant",
|
|
168
|
+
isApiErrorMessage: true,
|
|
169
|
+
apiErrorStatus: 529,
|
|
170
|
+
message: {
|
|
171
|
+
role: "assistant",
|
|
172
|
+
model: "<synthetic>",
|
|
173
|
+
content: [{ type: "text", text: "API Error: 529 Overloaded" }],
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
177
|
+
expect(entries).toHaveLength(1)
|
|
178
|
+
expect(entries[0].kind).toBe("api_error")
|
|
179
|
+
const ae = entries[0] as { status: number }
|
|
180
|
+
expect(ae.status).toBe(529)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("normalizes cancelled result to interrupted entry", () => {
|
|
184
|
+
const msg: ClaudeRawSdkMessage = {
|
|
185
|
+
type: "result",
|
|
186
|
+
subtype: "cancelled",
|
|
187
|
+
}
|
|
188
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
189
|
+
expect(entries).toHaveLength(1)
|
|
190
|
+
expect(entries[0].kind).toBe("interrupted")
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("normalizes system_init message with tools and agents", () => {
|
|
194
|
+
const msg: ClaudeRawSdkMessage = {
|
|
195
|
+
type: "system",
|
|
196
|
+
subtype: "init",
|
|
197
|
+
model: "claude-opus-4-5",
|
|
198
|
+
tools: ["Bash", "Read"],
|
|
199
|
+
agents: ["my-agent"],
|
|
200
|
+
slash_commands: ["help", "._internal"],
|
|
201
|
+
mcp_servers: [],
|
|
202
|
+
}
|
|
203
|
+
const entries = normalizeClaudeStreamMessage(msg)
|
|
204
|
+
expect(entries).toHaveLength(1)
|
|
205
|
+
expect(entries[0].kind).toBe("system_init")
|
|
206
|
+
const si = entries[0] as { model: string; tools: string[]; agents: string[]; slashCommands: string[] }
|
|
207
|
+
expect(si.model).toBe("claude-opus-4-5")
|
|
208
|
+
expect(si.tools).toEqual(["Bash", "Read"])
|
|
209
|
+
expect(si.agents).toEqual(["my-agent"])
|
|
210
|
+
// Internal slash commands prefixed with '._' are filtered
|
|
211
|
+
expect(si.slashCommands).toEqual(["help"])
|
|
212
|
+
})
|
|
213
|
+
})
|