@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,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone send/queue handler cluster for AgentCoordinator.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from agent.ts so the message-send pipeline and queue management
|
|
5
|
+
* logic lives in its own testable module. The coordinator delegates to these
|
|
6
|
+
* functions by passing an object literal that satisfies `SendCommandDeps`.
|
|
7
|
+
*
|
|
8
|
+
* Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
|
|
9
|
+
* calls, no Bun primitives). Every effectful operation is injected through
|
|
10
|
+
* the deps interface.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AgentProvider, ChatAttachment, CustomModelEntry, QueuedChatMessage, TranscriptEntry } from "../shared/types"
|
|
14
|
+
import { resolveClaudeApiModelId } from "../shared/types"
|
|
15
|
+
import type { ClientCommand } from "../shared/protocol"
|
|
16
|
+
import {
|
|
17
|
+
logSendToStartingProfile,
|
|
18
|
+
type SendMessageOptions,
|
|
19
|
+
type SendToStartingProfile,
|
|
20
|
+
} from "./claude-steer-log"
|
|
21
|
+
import type { StartTurnForChatArgs } from "./claude-turn-starter"
|
|
22
|
+
import {
|
|
23
|
+
getLatestContextWindowUsage,
|
|
24
|
+
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES,
|
|
25
|
+
shouldProactivelyCompact,
|
|
26
|
+
} from "./proactive-compact"
|
|
27
|
+
import {
|
|
28
|
+
codexServiceTierFromModelOptions,
|
|
29
|
+
getServerProviderCatalog,
|
|
30
|
+
normalizeClaudeModelOptions,
|
|
31
|
+
normalizeCodexModelOptions,
|
|
32
|
+
normalizeServerModel,
|
|
33
|
+
} from "./provider-catalog"
|
|
34
|
+
import { buildSteeredMessageContent } from "./claude-prompt-helpers"
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Structural sub-interfaces — only the slices this module calls.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Subset of EventStore used by the send command handler. */
|
|
41
|
+
interface SendCommandStore {
|
|
42
|
+
createChat(projectId: string): Promise<{ id: string }>
|
|
43
|
+
requireChat(chatId: string): { provider: AgentProvider | null }
|
|
44
|
+
getChat(chatId: string): { compactFailureCount?: number } | null
|
|
45
|
+
enqueueMessage(
|
|
46
|
+
chatId: string,
|
|
47
|
+
message: Omit<QueuedChatMessage, "id" | "createdAt"> & Partial<Pick<QueuedChatMessage, "id" | "createdAt">>,
|
|
48
|
+
): Promise<QueuedChatMessage>
|
|
49
|
+
removeQueuedMessage(chatId: string, queuedMessageId: string): Promise<void>
|
|
50
|
+
getQueuedMessages?: (chatId: string) => readonly QueuedChatMessage[]
|
|
51
|
+
getMessages(chatId: string): readonly TranscriptEntry[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Subset of the activeTurns map used by the send command handler. */
|
|
55
|
+
interface ActiveTurnsMap {
|
|
56
|
+
has(chatId: string): boolean
|
|
57
|
+
get(chatId: string): { proactiveCompactInjection?: boolean } | undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Subset of the claudeSessions map used by the send command handler. */
|
|
61
|
+
interface ClaudeSessionsMap {
|
|
62
|
+
get(chatId: string): { backgroundTaskIds: Set<string>; backgroundTaskDeadlineAt: number } | undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Subset of the autoResumeByChat map used by the send command handler. */
|
|
66
|
+
interface AutoResumeByChatMap {
|
|
67
|
+
set(chatId: string, value: boolean): void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Minimal analytics interface needed by the send command handler. */
|
|
71
|
+
interface SendCommandAnalytics {
|
|
72
|
+
track(event: string): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Dependency bundle injected by AgentCoordinator
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export interface SendCommandDeps {
|
|
80
|
+
/** The event store — for creating chats, queuing messages, and reading state. */
|
|
81
|
+
store: SendCommandStore
|
|
82
|
+
|
|
83
|
+
/** The active-turns map. Read-only from the handler's perspective (has/get). */
|
|
84
|
+
activeTurns: ActiveTurnsMap
|
|
85
|
+
|
|
86
|
+
/** The claude-sessions map. Used to clear background-task state on user send. */
|
|
87
|
+
claudeSessions: ClaudeSessionsMap
|
|
88
|
+
|
|
89
|
+
/** Per-chat auto-resume preference map. */
|
|
90
|
+
autoResumeByChat: AutoResumeByChatMap
|
|
91
|
+
|
|
92
|
+
/** Analytics reporter. */
|
|
93
|
+
analytics: SendCommandAnalytics
|
|
94
|
+
|
|
95
|
+
/** Returns the current app settings snapshot (for customModels). */
|
|
96
|
+
getAppSettingsSnapshot(): { customModels?: readonly CustomModelEntry[] }
|
|
97
|
+
|
|
98
|
+
/** Disarm the armed loop (user takeover). */
|
|
99
|
+
stopLoop(chatId: string, reason: "goal_met" | "user_send" | "chat_deleted"): Promise<void>
|
|
100
|
+
|
|
101
|
+
/** Emit a state-change event for a chat. */
|
|
102
|
+
emitStateChange(chatId: string): void
|
|
103
|
+
|
|
104
|
+
/** Start a new provider turn for the given chat. */
|
|
105
|
+
startTurnForChat(args: StartTurnForChatArgs): Promise<void>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Pure helpers (no deps required)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the provider to use for a new message, falling back through the
|
|
114
|
+
* command option → chat's current provider → "claude".
|
|
115
|
+
*/
|
|
116
|
+
export function resolveProvider(
|
|
117
|
+
options: SendMessageOptions,
|
|
118
|
+
currentProvider: AgentProvider | null,
|
|
119
|
+
): AgentProvider {
|
|
120
|
+
return options.provider ?? currentProvider ?? "claude"
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve the model/effort/planMode settings for a new provider turn.
|
|
125
|
+
* Falls through provider-specific normalization logic.
|
|
126
|
+
*/
|
|
127
|
+
export function getProviderSettings(
|
|
128
|
+
provider: AgentProvider,
|
|
129
|
+
options: SendMessageOptions,
|
|
130
|
+
customModels: readonly CustomModelEntry[],
|
|
131
|
+
) {
|
|
132
|
+
const catalog = getServerProviderCatalog(provider)
|
|
133
|
+
|
|
134
|
+
if (provider === "claude") {
|
|
135
|
+
const model = normalizeServerModel(provider, options.model, customModels)
|
|
136
|
+
const modelOptions = normalizeClaudeModelOptions(model, options.modelOptions, options.effort, customModels)
|
|
137
|
+
return {
|
|
138
|
+
model: resolveClaudeApiModelId(model, modelOptions.contextWindow),
|
|
139
|
+
effort: modelOptions.reasoningEffort,
|
|
140
|
+
serviceTier: undefined,
|
|
141
|
+
planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (provider === "openrouter") {
|
|
146
|
+
// OpenRouter's model list is fetched dynamically (settings.listOpenRouterModels),
|
|
147
|
+
// so the static server catalog is empty and normalizeServerModel would collapse
|
|
148
|
+
// every selection to the default. Trust the client-selected id — OpenRouter
|
|
149
|
+
// rejects invalid ids at the API — falling back to the default only when blank.
|
|
150
|
+
return {
|
|
151
|
+
model: options.model?.trim() || catalog.defaultModel,
|
|
152
|
+
effort: undefined,
|
|
153
|
+
serviceTier: undefined,
|
|
154
|
+
planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const modelOptions = normalizeCodexModelOptions(options.modelOptions, options.effort)
|
|
159
|
+
return {
|
|
160
|
+
model: normalizeServerModel(provider, options.model, customModels),
|
|
161
|
+
effort: modelOptions.reasoningEffort,
|
|
162
|
+
serviceTier: codexServiceTierFromModelOptions(modelOptions),
|
|
163
|
+
planMode: catalog.supportsPlanMode ? Boolean(options.planMode) : false,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Exported standalone functions
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Check whether a proactive `/compact` turn should be injected before the
|
|
173
|
+
* user's real message. Returns false for slash commands and when the circuit
|
|
174
|
+
* breaker has tripped (too many consecutive compact failures).
|
|
175
|
+
*/
|
|
176
|
+
export function shouldInjectProactiveCompact(
|
|
177
|
+
deps: SendCommandDeps,
|
|
178
|
+
chatId: string,
|
|
179
|
+
content: string,
|
|
180
|
+
): boolean {
|
|
181
|
+
// Never recurse — if the user (or Kanna itself) is already sending a
|
|
182
|
+
// slash command, run it as-is. Compacting before `/clear` or another
|
|
183
|
+
// `/compact` would be wasted work.
|
|
184
|
+
if (content.trimStart().startsWith("/")) return false
|
|
185
|
+
const failures = deps.store.getChat(chatId)?.compactFailureCount ?? 0
|
|
186
|
+
if (failures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) return false
|
|
187
|
+
const usage = getLatestContextWindowUsage(deps.store.getMessages(chatId))
|
|
188
|
+
return shouldProactivelyCompact(usage)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Append a message to the chat's queue and emit a state-change event.
|
|
193
|
+
* Returns the newly created queued message.
|
|
194
|
+
*/
|
|
195
|
+
export async function enqueueMessage(
|
|
196
|
+
deps: SendCommandDeps,
|
|
197
|
+
chatId: string,
|
|
198
|
+
content: string,
|
|
199
|
+
attachments: ChatAttachment[],
|
|
200
|
+
options?: SendMessageOptions,
|
|
201
|
+
): Promise<QueuedChatMessage> {
|
|
202
|
+
const queued = await deps.store.enqueueMessage(chatId, {
|
|
203
|
+
content,
|
|
204
|
+
attachments,
|
|
205
|
+
provider: options?.provider,
|
|
206
|
+
model: options?.model,
|
|
207
|
+
modelOptions: options?.modelOptions,
|
|
208
|
+
planMode: options?.planMode,
|
|
209
|
+
autoContinue: options?.autoContinue,
|
|
210
|
+
})
|
|
211
|
+
deps.emitStateChange(chatId)
|
|
212
|
+
return queued
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Dequeue a specific queued message and start a turn for it.
|
|
217
|
+
* If `options.steered` is true, the content is wrapped as a steered message.
|
|
218
|
+
*/
|
|
219
|
+
export async function dequeueAndStartQueuedMessage(
|
|
220
|
+
deps: SendCommandDeps,
|
|
221
|
+
chatId: string,
|
|
222
|
+
queuedMessage: QueuedChatMessage,
|
|
223
|
+
options?: { steered?: boolean },
|
|
224
|
+
): Promise<void> {
|
|
225
|
+
await deps.store.removeQueuedMessage(chatId, queuedMessage.id)
|
|
226
|
+
const chat = deps.store.requireChat(chatId)
|
|
227
|
+
|
|
228
|
+
// Mentions no longer short-circuit the main turn (Anthropic-style
|
|
229
|
+
// Task-tool pattern). The main agent always runs; mention metadata is
|
|
230
|
+
// still attached to the user_prompt entry by `startTurnForChat` →
|
|
231
|
+
// `appendUserPrompt`.
|
|
232
|
+
const provider = resolveProvider(queuedMessage, chat.provider)
|
|
233
|
+
const customModels = deps.getAppSettingsSnapshot().customModels ?? []
|
|
234
|
+
const settings = getProviderSettings(provider, queuedMessage, customModels)
|
|
235
|
+
|
|
236
|
+
// Auto-continue rate-limit recovery sends the literal "continue" as a
|
|
237
|
+
// resume signal. Appending it as a user_prompt entry adds noise to the
|
|
238
|
+
// transcript (shows as an "auto-sent" bubble right before a COMPACTED
|
|
239
|
+
// divider, confusing the user). Suppress the entry for that fallback
|
|
240
|
+
// case; agent-driven wakes with a meaningful custom prompt still appear.
|
|
241
|
+
const isRateLimitFallback = queuedMessage.autoContinue !== undefined
|
|
242
|
+
&& queuedMessage.content === "continue"
|
|
243
|
+
|
|
244
|
+
await deps.startTurnForChat({
|
|
245
|
+
chatId,
|
|
246
|
+
provider,
|
|
247
|
+
content: options?.steered ? buildSteeredMessageContent(queuedMessage.content) : queuedMessage.content,
|
|
248
|
+
attachments: queuedMessage.attachments,
|
|
249
|
+
model: settings.model,
|
|
250
|
+
effort: settings.effort,
|
|
251
|
+
serviceTier: settings.serviceTier,
|
|
252
|
+
planMode: settings.planMode,
|
|
253
|
+
appendUserPrompt: !isRateLimitFallback,
|
|
254
|
+
steered: options?.steered,
|
|
255
|
+
autoContinue: queuedMessage.autoContinue,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* If no turn is active and a queued message exists, dequeue and start it.
|
|
261
|
+
* Returns `true` if a queued message was started, `false` otherwise.
|
|
262
|
+
*/
|
|
263
|
+
export async function maybeStartNextQueuedMessage(
|
|
264
|
+
deps: SendCommandDeps,
|
|
265
|
+
chatId: string,
|
|
266
|
+
): Promise<boolean> {
|
|
267
|
+
if (deps.activeTurns.has(chatId)) return false
|
|
268
|
+
const nextQueuedMessage = typeof deps.store.getQueuedMessages === "function"
|
|
269
|
+
? deps.store.getQueuedMessages(chatId)[0]
|
|
270
|
+
: undefined
|
|
271
|
+
if (!nextQueuedMessage) return false
|
|
272
|
+
await dequeueAndStartQueuedMessage(deps, chatId, nextQueuedMessage)
|
|
273
|
+
return true
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Handle a `chat.send` command:
|
|
278
|
+
* 1. Clears background-task keep-alive guard on the existing session.
|
|
279
|
+
* 2. Disarms any armed loop (user takeover).
|
|
280
|
+
* 3. Creates the chat if it doesn't exist yet.
|
|
281
|
+
* 4. If a turn is active, enqueues the message and returns `queued: true`.
|
|
282
|
+
* 5. Optionally injects a proactive `/compact` turn ahead of the real message.
|
|
283
|
+
* 6. Otherwise starts the turn immediately.
|
|
284
|
+
*/
|
|
285
|
+
export async function sendCommand(
|
|
286
|
+
deps: SendCommandDeps,
|
|
287
|
+
command: Extract<ClientCommand, { type: "chat.send" }>,
|
|
288
|
+
): Promise<{ chatId: string; queuedMessageId?: string; queued?: true }> {
|
|
289
|
+
const profile: SendToStartingProfile | null = command.clientTraceId
|
|
290
|
+
? { traceId: command.clientTraceId, startedAt: performance.now() }
|
|
291
|
+
: null
|
|
292
|
+
let chatId = command.chatId
|
|
293
|
+
|
|
294
|
+
// A real user chat.send means the agent is active again — release any
|
|
295
|
+
// background-task keep-alive guard so the session reaps normally afterward.
|
|
296
|
+
// Auto-continue / agent wakes bypass `send` and intentionally do NOT clear it.
|
|
297
|
+
const existingClaudeSession = chatId ? deps.claudeSessions.get(chatId) : undefined
|
|
298
|
+
if (existingClaudeSession) {
|
|
299
|
+
existingClaudeSession.backgroundTaskIds.clear()
|
|
300
|
+
existingClaudeSession.backgroundTaskDeadlineAt = 0
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// A real user send is a takeover: disarm any armed loop so tools are
|
|
304
|
+
// restored and the generic wake path resumes. Auto-continue / background
|
|
305
|
+
// wakes bypass `send`, so they do NOT disarm.
|
|
306
|
+
// Awaited so a failed event-log write surfaces instead of silently
|
|
307
|
+
// leaving the loop armed (and tools blocked) after the takeover.
|
|
308
|
+
if (chatId) await deps.stopLoop(chatId, "user_send")
|
|
309
|
+
|
|
310
|
+
logSendToStartingProfile(profile, "chat_send.received", {
|
|
311
|
+
existingChatId: command.chatId ?? null,
|
|
312
|
+
projectId: command.projectId ?? null,
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
if (!chatId) {
|
|
316
|
+
if (!command.projectId) {
|
|
317
|
+
throw new Error("Missing projectId for new chat")
|
|
318
|
+
}
|
|
319
|
+
const created = await deps.store.createChat(command.projectId)
|
|
320
|
+
chatId = created.id
|
|
321
|
+
deps.analytics.track("chat_created")
|
|
322
|
+
logSendToStartingProfile(profile, "chat_send.chat_created", {
|
|
323
|
+
chatId,
|
|
324
|
+
projectId: command.projectId,
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (typeof command.autoResumeOnRateLimit === "boolean" && chatId) {
|
|
329
|
+
deps.autoResumeByChat.set(chatId, command.autoResumeOnRateLimit)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (deps.activeTurns.has(chatId)) {
|
|
333
|
+
deps.analytics.track("message_sent")
|
|
334
|
+
const queuedMessage = await enqueueMessage(deps, chatId, command.content, command.attachments ?? [], {
|
|
335
|
+
provider: command.provider,
|
|
336
|
+
model: command.model,
|
|
337
|
+
modelOptions: command.modelOptions,
|
|
338
|
+
effort: command.effort,
|
|
339
|
+
planMode: command.planMode,
|
|
340
|
+
})
|
|
341
|
+
return { chatId, queuedMessageId: queuedMessage.id, queued: true as const }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Mentions no longer short-circuit the main turn. The main agent always
|
|
345
|
+
// runs and decides whether to delegate via `mcp__kanna__delegate_subagent`
|
|
346
|
+
// (Anthropic-style Task-tool pattern). `parseMentions` still runs inside
|
|
347
|
+
// `startTurnForChat` → `appendUserPrompt` so the user_prompt entry
|
|
348
|
+
// continues to carry `subagentMentions` metadata for UI badges + analytics.
|
|
349
|
+
const chat = deps.store.requireChat(chatId)
|
|
350
|
+
const provider = resolveProvider(command, chat.provider)
|
|
351
|
+
const customModels = deps.getAppSettingsSnapshot().customModels ?? []
|
|
352
|
+
const settings = getProviderSettings(provider, command, customModels)
|
|
353
|
+
deps.analytics.track("message_sent")
|
|
354
|
+
|
|
355
|
+
// Proactive compact: if the latest usage snapshot crossed claude-code's
|
|
356
|
+
// auto-compact threshold, inject a synthetic `/compact` turn ahead of the
|
|
357
|
+
// user's real message. The user's prompt sits in the queue and runs after
|
|
358
|
+
// `/compact` produces its summary, so the next turn ships with a bounded
|
|
359
|
+
// history instead of looping on "Prompt is too long".
|
|
360
|
+
if (
|
|
361
|
+
provider === "claude" // openrouter intentionally excluded: /compact is claude-CLI-specific
|
|
362
|
+
&& shouldInjectProactiveCompact(deps, chatId, command.content)
|
|
363
|
+
) {
|
|
364
|
+
const queuedMessage = await enqueueMessage(deps, chatId, command.content, command.attachments ?? [], {
|
|
365
|
+
provider: command.provider,
|
|
366
|
+
model: command.model,
|
|
367
|
+
modelOptions: command.modelOptions,
|
|
368
|
+
effort: command.effort,
|
|
369
|
+
planMode: command.planMode,
|
|
370
|
+
})
|
|
371
|
+
await deps.startTurnForChat({
|
|
372
|
+
chatId,
|
|
373
|
+
provider,
|
|
374
|
+
content: "/compact",
|
|
375
|
+
attachments: [],
|
|
376
|
+
model: settings.model,
|
|
377
|
+
effort: settings.effort,
|
|
378
|
+
serviceTier: settings.serviceTier,
|
|
379
|
+
planMode: settings.planMode,
|
|
380
|
+
// /compact is a slash command, not the user's actual message — don't
|
|
381
|
+
// persist a user_prompt transcript entry for it.
|
|
382
|
+
appendUserPrompt: false,
|
|
383
|
+
profile,
|
|
384
|
+
})
|
|
385
|
+
// Tag the active turn so the result handler can update the circuit
|
|
386
|
+
// breaker (reset on success / increment on failure).
|
|
387
|
+
const compactActive = deps.activeTurns.get(chatId)
|
|
388
|
+
if (compactActive) compactActive.proactiveCompactInjection = true
|
|
389
|
+
|
|
390
|
+
logSendToStartingProfile(profile, "chat_send.proactive_compact_injected", {
|
|
391
|
+
chatId,
|
|
392
|
+
provider,
|
|
393
|
+
model: settings.model,
|
|
394
|
+
queuedUserMessageId: queuedMessage.id,
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
return { chatId, queuedMessageId: queuedMessage.id, queued: true as const }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
await deps.startTurnForChat({
|
|
401
|
+
chatId,
|
|
402
|
+
provider,
|
|
403
|
+
content: command.content,
|
|
404
|
+
attachments: command.attachments ?? [],
|
|
405
|
+
model: settings.model,
|
|
406
|
+
effort: settings.effort,
|
|
407
|
+
serviceTier: settings.serviceTier,
|
|
408
|
+
planMode: settings.planMode,
|
|
409
|
+
appendUserPrompt: true,
|
|
410
|
+
profile,
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
logSendToStartingProfile(profile, "chat_send.ready_for_ack", {
|
|
414
|
+
chatId,
|
|
415
|
+
provider,
|
|
416
|
+
model: settings.model,
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
return { chatId }
|
|
420
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
resolveClaudeDriverPreference,
|
|
4
|
+
getEnabledCustomMcpServers,
|
|
5
|
+
buildOAuthBearers,
|
|
6
|
+
resolveChatPolicy,
|
|
7
|
+
killPtyInstance,
|
|
8
|
+
type ClaudeSessionConfigHelpersDeps,
|
|
9
|
+
} from "./claude-session-config-helpers"
|
|
10
|
+
import { POLICY_DEFAULT } from "../shared/permission-policy"
|
|
11
|
+
import type { McpServerConfig, McpOAuthState } from "../shared/types"
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers / stubs
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
function makeServer(overrides: Partial<McpServerConfig & { enabled: boolean }> = {}): McpServerConfig {
|
|
18
|
+
return {
|
|
19
|
+
id: "srv-1",
|
|
20
|
+
name: "test-server",
|
|
21
|
+
enabled: true,
|
|
22
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
23
|
+
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
24
|
+
lastTest: { status: "none" as const },
|
|
25
|
+
transport: "http",
|
|
26
|
+
url: "https://example.com/mcp",
|
|
27
|
+
headers: {},
|
|
28
|
+
...overrides,
|
|
29
|
+
} as McpServerConfig
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeDeps(overrides: Partial<ClaudeSessionConfigHelpersDeps> = {}): ClaudeSessionConfigHelpersDeps {
|
|
33
|
+
return {
|
|
34
|
+
getAppSettingsSnapshot: () => ({}),
|
|
35
|
+
chatPolicy: POLICY_DEFAULT,
|
|
36
|
+
store: { state: null },
|
|
37
|
+
ptyInstanceRegistry: null,
|
|
38
|
+
ensureFreshToken: async () => "test-token",
|
|
39
|
+
persistOAuthState: null,
|
|
40
|
+
killProcessTree: async (_pid) => {},
|
|
41
|
+
...overrides,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// resolveClaudeDriverPreference
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
describe("resolveClaudeDriverPreference", () => {
|
|
50
|
+
const originalEnv = process.env.KANNA_CLAUDE_DRIVER
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
delete process.env.KANNA_CLAUDE_DRIVER
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("returns 'pty' when settings preference is 'pty'", () => {
|
|
57
|
+
const deps = makeDeps({
|
|
58
|
+
getAppSettingsSnapshot: () => ({ claudeDriver: { preference: "pty" } }),
|
|
59
|
+
})
|
|
60
|
+
expect(resolveClaudeDriverPreference(deps)).toBe("pty")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("returns 'sdk' when settings preference is 'sdk'", () => {
|
|
64
|
+
const deps = makeDeps({
|
|
65
|
+
getAppSettingsSnapshot: () => ({ claudeDriver: { preference: "sdk" } }),
|
|
66
|
+
})
|
|
67
|
+
expect(resolveClaudeDriverPreference(deps)).toBe("sdk")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("falls through to env var when settings have no preference", () => {
|
|
71
|
+
process.env.KANNA_CLAUDE_DRIVER = "pty"
|
|
72
|
+
const deps = makeDeps({ getAppSettingsSnapshot: () => ({}) })
|
|
73
|
+
expect(resolveClaudeDriverPreference(deps)).toBe("pty")
|
|
74
|
+
// restore
|
|
75
|
+
process.env.KANNA_CLAUDE_DRIVER = originalEnv ?? ""
|
|
76
|
+
if (!originalEnv) delete process.env.KANNA_CLAUDE_DRIVER
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("defaults to 'sdk' when no settings and no env var", () => {
|
|
80
|
+
const deps = makeDeps({ getAppSettingsSnapshot: () => ({}) })
|
|
81
|
+
expect(resolveClaudeDriverPreference(deps)).toBe("sdk")
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// getEnabledCustomMcpServers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe("getEnabledCustomMcpServers", () => {
|
|
90
|
+
test("returns only enabled servers", () => {
|
|
91
|
+
const enabled = makeServer({ id: "s1", enabled: true })
|
|
92
|
+
const disabled = makeServer({ id: "s2", enabled: false })
|
|
93
|
+
const deps = makeDeps({
|
|
94
|
+
getAppSettingsSnapshot: () => ({ customMcpServers: [enabled, disabled] }),
|
|
95
|
+
})
|
|
96
|
+
const result = getEnabledCustomMcpServers(deps)
|
|
97
|
+
expect(result).toHaveLength(1)
|
|
98
|
+
expect(result[0].id).toBe("s1")
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("returns empty array when customMcpServers is not an array", () => {
|
|
102
|
+
const deps = makeDeps({ getAppSettingsSnapshot: () => ({}) })
|
|
103
|
+
expect(getEnabledCustomMcpServers(deps)).toEqual([])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("returns empty array when all servers are disabled", () => {
|
|
107
|
+
const deps = makeDeps({
|
|
108
|
+
getAppSettingsSnapshot: () => ({
|
|
109
|
+
customMcpServers: [makeServer({ enabled: false })],
|
|
110
|
+
}),
|
|
111
|
+
})
|
|
112
|
+
expect(getEnabledCustomMcpServers(deps)).toEqual([])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// buildOAuthBearers
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe("buildOAuthBearers", () => {
|
|
121
|
+
test("skips stdio servers", async () => {
|
|
122
|
+
const ensureFreshToken = mock(async () => "token")
|
|
123
|
+
const deps = makeDeps({ ensureFreshToken })
|
|
124
|
+
const stdioServer = makeServer({ transport: "stdio", command: "node", args: [], env: {} })
|
|
125
|
+
const result = await buildOAuthBearers(deps, [stdioServer as McpServerConfig])
|
|
126
|
+
expect(result.size).toBe(0)
|
|
127
|
+
expect(ensureFreshToken).not.toHaveBeenCalled()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("skips unauthenticated servers", async () => {
|
|
131
|
+
const ensureFreshToken = mock(async () => "token")
|
|
132
|
+
const unauthOauth: McpOAuthState = { enabled: true, status: "unauthenticated" }
|
|
133
|
+
const server = makeServer({ oauth: unauthOauth })
|
|
134
|
+
const deps = makeDeps({ ensureFreshToken })
|
|
135
|
+
const result = await buildOAuthBearers(deps, [server])
|
|
136
|
+
expect(result.size).toBe(0)
|
|
137
|
+
expect(ensureFreshToken).not.toHaveBeenCalled()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("fetches token for authenticated network servers and stores by id", async () => {
|
|
141
|
+
const ensureFreshToken = mock(async () => "bearer-xyz")
|
|
142
|
+
const authOauth: McpOAuthState = { enabled: true, status: "authenticated" }
|
|
143
|
+
const server = makeServer({ id: "srv-auth", oauth: authOauth })
|
|
144
|
+
const deps = makeDeps({ ensureFreshToken })
|
|
145
|
+
const result = await buildOAuthBearers(deps, [server])
|
|
146
|
+
expect(result.size).toBe(1)
|
|
147
|
+
expect(result.get("srv-auth")).toBe("bearer-xyz")
|
|
148
|
+
expect(ensureFreshToken).toHaveBeenCalledTimes(1)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("calls persistOAuthState when token is refreshed", async () => {
|
|
152
|
+
const persisted: Array<{ id: string; oauth: McpOAuthState }> = []
|
|
153
|
+
const authOauth: McpOAuthState = { enabled: true, status: "authenticated" }
|
|
154
|
+
const server = makeServer({ id: "srv-p", oauth: authOauth })
|
|
155
|
+
const deps = makeDeps({
|
|
156
|
+
ensureFreshToken: async (_s, opts) => {
|
|
157
|
+
opts.persist(authOauth)
|
|
158
|
+
return "new-token"
|
|
159
|
+
},
|
|
160
|
+
persistOAuthState: (id, oauth) => { persisted.push({ id, oauth }) },
|
|
161
|
+
})
|
|
162
|
+
await buildOAuthBearers(deps, [server])
|
|
163
|
+
expect(persisted).toHaveLength(1)
|
|
164
|
+
expect(persisted[0].id).toBe("srv-p")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("continues processing other servers when one throws", async () => {
|
|
168
|
+
const authOauth: McpOAuthState = { enabled: true, status: "authenticated" }
|
|
169
|
+
const bad = makeServer({ id: "bad", oauth: authOauth })
|
|
170
|
+
const good = makeServer({ id: "good", oauth: authOauth, name: "good-server" })
|
|
171
|
+
const ensureFreshToken = mock(async (s: McpServerConfig) => {
|
|
172
|
+
if (s.id === "bad") throw new Error("refresh failed")
|
|
173
|
+
return "good-token"
|
|
174
|
+
})
|
|
175
|
+
const deps = makeDeps({ ensureFreshToken })
|
|
176
|
+
const result = await buildOAuthBearers(deps, [bad, good])
|
|
177
|
+
expect(result.has("bad")).toBe(false)
|
|
178
|
+
expect(result.get("good")).toBe("good-token")
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// resolveChatPolicy
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
describe("resolveChatPolicy", () => {
|
|
187
|
+
test("returns base chatPolicy when store has no state", () => {
|
|
188
|
+
const deps = makeDeps({ store: { state: null } })
|
|
189
|
+
const result = resolveChatPolicy(deps, "chat-1")
|
|
190
|
+
expect(result).toBe(POLICY_DEFAULT)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test("returns base chatPolicy when chat has no policyOverride", () => {
|
|
194
|
+
const deps = makeDeps({
|
|
195
|
+
store: {
|
|
196
|
+
state: {
|
|
197
|
+
chatsById: new Map([["chat-1", { policyOverride: null }]]),
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
const result = resolveChatPolicy(deps, "chat-1")
|
|
202
|
+
expect(result).toEqual(POLICY_DEFAULT)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test("applies policyOverride when present", () => {
|
|
206
|
+
const override = { defaultAction: "auto-allow" as const }
|
|
207
|
+
const deps = makeDeps({
|
|
208
|
+
store: {
|
|
209
|
+
state: {
|
|
210
|
+
chatsById: new Map([["chat-1", { policyOverride: override }]]),
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
const result = resolveChatPolicy(deps, "chat-1")
|
|
215
|
+
expect(result.defaultAction).toBe("auto-allow")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test("falls back to base policy when chat id not in store", () => {
|
|
219
|
+
const deps = makeDeps({
|
|
220
|
+
store: {
|
|
221
|
+
state: { chatsById: new Map() },
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
const result = resolveChatPolicy(deps, "nonexistent")
|
|
225
|
+
expect(result).toEqual(POLICY_DEFAULT)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// killPtyInstance
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
describe("killPtyInstance", () => {
|
|
234
|
+
test("throws when no PTY instance found for chat", async () => {
|
|
235
|
+
const deps = makeDeps({
|
|
236
|
+
ptyInstanceRegistry: {
|
|
237
|
+
snapshot: () => [],
|
|
238
|
+
markExitedIfCurrent: () => {},
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
await expect(killPtyInstance(deps, "chat-1")).rejects.toThrow("No live PTY instance for chat")
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("throws when PTY instance has no pid", async () => {
|
|
245
|
+
const deps = makeDeps({
|
|
246
|
+
ptyInstanceRegistry: {
|
|
247
|
+
snapshot: () => [{ chatId: "chat-1", pid: null }],
|
|
248
|
+
markExitedIfCurrent: () => {},
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
await expect(killPtyInstance(deps, "chat-1")).rejects.toThrow("No live PTY instance for chat")
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test("calls killProcessTree with the instance pid", async () => {
|
|
255
|
+
const killed: number[] = []
|
|
256
|
+
const deps = makeDeps({
|
|
257
|
+
ptyInstanceRegistry: {
|
|
258
|
+
snapshot: () => [{ chatId: "chat-1", pid: 42 }],
|
|
259
|
+
markExitedIfCurrent: () => {},
|
|
260
|
+
},
|
|
261
|
+
killProcessTree: async (pid) => { killed.push(pid) },
|
|
262
|
+
})
|
|
263
|
+
await killPtyInstance(deps, "chat-1")
|
|
264
|
+
expect(killed).toEqual([42])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test("calls markExitedIfCurrent after killProcessTree", async () => {
|
|
268
|
+
const marked: Array<{ chatId: string; pid: number }> = []
|
|
269
|
+
const deps = makeDeps({
|
|
270
|
+
ptyInstanceRegistry: {
|
|
271
|
+
snapshot: () => [{ chatId: "chat-1", pid: 99 }],
|
|
272
|
+
markExitedIfCurrent: (chatId, pid) => { marked.push({ chatId, pid }) },
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
await killPtyInstance(deps, "chat-1")
|
|
276
|
+
expect(marked).toHaveLength(1)
|
|
277
|
+
expect(marked[0]).toMatchObject({ chatId: "chat-1", pid: 99 })
|
|
278
|
+
})
|
|
279
|
+
})
|