claude-code-rust 0.12.3 → 0.12.4
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/agent-sdk/dist/bridge/commands.js +20 -0
- package/agent-sdk/dist/bridge/events.js +15 -1
- package/agent-sdk/dist/bridge/logger.js +10 -0
- package/agent-sdk/dist/bridge/message_handlers.js +172 -1
- package/agent-sdk/dist/bridge/session_lifecycle.js +37 -0
- package/agent-sdk/dist/bridge/state_parsing.js +9 -0
- package/agent-sdk/dist/bridge/tooling.js +55 -8
- package/agent-sdk/dist/bridge.js +409 -1
- package/agent-sdk/dist/bridge.test.js +622 -5
- package/package.json +4 -4
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, applySessionAgent, applySessionEffort, emitAgentConfigOptionUpdate, emitEffortConfigOptionUpdate, handleTaskSystemMessage, handleSdkMessage, isShellToolName, mapSdkAccountInfo, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRuntimeSessionState, parseRateLimitStatus, bridgeMcpConfigToSdk, mapMcpServerStatus, mapMcpServerStatusConfig, normalizeSettingsParseError, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, unwrapToolUseResult, updateAvailableCommands, handleReloadPluginsCommand, } from "./bridge.js";
|
|
3
|
+
import { AsyncQueue, CACHE_SPLIT_POLICY, buildApiRetryUpdate, buildRateLimitUpdate, buildRewindConversationPlan, buildQueryOptions, canGenerateSessionTitle, generatePersistedSessionTitle, buildSessionMutationOptions, buildSessionListOptions, buildToolResultFields, createToolCall, applySessionAgent, applySessionEffort, emitAgentConfigOptionUpdate, emitEffortConfigOptionUpdate, handleTaskSystemMessage, handleSdkMessage, isShellToolName, mapSdkAccountInfo, mapAvailableAgents, mapAvailableModels, mapSessionMessagesToUpdates, mapSdkSessions, agentSdkVersionCompatibilityError, looksLikeAuthRequired, normalizeToolResultText, parseFastModeState, parseRuntimeSessionState, parseRateLimitStatus, bridgeMcpConfigToSdk, mapMcpServerStatus, mapMcpServerStatusConfig, normalizeSettingsParseError, normalizeToolKind, parseCommandEnvelope, permissionOptionsFromSuggestions, permissionResultFromOutcome, previewKilobyteLabel, staleMcpAuthCandidates, resolveInstalledAgentSdkVersion, rewindTargetsFromSessionMessages, unwrapToolUseResult, updateAvailableCommands, handleReloadPluginsCommand, } from "./bridge.js";
|
|
4
4
|
import { availableModesForSession, buildModeState, markModeUnavailableForSession, permissionModeFailureLooksUnsupported, refreshSupportedModesForSession, } from "./bridge/commands.js";
|
|
5
5
|
import { handleMcpSetServersCommand } from "./bridge/mcp.js";
|
|
6
|
-
import { emitCurrentModelUpdate, handleUserDialogResponse, refreshCurrentModel, resolveCurrentModel, sessions, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
|
|
6
|
+
import { emitCurrentModelUpdate, handleUserDialogResponse, closeSessionsBeforeRegister, refreshCurrentModel, resolveCurrentModel, sessions, shouldInvalidateResolvedRuntimeModel, shouldEmitStartupAuthRequiredForAccount, } from "./bridge/session_lifecycle.js";
|
|
7
7
|
import { classifyTurnErrorKind } from "./bridge/error_classification.js";
|
|
8
8
|
import { emitToolCall, emitToolProgressUpdate, emitToolResultUpdate } from "./bridge/tool_calls.js";
|
|
9
9
|
import { linkTaskToolUse } from "./bridge/task_links.js";
|
|
10
10
|
import { requestAskUserQuestionAnswers } from "./bridge/user_interaction.js";
|
|
11
|
-
import { handleResultMessage } from "./bridge/message_handlers.js";
|
|
11
|
+
import { flushPendingWorkerShutdown, handleResultMessage } from "./bridge/message_handlers.js";
|
|
12
12
|
const BRIDGE_RUNTIME_PROCESS_NAME = process.platform === "win32" ? "claude-rs-bridge-node.exe" : "claude-rs-bridge-node";
|
|
13
13
|
const BRIDGE_RUNTIME_GUARD_PROMPT = `Do not terminate the Claude Rust bridge runtime process \`${BRIDGE_RUNTIME_PROCESS_NAME}\`; ` +
|
|
14
14
|
"when cleaning up development servers, only stop processes by explicit PIDs you started in this session.";
|
|
@@ -39,11 +39,34 @@ function makeSessionState() {
|
|
|
39
39
|
pendingQuestions: new Map(),
|
|
40
40
|
pendingUserDialogs: new Map(),
|
|
41
41
|
pendingElicitations: new Map(),
|
|
42
|
+
informationalDedupKeys: new Set(),
|
|
42
43
|
mcpStatusRevalidatedAt: new Map(),
|
|
43
44
|
hiddenToolUseIds: new Set(),
|
|
44
45
|
authHintSent: false,
|
|
45
46
|
};
|
|
46
47
|
}
|
|
48
|
+
test("closeSessionsBeforeRegister closes same-key stale session before replacement registration", async () => {
|
|
49
|
+
sessions.clear();
|
|
50
|
+
let staleClosed = 0;
|
|
51
|
+
const stale = makeSessionState();
|
|
52
|
+
stale.sessionId = "session-1";
|
|
53
|
+
stale.query = {
|
|
54
|
+
close: () => {
|
|
55
|
+
staleClosed += 1;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const replacement = makeSessionState();
|
|
59
|
+
replacement.sessionId = "session-1";
|
|
60
|
+
sessions.set(stale.sessionId, stale);
|
|
61
|
+
await closeSessionsBeforeRegister(replacement, [stale], "req-1");
|
|
62
|
+
assert.equal(staleClosed, 1);
|
|
63
|
+
assert.equal(sessions.has("session-1"), false);
|
|
64
|
+
sessions.set(replacement.sessionId, replacement);
|
|
65
|
+
await closeSessionsBeforeRegister(replacement, [stale], "req-2");
|
|
66
|
+
assert.equal(staleClosed, 2);
|
|
67
|
+
assert.equal(sessions.get("session-1"), replacement);
|
|
68
|
+
sessions.clear();
|
|
69
|
+
});
|
|
47
70
|
test("availableModesForSession omits conditional modes when unsupported", () => {
|
|
48
71
|
const session = makeSessionState();
|
|
49
72
|
refreshSupportedModesForSession(session);
|
|
@@ -706,6 +729,150 @@ test("parseCommandEnvelope validates get_context_usage command", () => {
|
|
|
706
729
|
session_id: "session-123",
|
|
707
730
|
});
|
|
708
731
|
});
|
|
732
|
+
test("parseCommandEnvelope validates get_rewind_targets command", () => {
|
|
733
|
+
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
734
|
+
request_id: "req-rewind-targets",
|
|
735
|
+
command: "get_rewind_targets",
|
|
736
|
+
session_id: "session-123",
|
|
737
|
+
}));
|
|
738
|
+
assert.equal(parsed.requestId, "req-rewind-targets");
|
|
739
|
+
assert.deepEqual(parsed.command, {
|
|
740
|
+
command: "get_rewind_targets",
|
|
741
|
+
session_id: "session-123",
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
test("parseCommandEnvelope validates rewind command modes", () => {
|
|
745
|
+
for (const restoreMode of ["both", "conversation", "code"]) {
|
|
746
|
+
const parsed = parseCommandEnvelope(JSON.stringify({
|
|
747
|
+
request_id: "req-rewind",
|
|
748
|
+
command: "rewind",
|
|
749
|
+
session_id: "session-123",
|
|
750
|
+
target_user_message_id: "user-1",
|
|
751
|
+
restore_mode: restoreMode,
|
|
752
|
+
launch_settings: {
|
|
753
|
+
language: "German",
|
|
754
|
+
},
|
|
755
|
+
}));
|
|
756
|
+
assert.equal(parsed.requestId, "req-rewind");
|
|
757
|
+
assert.deepEqual(parsed.command, {
|
|
758
|
+
command: "rewind",
|
|
759
|
+
session_id: "session-123",
|
|
760
|
+
target_user_message_id: "user-1",
|
|
761
|
+
restore_mode: restoreMode,
|
|
762
|
+
launch_settings: { language: "German" },
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
test("parseCommandEnvelope rejects invalid rewind mode", () => {
|
|
767
|
+
assert.throws(() => parseCommandEnvelope(JSON.stringify({
|
|
768
|
+
command: "rewind",
|
|
769
|
+
session_id: "session-123",
|
|
770
|
+
target_user_message_id: "user-1",
|
|
771
|
+
restore_mode: "files",
|
|
772
|
+
})), /rewind\.restore_mode must be one of both, conversation, code/);
|
|
773
|
+
});
|
|
774
|
+
test("rewindTargetsFromSessionMessages filters user text messages with UUIDs", () => {
|
|
775
|
+
const messages = [
|
|
776
|
+
{
|
|
777
|
+
type: "user",
|
|
778
|
+
uuid: "user-1",
|
|
779
|
+
message: { role: "user", content: [{ type: "text", text: " first prompt\nline " }] },
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
type: "assistant",
|
|
783
|
+
uuid: "assistant-1",
|
|
784
|
+
message: { role: "assistant", content: [{ type: "text", text: "ignored" }] },
|
|
785
|
+
},
|
|
786
|
+
{
|
|
787
|
+
type: "user",
|
|
788
|
+
uuid: "tool-result",
|
|
789
|
+
message: { role: "user", content: [{ type: "tool_result", content: "ignored" }] },
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
type: "user",
|
|
793
|
+
uuid: "user-2",
|
|
794
|
+
message: { role: "user", content: [{ type: "text", text: "second prompt" }] },
|
|
795
|
+
},
|
|
796
|
+
];
|
|
797
|
+
assert.deepEqual(rewindTargetsFromSessionMessages(messages), [
|
|
798
|
+
{
|
|
799
|
+
uuid: "user-2",
|
|
800
|
+
first_text: "second prompt",
|
|
801
|
+
input_text: "second prompt",
|
|
802
|
+
index: 3,
|
|
803
|
+
previous_assistant_uuid: "assistant-1",
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
uuid: "user-1",
|
|
807
|
+
first_text: "first prompt line",
|
|
808
|
+
input_text: "first prompt\nline",
|
|
809
|
+
index: 0,
|
|
810
|
+
},
|
|
811
|
+
]);
|
|
812
|
+
});
|
|
813
|
+
test("buildRewindConversationPlan anchors at previous assistant message", () => {
|
|
814
|
+
const messages = [
|
|
815
|
+
{
|
|
816
|
+
type: "user",
|
|
817
|
+
uuid: "user-1",
|
|
818
|
+
message: { role: "user", content: "first prompt" },
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
type: "assistant",
|
|
822
|
+
uuid: "assistant-1",
|
|
823
|
+
message: { role: "assistant", content: [{ type: "text", text: "reply" }] },
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
type: "user",
|
|
827
|
+
uuid: "user-2",
|
|
828
|
+
message: { role: "user", content: "second prompt" },
|
|
829
|
+
},
|
|
830
|
+
];
|
|
831
|
+
const plan = buildRewindConversationPlan(messages, "user-2");
|
|
832
|
+
assert.ok(plan);
|
|
833
|
+
assert.equal(plan.inputText, "second prompt");
|
|
834
|
+
assert.equal(plan.previousAssistantUuid, "assistant-1");
|
|
835
|
+
assert.equal(plan.targetIndex, 2);
|
|
836
|
+
assert.deepEqual(plan.retainedMessages.map((message) => message.uuid), ["user-1", "assistant-1"]);
|
|
837
|
+
assert.ok(plan.resumeUpdates.length > 0);
|
|
838
|
+
});
|
|
839
|
+
test("buildRewindConversationPlan treats first user message as fresh replacement", () => {
|
|
840
|
+
const messages = [
|
|
841
|
+
{
|
|
842
|
+
type: "user",
|
|
843
|
+
uuid: "user-1",
|
|
844
|
+
message: { role: "user", content: " first prompt\nline " },
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
type: "assistant",
|
|
848
|
+
uuid: "assistant-1",
|
|
849
|
+
message: { role: "assistant", content: [{ type: "text", text: "reply" }] },
|
|
850
|
+
},
|
|
851
|
+
];
|
|
852
|
+
const plan = buildRewindConversationPlan(messages, "user-1");
|
|
853
|
+
assert.ok(plan);
|
|
854
|
+
assert.equal(plan.inputText, " first prompt\nline ");
|
|
855
|
+
assert.equal(plan.previousAssistantUuid, undefined);
|
|
856
|
+
assert.equal(plan.targetIndex, 0);
|
|
857
|
+
assert.deepEqual(plan.retainedMessages, []);
|
|
858
|
+
assert.deepEqual(plan.resumeUpdates, []);
|
|
859
|
+
});
|
|
860
|
+
test("buildRewindConversationPlan rejects stale or inconsistent targets", () => {
|
|
861
|
+
const messages = [
|
|
862
|
+
{
|
|
863
|
+
type: "user",
|
|
864
|
+
uuid: "user-1",
|
|
865
|
+
message: { role: "user", content: "first prompt" },
|
|
866
|
+
},
|
|
867
|
+
{
|
|
868
|
+
type: "user",
|
|
869
|
+
uuid: "user-2",
|
|
870
|
+
message: { role: "user", content: "second prompt" },
|
|
871
|
+
},
|
|
872
|
+
];
|
|
873
|
+
assert.equal(buildRewindConversationPlan(messages, "missing-user"), null);
|
|
874
|
+
assert.equal(buildRewindConversationPlan(messages, "user-2"), null);
|
|
875
|
+
});
|
|
709
876
|
test("staleMcpAuthCandidates selects previously connected servers that regressed to needs-auth", () => {
|
|
710
877
|
const candidates = staleMcpAuthCandidates([
|
|
711
878
|
{
|
|
@@ -817,12 +984,31 @@ test("buildQueryOptions maps launch settings into sdk query options", () => {
|
|
|
817
984
|
assert.equal("effort" in options, false);
|
|
818
985
|
assert.equal(options.agentProgressSummaries, true);
|
|
819
986
|
assert.equal(options.promptSuggestions, true);
|
|
987
|
+
assert.equal(options.enableFileCheckpointing, true);
|
|
820
988
|
assert.equal(options.sessionId, "session-1");
|
|
821
989
|
assert.deepEqual(options.settingSources, ["user", "project", "local"]);
|
|
822
990
|
assert.deepEqual(options.toolConfig, {
|
|
823
991
|
askUserQuestion: { previewFormat: "markdown" },
|
|
824
992
|
});
|
|
825
993
|
});
|
|
994
|
+
test("buildQueryOptions includes resumeSessionAt when provided", () => {
|
|
995
|
+
const input = new AsyncQueue();
|
|
996
|
+
const options = buildQueryOptions({
|
|
997
|
+
cwd: "C:/work",
|
|
998
|
+
resume: "session-1",
|
|
999
|
+
resumeSessionAt: "assistant-1",
|
|
1000
|
+
launchSettings: {},
|
|
1001
|
+
provisionalSessionId: "session-1",
|
|
1002
|
+
input,
|
|
1003
|
+
canUseTool: async () => ({ behavior: "deny", message: "not used" }),
|
|
1004
|
+
enableSdkDebug: false,
|
|
1005
|
+
enableSpawnDebug: false,
|
|
1006
|
+
sessionIdForLogs: () => "session-1",
|
|
1007
|
+
});
|
|
1008
|
+
assert.equal(options.resume, "session-1");
|
|
1009
|
+
assert.equal(options.resumeSessionAt, "assistant-1");
|
|
1010
|
+
assert.equal("sessionId" in options, false);
|
|
1011
|
+
});
|
|
826
1012
|
test("buildQueryOptions forwards settings and maps startup model and permission mode", () => {
|
|
827
1013
|
const input = new AsyncQueue();
|
|
828
1014
|
const options = buildQueryOptions({
|
|
@@ -1389,6 +1575,96 @@ test("handleSdkMessage emits transcript retraction for model_refusal_fallback",
|
|
|
1389
1575
|
]);
|
|
1390
1576
|
assert.equal(events.some((event) => event.update?.type === "system_notice_update"), false);
|
|
1391
1577
|
});
|
|
1578
|
+
test("handleSdkMessage emits warning notice for model_refusal_no_fallback with explanation", () => {
|
|
1579
|
+
const session = makeSessionState();
|
|
1580
|
+
const events = captureBridgeEvents(() => {
|
|
1581
|
+
handleSdkMessage(session, {
|
|
1582
|
+
type: "system",
|
|
1583
|
+
subtype: "model_refusal_no_fallback",
|
|
1584
|
+
original_model: "claude-opus-4-1",
|
|
1585
|
+
request_id: "req-1",
|
|
1586
|
+
api_refusal_category: "cyber",
|
|
1587
|
+
api_refusal_explanation: "policy text",
|
|
1588
|
+
refused_user_message_uuid: "user-1",
|
|
1589
|
+
content: "raw content",
|
|
1590
|
+
uuid: "refusal-notice",
|
|
1591
|
+
session_id: "session-1",
|
|
1592
|
+
});
|
|
1593
|
+
});
|
|
1594
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1595
|
+
{
|
|
1596
|
+
type: "system_notice_update",
|
|
1597
|
+
severity: "warning",
|
|
1598
|
+
message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Reason: policy text.",
|
|
1599
|
+
},
|
|
1600
|
+
]);
|
|
1601
|
+
});
|
|
1602
|
+
test("handleSdkMessage emits warning notice for model_refusal_no_fallback with category only", () => {
|
|
1603
|
+
const session = makeSessionState();
|
|
1604
|
+
const events = captureBridgeEvents(() => {
|
|
1605
|
+
handleSdkMessage(session, {
|
|
1606
|
+
type: "system",
|
|
1607
|
+
subtype: "model_refusal_no_fallback",
|
|
1608
|
+
original_model: "claude-opus-4-1",
|
|
1609
|
+
request_id: "req-1",
|
|
1610
|
+
api_refusal_category: "cyber",
|
|
1611
|
+
uuid: "refusal-notice",
|
|
1612
|
+
session_id: "session-1",
|
|
1613
|
+
});
|
|
1614
|
+
});
|
|
1615
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1616
|
+
{
|
|
1617
|
+
type: "system_notice_update",
|
|
1618
|
+
severity: "warning",
|
|
1619
|
+
message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Refusal category: cyber.",
|
|
1620
|
+
},
|
|
1621
|
+
]);
|
|
1622
|
+
});
|
|
1623
|
+
test("handleSdkMessage emits warning notice for model_refusal_no_fallback with content detail", () => {
|
|
1624
|
+
const session = makeSessionState();
|
|
1625
|
+
const events = captureBridgeEvents(() => {
|
|
1626
|
+
handleSdkMessage(session, {
|
|
1627
|
+
type: "system",
|
|
1628
|
+
subtype: "model_refusal_no_fallback",
|
|
1629
|
+
original_model: "claude-opus-4-1",
|
|
1630
|
+
request_id: "req-1",
|
|
1631
|
+
content: "Refused by policy!",
|
|
1632
|
+
uuid: "refusal-notice",
|
|
1633
|
+
session_id: "session-1",
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1637
|
+
{
|
|
1638
|
+
type: "system_notice_update",
|
|
1639
|
+
severity: "warning",
|
|
1640
|
+
message: "Could not continue with claude-opus-4-1: model refused the request and no fallback model is configured. Refused by policy!",
|
|
1641
|
+
},
|
|
1642
|
+
]);
|
|
1643
|
+
});
|
|
1644
|
+
test("handleSdkMessage emits readable model_refusal_no_fallback notice for empty metadata", () => {
|
|
1645
|
+
const session = makeSessionState();
|
|
1646
|
+
const events = captureBridgeEvents(() => {
|
|
1647
|
+
handleSdkMessage(session, {
|
|
1648
|
+
type: "system",
|
|
1649
|
+
subtype: "model_refusal_no_fallback",
|
|
1650
|
+
original_model: " ",
|
|
1651
|
+
request_id: null,
|
|
1652
|
+
api_refusal_category: " ",
|
|
1653
|
+
api_refusal_explanation: null,
|
|
1654
|
+
refused_user_message_uuid: null,
|
|
1655
|
+
content: " ",
|
|
1656
|
+
uuid: "refusal-notice",
|
|
1657
|
+
session_id: "session-1",
|
|
1658
|
+
});
|
|
1659
|
+
});
|
|
1660
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
1661
|
+
{
|
|
1662
|
+
type: "system_notice_update",
|
|
1663
|
+
severity: "warning",
|
|
1664
|
+
message: "Could not continue with the selected model: model refused the request and no fallback model is configured.",
|
|
1665
|
+
},
|
|
1666
|
+
]);
|
|
1667
|
+
});
|
|
1392
1668
|
test("handleSdkMessage emits tolerant transcript retraction for model_fallback", () => {
|
|
1393
1669
|
const session = makeSessionState();
|
|
1394
1670
|
const events = captureBridgeEvents(() => {
|
|
@@ -2908,6 +3184,7 @@ test("normalizeToolKind maps known tool names", () => {
|
|
|
2908
3184
|
assert.equal(normalizeToolKind("ShowOnboardingRolePicker"), "other");
|
|
2909
3185
|
assert.equal(normalizeToolKind("TaskOutput"), "other");
|
|
2910
3186
|
assert.equal(normalizeToolKind("TaskStop"), "other");
|
|
3187
|
+
assert.equal(normalizeToolKind("ReadMcpResourceDir"), "read");
|
|
2911
3188
|
assert.equal(normalizeToolKind("Task"), "think");
|
|
2912
3189
|
assert.equal(normalizeToolKind("Agent"), "think");
|
|
2913
3190
|
assert.equal(normalizeToolKind("EnterPlanMode"), "switch_mode");
|
|
@@ -2925,6 +3202,16 @@ test("shell tool titles use input command", () => {
|
|
|
2925
3202
|
assert.equal(createToolCall("tc-powershell-title", "PowerShell", { command: "Get-ChildItem" }).title, "Get-ChildItem");
|
|
2926
3203
|
assert.equal(createToolCall("tc-powershell-empty", "PowerShell", {}).title, "Terminal");
|
|
2927
3204
|
});
|
|
3205
|
+
test("ReadMcpResourceDir titles include server and URI context", () => {
|
|
3206
|
+
assert.equal(createToolCall("tc-mcp-dir-title", "ReadMcpResourceDir", {
|
|
3207
|
+
server: "docs",
|
|
3208
|
+
uri: "file://manuals/",
|
|
3209
|
+
}).title, "ReadMcpResourceDir docs file://manuals/");
|
|
3210
|
+
assert.equal(createToolCall("tc-mcp-dir-uri-title", "ReadMcpResourceDir", {
|
|
3211
|
+
uri: "file://manuals/",
|
|
3212
|
+
}).title, "ReadMcpResourceDir file://manuals/");
|
|
3213
|
+
assert.equal(createToolCall("tc-mcp-dir-fallback-title", "ReadMcpResourceDir", {}).title, "ReadMcpResourceDir");
|
|
3214
|
+
});
|
|
2928
3215
|
test("parseFastModeState accepts known values and rejects unknown values", () => {
|
|
2929
3216
|
assert.equal(parseFastModeState("off"), "off");
|
|
2930
3217
|
assert.equal(parseFastModeState("cooldown"), "cooldown");
|
|
@@ -2957,10 +3244,14 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
|
|
|
2957
3244
|
overageDisabledReason: "out_of_credits",
|
|
2958
3245
|
isUsingOverage: false,
|
|
2959
3246
|
surpassedThreshold: 0.9,
|
|
3247
|
+
errorCode: "credits_required",
|
|
3248
|
+
canUserPurchaseCredits: true,
|
|
3249
|
+
hasChargeableSavedPaymentMethod: false,
|
|
2960
3250
|
});
|
|
2961
3251
|
assert.deepEqual(update, {
|
|
2962
3252
|
type: "rate_limit_update",
|
|
2963
3253
|
status: "allowed_warning",
|
|
3254
|
+
error_code: "credits_required",
|
|
2964
3255
|
resets_at: 1_741_280_000,
|
|
2965
3256
|
utilization: 0.92,
|
|
2966
3257
|
rate_limit_type: "five_hour",
|
|
@@ -2969,6 +3260,8 @@ test("buildRateLimitUpdate maps SDK fields to wire shape", () => {
|
|
|
2969
3260
|
overage_disabled_reason: "out_of_credits",
|
|
2970
3261
|
is_using_overage: false,
|
|
2971
3262
|
surpassed_threshold: 0.9,
|
|
3263
|
+
can_user_purchase_credits: true,
|
|
3264
|
+
has_chargeable_saved_payment_method: false,
|
|
2972
3265
|
});
|
|
2973
3266
|
});
|
|
2974
3267
|
test("buildRateLimitUpdate normalizes SDK overage boolean spellings", () => {
|
|
@@ -2998,6 +3291,10 @@ test("buildRateLimitUpdate rejects invalid payloads", () => {
|
|
|
2998
3291
|
status: "rejected",
|
|
2999
3292
|
overageStatus: "bad_status",
|
|
3000
3293
|
}), { type: "rate_limit_update", status: "rejected" });
|
|
3294
|
+
assert.deepEqual(buildRateLimitUpdate({ status: "rejected", errorCode: "other" }), {
|
|
3295
|
+
type: "rate_limit_update",
|
|
3296
|
+
status: "rejected",
|
|
3297
|
+
});
|
|
3001
3298
|
});
|
|
3002
3299
|
test("buildApiRetryUpdate maps SDK api_retry messages to wire shape", () => {
|
|
3003
3300
|
assert.deepEqual(buildApiRetryUpdate({
|
|
@@ -3283,6 +3580,197 @@ test("handleSdkMessage emits system notices for notifications and plugin failure
|
|
|
3283
3580
|
{ type: "system_notice_update", severity: "warning", message: "Plugin install failed acme: download failed" },
|
|
3284
3581
|
]);
|
|
3285
3582
|
});
|
|
3583
|
+
test("handleSdkMessage maps informational system messages to notices by level", () => {
|
|
3584
|
+
const session = makeSessionState();
|
|
3585
|
+
const events = captureBridgeEvents(() => {
|
|
3586
|
+
handleSdkMessage(session, {
|
|
3587
|
+
type: "system",
|
|
3588
|
+
subtype: "informational",
|
|
3589
|
+
content: " Sync ready ",
|
|
3590
|
+
level: "notice",
|
|
3591
|
+
uuid: "message-info-notice",
|
|
3592
|
+
session_id: "session-1",
|
|
3593
|
+
});
|
|
3594
|
+
handleSdkMessage(session, {
|
|
3595
|
+
type: "system",
|
|
3596
|
+
subtype: "informational",
|
|
3597
|
+
content: "Try /compact",
|
|
3598
|
+
level: "suggestion",
|
|
3599
|
+
uuid: "message-info-suggestion",
|
|
3600
|
+
session_id: "session-1",
|
|
3601
|
+
});
|
|
3602
|
+
handleSdkMessage(session, {
|
|
3603
|
+
type: "system",
|
|
3604
|
+
subtype: "informational",
|
|
3605
|
+
content: "Hook blocked continuation",
|
|
3606
|
+
level: "warning",
|
|
3607
|
+
uuid: "message-info-warning",
|
|
3608
|
+
session_id: "session-1",
|
|
3609
|
+
});
|
|
3610
|
+
});
|
|
3611
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3612
|
+
{ type: "system_notice_update", severity: "info", message: "Sync ready" },
|
|
3613
|
+
{ type: "system_notice_update", severity: "info", message: "Suggestion: Try /compact" },
|
|
3614
|
+
{ type: "system_notice_update", severity: "warning", message: "Hook blocked continuation" },
|
|
3615
|
+
]);
|
|
3616
|
+
});
|
|
3617
|
+
test("handleSdkMessage keeps informational info log-only unless continuation is prevented", () => {
|
|
3618
|
+
const session = makeSessionState();
|
|
3619
|
+
const events = captureBridgeEvents(() => {
|
|
3620
|
+
handleSdkMessage(session, {
|
|
3621
|
+
type: "system",
|
|
3622
|
+
subtype: "informational",
|
|
3623
|
+
content: "Transcript-only progress",
|
|
3624
|
+
level: "info",
|
|
3625
|
+
uuid: "message-info-log-only",
|
|
3626
|
+
session_id: "session-1",
|
|
3627
|
+
});
|
|
3628
|
+
handleSdkMessage(session, {
|
|
3629
|
+
type: "system",
|
|
3630
|
+
subtype: "informational",
|
|
3631
|
+
content: "Stop hook denied continuation",
|
|
3632
|
+
level: "info",
|
|
3633
|
+
prevent_continuation: true,
|
|
3634
|
+
uuid: "message-info-prevented",
|
|
3635
|
+
session_id: "session-1",
|
|
3636
|
+
});
|
|
3637
|
+
});
|
|
3638
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3639
|
+
{
|
|
3640
|
+
type: "system_notice_update",
|
|
3641
|
+
severity: "warning",
|
|
3642
|
+
message: "Stop hook denied continuation",
|
|
3643
|
+
},
|
|
3644
|
+
]);
|
|
3645
|
+
});
|
|
3646
|
+
test("handleSdkMessage deduplicates informational messages by tool use, level, and content", () => {
|
|
3647
|
+
const session = makeSessionState();
|
|
3648
|
+
const events = captureBridgeEvents(() => {
|
|
3649
|
+
handleSdkMessage(session, {
|
|
3650
|
+
type: "system",
|
|
3651
|
+
subtype: "informational",
|
|
3652
|
+
content: "Progress",
|
|
3653
|
+
level: "notice",
|
|
3654
|
+
tool_use_id: "tool-1",
|
|
3655
|
+
uuid: "message-info-1",
|
|
3656
|
+
session_id: "session-1",
|
|
3657
|
+
});
|
|
3658
|
+
handleSdkMessage(session, {
|
|
3659
|
+
type: "system",
|
|
3660
|
+
subtype: "informational",
|
|
3661
|
+
content: " Progress ",
|
|
3662
|
+
level: "notice",
|
|
3663
|
+
tool_use_id: "tool-1",
|
|
3664
|
+
uuid: "message-info-2",
|
|
3665
|
+
session_id: "session-1",
|
|
3666
|
+
});
|
|
3667
|
+
handleSdkMessage(session, {
|
|
3668
|
+
type: "system",
|
|
3669
|
+
subtype: "informational",
|
|
3670
|
+
content: "Progress updated",
|
|
3671
|
+
level: "notice",
|
|
3672
|
+
tool_use_id: "tool-1",
|
|
3673
|
+
uuid: "message-info-3",
|
|
3674
|
+
session_id: "session-1",
|
|
3675
|
+
});
|
|
3676
|
+
});
|
|
3677
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3678
|
+
{ type: "system_notice_update", severity: "info", message: "Progress" },
|
|
3679
|
+
{ type: "system_notice_update", severity: "info", message: "Progress updated" },
|
|
3680
|
+
]);
|
|
3681
|
+
});
|
|
3682
|
+
test("handleSdkMessage does not deduplicate informational messages without a tool use id", () => {
|
|
3683
|
+
const session = makeSessionState();
|
|
3684
|
+
const events = captureBridgeEvents(() => {
|
|
3685
|
+
for (const uuid of ["message-info-1", "message-info-2"]) {
|
|
3686
|
+
handleSdkMessage(session, {
|
|
3687
|
+
type: "system",
|
|
3688
|
+
subtype: "informational",
|
|
3689
|
+
content: "Repeated global notice",
|
|
3690
|
+
level: "notice",
|
|
3691
|
+
uuid,
|
|
3692
|
+
session_id: "session-1",
|
|
3693
|
+
});
|
|
3694
|
+
}
|
|
3695
|
+
});
|
|
3696
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3697
|
+
{ type: "system_notice_update", severity: "info", message: "Repeated global notice" },
|
|
3698
|
+
{ type: "system_notice_update", severity: "info", message: "Repeated global notice" },
|
|
3699
|
+
]);
|
|
3700
|
+
});
|
|
3701
|
+
test("handleSdkMessage treats worker shutdown before connect as log-only", () => {
|
|
3702
|
+
const session = makeSessionState();
|
|
3703
|
+
session.connected = false;
|
|
3704
|
+
const events = captureBridgeEvents(() => {
|
|
3705
|
+
handleSdkMessage(session, {
|
|
3706
|
+
type: "system",
|
|
3707
|
+
subtype: "worker_shutting_down",
|
|
3708
|
+
reason: "host_exit",
|
|
3709
|
+
uuid: "message-worker-shutdown",
|
|
3710
|
+
session_id: "session-1",
|
|
3711
|
+
});
|
|
3712
|
+
flushPendingWorkerShutdown(session);
|
|
3713
|
+
});
|
|
3714
|
+
assert.deepEqual(events, []);
|
|
3715
|
+
assert.equal(session.pendingWorkerShutdown, undefined);
|
|
3716
|
+
});
|
|
3717
|
+
test("handleSdkMessage flushes connected worker shutdown only when stream ends", () => {
|
|
3718
|
+
const session = makeSessionState();
|
|
3719
|
+
const events = captureBridgeEvents(() => {
|
|
3720
|
+
handleSdkMessage(session, {
|
|
3721
|
+
type: "system",
|
|
3722
|
+
subtype: "worker_shutting_down",
|
|
3723
|
+
reason: "host_exit",
|
|
3724
|
+
uuid: "message-worker-shutdown",
|
|
3725
|
+
session_id: "session-1",
|
|
3726
|
+
});
|
|
3727
|
+
flushPendingWorkerShutdown(session);
|
|
3728
|
+
});
|
|
3729
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3730
|
+
{
|
|
3731
|
+
type: "system_notice_update",
|
|
3732
|
+
severity: "warning",
|
|
3733
|
+
message: "Claude worker is shutting down: host_exit",
|
|
3734
|
+
},
|
|
3735
|
+
]);
|
|
3736
|
+
});
|
|
3737
|
+
test("handleSdkMessage cancels pending worker shutdown after later SDK activity", () => {
|
|
3738
|
+
const session = makeSessionState();
|
|
3739
|
+
const events = captureBridgeEvents(() => {
|
|
3740
|
+
handleSdkMessage(session, {
|
|
3741
|
+
type: "system",
|
|
3742
|
+
subtype: "worker_shutting_down",
|
|
3743
|
+
reason: "host_exit",
|
|
3744
|
+
uuid: "message-worker-shutdown",
|
|
3745
|
+
session_id: "session-1",
|
|
3746
|
+
});
|
|
3747
|
+
handleSdkMessage(session, {
|
|
3748
|
+
type: "system",
|
|
3749
|
+
subtype: "notification",
|
|
3750
|
+
text: "Still running",
|
|
3751
|
+
priority: "low",
|
|
3752
|
+
uuid: "message-notification",
|
|
3753
|
+
session_id: "session-1",
|
|
3754
|
+
});
|
|
3755
|
+
flushPendingWorkerShutdown(session);
|
|
3756
|
+
});
|
|
3757
|
+
assert.deepEqual(events.map((event) => event.update), [
|
|
3758
|
+
{ type: "system_notice_update", severity: "info", message: "Still running" },
|
|
3759
|
+
]);
|
|
3760
|
+
});
|
|
3761
|
+
test("handleSdkMessage ignores unknown future system subtypes", () => {
|
|
3762
|
+
const session = makeSessionState();
|
|
3763
|
+
const events = captureBridgeEvents(() => {
|
|
3764
|
+
handleSdkMessage(session, {
|
|
3765
|
+
type: "system",
|
|
3766
|
+
subtype: "future_subtype",
|
|
3767
|
+
content: "Unknown",
|
|
3768
|
+
uuid: "message-future",
|
|
3769
|
+
session_id: "session-1",
|
|
3770
|
+
});
|
|
3771
|
+
});
|
|
3772
|
+
assert.deepEqual(events, []);
|
|
3773
|
+
});
|
|
3286
3774
|
test("handleSdkMessage treats mirror errors as log-only diagnostics", () => {
|
|
3287
3775
|
const session = makeSessionState();
|
|
3288
3776
|
const events = captureBridgeEvents(() => {
|
|
@@ -4185,6 +4673,133 @@ test("buildToolResultFields marks ReadMcpResource error output as failed", () =>
|
|
|
4185
4673
|
},
|
|
4186
4674
|
]);
|
|
4187
4675
|
});
|
|
4676
|
+
test("buildToolResultFields renders structured ReadMcpResourceDir listings", () => {
|
|
4677
|
+
const base = createToolCall("tc-mcp-dir", "ReadMcpResourceDir", {
|
|
4678
|
+
server: "docs",
|
|
4679
|
+
uri: "file://manuals/",
|
|
4680
|
+
});
|
|
4681
|
+
const fields = buildToolResultFields(false, {
|
|
4682
|
+
resources: [
|
|
4683
|
+
{
|
|
4684
|
+
name: "guide.md",
|
|
4685
|
+
uri: "file://manuals/guide.md",
|
|
4686
|
+
mimeType: "text/markdown",
|
|
4687
|
+
},
|
|
4688
|
+
{
|
|
4689
|
+
name: "images",
|
|
4690
|
+
uri: "file://manuals/images",
|
|
4691
|
+
mimeType: "inode/directory",
|
|
4692
|
+
},
|
|
4693
|
+
{
|
|
4694
|
+
name: "readme",
|
|
4695
|
+
uri: "file://manuals/readme",
|
|
4696
|
+
},
|
|
4697
|
+
],
|
|
4698
|
+
}, base);
|
|
4699
|
+
const expected = "guide.md - file://manuals/guide.md (text/markdown)\n" +
|
|
4700
|
+
"images - file://manuals/images (directory)\n" +
|
|
4701
|
+
"readme - file://manuals/readme";
|
|
4702
|
+
assert.equal(fields.status, "completed");
|
|
4703
|
+
assert.equal(fields.raw_output, expected);
|
|
4704
|
+
assert.deepEqual(fields.content, [
|
|
4705
|
+
{
|
|
4706
|
+
type: "content",
|
|
4707
|
+
content: { type: "text", text: expected },
|
|
4708
|
+
},
|
|
4709
|
+
]);
|
|
4710
|
+
});
|
|
4711
|
+
test("buildToolResultFields renders empty ReadMcpResourceDir listings", () => {
|
|
4712
|
+
const base = createToolCall("tc-mcp-dir-empty", "ReadMcpResourceDir", {
|
|
4713
|
+
server: "docs",
|
|
4714
|
+
uri: "file://empty/",
|
|
4715
|
+
});
|
|
4716
|
+
const fields = buildToolResultFields(false, { resources: [] }, base);
|
|
4717
|
+
assert.equal(fields.status, "completed");
|
|
4718
|
+
assert.equal(fields.raw_output, "No resources found.");
|
|
4719
|
+
assert.deepEqual(fields.content, [
|
|
4720
|
+
{
|
|
4721
|
+
type: "content",
|
|
4722
|
+
content: { type: "text", text: "No resources found." },
|
|
4723
|
+
},
|
|
4724
|
+
]);
|
|
4725
|
+
});
|
|
4726
|
+
test("buildToolResultFields marks ReadMcpResourceDir error output as failed", () => {
|
|
4727
|
+
const base = createToolCall("tc-mcp-dir-error", "ReadMcpResourceDir", {
|
|
4728
|
+
server: "docs",
|
|
4729
|
+
uri: "file://missing/",
|
|
4730
|
+
});
|
|
4731
|
+
const fields = buildToolResultFields(false, {
|
|
4732
|
+
resources: [],
|
|
4733
|
+
error: "directory not found",
|
|
4734
|
+
}, base);
|
|
4735
|
+
assert.equal(fields.status, "failed");
|
|
4736
|
+
assert.equal(fields.raw_output, "Error: directory not found");
|
|
4737
|
+
assert.deepEqual(fields.content, [
|
|
4738
|
+
{
|
|
4739
|
+
type: "content",
|
|
4740
|
+
content: { type: "text", text: "Error: directory not found" },
|
|
4741
|
+
},
|
|
4742
|
+
]);
|
|
4743
|
+
});
|
|
4744
|
+
test("buildToolResultFields parses ReadMcpResourceDir transcript JSON", () => {
|
|
4745
|
+
const base = createToolCall("tc-mcp-dir-history", "ReadMcpResourceDir", {
|
|
4746
|
+
server: "docs",
|
|
4747
|
+
uri: "file://manuals/",
|
|
4748
|
+
});
|
|
4749
|
+
const transcriptJson = JSON.stringify({
|
|
4750
|
+
resources: [
|
|
4751
|
+
{
|
|
4752
|
+
name: "api.json",
|
|
4753
|
+
uri: "file://manuals/api.json",
|
|
4754
|
+
mimeType: "application/json",
|
|
4755
|
+
},
|
|
4756
|
+
],
|
|
4757
|
+
});
|
|
4758
|
+
const fields = buildToolResultFields(false, transcriptJson, base, {
|
|
4759
|
+
type: "tool_result",
|
|
4760
|
+
tool_use_id: "tc-mcp-dir-history",
|
|
4761
|
+
content: transcriptJson,
|
|
4762
|
+
});
|
|
4763
|
+
assert.equal(fields.raw_output, "api.json - file://manuals/api.json (application/json)");
|
|
4764
|
+
assert.deepEqual(fields.content, [
|
|
4765
|
+
{
|
|
4766
|
+
type: "content",
|
|
4767
|
+
content: {
|
|
4768
|
+
type: "text",
|
|
4769
|
+
text: "api.json - file://manuals/api.json (application/json)",
|
|
4770
|
+
},
|
|
4771
|
+
},
|
|
4772
|
+
]);
|
|
4773
|
+
});
|
|
4774
|
+
test("buildToolResultFields skips invalid ReadMcpResourceDir entries", () => {
|
|
4775
|
+
const base = createToolCall("tc-mcp-dir-invalid", "ReadMcpResourceDir", {
|
|
4776
|
+
server: "docs",
|
|
4777
|
+
uri: "file://manuals/",
|
|
4778
|
+
});
|
|
4779
|
+
const fields = buildToolResultFields(false, {
|
|
4780
|
+
resources: [
|
|
4781
|
+
{ name: "missing-uri" },
|
|
4782
|
+
{ uri: "file://manuals/missing-name" },
|
|
4783
|
+
null,
|
|
4784
|
+
{
|
|
4785
|
+
name: "valid.txt",
|
|
4786
|
+
uri: "file://manuals/valid.txt",
|
|
4787
|
+
mimeType: "text/plain",
|
|
4788
|
+
},
|
|
4789
|
+
],
|
|
4790
|
+
}, base);
|
|
4791
|
+
assert.equal(fields.status, "completed");
|
|
4792
|
+
assert.equal(fields.raw_output, "valid.txt - file://manuals/valid.txt (text/plain)");
|
|
4793
|
+
assert.deepEqual(fields.content, [
|
|
4794
|
+
{
|
|
4795
|
+
type: "content",
|
|
4796
|
+
content: {
|
|
4797
|
+
type: "text",
|
|
4798
|
+
text: "valid.txt - file://manuals/valid.txt (text/plain)",
|
|
4799
|
+
},
|
|
4800
|
+
},
|
|
4801
|
+
]);
|
|
4802
|
+
});
|
|
4188
4803
|
test("buildToolResultFields preserves WebFetch artifactRead only as metadata", () => {
|
|
4189
4804
|
const base = createToolCall("tc-web-fetch-artifact", "WebFetch", {
|
|
4190
4805
|
url: "https://artifact.local/dashboard",
|
|
@@ -4405,7 +5020,7 @@ test("looksLikeAuthRequired detects login hints", () => {
|
|
|
4405
5020
|
assert.equal(looksLikeAuthRequired("normal tool output"), false);
|
|
4406
5021
|
});
|
|
4407
5022
|
test("agent sdk version compatibility check matches pinned version", () => {
|
|
4408
|
-
assert.equal(resolveInstalledAgentSdkVersion(), "0.3.
|
|
5023
|
+
assert.equal(resolveInstalledAgentSdkVersion(), "0.3.193");
|
|
4409
5024
|
assert.equal(agentSdkVersionCompatibilityError(), undefined);
|
|
4410
5025
|
});
|
|
4411
5026
|
test("mapSessionMessagesToUpdates maps message content blocks", () => {
|
|
@@ -4919,13 +5534,15 @@ test("mapSdkSessions normalizes and sorts sessions", () => {
|
|
|
4919
5534
|
},
|
|
4920
5535
|
]);
|
|
4921
5536
|
});
|
|
4922
|
-
test("buildSessionListOptions
|
|
5537
|
+
test("buildSessionListOptions includes SDK-created sessions for resume listings", () => {
|
|
4923
5538
|
assert.deepEqual(buildSessionListOptions("C:/repo"), {
|
|
4924
5539
|
dir: "C:/repo",
|
|
5540
|
+
includeProgrammatic: true,
|
|
4925
5541
|
includeWorktrees: true,
|
|
4926
5542
|
limit: 50,
|
|
4927
5543
|
});
|
|
4928
5544
|
assert.deepEqual(buildSessionListOptions(undefined), {
|
|
5545
|
+
includeProgrammatic: true,
|
|
4929
5546
|
limit: 50,
|
|
4930
5547
|
});
|
|
4931
5548
|
});
|