@cuongtran001/kanna 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/index-CLsOWXff.css +32 -0
- package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
- package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +158 -0
- package/src/server/agent-coordinator.ts +919 -0
- package/src/server/agent-deps-builders.ts +425 -0
- package/src/server/agent.oauth-rotation.test.ts +101 -0
- package/src/server/agent.test.ts +24 -8
- package/src/server/agent.ts +35 -4963
- package/src/server/auto-continue/read-model.test.ts +2 -2
- package/src/server/auto-continue/read-model.ts +4 -1
- package/src/server/claude-autocontinue-commands.test.ts +494 -0
- package/src/server/claude-autocontinue-commands.ts +256 -0
- package/src/server/claude-cancel-handler.test.ts +472 -0
- package/src/server/claude-cancel-handler.ts +241 -0
- package/src/server/claude-chat-management.test.ts +395 -0
- package/src/server/claude-chat-management.ts +251 -0
- package/src/server/claude-harness-stream.test.ts +273 -0
- package/src/server/claude-harness-stream.ts +158 -0
- package/src/server/claude-loop-orch-commands.test.ts +579 -0
- package/src/server/claude-loop-orch-commands.ts +466 -0
- package/src/server/claude-message-normalizer.test.ts +213 -0
- package/src/server/claude-message-normalizer.ts +436 -0
- package/src/server/claude-prompt-helpers.test.ts +248 -0
- package/src/server/claude-prompt-helpers.ts +139 -0
- package/src/server/claude-sdk-queue.test.ts +169 -0
- package/src/server/claude-sdk-queue.ts +82 -0
- package/src/server/claude-send-command.test.ts +414 -0
- package/src/server/claude-send-command.ts +420 -0
- package/src/server/claude-session-config-helpers.test.ts +279 -0
- package/src/server/claude-session-config-helpers.ts +151 -0
- package/src/server/claude-session-config.test.ts +250 -0
- package/src/server/claude-session-config.ts +159 -0
- package/src/server/claude-session-error-handler.test.ts +498 -0
- package/src/server/claude-session-error-handler.ts +361 -0
- package/src/server/claude-session-lifecycle.test.ts +669 -0
- package/src/server/claude-session-lifecycle.ts +283 -0
- package/src/server/claude-session-rebuild.test.ts +224 -0
- package/src/server/claude-session-rebuild.ts +101 -0
- package/src/server/claude-session-runner.test.ts +523 -0
- package/src/server/claude-session-runner.ts +521 -0
- package/src/server/claude-session-spawner.test.ts +401 -0
- package/src/server/claude-session-spawner.ts +401 -0
- package/src/server/claude-session-start.test.ts +205 -0
- package/src/server/claude-session-start.ts +277 -0
- package/src/server/claude-session-state-queries.test.ts +344 -0
- package/src/server/claude-session-state-queries.ts +160 -0
- package/src/server/claude-session-state.ts +104 -0
- package/src/server/claude-slash-commands.test.ts +277 -0
- package/src/server/claude-slash-commands.ts +234 -0
- package/src/server/claude-spawn-helpers.test.ts +221 -0
- package/src/server/claude-spawn-helpers.ts +179 -0
- package/src/server/claude-steer-log.test.ts +243 -0
- package/src/server/claude-steer-log.ts +58 -0
- package/src/server/claude-subagent-tool-response.test.ts +213 -0
- package/src/server/claude-subagent-tool-response.ts +165 -0
- package/src/server/claude-subagent-wiring.test.ts +263 -0
- package/src/server/claude-subagent-wiring.ts +324 -0
- package/src/server/claude-tool-respond.test.ts +321 -0
- package/src/server/claude-tool-respond.ts +142 -0
- package/src/server/claude-turn-runner.test.ts +310 -0
- package/src/server/claude-turn-runner.ts +228 -0
- package/src/server/claude-turn-starter.test.ts +266 -0
- package/src/server/claude-turn-starter.ts +566 -0
- package/src/server/claude-usage-math.test.ts +169 -0
- package/src/server/claude-usage-math.ts +132 -0
- package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
- package/src/server/diff-store-branch-ops.adapter.ts +602 -0
- package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
- package/src/server/diff-store-commit-ops.adapter.ts +258 -0
- package/src/server/diff-store-errors.ts +85 -0
- package/src/server/diff-store-file-ops.adapter.ts +212 -0
- package/src/server/diff-store-git-branch.adapter.ts +686 -0
- package/src/server/diff-store-io.adapter.ts +27 -0
- package/src/server/diff-store-parse.test.ts +212 -0
- package/src/server/diff-store-parse.ts +122 -0
- package/src/server/diff-store-state.ts +82 -0
- package/src/server/diff-store.ts +76 -1854
- package/src/server/event-store-apply.test.ts +280 -0
- package/src/server/event-store-apply.ts +132 -0
- package/src/server/event-store-chat-lifecycle.test.ts +408 -0
- package/src/server/event-store-chat-lifecycle.ts +441 -0
- package/src/server/event-store-entity-write.ts +356 -0
- package/src/server/event-store-helpers.test.ts +230 -0
- package/src/server/event-store-helpers.ts +235 -0
- package/src/server/event-store-init.ts +276 -0
- package/src/server/event-store-messages.adapter.test.ts +365 -0
- package/src/server/event-store-messages.adapter.ts +213 -0
- package/src/server/event-store-orch-subscription.ts +124 -0
- package/src/server/event-store-orch.test.ts +462 -0
- package/src/server/event-store-orch.ts +312 -0
- package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
- package/src/server/event-store-peripheral-events.adapter.ts +105 -0
- package/src/server/event-store-snapshot.test.ts +597 -0
- package/src/server/event-store-snapshot.ts +658 -0
- package/src/server/event-store-subagent.test.ts +408 -0
- package/src/server/event-store-subagent.ts +198 -0
- package/src/server/event-store-tool-requests.test.ts +210 -0
- package/src/server/event-store-tool-requests.ts +131 -0
- package/src/server/event-store-transcript-write.adapter.ts +285 -0
- package/src/server/event-store-write-ops.test.ts +554 -0
- package/src/server/event-store-write-ops.ts +529 -0
- package/src/server/event-store.test.ts +35 -0
- package/src/server/event-store.ts +251 -2284
- package/src/server/events.ts +3 -0
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/read-models.ts +26 -1
- package/src/server/subagent-orchestrator.ts +3 -0
- package/src/server/ws-router-agent-ctrl.test.ts +187 -0
- package/src/server/ws-router-agent-ctrl.ts +137 -0
- package/src/server/ws-router-broadcast.ts +488 -0
- package/src/server/ws-router-chat.test.ts +461 -0
- package/src/server/ws-router-chat.ts +279 -0
- package/src/server/ws-router-defaults.test.ts +283 -0
- package/src/server/ws-router-defaults.ts +434 -0
- package/src/server/ws-router-diff.test.ts +167 -0
- package/src/server/ws-router-diff.ts +250 -0
- package/src/server/ws-router-envelope.ts +329 -0
- package/src/server/ws-router-misc.test.ts +422 -0
- package/src/server/ws-router-misc.ts +265 -0
- package/src/server/ws-router-orch.test.ts +190 -0
- package/src/server/ws-router-orch.ts +93 -0
- package/src/server/ws-router-project.test.ts +403 -0
- package/src/server/ws-router-project.ts +256 -0
- package/src/server/ws-router-push.test.ts +193 -0
- package/src/server/ws-router-push.ts +120 -0
- package/src/server/ws-router-settings.test.ts +173 -0
- package/src/server/ws-router-settings.ts +416 -0
- package/src/server/ws-router-skills.test.ts +194 -0
- package/src/server/ws-router-skills.ts +204 -0
- package/src/server/ws-router-utils.ts +224 -0
- package/src/server/ws-router.ts +339 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/core-types.ts +27 -0
- package/src/shared/git-diff-types.ts +165 -0
- package/src/shared/loop-progress.test.ts +106 -0
- package/src/shared/loop-progress.ts +88 -0
- package/src/shared/mcp-types.ts +105 -0
- package/src/shared/provider-model-types.ts +437 -0
- package/src/shared/subagent-types.ts +198 -0
- package/src/shared/tool-call-types.ts +314 -0
- package/src/shared/transcript-types.ts +270 -0
- package/src/shared/types.ts +27 -1668
- package/dist/client/assets/channel-BMkhjiLI.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
- package/dist/client/assets/index-H6C-f2Vp.css +0 -32
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Claude session spawner — the logic extracted from the private
|
|
3
|
+
* `startClaudeTurn` method of AgentCoordinator.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* - Decide whether an existing session can be reused or must be evicted and
|
|
7
|
+
* replaced (localPath / effort / forkSession / loop-armed flip).
|
|
8
|
+
* - Pick an OAuth pool token, look up OpenRouter pricing, build the system
|
|
9
|
+
* prompt append and delegation context, then spawn the ClaudeSessionHandle
|
|
10
|
+
* via either the PTY or SDK driver.
|
|
11
|
+
* - Register the new ClaudeSessionState in the claudeSessions map, fire
|
|
12
|
+
* enforceClaudeSessionBudget, start the session event loop, and load slash
|
|
13
|
+
* commands in the background.
|
|
14
|
+
* - When reusing an existing session: update lastUsedAt, call setModel /
|
|
15
|
+
* setPermissionMode if the options changed.
|
|
16
|
+
* - Return a HarnessTurn built from the session handle.
|
|
17
|
+
*
|
|
18
|
+
* Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
|
|
19
|
+
* calls, no Bun primitives). Every effectful operation is injected through
|
|
20
|
+
* SpawnClaudeTurnDeps so the module remains testable without real drivers.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { AnyValue } from "../shared/errors"
|
|
24
|
+
import type {
|
|
25
|
+
AgentProvider,
|
|
26
|
+
ClaudeDriverPreference,
|
|
27
|
+
LlmProviderSnapshot,
|
|
28
|
+
McpServerConfig,
|
|
29
|
+
OpenRouterModel,
|
|
30
|
+
ResolvedStackBinding,
|
|
31
|
+
Subagent,
|
|
32
|
+
} from "../shared/types"
|
|
33
|
+
import { buildKannaSystemPromptAppend } from "../shared/kanna-system-prompt"
|
|
34
|
+
import { resolveModelPrice, stripModelVariantSuffix } from "../shared/token-pricing"
|
|
35
|
+
import type { ModelPrice } from "../shared/token-pricing"
|
|
36
|
+
import { maskOauthKey } from "../shared/mask-oauth-key"
|
|
37
|
+
import { log } from "../shared/log"
|
|
38
|
+
import { OAuthPoolUnavailableError } from "./oauth-errors"
|
|
39
|
+
import type { ClaudeSessionHandle, HarnessTurn, HarnessToolRequest } from "./harness-types"
|
|
40
|
+
import type { ActiveTurn, ClaudeSessionState, SlashCommand } from "./claude-session-state"
|
|
41
|
+
import type { KannaMcpDelegationContext, SetupLoopHandlerResult } from "./kanna-mcp"
|
|
42
|
+
import type { LoopSetupInput } from "./loop-template"
|
|
43
|
+
import type { LoopState } from "./auto-continue/read-model"
|
|
44
|
+
import type { ChatPermissionPolicy } from "../shared/permission-policy"
|
|
45
|
+
import type { OrchRunDetail, OrchRunInput } from "../shared/orchestration-types"
|
|
46
|
+
import type { StartClaudeSessionPtyArgs } from "./claude-pty/driver"
|
|
47
|
+
import type { SubagentOrchestrator } from "./subagent-orchestrator"
|
|
48
|
+
import type { TunnelGateway } from "./cloudflare-tunnel/gateway"
|
|
49
|
+
import type { ToolCallbackService } from "./tool-callback"
|
|
50
|
+
import type { ClaudePtyRegistry } from "./claude-pty/pid-registry.adapter"
|
|
51
|
+
import type { PtyInstanceRegistry } from "./claude-pty/pty-instance-registry"
|
|
52
|
+
import type { WorkflowRegistry } from "./workflow-registry"
|
|
53
|
+
import type { SubagentTranscriptRegistry } from "./subagent-transcript-registry"
|
|
54
|
+
// Type-only import from the SDK session start module — no IO is pulled in.
|
|
55
|
+
import type { startClaudeSession as StartClaudeSessionFn } from "./claude-session-start"
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Minimal structural interfaces — only the operations this module calls.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** Subset of OAuthTokenPool used by spawnClaudeTurn. */
|
|
62
|
+
interface SpawnOAuthPool {
|
|
63
|
+
pickActive(chatId: string): { id: string; token: string; label: string } | null | undefined
|
|
64
|
+
hasAnyToken(): boolean
|
|
65
|
+
markUsed(tokenId: string): void
|
|
66
|
+
release(chatId: string): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Subset of EventStore used by spawnClaudeTurn (only for slash-command load). */
|
|
70
|
+
interface SpawnStore {
|
|
71
|
+
recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]): Promise<void>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Exported arg types
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Arguments for spawnClaudeTurn — mirrors the private startClaudeTurn args. */
|
|
79
|
+
export interface SpawnClaudeTurnArgs {
|
|
80
|
+
chatId: string
|
|
81
|
+
projectId: string
|
|
82
|
+
localPath: string
|
|
83
|
+
additionalDirectories?: string[]
|
|
84
|
+
stackProjects?: ResolvedStackBinding[]
|
|
85
|
+
model: string
|
|
86
|
+
effort?: string
|
|
87
|
+
planMode: boolean
|
|
88
|
+
sessionToken: string | null
|
|
89
|
+
forkSession: boolean
|
|
90
|
+
onToolRequest: (request: HarnessToolRequest) => Promise<AnyValue>
|
|
91
|
+
provider: AgentProvider
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Dependency bundle
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* All AgentCoordinator fields and methods accessed by spawnClaudeTurn.
|
|
100
|
+
*
|
|
101
|
+
* Mutable maps are passed directly; private methods are exposed as function
|
|
102
|
+
* references so the extracted function can be tested independently.
|
|
103
|
+
*/
|
|
104
|
+
export interface SpawnClaudeTurnDeps {
|
|
105
|
+
// Mutable session state maps
|
|
106
|
+
claudeSessions: Map<string, ClaudeSessionState>
|
|
107
|
+
activeTurns: Map<string, ActiveTurn>
|
|
108
|
+
mentionedSubagentIdsByChat: Map<string, string[]>
|
|
109
|
+
|
|
110
|
+
// OAuth pool (structural subset)
|
|
111
|
+
oauthPool: SpawnOAuthPool | null
|
|
112
|
+
|
|
113
|
+
// Store (structural subset)
|
|
114
|
+
store: SpawnStore
|
|
115
|
+
|
|
116
|
+
// Session-start function references (injected so tests can stub them)
|
|
117
|
+
startClaudeSessionFn: typeof StartClaudeSessionFn
|
|
118
|
+
startClaudeSessionPTYFn: (args: StartClaudeSessionPtyArgs) => Promise<ClaudeSessionHandle>
|
|
119
|
+
|
|
120
|
+
// Opaque state references passed through to the start functions
|
|
121
|
+
subagentOrchestrator: SubagentOrchestrator
|
|
122
|
+
toolCallback: ToolCallbackService | null
|
|
123
|
+
tunnelGateway: TunnelGateway | null
|
|
124
|
+
claudePtyRegistry: ClaudePtyRegistry | null
|
|
125
|
+
ptyInstanceRegistry: PtyInstanceRegistry | null
|
|
126
|
+
workflowRegistry: WorkflowRegistry | null
|
|
127
|
+
subagentTranscriptRegistry: SubagentTranscriptRegistry | null
|
|
128
|
+
|
|
129
|
+
// Method references for private AgentCoordinator helpers
|
|
130
|
+
resolveClaudeDriverPreference: () => ClaudeDriverPreference
|
|
131
|
+
isLoopArmed: (chatId: string) => LoopState | null
|
|
132
|
+
closeClaudeSession: (chatId: string, session: ClaudeSessionState) => void
|
|
133
|
+
enforceClaudeSessionBudget: (protectedChatId?: string) => void
|
|
134
|
+
readLlmProvider: () => Promise<LlmProviderSnapshot>
|
|
135
|
+
buildPoolUnavailableMessage: (reservedFor: string, scopeSuffix: string) => string
|
|
136
|
+
listOpenRouterModelsFn: (() => Promise<OpenRouterModel[]>) | null
|
|
137
|
+
getSubagents: () => Subagent[]
|
|
138
|
+
getAppSettingsSnapshot: () => { globalPromptAppend?: string }
|
|
139
|
+
getEnabledCustomMcpServers: () => readonly McpServerConfig[]
|
|
140
|
+
buildOAuthBearers: (servers: readonly McpServerConfig[]) => Promise<Map<string, string>>
|
|
141
|
+
setupLoop: (chatId: string, input: LoopSetupInput) => Promise<SetupLoopHandlerResult>
|
|
142
|
+
stopLoop: (chatId: string, reason: "goal_met" | "user_send" | "chat_deleted") => Promise<void>
|
|
143
|
+
runOrchestration: (
|
|
144
|
+
chatId: string,
|
|
145
|
+
input: OrchRunInput,
|
|
146
|
+
) => Promise<{ ok: true; runId: string } | { ok: false; errors: string[] }>
|
|
147
|
+
cancelOrchRun: (runId: string) => Promise<void>
|
|
148
|
+
getOrchRunDetail: (runId: string) => OrchRunDetail | null
|
|
149
|
+
resolveChatPolicy: (chatId: string) => ChatPermissionPolicy
|
|
150
|
+
/** Fires the session event loop. Return value is discarded (fire-and-forget). */
|
|
151
|
+
runClaudeSession: (session: ClaudeSessionState) => void
|
|
152
|
+
mergeLocalCatalog: (commands: SlashCommand[], cwd: string) => SlashCommand[]
|
|
153
|
+
emitStateChange: (chatId: string) => void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Core function
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Spawns or reuses a Claude session for a chat turn.
|
|
162
|
+
*
|
|
163
|
+
* Extracted from AgentCoordinator.startClaudeTurn. Behavior is identical to
|
|
164
|
+
* the original private method; all side-effectful operations are injected via
|
|
165
|
+
* `deps`.
|
|
166
|
+
*
|
|
167
|
+
* Returns a HarnessTurn that delegates interrupt / getAccountInfo to the live
|
|
168
|
+
* session handle. The stream is empty (the event loop in runClaudeSession is
|
|
169
|
+
* the actual reader).
|
|
170
|
+
*/
|
|
171
|
+
export async function spawnClaudeTurn(
|
|
172
|
+
deps: SpawnClaudeTurnDeps,
|
|
173
|
+
args: SpawnClaudeTurnArgs,
|
|
174
|
+
): Promise<HarnessTurn> {
|
|
175
|
+
let session = deps.claudeSessions.get(args.chatId)
|
|
176
|
+
|
|
177
|
+
const driverIsPty = args.provider !== "openrouter"
|
|
178
|
+
&& deps.resolveClaudeDriverPreference() === "pty"
|
|
179
|
+
const loopArmedNow = deps.isLoopArmed(args.chatId) !== null
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
!session ||
|
|
183
|
+
session.localPath !== args.localPath ||
|
|
184
|
+
session.effort !== args.effort ||
|
|
185
|
+
args.forkSession ||
|
|
186
|
+
session.additionalDirectories.join("|") !== (args.additionalDirectories ?? []).join("|") ||
|
|
187
|
+
// Both drivers bake the loop tool-block into the spawn (PTY via
|
|
188
|
+
// --disallowedTools CLI args, SDK via options.disallowedTools), so an
|
|
189
|
+
// armed-state flip (arm OR disarm) requires a fresh session. The SDK's
|
|
190
|
+
// dynamic canUseTool deny remains as belt-and-suspenders mid-turn.
|
|
191
|
+
session.loopArmedAtSpawn !== loopArmedNow
|
|
192
|
+
) {
|
|
193
|
+
if (session) {
|
|
194
|
+
deps.closeClaudeSession(args.chatId, session)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
deps.enforceClaudeSessionBudget(args.chatId)
|
|
198
|
+
const isOpenRouter = args.provider === "openrouter"
|
|
199
|
+
const openrouterApiKey = isOpenRouter ? (await deps.readLlmProvider()).apiKey : null
|
|
200
|
+
const picked = isOpenRouter ? null : (deps.oauthPool?.pickActive(args.chatId) ?? null)
|
|
201
|
+
// If the pool is populated but every token is currently unusable
|
|
202
|
+
// (limited/error/disabled/reserved), refuse to spawn rather than let
|
|
203
|
+
// the CLI fall back to its keychain auth — that path serves whichever
|
|
204
|
+
// login the CLI binary's keychain holds, which is typically
|
|
205
|
+
// expired in a pool-managed setup and produces opaque 401 loops.
|
|
206
|
+
if (!isOpenRouter && deps.oauthPool && deps.oauthPool.hasAnyToken() && !picked) {
|
|
207
|
+
throw new OAuthPoolUnavailableError(deps.buildPoolUnavailableMessage(args.chatId, ""))
|
|
208
|
+
}
|
|
209
|
+
if (picked) deps.oauthPool!.markUsed(picked.id)
|
|
210
|
+
|
|
211
|
+
let openrouterTurnPrice: ModelPrice | null = null
|
|
212
|
+
let openrouterContextWindow: number | undefined
|
|
213
|
+
if (isOpenRouter && deps.listOpenRouterModelsFn) {
|
|
214
|
+
try {
|
|
215
|
+
const models = await deps.listOpenRouterModelsFn()
|
|
216
|
+
// OpenRouter routing variants (":nitro", ":floor", ...) aren't their
|
|
217
|
+
// own /models entries — fall back to the base id for pricing/context.
|
|
218
|
+
const baseModelId = stripModelVariantSuffix(args.model)
|
|
219
|
+
const m = models.find((x) => x.id === args.model)
|
|
220
|
+
?? models.find((x) => x.id === baseModelId)
|
|
221
|
+
openrouterTurnPrice = resolveModelPrice(baseModelId, m?.pricing ?? null)
|
|
222
|
+
if (m && m.contextLength > 0) openrouterContextWindow = m.contextLength
|
|
223
|
+
} catch (err) {
|
|
224
|
+
log.warn("[kanna/agent] openrouter pricing lookup failed", String(err))
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const usePty = driverIsPty
|
|
229
|
+
const systemPromptAppend = buildKannaSystemPromptAppend(deps.getSubagents(), {
|
|
230
|
+
globalPromptAppend: deps.getAppSettingsSnapshot().globalPromptAppend,
|
|
231
|
+
stackProjects: args.stackProjects,
|
|
232
|
+
})
|
|
233
|
+
const chatIdForCtx = args.chatId
|
|
234
|
+
const delegationContext: KannaMcpDelegationContext = {
|
|
235
|
+
parentSubagentId: null,
|
|
236
|
+
parentRunId: null,
|
|
237
|
+
ancestorSubagentIds: [],
|
|
238
|
+
depth: 0,
|
|
239
|
+
getParentUserMessageId: () => deps.activeTurns.get(chatIdForCtx)?.userMessageId ?? null,
|
|
240
|
+
getMentionedSubagentIds: () => deps.mentionedSubagentIdsByChat.get(chatIdForCtx) ?? [],
|
|
241
|
+
}
|
|
242
|
+
const enabledMcpServers = deps.getEnabledCustomMcpServers()
|
|
243
|
+
const oauthBearers = await deps.buildOAuthBearers(enabledMcpServers)
|
|
244
|
+
let started: ClaudeSessionHandle
|
|
245
|
+
try {
|
|
246
|
+
started = usePty
|
|
247
|
+
? await deps.startClaudeSessionPTYFn({
|
|
248
|
+
chatId: args.chatId,
|
|
249
|
+
projectId: args.projectId,
|
|
250
|
+
localPath: args.localPath,
|
|
251
|
+
model: args.model,
|
|
252
|
+
effort: args.effort,
|
|
253
|
+
planMode: args.planMode,
|
|
254
|
+
sessionToken: args.sessionToken,
|
|
255
|
+
forkSession: args.forkSession,
|
|
256
|
+
oauthToken: picked?.token ?? null,
|
|
257
|
+
oauthLabel: picked?.label,
|
|
258
|
+
oauthKeyMasked: picked ? maskOauthKey(picked.token) : undefined,
|
|
259
|
+
additionalDirectories: args.additionalDirectories,
|
|
260
|
+
onToolRequest: args.onToolRequest,
|
|
261
|
+
systemPromptAppend,
|
|
262
|
+
subagentOrchestrator: deps.subagentOrchestrator,
|
|
263
|
+
delegationContext,
|
|
264
|
+
setupLoop: delegationContext.depth === 0
|
|
265
|
+
? (input) => deps.setupLoop(chatIdForCtx, input)
|
|
266
|
+
: undefined,
|
|
267
|
+
stopLoop: delegationContext.depth === 0
|
|
268
|
+
? () => deps.stopLoop(chatIdForCtx, "goal_met")
|
|
269
|
+
: undefined,
|
|
270
|
+
isLoopArmed: delegationContext.depth === 0
|
|
271
|
+
? () => deps.isLoopArmed(chatIdForCtx) !== null
|
|
272
|
+
: undefined,
|
|
273
|
+
runOrch: delegationContext.depth === 0
|
|
274
|
+
? (input) => deps.runOrchestration(chatIdForCtx, input)
|
|
275
|
+
: undefined,
|
|
276
|
+
cancelOrchRun: delegationContext.depth === 0
|
|
277
|
+
? (runId) => deps.cancelOrchRun(runId)
|
|
278
|
+
: undefined,
|
|
279
|
+
getOrchRunStatus: delegationContext.depth === 0
|
|
280
|
+
? (runId) => deps.getOrchRunDetail(runId)
|
|
281
|
+
: undefined,
|
|
282
|
+
toolCallback: deps.toolCallback ?? undefined,
|
|
283
|
+
tunnelGateway: deps.tunnelGateway,
|
|
284
|
+
chatPolicy: deps.resolveChatPolicy(args.chatId),
|
|
285
|
+
ptyRegistry: deps.claudePtyRegistry ?? undefined,
|
|
286
|
+
ptyInstanceRegistry: deps.ptyInstanceRegistry ?? undefined,
|
|
287
|
+
workflowRegistry: deps.workflowRegistry ?? undefined,
|
|
288
|
+
subagentTranscriptRegistry: deps.subagentTranscriptRegistry ?? undefined,
|
|
289
|
+
customMcpServers: enabledMcpServers,
|
|
290
|
+
oauthBearers,
|
|
291
|
+
})
|
|
292
|
+
: await deps.startClaudeSessionFn({
|
|
293
|
+
projectId: args.projectId,
|
|
294
|
+
localPath: args.localPath,
|
|
295
|
+
model: args.model,
|
|
296
|
+
effort: args.effort,
|
|
297
|
+
planMode: args.planMode,
|
|
298
|
+
sessionToken: args.sessionToken,
|
|
299
|
+
forkSession: args.forkSession,
|
|
300
|
+
oauthToken: picked?.token ?? null,
|
|
301
|
+
openrouterApiKey,
|
|
302
|
+
additionalDirectories: args.additionalDirectories,
|
|
303
|
+
chatId: args.chatId,
|
|
304
|
+
tunnelGateway: deps.tunnelGateway,
|
|
305
|
+
onToolRequest: args.onToolRequest,
|
|
306
|
+
systemPromptAppend,
|
|
307
|
+
subagentOrchestrator: deps.subagentOrchestrator,
|
|
308
|
+
delegationContext,
|
|
309
|
+
setupLoop: delegationContext.depth === 0
|
|
310
|
+
? (input) => deps.setupLoop(chatIdForCtx, input)
|
|
311
|
+
: undefined,
|
|
312
|
+
stopLoop: delegationContext.depth === 0
|
|
313
|
+
? () => deps.stopLoop(chatIdForCtx, "goal_met")
|
|
314
|
+
: undefined,
|
|
315
|
+
isLoopArmed: delegationContext.depth === 0
|
|
316
|
+
? () => deps.isLoopArmed(chatIdForCtx) !== null
|
|
317
|
+
: undefined,
|
|
318
|
+
runOrch: delegationContext.depth === 0
|
|
319
|
+
? (input) => deps.runOrchestration(chatIdForCtx, input)
|
|
320
|
+
: undefined,
|
|
321
|
+
cancelOrchRun: delegationContext.depth === 0
|
|
322
|
+
? (runId) => deps.cancelOrchRun(runId)
|
|
323
|
+
: undefined,
|
|
324
|
+
getOrchRunStatus: delegationContext.depth === 0
|
|
325
|
+
? (runId) => deps.getOrchRunDetail(runId)
|
|
326
|
+
: undefined,
|
|
327
|
+
toolCallback: deps.toolCallback ?? undefined,
|
|
328
|
+
chatPolicy: deps.resolveChatPolicy(args.chatId),
|
|
329
|
+
customMcpServers: enabledMcpServers,
|
|
330
|
+
oauthBearers,
|
|
331
|
+
turnPrice: openrouterTurnPrice,
|
|
332
|
+
contextWindowOverride: openrouterContextWindow,
|
|
333
|
+
})
|
|
334
|
+
} catch (err) {
|
|
335
|
+
// Spawn failed before we registered the session — release the OAuth
|
|
336
|
+
// pool reservation. Without this the token stays "in use" until process
|
|
337
|
+
// restart, eventually starving every chat once all tokens are reserved.
|
|
338
|
+
if (picked) deps.oauthPool?.release(args.chatId)
|
|
339
|
+
throw err
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
session = {
|
|
343
|
+
id: crypto.randomUUID(),
|
|
344
|
+
chatId: args.chatId,
|
|
345
|
+
session: started,
|
|
346
|
+
localPath: args.localPath,
|
|
347
|
+
additionalDirectories: args.additionalDirectories ?? [],
|
|
348
|
+
model: args.model,
|
|
349
|
+
effort: args.effort,
|
|
350
|
+
planMode: args.planMode,
|
|
351
|
+
sessionToken: args.sessionToken,
|
|
352
|
+
accountInfoLoaded: false,
|
|
353
|
+
nextPromptSeq: 0,
|
|
354
|
+
pendingPromptSeqs: [],
|
|
355
|
+
activeTokenId: picked?.id ?? null,
|
|
356
|
+
oauthKeyMasked: picked ? maskOauthKey(picked.token) : null,
|
|
357
|
+
oauthLabel: picked?.label ?? null,
|
|
358
|
+
openrouterKeyMasked: openrouterApiKey ? maskOauthKey(openrouterApiKey) : null,
|
|
359
|
+
openrouterModel: isOpenRouter ? args.model : null,
|
|
360
|
+
lastUsedAt: Date.now(),
|
|
361
|
+
backgroundTaskIds: new Set<string>(),
|
|
362
|
+
backgroundTaskDeadlineAt: 0,
|
|
363
|
+
loopArmedAtSpawn: loopArmedNow,
|
|
364
|
+
cancelledResultPending: 0,
|
|
365
|
+
suppressSessionTokenPersist: false,
|
|
366
|
+
}
|
|
367
|
+
deps.claudeSessions.set(args.chatId, session)
|
|
368
|
+
deps.enforceClaudeSessionBudget(args.chatId)
|
|
369
|
+
void deps.runClaudeSession(session)
|
|
370
|
+
void (async () => {
|
|
371
|
+
try {
|
|
372
|
+
const commands = await started.getSupportedCommands()
|
|
373
|
+
const merged = deps.mergeLocalCatalog(commands, args.localPath)
|
|
374
|
+
await deps.store.recordSessionCommandsLoaded(args.chatId, merged)
|
|
375
|
+
deps.emitStateChange(args.chatId)
|
|
376
|
+
} catch (error) {
|
|
377
|
+
log.warn("[kanna/agent] failed to load slash commands", String(error))
|
|
378
|
+
}
|
|
379
|
+
})()
|
|
380
|
+
} else {
|
|
381
|
+
session.lastUsedAt = Date.now()
|
|
382
|
+
if (session.model !== args.model) {
|
|
383
|
+
await session.session.setModel(args.model)
|
|
384
|
+
session.model = args.model
|
|
385
|
+
}
|
|
386
|
+
if (session.planMode !== args.planMode) {
|
|
387
|
+
await session.session.setPermissionMode(args.planMode)
|
|
388
|
+
session.planMode = args.planMode
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
provider: "claude",
|
|
394
|
+
stream: {
|
|
395
|
+
async *[Symbol.asyncIterator]() {},
|
|
396
|
+
},
|
|
397
|
+
getAccountInfo: session.session.getAccountInfo,
|
|
398
|
+
interrupt: session.session.interrupt,
|
|
399
|
+
close: () => {},
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// SDK mock — must be registered before the module under test is imported.
|
|
5
|
+
// Bun hoists mock.module() calls, so this executes before the top-level
|
|
6
|
+
// `import { query } from "@anthropic-ai/claude-agent-sdk"` in the source.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
type FakeQuery = {
|
|
10
|
+
accountInfo: () => Promise<unknown>
|
|
11
|
+
interrupt: () => Promise<void>
|
|
12
|
+
close: () => void
|
|
13
|
+
setModel: (m: string) => Promise<void>
|
|
14
|
+
setPermissionMode: (m: string) => Promise<void>
|
|
15
|
+
supportedCommands: () => Promise<unknown[]>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let mockQueryFn: (args: unknown) => FakeQuery
|
|
19
|
+
let capturedQueryArgs: unknown
|
|
20
|
+
|
|
21
|
+
const defaultFakeQ = (): FakeQuery => ({
|
|
22
|
+
accountInfo: async () => ({ provider: "claude", label: "test" }),
|
|
23
|
+
interrupt: async () => {},
|
|
24
|
+
close: () => {},
|
|
25
|
+
setModel: async () => {},
|
|
26
|
+
setPermissionMode: async () => {},
|
|
27
|
+
supportedCommands: async () => [],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
mock.module("@anthropic-ai/claude-agent-sdk", () => ({
|
|
31
|
+
query: (args: unknown) => {
|
|
32
|
+
capturedQueryArgs = args
|
|
33
|
+
return mockQueryFn(args)
|
|
34
|
+
},
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
// Mock kanna-mcp so we don't need a real MCP server
|
|
38
|
+
mock.module("./kanna-mcp", () => ({
|
|
39
|
+
createKannaMcpServer: () => ({ transport: "stdio", command: "echo", args: [] }),
|
|
40
|
+
}))
|
|
41
|
+
|
|
42
|
+
// NOTE: We do NOT mock.module("./claude-prompt-helpers") — toSdkEffort is pure
|
|
43
|
+
// and safe to use for real; the identity mock leaked into
|
|
44
|
+
// claude-prompt-helpers.test.ts under CI's file ordering.
|
|
45
|
+
|
|
46
|
+
// NOTE: We do NOT mock.module these siblings — that would pollute the global
|
|
47
|
+
// module registry and break their own test files that run later:
|
|
48
|
+
// ./claude-spawn-helpers, ./claude-harness-stream, ./claude-sdk-queue,
|
|
49
|
+
// ./claude-usage-math, ./claude-session-config (re-exported by agent.ts)
|
|
50
|
+
// Instead we inject fakes through the StartClaudeSessionDeps parameter.
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Import the module under test AFTER mock.module() registrations
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
import { startClaudeSession, type StartClaudeSessionDeps } from "./claude-session-start"
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Fake deps (replace the 4 sibling modules that must not be mock.module-d)
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** Minimal queue fake: satisfies the structural AsyncMessageQueueCtor dep. */
|
|
62
|
+
class FakeQueue<T> implements AsyncIterable<T> {
|
|
63
|
+
private items: T[] = []
|
|
64
|
+
private _closed = false
|
|
65
|
+
push(item: T) {
|
|
66
|
+
if (this._closed) throw new Error("Cannot push to a closed queue")
|
|
67
|
+
this.items.push(item)
|
|
68
|
+
}
|
|
69
|
+
close() { this._closed = true }
|
|
70
|
+
get closed() { return this._closed }
|
|
71
|
+
get pushedItems() { return [...this.items] }
|
|
72
|
+
[Symbol.asyncIterator](): AsyncIterator<T, undefined> {
|
|
73
|
+
let i = 0
|
|
74
|
+
return {
|
|
75
|
+
next: async (): Promise<IteratorResult<T, undefined>> => {
|
|
76
|
+
if (i < this.items.length) return { done: false, value: this.items[i++] }
|
|
77
|
+
return { done: true, value: undefined }
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function makeFakeDeps(): StartClaudeSessionDeps {
|
|
84
|
+
return {
|
|
85
|
+
buildCanUseTool: () => async () => ({ behavior: "allow" as const }),
|
|
86
|
+
buildClaudeEnv: (env) => env,
|
|
87
|
+
loopBlockedNativeTools: [] as readonly string[],
|
|
88
|
+
AsyncMessageQueueCtor: FakeQueue,
|
|
89
|
+
toClaudeMessageStream: () => (async function* () {})(),
|
|
90
|
+
createClaudeHarnessStream: () => (async function* () {})(),
|
|
91
|
+
parseConfiguredContextWindowFromModelId: () => undefined,
|
|
92
|
+
buildUserMcpServers: () => ({}),
|
|
93
|
+
claudeToolset: ["Bash", "Read", "Write"] as readonly string[],
|
|
94
|
+
sdkRestrictedFsNativeTools: ["Read", "Write"] as readonly string[],
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Minimal valid args reused across tests
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
const BASE_ARGS = {
|
|
102
|
+
projectId: "proj-1",
|
|
103
|
+
localPath: "/tmp/test",
|
|
104
|
+
model: "claude-opus-4-5",
|
|
105
|
+
planMode: false,
|
|
106
|
+
sessionToken: null,
|
|
107
|
+
forkSession: false,
|
|
108
|
+
oauthToken: null,
|
|
109
|
+
onToolRequest: async () => null,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
mockQueryFn = defaultFakeQ
|
|
114
|
+
capturedQueryArgs = undefined
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("startClaudeSession", () => {
|
|
118
|
+
// ── 1. Shape of returned handle ────────────────────────────────────────
|
|
119
|
+
test("returned handle has provider = 'claude'", async () => {
|
|
120
|
+
const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
|
|
121
|
+
expect(handle.provider).toBe("claude")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("returned handle exposes stream as async iterable", async () => {
|
|
125
|
+
const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
|
|
126
|
+
expect(handle.stream).toBeDefined()
|
|
127
|
+
expect(typeof handle.stream[Symbol.asyncIterator]).toBe("function")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("returned handle exposes sendPrompt, setModel, setPermissionMode, getSupportedCommands, interrupt, close", async () => {
|
|
131
|
+
const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
|
|
132
|
+
expect(typeof handle.sendPrompt).toBe("function")
|
|
133
|
+
expect(typeof handle.setModel).toBe("function")
|
|
134
|
+
expect(typeof handle.setPermissionMode).toBe("function")
|
|
135
|
+
expect(typeof handle.getSupportedCommands).toBe("function")
|
|
136
|
+
expect(typeof handle.interrupt).toBe("function")
|
|
137
|
+
expect(typeof handle.close).toBe("function")
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ── 2. keepAlive behaviour ─────────────────────────────────────────────
|
|
141
|
+
test("handle has no pushChannelPrompt when keepAlive is not set", async () => {
|
|
142
|
+
const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
|
|
143
|
+
expect(handle.pushChannelPrompt).toBeUndefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("handle exposes pushChannelPrompt when keepAlive: true", async () => {
|
|
147
|
+
const handle = await startClaudeSession({ ...BASE_ARGS, keepAlive: true }, makeFakeDeps())
|
|
148
|
+
expect(typeof handle.pushChannelPrompt).toBe("function")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ── 3. close() delegates to underlying query ───────────────────────────
|
|
152
|
+
test("close() can be called without throwing", async () => {
|
|
153
|
+
const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
|
|
154
|
+
expect(() => handle.close()).not.toThrow()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// ── 4. getAccountInfo error-safety ─────────────────────────────────────
|
|
158
|
+
test("getAccountInfo returns null when underlying accountInfo() throws", async () => {
|
|
159
|
+
mockQueryFn = () => ({
|
|
160
|
+
...defaultFakeQ(),
|
|
161
|
+
accountInfo: async () => { throw new Error("network error") },
|
|
162
|
+
})
|
|
163
|
+
const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
|
|
164
|
+
const info = await handle.getAccountInfo?.()
|
|
165
|
+
expect(info).toBeNull()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// ── 5. getSupportedCommands error-safety ───────────────────────────────
|
|
169
|
+
test("getSupportedCommands returns [] when supportedCommands() throws", async () => {
|
|
170
|
+
mockQueryFn = () => ({
|
|
171
|
+
...defaultFakeQ(),
|
|
172
|
+
supportedCommands: async () => { throw new Error("not ready") },
|
|
173
|
+
})
|
|
174
|
+
const handle = await startClaudeSession(BASE_ARGS, makeFakeDeps())
|
|
175
|
+
const cmds = await handle.getSupportedCommands()
|
|
176
|
+
expect(cmds).toEqual([])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// ── 6. SDK options — model is forwarded ───────────────────────────────
|
|
180
|
+
test("SDK options include the model passed to startClaudeSession", async () => {
|
|
181
|
+
await startClaudeSession({ ...BASE_ARGS, model: "claude-sonnet-4-5" }, makeFakeDeps())
|
|
182
|
+
const args = capturedQueryArgs as { options: { model: string } }
|
|
183
|
+
expect(args.options.model).toBe("claude-sonnet-4-5")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ── 7. SDK options — cwd is localPath ─────────────────────────────────
|
|
187
|
+
test("SDK cwd equals localPath", async () => {
|
|
188
|
+
await startClaudeSession({ ...BASE_ARGS, localPath: "/projects/myapp" }, makeFakeDeps())
|
|
189
|
+
const args = capturedQueryArgs as { options: { cwd: string } }
|
|
190
|
+
expect(args.options.cwd).toBe("/projects/myapp")
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// ── 8. SDK options — planMode maps to permissionMode ──────────────────
|
|
194
|
+
test("planMode:true maps to permissionMode 'plan'", async () => {
|
|
195
|
+
await startClaudeSession({ ...BASE_ARGS, planMode: true }, makeFakeDeps())
|
|
196
|
+
const args = capturedQueryArgs as { options: { permissionMode: string } }
|
|
197
|
+
expect(args.options.permissionMode).toBe("plan")
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("planMode:false maps to permissionMode 'acceptEdits'", async () => {
|
|
201
|
+
await startClaudeSession({ ...BASE_ARGS, planMode: false }, makeFakeDeps())
|
|
202
|
+
const args = capturedQueryArgs as { options: { permissionMode: string } }
|
|
203
|
+
expect(args.options.permissionMode).toBe("acceptEdits")
|
|
204
|
+
})
|
|
205
|
+
})
|