@jskit-ai/assistant-core 0.1.200 → 0.1.202
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/examples/conversation/README.md +8 -1
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +4 -1
- package/package.json +99 -36
- package/src/client/conversation/AssistantConversationElement.vue +27 -4
- package/src/client/conversation/AssistantConversationStatus.vue +24 -0
- package/src/client/conversation/AssistantGoalControl.vue +8 -5
- package/src/client/conversation/AssistantTranscript.vue +10 -3
- package/src/client/conversation/index.js +2 -0
- package/src/client/conversation/messageDelivery.js +115 -32
- package/src/client/conversation/retainedConversation.js +32 -0
- package/src/client/conversation/useAssistantQuestions.js +76 -0
- package/src/client/lib/assistantApi.js +72 -2
- package/src/server/conversation/agentRun.js +222 -0
- package/src/server/conversation/attachments.js +93 -0
- package/src/server/conversation/claudeClient.js +221 -0
- package/src/server/conversation/claudeHistory.js +256 -0
- package/src/server/conversation/claudeProcess.js +327 -0
- package/src/server/conversation/claudeTools.js +42 -0
- package/src/server/conversation/claudeTurn.js +1082 -0
- package/src/server/conversation/codexConfiguration.js +1183 -0
- package/src/server/conversation/codexConversations.js +1082 -0
- package/src/server/conversation/codexDetachedTurn.js +332 -0
- package/src/server/conversation/codexEvents.js +194 -1
- package/src/server/conversation/codexGoalCommands.js +124 -0
- package/src/server/conversation/codexHelperExecution.js +306 -0
- package/src/server/conversation/codexHelperThreadLedger.js +804 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
- package/src/server/conversation/codexHelperThreads.js +638 -0
- package/src/server/conversation/codexHistoryAdapter.js +369 -0
- package/src/server/conversation/codexMessageCommands.js +663 -0
- package/src/server/conversation/codexMessageDelivery.js +211 -0
- package/src/server/conversation/codexNativeHistoryExport.js +95 -0
- package/src/server/conversation/codexNotificationObserver.js +482 -0
- package/src/server/conversation/codexNotificationQueue.js +103 -0
- package/src/server/conversation/codexObservationControl.js +174 -0
- package/src/server/conversation/codexOutput.js +931 -0
- package/src/server/conversation/codexProcess.js +1559 -0
- package/src/server/conversation/codexProcessLeader.js +14 -0
- package/src/server/conversation/codexProvider.js +2710 -0
- package/src/server/conversation/codexProviderOwner.js +1058 -0
- package/src/server/conversation/codexProviderSelection.js +218 -0
- package/src/server/conversation/codexReasoning.js +314 -0
- package/src/server/conversation/codexRenewalCommands.js +290 -0
- package/src/server/conversation/codexResultSettlement.js +409 -0
- package/src/server/conversation/codexRunJournal.js +658 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
- package/src/server/conversation/codexSessionCleanup.js +96 -0
- package/src/server/conversation/codexSessionReconciliation.js +284 -0
- package/src/server/conversation/codexThreadReadiness.js +397 -0
- package/src/server/conversation/codexThreadStatus.js +354 -0
- package/src/server/conversation/codexTurn.js +701 -255
- package/src/server/conversation/codexTurnControl.js +317 -0
- package/src/server/conversation/codexTurnRecovery.js +990 -0
- package/src/server/conversation/codexTurnState.js +549 -0
- package/src/server/conversation/commandHook.js +18 -0
- package/src/server/conversation/commandWrapper.js +37 -0
- package/src/server/conversation/configuration.js +43 -0
- package/src/server/conversation/continuity.js +289 -0
- package/src/server/conversation/fileStorage.js +55 -0
- package/src/server/conversation/hookBridge.js +19 -0
- package/src/server/conversation/index.js +13 -0
- package/src/server/conversation/localExecution.js +665 -0
- package/src/server/conversation/memoryStorage.js +7 -72
- package/src/server/conversation/nativeHistoryExport.js +90 -0
- package/src/server/conversation/nativeStorage.js +23 -0
- package/src/server/conversation/openCodeClient.js +209 -19
- package/src/server/conversation/openCodePlugin.js +84 -0
- package/src/server/conversation/openCodeProcess.js +476 -0
- package/src/server/conversation/openCodeRuntime.js +1578 -0
- package/src/server/conversation/openCodeTools.js +60 -0
- package/src/server/conversation/openCodeTurn.js +557 -0
- package/src/server/conversation/provider.js +30 -0
- package/src/server/conversation/providers/api.js +136 -0
- package/src/server/conversation/providers/claude.js +63 -0
- package/src/server/conversation/providers/claudeDriver.js +425 -0
- package/src/server/conversation/providers/codex.js +248 -0
- package/src/server/conversation/providers/codexDriver.js +1078 -0
- package/src/server/conversation/providers/opencode.js +49 -0
- package/src/server/conversation/providers/opencodeDriver.js +650 -0
- package/src/server/conversation/providers/scoped.js +312 -0
- package/src/server/conversation/runtime.js +2220 -0
- package/src/server/conversation/runtimeStateUpgrade.js +125 -0
- package/src/server/conversation/storage.js +184 -0
- package/src/server/conversation/streams.js +7 -2
- package/src/server/conversation/structuredOutput.js +240 -0
- package/src/server/conversation/systemPrompt.js +54 -0
- package/src/server/conversation/tools.js +84 -0
- package/src/server/conversation/transcript.js +5 -4
- package/src/server/hosts/claudeHistory.js +5 -0
- package/src/server/hosts/claudeProcess.js +11 -0
- package/src/server/hosts/claudeTurn.js +4 -0
- package/src/server/hosts/codexConfiguration.js +20 -0
- package/src/server/hosts/codexEvents.js +13 -0
- package/src/server/hosts/codexProcess.js +12 -0
- package/src/server/hosts/codexProvider.js +23 -0
- package/src/server/hosts/codexTurn.js +20 -0
- package/src/server/hosts/nativeHistory.js +3 -0
- package/src/server/hosts/openCodeClient.js +3 -0
- package/src/server/hosts/openCodeProcess.js +23 -0
- package/src/server/hosts/openCodeTurn.js +7 -0
- package/src/server/index.js +2 -0
- package/src/server/lib/aiConnectionClient.js +13 -6
- package/src/server/lib/assistantCompletion.js +190 -0
- package/src/server/lib/assistantToolLoop.js +314 -0
- package/src/server/lib/serviceToolCatalog.js +25 -10
- package/src/shared/assistantResponseText.js +29 -0
- package/src/shared/conversation/attachments.js +19 -0
- package/src/shared/conversation/index.js +3 -0
- package/src/shared/conversation/pagination.js +78 -0
- package/src/shared/conversation/patches.js +150 -0
- package/src/shared/conversation/questions.js +48 -0
- package/src/shared/conversation/streaming.js +7 -4
- package/src/shared/index.js +2 -0
- package/src/shared/nativeProviders.js +45 -0
- package/src/testing/conversationStorageContract.js +31 -1
- package/src/testing/nativeCodex.js +11 -0
- package/src/testing/nativeOpenCode.js +3 -0
- package/test/aiConnectionClient.test.js +2 -0
- package/test/apiConversationDriver.test.js +73 -0
- package/test/assistantApiSurfaceHeader.test.js +85 -0
- package/test/assistantBoundedToolLoop.test.js +285 -0
- package/test/assistantScroll.browser.test.js +16 -0
- package/test/claudeClient.test.js +189 -0
- package/test/claudeConversationSession.test.js +1173 -0
- package/test/claudeInstructionResume.test.js +82 -0
- package/test/claudeProcess.test.js +204 -0
- package/test/claudeTurn.test.js +253 -0
- package/test/codexConversationSession.test.js +2122 -0
- package/test/codexGoal.test.js +37 -0
- package/test/codexHelperDeletion.test.js +163 -0
- package/test/codexHelperThreadLedger.test.js +494 -0
- package/test/codexHistoryAdapter.test.js +541 -0
- package/test/codexInteractiveArguments.test.js +30 -0
- package/test/codexIsolation.test.js +756 -0
- package/test/codexModelCatalog.test.js +362 -0
- package/test/codexNativeHistoryExport.test.js +81 -0
- package/test/codexThreadSelection.test.js +136 -0
- package/test/codexTurn.test.js +2158 -0
- package/test/conversation.test.js +110 -0
- package/test/conversationAgentRun.test.js +326 -0
- package/test/conversationApiFacilities.test.js +90 -0
- package/test/conversationAttachments.test.js +238 -0
- package/test/conversationCommandWrapper.test.js +48 -0
- package/test/conversationConfiguration.test.js +174 -0
- package/test/conversationNativeStorage.test.js +25 -0
- package/test/conversationReplacement.test.js +172 -0
- package/test/conversationRuntime.test.js +241 -0
- package/test/conversationRuntimeStateUpgrade.test.js +144 -0
- package/test/conversationSession.test.js +1080 -0
- package/test/conversationStreaming.test.js +68 -0
- package/test/conversationSystemPrompt.test.js +84 -0
- package/test/conversationTools.test.js +416 -0
- package/test/fileConversationStorage.test.js +73 -0
- package/test/fixtures/codexHelperThreadLedger.js +39 -0
- package/test/localConversationExecution.test.js +90 -0
- package/test/messageDelivery.test.js +234 -1
- package/test/nativeConversationRetirement.test.js +215 -0
- package/test/openCodeClient.test.js +515 -0
- package/test/openCodeProcess.test.js +177 -0
- package/test/openCodeRuntime.test.js +322 -0
- package/test/openCodeTools.test.js +287 -0
- package/test/opencodeConversationSession.test.js +789 -0
- package/test/serviceToolCatalog.test.js +24 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import { createClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
|
|
11
|
+
|
|
12
|
+
const execute = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
test("native Claude replaces a recorded system prompt on resume and retains ordinary history", { timeout: 90_000 }, async (t) => {
|
|
15
|
+
try { await execute("claude", ["--version"]); }
|
|
16
|
+
catch (error) { if (error.code === "ENOENT") { t.skip("Claude Code is not installed."); return; } throw error; }
|
|
17
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "jskit-claude-instructions-"));
|
|
18
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
19
|
+
const requests = [];
|
|
20
|
+
const server = createServer(async (request, response) => {
|
|
21
|
+
if (request.url.includes("count_tokens")) {
|
|
22
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
23
|
+
response.end(JSON.stringify({ input_tokens: 20 }));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (!request.url.startsWith("/v1/messages")) { response.writeHead(404); response.end(); return; }
|
|
27
|
+
let input = "";
|
|
28
|
+
for await (const chunk of request) input += chunk;
|
|
29
|
+
const body = JSON.parse(input);
|
|
30
|
+
requests.push(body);
|
|
31
|
+
const message = { id: "msg_local", type: "message", role: "assistant", model: body.model,
|
|
32
|
+
content: [{ type: "text", text: "ACK" }], stop_reason: "end_turn", stop_sequence: null,
|
|
33
|
+
usage: { input_tokens: 20, output_tokens: 1 } };
|
|
34
|
+
if (!body.stream) {
|
|
35
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
36
|
+
response.end(JSON.stringify(message));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
40
|
+
const event = (type, data) => response.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
41
|
+
event("message_start", { message: { ...message, content: [], stop_reason: null, usage: { input_tokens: 20, output_tokens: 0 } } });
|
|
42
|
+
event("content_block_start", { index: 0, content_block: { type: "text", text: "" } });
|
|
43
|
+
event("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ACK" } });
|
|
44
|
+
event("content_block_stop", { index: 0 });
|
|
45
|
+
event("message_delta", { delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } });
|
|
46
|
+
event("message_stop", {});
|
|
47
|
+
response.end();
|
|
48
|
+
});
|
|
49
|
+
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
|
|
50
|
+
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
|
|
51
|
+
const env = {
|
|
52
|
+
PATH: process.env.PATH, HOME: root, CLAUDE_CONFIG_DIR: path.join(root, "config"),
|
|
53
|
+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.address().port}`,
|
|
54
|
+
ANTHROPIC_AUTH_TOKEN: "local-test", ANTHROPIC_MODEL: "fixture-model",
|
|
55
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: "fixture-model", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
|
|
56
|
+
CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1", DISABLE_AUTOUPDATER: "1"
|
|
57
|
+
};
|
|
58
|
+
const runtime = createClaudeConversationTurn({ conversationId: "instruction-arguments", process: { getProcess: () => null,
|
|
59
|
+
isActive: () => false, startProcess: () => {}, stopProcess: () => {} } });
|
|
60
|
+
async function turn(id, instructions, text, resume) {
|
|
61
|
+
const { stdout } = await execute("claude", ["--bare", "--print", "--output-format", "json", "--model", "fixture-model",
|
|
62
|
+
"--tools", "", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}',
|
|
63
|
+
resume ? "--resume" : "--session-id", id, ...instructions, "--", text], { cwd: root, env, timeout: 25_000 });
|
|
64
|
+
const result = JSON.parse(stdout);
|
|
65
|
+
assert.equal(result.is_error, false);
|
|
66
|
+
assert.equal(result.session_id, id);
|
|
67
|
+
return requests.at(-1);
|
|
68
|
+
}
|
|
69
|
+
for (const instructionMode of ["append", "replace"]) {
|
|
70
|
+
const id = randomUUID();
|
|
71
|
+
await turn(id, [instructionMode === "append" ? "--append-system-prompt" : "--system-prompt",
|
|
72
|
+
"CALENDAR_INSTRUCTIONS_A", "--system-prompt-snapshot", "on"], "First request", false);
|
|
73
|
+
const current = runtime.instructionArguments({ systemPrompt: "CALENDAR_INSTRUCTIONS_B", instructionMode });
|
|
74
|
+
const resumed = await turn(id, current, "Second request", true);
|
|
75
|
+
assert.match(JSON.stringify(resumed.system), /CALENDAR_INSTRUCTIONS_B/);
|
|
76
|
+
assert.doesNotMatch(JSON.stringify(resumed.system), /CALENDAR_INSTRUCTIONS_A/);
|
|
77
|
+
const followup = await turn(id, current, "Third request", true);
|
|
78
|
+
assert.equal(followup.messages.filter(message => message.role === "user").length, 3);
|
|
79
|
+
assert.match(JSON.stringify(followup.messages.at(-1)), /Third request/);
|
|
80
|
+
assert.doesNotMatch(JSON.stringify(followup.messages), /CALENDAR_INSTRUCTIONS_[AB]/);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os, { tmpdir } from "node:os";
|
|
4
|
+
import path, { join } from "node:path";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { claudePlanUsage, claudeCodeArguments, claudeModelConfiguration, createClaudeCodeProcess, readClaudeCodeAuthStatus, stopClaudeCodeProcess } from "../src/server/conversation/claudeProcess.js";
|
|
7
|
+
|
|
8
|
+
test("native command construction preserves tool-free constraints without granting permissions by default", () => {
|
|
9
|
+
const args = claudeCodeArguments({ sessionId: "session", resume: true, model: "sonnet", effort: "high", toolFree: true });
|
|
10
|
+
assert.equal(args[args.indexOf("--resume") + 1], "session");
|
|
11
|
+
assert.equal(args[args.indexOf("--tools") + 1], "");
|
|
12
|
+
assert.equal(args[args.indexOf("--thinking-display") + 1], "summarized");
|
|
13
|
+
assert.equal(args.includes("bypassPermissions"), false);
|
|
14
|
+
assert.equal(claudeCodeArguments().includes("--permission-mode"), false);
|
|
15
|
+
assert.equal(claudeCodeArguments({ terminal: true }).includes("--print"), false);
|
|
16
|
+
const tools = claudeCodeArguments({ toolFree: true, applicationTools: true });
|
|
17
|
+
assert.equal(tools[tools.indexOf("--tools") + 1], "");
|
|
18
|
+
assert.equal(tools[tools.indexOf("--permission-mode") + 1], "dontAsk");
|
|
19
|
+
assert.equal(tools[tools.indexOf("--allowedTools") + 1], "mcp__application__*");
|
|
20
|
+
assert.equal(tools.includes("--safe-mode"), true);
|
|
21
|
+
assert.equal(tools.includes("--restricted"), true);
|
|
22
|
+
assert.deepEqual(JSON.parse(tools[tools.indexOf("--mcp-config") + 1]), { mcpServers: { application: { type: "sdk", name: "application" } } });
|
|
23
|
+
const coding = claudeCodeArguments({ isolated: true, applicationTools: true, permissionMode: "bypassPermissions" });
|
|
24
|
+
assert.equal(coding.includes("--restricted"), false);
|
|
25
|
+
assert.equal(coding[coding.indexOf("--permission-mode") + 1], "bypassPermissions");
|
|
26
|
+
assert.match(coding[coding.indexOf("--tools") + 1], /Bash,Read,Edit,Write/);
|
|
27
|
+
assert.equal(coding.includes("--safe-mode"), true);
|
|
28
|
+
assert.deepEqual(JSON.parse(coding[coding.indexOf("--mcp-config") + 1]), { mcpServers: { application: { type: "sdk", name: "application" } } });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
async function fixture(t, body) {
|
|
32
|
+
const directory = await mkdtemp(join(tmpdir(), "jskit-claude-process-"));
|
|
33
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
34
|
+
const command = join(directory, "claude-fixture.mjs");
|
|
35
|
+
await writeFile(command, `#!${process.execPath}\n${body}`, { mode: 0o700 });
|
|
36
|
+
return { command, workdir: directory };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("standalone Claude startup uses the supplied execution host and carries its native protocol", { skip: process.platform === "win32" }, async (t) => {
|
|
40
|
+
const setup = await fixture(t, `
|
|
41
|
+
import { createInterface } from "node:readline";
|
|
42
|
+
createInterface({ input: process.stdin }).on("line", line => {
|
|
43
|
+
const frame = JSON.parse(line);
|
|
44
|
+
process.stdout.write(JSON.stringify(frame.type === "control_request"
|
|
45
|
+
? { type: "control_response", response: { subtype: "success", request_id: frame.request_id,
|
|
46
|
+
response: { models: [{ value: "fixture" }], parentContext: process.env.CLAUDECODE || "",
|
|
47
|
+
automaticUpdates: process.env.DISABLE_AUTOUPDATER } } }
|
|
48
|
+
: frame) + "\\n");
|
|
49
|
+
});
|
|
50
|
+
`);
|
|
51
|
+
const observed = Promise.withResolvers();
|
|
52
|
+
const native = await createClaudeCodeProcess({ ...setup, env: { ...process.env, CLAUDECODE: "parent-context" },
|
|
53
|
+
onEvent: (frame) => observed.resolve(frame) });
|
|
54
|
+
t.after(() => native.stop());
|
|
55
|
+
assert.equal(native.initialization.models[0].value, "fixture");
|
|
56
|
+
assert.equal(native.initialization.parentContext, "");
|
|
57
|
+
assert.equal(native.initialization.automaticUpdates, "1");
|
|
58
|
+
await native.client.send("Unicode 🌏", { messageId: "request", sessionId: "conversation" });
|
|
59
|
+
const event = await observed.promise;
|
|
60
|
+
assert.equal(event.message.content, "Unicode 🌏");
|
|
61
|
+
assert.equal(event.uuid, "request");
|
|
62
|
+
assert.equal(event.session_id, "conversation");
|
|
63
|
+
assert.deepEqual(await native.stop(), { scopeEmpty: true, exited: true });
|
|
64
|
+
assert.deepEqual(await native.stop(), { scopeEmpty: true, exited: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("failed native initialization stops its owned process and reports the protocol failure", { skip: process.platform === "win32" }, async (t) => {
|
|
68
|
+
const setup = await fixture(t, 'process.stdin.once("data", () => process.stdout.write("invalid frame\\n"));');
|
|
69
|
+
await assert.rejects(createClaudeCodeProcess(setup), /invalid UTF-8 or JSON/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Claude stop uses the retained process before recovery and accepts the original host exit proofs", async () => {
|
|
73
|
+
const recovered = [];
|
|
74
|
+
const stopExecution = async (...args) => { recovered.push(args); return { scopeEmpty: true }; };
|
|
75
|
+
assert.deepEqual(await stopClaudeCodeProcess({ process: { stop: async () => ({ exited: true }) },
|
|
76
|
+
executionId: "retained", stopExecution }), { exited: true });
|
|
77
|
+
assert.deepEqual(recovered, []);
|
|
78
|
+
assert.deepEqual(await stopClaudeCodeProcess({ executionId: "recovered", stopExecution }),
|
|
79
|
+
{ scopeEmpty: true, exited: true });
|
|
80
|
+
assert.deepEqual(recovered, [["recovered", { allowMissingRecordScopeRecovery: true, reason: "claude-code-stop" }]]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("Claude stop keeps unconfirmed cleanup retryable and preserves host failures", async () => {
|
|
84
|
+
let stopped = false;
|
|
85
|
+
const input = { executionId: "recovered", stopExecution: async () => ({ scopeEmpty: stopped }) };
|
|
86
|
+
await assert.rejects(stopClaudeCodeProcess(input), {
|
|
87
|
+
code: "claude_stop_unconfirmed", stopProof: { scopeEmpty: false }
|
|
88
|
+
});
|
|
89
|
+
stopped = true;
|
|
90
|
+
assert.deepEqual(await stopClaudeCodeProcess(input), { scopeEmpty: true, exited: true });
|
|
91
|
+
const failure = new Error("Execution host unavailable");
|
|
92
|
+
await assert.rejects(stopClaudeCodeProcess({ ...input, stopExecution: async () => { throw failure; } }), failure);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("Claude model configuration maps background calls and resets external settings", () => {
|
|
96
|
+
for (const [provider, model, window] of [["deepseek", "deepseek-flash", "786432"], ["zai-coding-plan", "glm-5.3", "1000000"]]) {
|
|
97
|
+
const configured = claudeModelConfiguration({ providerId: provider, model: model }, { apiKey: "test-secret", baseUrl: "https://provider.test" });
|
|
98
|
+
assert.equal(configured.model, `${model}[1m]`);
|
|
99
|
+
assert.equal(configured.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, window);
|
|
100
|
+
for (const name of ["ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_FABLE_MODEL"]) {
|
|
101
|
+
assert.equal(configured.env[name], `${model}[1m]`);
|
|
102
|
+
}
|
|
103
|
+
const native = claudeModelConfiguration({ providerId: "anthropic", model: "opus" }, null);
|
|
104
|
+
for (const name of Object.keys(configured.env)) {
|
|
105
|
+
if (!["ANTHROPIC_BASE_URL", "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"].includes(name)) assert.equal(native.env[name], "", name);
|
|
106
|
+
}
|
|
107
|
+
assert.equal(native.model, "opus");
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("Claude status reads native JSON and returns only public account details", async () => {
|
|
112
|
+
const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture" }, commandRunner: async (input) => {
|
|
113
|
+
assert.deepEqual(input.args, ["auth", "status", "--json"]);
|
|
114
|
+
assert.equal(input.timeout, 30_000);
|
|
115
|
+
assert.equal(input.credentialHome.home, "/home/fixture");
|
|
116
|
+
return { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai", subscriptionType: "max", accessToken: "not-public" }) };
|
|
117
|
+
} });
|
|
118
|
+
assert.equal(status.loggedIn, true);
|
|
119
|
+
assert.equal(status.email, "owner@example.test");
|
|
120
|
+
assert.equal(JSON.stringify(status).includes("not-public"), false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("Claude signed-out JSON is normal while failed and malformed status responses stay errors", async () => {
|
|
124
|
+
const readStatus = (result) => readClaudeCodeAuthStatus({
|
|
125
|
+
credentialHome: { home: "/home/fixture" }, commandRunner: async () => result
|
|
126
|
+
});
|
|
127
|
+
const signedOut = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false, authMethod: "none" }) };
|
|
128
|
+
assert.deepEqual(await readStatus(signedOut), {
|
|
129
|
+
loggedIn: false, email: "", authMethod: "none", subscriptionType: ""
|
|
130
|
+
});
|
|
131
|
+
for (const result of [
|
|
132
|
+
{ ...signedOut, timedOut: true },
|
|
133
|
+
{ ...signedOut, signal: "SIGTERM" },
|
|
134
|
+
{ ...signedOut, exitCode: 2 },
|
|
135
|
+
{ ...signedOut, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test" }) },
|
|
136
|
+
{ ...signedOut, stdout: "invalid" },
|
|
137
|
+
{ ok: true, stdout: "{}" },
|
|
138
|
+
{ ok: true, stdout: "null" }
|
|
139
|
+
]) {
|
|
140
|
+
const status = await readStatus(result);
|
|
141
|
+
assert.equal(status.loggedIn, false);
|
|
142
|
+
assert.ok(status.error);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("Claude status shares concurrent reads and invalidates when native account files change", async (t) => {
|
|
147
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
|
|
148
|
+
t.after(() => rm(home, { recursive: true, force: true }));
|
|
149
|
+
const configRoot = path.join(home, ".claude");
|
|
150
|
+
await mkdir(configRoot);
|
|
151
|
+
let calls = 0;
|
|
152
|
+
let email = "first@example.test";
|
|
153
|
+
const input = { env: { CLAUDE_CONFIG_DIR: configRoot }, credentialHome: { home }, commandRunner: async () => {
|
|
154
|
+
calls += 1;
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
156
|
+
return { ok: true, stdout: JSON.stringify({ loggedIn: true, email, authMethod: "claude.ai" }) };
|
|
157
|
+
} };
|
|
158
|
+
const statuses = await Promise.all(Array.from({ length: 8 }, () => readClaudeCodeAuthStatus(input)));
|
|
159
|
+
assert.equal(calls, 1);
|
|
160
|
+
assert.ok(statuses.every((status) => status.email === email));
|
|
161
|
+
if (process.platform !== "darwin") {
|
|
162
|
+
await readClaudeCodeAuthStatus(input);
|
|
163
|
+
assert.equal(calls, 1);
|
|
164
|
+
}
|
|
165
|
+
for (const file of [path.join(configRoot, ".credentials.json"), path.join(home, ".claude.json"), path.join(configRoot, ".claude.json")]) {
|
|
166
|
+
email = `${calls}@example.test`;
|
|
167
|
+
// Deliberately not JSON: the wrapper must never parse native credentials.
|
|
168
|
+
await writeFile(file, "native-account-changed");
|
|
169
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).email, email);
|
|
170
|
+
await rm(file);
|
|
171
|
+
const before = calls;
|
|
172
|
+
await readClaudeCodeAuthStatus(input);
|
|
173
|
+
assert.equal(calls, before + 1);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("Claude status failures do not poison later reads or share across credential homes", async () => {
|
|
178
|
+
let calls = 0;
|
|
179
|
+
const commandRunner = async () => {
|
|
180
|
+
calls += 1;
|
|
181
|
+
return calls === 1 ? { ok: false, error: "Temporary failure" }
|
|
182
|
+
: { ok: true, stdout: JSON.stringify({ loggedIn: false }) };
|
|
183
|
+
};
|
|
184
|
+
const input = { env: {}, credentialHome: { home: "/home/fixture" }, commandRunner };
|
|
185
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).error, "Temporary failure");
|
|
186
|
+
assert.equal((await readClaudeCodeAuthStatus(input)).error, undefined);
|
|
187
|
+
assert.equal(calls, 2);
|
|
188
|
+
await readClaudeCodeAuthStatus({ ...input, credentialHome: { home: "/home/another" } });
|
|
189
|
+
assert.equal(calls, 3);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("Claude plan usage retains real windows and never invents an allowance after reset", () => {
|
|
193
|
+
assert.deepEqual(claudePlanUsage({ rate_limits_available: false }).windows, []);
|
|
194
|
+
const usage = claudePlanUsage({ rate_limits_available: true, rate_limits: {
|
|
195
|
+
five_hour: { utilization: 25, resets_at: "2099-01-01T00:00:00Z" },
|
|
196
|
+
seven_day: { utilization: 100, resets_at: null },
|
|
197
|
+
seven_day_sonnet: { utilization: 10, resets_at: "2000-01-01T00:00:00Z" },
|
|
198
|
+
seven_day_opus: { utilization: null, resets_at: null }
|
|
199
|
+
} });
|
|
200
|
+
assert.equal(usage.status, "available");
|
|
201
|
+
assert.deepEqual(usage.windows.map(({ id, remainingPercent }) => ({ id, remainingPercent })), [
|
|
202
|
+
{ id: "five_hour", remainingPercent: 75 }, { id: "seven_day", remainingPercent: 0 }
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createClaudeConversationTurn, claudeNativeMessageId, runClaudeRenewalTurn,
|
|
4
|
+
waitForClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
|
|
5
|
+
|
|
6
|
+
function fixture() {
|
|
7
|
+
const events = [];
|
|
8
|
+
const turn = createClaudeConversationTurn({ conversationId: "native-session", onEvent: async event => events.push(event) });
|
|
9
|
+
const client = { async send(_message, { messageId }) {
|
|
10
|
+
await turn.receive({ type: "user", uuid: messageId, session_id: "native-session" });
|
|
11
|
+
}, async interrupt() {} };
|
|
12
|
+
const send = (id = "first", options = {}) => turn.send(client, "Hello", {
|
|
13
|
+
messageId: claudeNativeMessageId(id), accept: async () => {}, ...options
|
|
14
|
+
});
|
|
15
|
+
turn.begin({ id: "turn-1" });
|
|
16
|
+
return { turn, events, client, send };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test("a pipe write is not admission; queued and replayed acknowledgements persist the user exactly once", async () => {
|
|
20
|
+
const { turn, client, send, events } = fixture();
|
|
21
|
+
client.send = async () => {};
|
|
22
|
+
let saved = 0;
|
|
23
|
+
let delivered = false;
|
|
24
|
+
const sending = send("first", { accept: async () => { saved += 1; } }).then(() => { delivered = true; });
|
|
25
|
+
await turn.receive({ type: "user", uuid: "tool-result" });
|
|
26
|
+
await Promise.resolve();
|
|
27
|
+
assert.equal(delivered, false);
|
|
28
|
+
const uuid = claudeNativeMessageId("first");
|
|
29
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: uuid, state: "queued" });
|
|
30
|
+
await sending;
|
|
31
|
+
await turn.receive({ type: "user", uuid });
|
|
32
|
+
assert.equal(saved, 1);
|
|
33
|
+
assert.equal(events.filter(event => event.type === "admitted").length, 2);
|
|
34
|
+
turn.stop();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("steering waits for the generation's terminal event after the control acknowledgement", async () => {
|
|
38
|
+
const { turn, send, client, events } = fixture();
|
|
39
|
+
await send();
|
|
40
|
+
let interrupted = false;
|
|
41
|
+
const steering = turn.interruptGeneration(client).then(() => { interrupted = true; });
|
|
42
|
+
await Promise.resolve();
|
|
43
|
+
assert.equal(interrupted, false);
|
|
44
|
+
await turn.receive({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
|
|
45
|
+
await steering;
|
|
46
|
+
assert.equal(events.some(event => event.type === "settled"), false);
|
|
47
|
+
await send("follow-up");
|
|
48
|
+
await turn.receive({ type: "result", subtype: "success", result: "Revised answer" });
|
|
49
|
+
assert.equal(turn.read().text, "Revised answer");
|
|
50
|
+
assert.equal(turn.read().status, "completed");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("interruption does not claim process exit or complete work on a control acknowledgement", async () => {
|
|
54
|
+
const { turn, send, client, events } = fixture();
|
|
55
|
+
await send();
|
|
56
|
+
await turn.receive({ type: "system", subtype: "status", status: "compacting" });
|
|
57
|
+
await turn.interrupt(client);
|
|
58
|
+
await turn.receive({ type: "result", subtype: "success", terminal_reason: "aborted_tools", result: "" });
|
|
59
|
+
assert.equal(turn.read().active, true);
|
|
60
|
+
assert.equal(events.some(event => event.type === "settled"), false);
|
|
61
|
+
turn.stop("Owned process scope drained.");
|
|
62
|
+
assert.equal(turn.read().active, false);
|
|
63
|
+
assert.equal(turn.read().phase, "");
|
|
64
|
+
assert.equal(turn.read().status, "interrupted");
|
|
65
|
+
turn.begin({ id: "next" });
|
|
66
|
+
await send("next");
|
|
67
|
+
await turn.receive({ type: "result", subtype: "success", result: "Resumed" });
|
|
68
|
+
assert.equal(turn.read().text, "Resumed");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("lost acknowledgement stays uncertain and stop rejects pending admission", async () => {
|
|
72
|
+
const { turn, client, send } = fixture();
|
|
73
|
+
client.send = async () => {};
|
|
74
|
+
await assert.rejects(send("timeout", { timeoutMs: 5 }), error => error.delivery === "uncertain");
|
|
75
|
+
const pending = send("stopped");
|
|
76
|
+
turn.stop("Connection lost.");
|
|
77
|
+
await assert.rejects(pending, /Connection lost/);
|
|
78
|
+
assert.equal(turn.read().pendingCommands, 0);
|
|
79
|
+
await turn.receive({ type: "result", subtype: "success", result: "Late reply" });
|
|
80
|
+
assert.equal(turn.read().text, "");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("native session identity and helper output bounds are enforced without truncating answers", async () => {
|
|
84
|
+
const { turn, send } = fixture();
|
|
85
|
+
await assert.rejects(turn.receive({ type: "user", session_id: "another-session" }), /different conversation id/);
|
|
86
|
+
turn.stop();
|
|
87
|
+
turn.begin({ id: "bounded", maxOutputCharacters: 5 });
|
|
88
|
+
await send();
|
|
89
|
+
await turn.receive({ type: "assistant", uuid: "reasoning", message: { content: [{ type: "thinking", thinking: "Longer reasoning summary" }] } });
|
|
90
|
+
await assert.rejects(turn.receive({ type: "assistant", uuid: "answer", message: { content: [{ type: "text", text: "Too long" }] } }), /size limit/);
|
|
91
|
+
await assert.rejects(turn.receive({ type: "result", subtype: "success", structured_output: { text: "Too long" } }), /size limit/);
|
|
92
|
+
turn.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
function renewalFixture() {
|
|
96
|
+
const input = { clientMessageId: "renewal-operation", prompt: "Approved prompt", timeoutMs: 100 };
|
|
97
|
+
const uuid = claudeNativeMessageId(input.clientMessageId);
|
|
98
|
+
const events = [];
|
|
99
|
+
const history = { userIds: [], messages: [] };
|
|
100
|
+
const state = { id: "exact-native-conversation", lastMessageId: "", turn: null, process: null };
|
|
101
|
+
const conversation = {
|
|
102
|
+
state,
|
|
103
|
+
async readHistory() { events.push("history"); return history; },
|
|
104
|
+
async send(value, options) {
|
|
105
|
+
events.push(["send", value, options]);
|
|
106
|
+
state.lastMessageId = uuid;
|
|
107
|
+
state.turn = { id: uuid, active: true };
|
|
108
|
+
},
|
|
109
|
+
async wait(value) { events.push(["wait", value]); return { status: "completed", text: "Finished", threadId: state.id, turnId: uuid }; },
|
|
110
|
+
async stop(reason) { events.push(["stop", reason]); return { exited: true }; }
|
|
111
|
+
};
|
|
112
|
+
return { input, uuid, events, history, state, conversation };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
test("renewal recovers the exact accepted native answer without another dispatch and closes before returning", async () => {
|
|
116
|
+
const f = renewalFixture();
|
|
117
|
+
f.history.userIds.push(f.uuid);
|
|
118
|
+
f.history.messages.push({ userId: "other", role: "assistant", text: "Unrelated" },
|
|
119
|
+
{ userId: f.uuid, role: "thinking", text: "Private reasoning" },
|
|
120
|
+
{ userId: f.uuid, role: "assistant", text: "First" }, { userId: f.uuid, role: "assistant", text: "Second" });
|
|
121
|
+
const result = await runClaudeRenewalTurn(f.conversation, f.input);
|
|
122
|
+
assert.deepEqual(result, { text: "First\nSecond", threadId: f.state.id, turnId: f.uuid, reconciled: true,
|
|
123
|
+
clientMessageId: f.input.clientMessageId, freshThread: false, processExitProof: { exited: true } });
|
|
124
|
+
assert.deepEqual(f.events, ["history", ["stop", undefined]]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("renewal refuses changed or unrelated successor history before native work and never resends unreadable accepted input", async () => {
|
|
128
|
+
const f = renewalFixture();
|
|
129
|
+
await assert.rejects(runClaudeRenewalTurn(f.conversation, { ...f.input, expectedThreadId: "different" }), /expected native history/);
|
|
130
|
+
await assert.rejects(runClaudeRenewalTurn(f.conversation, { ...f.input, requireFreshHistory: true,
|
|
131
|
+
forbiddenThreadId: f.state.id }), /expected native history/);
|
|
132
|
+
assert.deepEqual(f.events, []);
|
|
133
|
+
f.history.userIds.push("unrelated");
|
|
134
|
+
await assert.rejects(runClaudeRenewalTurn(f.conversation, { ...f.input, requireFreshHistory: true }), /unrelated messages/);
|
|
135
|
+
assert.deepEqual(f.events, ["history"]);
|
|
136
|
+
f.history.userIds.splice(0, 1, f.uuid);
|
|
137
|
+
await assert.rejects(runClaudeRenewalTurn(f.conversation, f.input), error => {
|
|
138
|
+
assert.equal(error.code, "assistant_claude_turn_unreadable");
|
|
139
|
+
assert.deepEqual(error.details, { clientMessageId: f.input.clientMessageId, inputAccepted: true,
|
|
140
|
+
threadId: f.state.id, turnId: f.uuid });
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
assert.equal(f.events.some(event => event[0] === "send"), false);
|
|
144
|
+
assert.match(f.events.at(-1)[1], /will not be submitted again/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("renewal dispatches once with the original schema and waits before confirmed cleanup", async () => {
|
|
148
|
+
const f = renewalFixture();
|
|
149
|
+
const outputSchema = { type: "object" };
|
|
150
|
+
const result = await runClaudeRenewalTurn(f.conversation, { ...f.input, outputSchema, requireFreshHistory: true });
|
|
151
|
+
assert.equal(result.freshThread, true);
|
|
152
|
+
assert.deepEqual(f.events, ["history", ["send", { message: f.input.prompt,
|
|
153
|
+
messageId: f.input.clientMessageId, outputSchema }, { renewal: true }],
|
|
154
|
+
["wait", { conversationId: f.state.id, timeoutMs: 100 }], ["stop", undefined]]);
|
|
155
|
+
assert.deepEqual(result.processExitProof, { exited: true });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("renewal preserves acceptance evidence and retries cleanup when the successful turn's close fails", async () => {
|
|
159
|
+
const f = renewalFixture();
|
|
160
|
+
const problem = Object.assign(new Error("Exit proof unavailable"), { details: { retained: true } });
|
|
161
|
+
let stops = 0;
|
|
162
|
+
f.conversation.stop = async reason => {
|
|
163
|
+
f.events.push(["stop", reason]);
|
|
164
|
+
if (++stops === 1) throw problem;
|
|
165
|
+
return { exited: true };
|
|
166
|
+
};
|
|
167
|
+
await assert.rejects(runClaudeRenewalTurn(f.conversation, f.input, { acceptedField: "handoverPromptAccepted" }), error => {
|
|
168
|
+
assert.equal(error, problem);
|
|
169
|
+
assert.deepEqual(error.details, { retained: true, clientMessageId: f.input.clientMessageId,
|
|
170
|
+
handoverPromptAccepted: true, threadId: f.state.id, turnId: f.uuid });
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
assert.equal(f.events.filter(event => event[0] === "send").length, 1);
|
|
174
|
+
assert.deepEqual(f.events.slice(-2), [["stop", undefined], ["stop", problem.message]]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("wait cleans up a restored execution before reading saved output and keeps the existing completion on success", async () => {
|
|
178
|
+
const events = [];
|
|
179
|
+
const state = { executionId: "owned-process", process: null, turn: { active: false } };
|
|
180
|
+
const saved = { text: "Saved output", status: "completed" };
|
|
181
|
+
const conversation = { state,
|
|
182
|
+
async stop(reason) { events.push(["stop", reason]); },
|
|
183
|
+
async read(input) { events.push(["read", input]); return saved; },
|
|
184
|
+
async interrupt() { events.push("interrupt"); }
|
|
185
|
+
};
|
|
186
|
+
const input = { messageId: "accepted" };
|
|
187
|
+
assert.equal(await waitForClaudeConversationTurn(conversation, input), saved);
|
|
188
|
+
assert.deepEqual(events, [["stop", "Claude was interrupted when the application disconnected."], ["read", input]]);
|
|
189
|
+
state.process = {};
|
|
190
|
+
state.turn.active = true;
|
|
191
|
+
state.completion = Promise.withResolvers();
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
const waiting = waitForClaudeConversationTurn(conversation, {}, { signal: controller.signal, defaultTimeoutMs: Infinity });
|
|
194
|
+
state.completion.resolve(saved);
|
|
195
|
+
assert.equal(await waiting, saved);
|
|
196
|
+
controller.abort();
|
|
197
|
+
assert.equal(events.includes("interrupt"), false, "Completed waits remove their abort listener");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("wait interrupts on its bounded timeout or caller abort without replacing the conversation completion", async () => {
|
|
201
|
+
let interrupts = 0;
|
|
202
|
+
const state = { process: {}, turn: { active: true }, completion: Promise.withResolvers() };
|
|
203
|
+
const conversation = { state, async interrupt() { interrupts++; } };
|
|
204
|
+
await assert.rejects(waitForClaudeConversationTurn(conversation, { timeoutMs: 10_000 }, { maximumTimeoutMs: 5 }), /within the time limit/);
|
|
205
|
+
assert.equal(interrupts, 1);
|
|
206
|
+
const controller = new AbortController();
|
|
207
|
+
conversation.interrupt = async () => { interrupts++; state.completion.resolve({ status: "interrupted" }); };
|
|
208
|
+
const waiting = waitForClaudeConversationTurn(conversation, {}, { signal: controller.signal, defaultTimeoutMs: Infinity });
|
|
209
|
+
controller.abort();
|
|
210
|
+
assert.deepEqual(await waiting, { status: "interrupted" });
|
|
211
|
+
assert.equal(interrupts, 2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("Claude split completions carry the exact live output identity before native block retirement", async () => {
|
|
215
|
+
const { turn, events, send } = fixture();
|
|
216
|
+
await send();
|
|
217
|
+
await turn.receive({ type: "stream_event", event: { type: "message_start", message: { id: "same-api-message" } } });
|
|
218
|
+
for (const [index, block] of [
|
|
219
|
+
{ type: "thinking", thinking: "The reasoning." }, { type: "text", text: "The answer." }
|
|
220
|
+
].entries()) {
|
|
221
|
+
await turn.receive({ type: "stream_event", event: { type: "content_block_start", index, content_block: { type: block.type } } });
|
|
222
|
+
await turn.receive({ type: "stream_event", event: { type: "content_block_delta", index,
|
|
223
|
+
delta: block.type === "thinking" ? { thinking: block.thinking } : { text: block.text } } });
|
|
224
|
+
// Claude's documented wire order completes the split frame before its stop.
|
|
225
|
+
await turn.receive({ type: "assistant", uuid: `saved-block-${index}`, message: { id: "same-api-message", content: [block] } });
|
|
226
|
+
const complete = events.filter(event => event.type === "message" && event.message.complete).at(-1).message;
|
|
227
|
+
assert.equal(complete.id, `claude_saved-block-${index}_0`, "Native history keeps its frame UUID and local index");
|
|
228
|
+
assert.equal(complete.outputId, `claude_same-api-message_${index}`, "The existing live map supplies the global block index");
|
|
229
|
+
await turn.receive({ type: "stream_event", event: { type: "content_block_stop", index } });
|
|
230
|
+
assert.equal(events.at(-1).messageId, complete.outputId);
|
|
231
|
+
}
|
|
232
|
+
assert.deepEqual(turn.read().messages.map(message => message.id), ["claude_saved-block-0_0", "claude_saved-block-1_0"]);
|
|
233
|
+
assert.deepEqual(turn.read().messages.map(message => message.outputId), ["claude_same-api-message_0", "claude_same-api-message_1"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("Claude output identity is absent without unique live proof or with a conflicting explicit index", async () => {
|
|
237
|
+
for (const mode of ["retired", "ambiguous", "foreign", "missing-id", "wrong-index", "invalid-index", "explicit-index"]) {
|
|
238
|
+
const { turn, events } = fixture();
|
|
239
|
+
const receive = event => turn.receive({ type: "stream_event", event });
|
|
240
|
+
await receive({ type: "message_start", message: { id: mode === "missing-id" ? undefined : "native" } });
|
|
241
|
+
await receive({ type: "content_block_start", index: 3, content_block: { type: "text", text: "Partial" } });
|
|
242
|
+
if (mode === "retired") await receive({ type: "content_block_stop", index: 3 });
|
|
243
|
+
if (mode === "ambiguous") await receive({ type: "content_block_start", index: 4, content_block: { type: "text", text: "Another" } });
|
|
244
|
+
await turn.receive({ type: "assistant", uuid: "saved", message: {
|
|
245
|
+
id: mode === "missing-id" ? undefined : mode === "foreign" ? "other" : "native", content: [{ type: "text", text: "Complete" }]
|
|
246
|
+
}, ...(mode === "wrong-index" ? { apiBlockIndex: 4 } : mode === "invalid-index" ? { apiBlockIndex: "3" }
|
|
247
|
+
: mode === "explicit-index" ? { apiBlockIndex: 3 } : {}) });
|
|
248
|
+
const message = events.filter(event => event.type === "message").at(-1).message;
|
|
249
|
+
assert.equal(message.id, "claude_saved_0");
|
|
250
|
+
if (mode === "explicit-index") assert.equal(message.outputId, "claude_native_3");
|
|
251
|
+
else assert.equal(Object.hasOwn(message, "outputId"), false, mode);
|
|
252
|
+
}
|
|
253
|
+
});
|