@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,1173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdir, mkdtemp, rename, rm, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createConversationRuntime, createFileConversationStorage, upgradeConversationRuntimeState } from "../src/server/conversation/index.js";
|
|
7
|
+
import { createLocalConversationExecution } from "../src/server/conversation/localExecution.js";
|
|
8
|
+
import { createClaudeConversationDriver } from "../src/server/conversation/providers/claudeDriver.js";
|
|
9
|
+
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
10
|
+
import { createSchema } from "json-rest-schema";
|
|
11
|
+
|
|
12
|
+
const configuration = { systemPrompt: "Keep these instructions fresh.", model: "test-model", effort: "high" };
|
|
13
|
+
const input = { messageId: "first", text: "Hello" };
|
|
14
|
+
|
|
15
|
+
test("Claude exposes the durable native identity before dispatch and respects a rejected gate", async t => {
|
|
16
|
+
const f = await fixture(t);
|
|
17
|
+
await f.first.close();
|
|
18
|
+
const driver = createClaudeConversationDriver(f.driverOptions);
|
|
19
|
+
const readBinding = () => f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
20
|
+
const provider = await driver.open({ binding: await readBinding(),
|
|
21
|
+
writeBinding: binding => f.storage.write("conversation", async tx => {
|
|
22
|
+
const metadata = await tx.readMetadata();
|
|
23
|
+
metadata.runtime.binding = binding;
|
|
24
|
+
await tx.writeMetadata(metadata);
|
|
25
|
+
}),
|
|
26
|
+
onFailure() {}
|
|
27
|
+
});
|
|
28
|
+
const blocked = new Error("Dispatch stopped by the persisted delivery gate.");
|
|
29
|
+
let checked = false;
|
|
30
|
+
try {
|
|
31
|
+
await assert.rejects(provider.run({ configuration, input, signal: new AbortController().signal,
|
|
32
|
+
async beforeDispatch(identity) {
|
|
33
|
+
assert.deepEqual(identity, { threadId: (await readBinding()).conversationId });
|
|
34
|
+
assert.ok(identity.threadId);
|
|
35
|
+
checked = true;
|
|
36
|
+
throw blocked;
|
|
37
|
+
},
|
|
38
|
+
accept() { assert.fail("A rejected dispatch must not admit the message."); },
|
|
39
|
+
onMessage() {}, onEvent() {}
|
|
40
|
+
}), error => error === blocked);
|
|
41
|
+
assert.equal(checked, true);
|
|
42
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 0);
|
|
43
|
+
} finally { await provider.dispose(); }
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("native Claude coding tools require the server-side host grant", async t => {
|
|
47
|
+
const f = await fixture(t, { nativeTools: true });
|
|
48
|
+
await f.conversation.send(input);
|
|
49
|
+
const result = await f.conversation.wait();
|
|
50
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "complete", result.error);
|
|
51
|
+
assert.equal(result.capabilities.nativeTools, true);
|
|
52
|
+
const args = (await f.trace()).find(row => row.args?.includes("--print")).args;
|
|
53
|
+
assert.equal(args[args.indexOf("--permission-mode") + 1], "bypassPermissions");
|
|
54
|
+
assert.match(args[args.indexOf("--tools") + 1], /Bash,Read,Edit,Write/);
|
|
55
|
+
assert.equal(args.includes("--restricted"), false);
|
|
56
|
+
assert.equal(args.includes("--safe-mode"), true);
|
|
57
|
+
assert.deepEqual(JSON.parse(args[args.indexOf("--mcp-config") + 1]), { mcpServers: {} });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("Claude installs the host command hook through SDK controls while ambient hooks stay disabled", async t => {
|
|
61
|
+
const f = await fixture(t, { nativeTools: true, commandWrapper: "/host/command wrapper" });
|
|
62
|
+
await f.conversation.send({ messageId: "command", text: "wrapped-command" });
|
|
63
|
+
const state = await f.conversation.wait();
|
|
64
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "complete", state.error);
|
|
65
|
+
const output = JSON.parse(state.conversationLog[0].assistant.text).hookSpecificOutput;
|
|
66
|
+
assert.equal(output.updatedInput.command, "'/host/command wrapper' 'pwd'");
|
|
67
|
+
assert.equal(output.updatedInput.timeout, 100);
|
|
68
|
+
const trace = await f.trace();
|
|
69
|
+
assert.equal(trace.find(row => row.args?.includes("--print")).args.includes("--safe-mode"), true);
|
|
70
|
+
assert.deepEqual(trace.find(row => row.frame?.request?.subtype === "initialize").frame.request.hooks, {
|
|
71
|
+
PreToolUse: [{ matcher: "^Bash$", hookCallbackIds: ["jskit-command-wrapper"], timeout: 30 }]
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("Claude receives authorized attachments and carries them into a replacement once", async t => {
|
|
76
|
+
const image = Buffer.from("authorized-image");
|
|
77
|
+
const receipt = { attachmentId: "picture", fileName: "picture.png", size: image.length };
|
|
78
|
+
let reads = 0;
|
|
79
|
+
const f = await fixture(t, { attachments: { resolve: async () => {
|
|
80
|
+
reads++;
|
|
81
|
+
return { attachments: [receipt], content: [{ type: "image", image, mediaType: "image/png" }] };
|
|
82
|
+
} } });
|
|
83
|
+
await f.conversation.send({ ...input, attachmentIds: [receipt.attachmentId] });
|
|
84
|
+
const first = await f.conversation.wait();
|
|
85
|
+
assert.equal(first.conversationLog[0].metadata.runtime.status, "complete", first.error);
|
|
86
|
+
assert.deepEqual(first.conversationLog[0].user.attachments, [receipt]);
|
|
87
|
+
assert.deepEqual((await f.trace()).find(row => row.frame?.type === "user").frame.message.content, [
|
|
88
|
+
{ type: "text", text: input.text }, { type: "image", source: { type: "base64", media_type: "image/png", data: image.toString("base64") } }
|
|
89
|
+
]);
|
|
90
|
+
await f.conversation.replace({ operationId: "renew", reason: "renewal", expectedSegmentId: first.segmentId });
|
|
91
|
+
await f.conversation.send({ messageId: "second", text: "Continue" });
|
|
92
|
+
await f.conversation.wait();
|
|
93
|
+
const renewed = (await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content;
|
|
94
|
+
assert.equal(renewed.filter(part => part.type === "image").length, 1);
|
|
95
|
+
await f.conversation.send({ messageId: "third", text: "Next" });
|
|
96
|
+
await f.conversation.wait();
|
|
97
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content, "Next");
|
|
98
|
+
assert.equal(reads, 2);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("native replacement saves preparation before close and retries the same inert successor after an interrupted commit", async t => {
|
|
102
|
+
const local = createLocalConversationExecution();
|
|
103
|
+
const nativeIds = new Set();
|
|
104
|
+
const events = [];
|
|
105
|
+
let failCommit = false;
|
|
106
|
+
let inspectClose = false;
|
|
107
|
+
let readRuntime;
|
|
108
|
+
const f = await fixture(t, {
|
|
109
|
+
execution: {
|
|
110
|
+
async start(options) {
|
|
111
|
+
const native = await local.start(options);
|
|
112
|
+
if (options.args.includes("--print")) nativeIds.add(native.id);
|
|
113
|
+
return native;
|
|
114
|
+
},
|
|
115
|
+
async stop(id) {
|
|
116
|
+
if (inspectClose && nativeIds.has(id)) {
|
|
117
|
+
const saved = await readRuntime();
|
|
118
|
+
assert.ok(saved.replacement.preparedAt, "Preparing is durable before the predecessor is closed");
|
|
119
|
+
events.push("close");
|
|
120
|
+
}
|
|
121
|
+
return local.stop(id);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
wrapStorage: storage => ({ ...storage, write: (id, callback) => storage.write(id, async transaction => {
|
|
125
|
+
const previous = await transaction.readMetadata();
|
|
126
|
+
const result = await callback(transaction);
|
|
127
|
+
const next = await transaction.readMetadata();
|
|
128
|
+
if (next.runtime?.replacement && !previous.runtime?.replacement) events.push("prepared");
|
|
129
|
+
if (previous.runtime?.replacement && !next.runtime?.replacement) {
|
|
130
|
+
events.push("commit");
|
|
131
|
+
if (failCommit) throw new Error("Storage offline during native replacement commit");
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}) })
|
|
135
|
+
});
|
|
136
|
+
t.after(() => local.close());
|
|
137
|
+
readRuntime = () => f.storage.read("conversation", async transaction => (await transaction.readMetadata()).runtime);
|
|
138
|
+
await f.conversation.send(input);
|
|
139
|
+
const before = await f.conversation.wait();
|
|
140
|
+
const predecessor = await readRuntime();
|
|
141
|
+
const operation = { operationId: "native-renewal", reason: "renewal", expectedSegmentId: before.segmentId,
|
|
142
|
+
briefing: "Keep the agreed blue clock." };
|
|
143
|
+
failCommit = true;
|
|
144
|
+
inspectClose = true;
|
|
145
|
+
try { await assert.rejects(f.conversation.replace(operation), /Storage offline during native replacement commit/); }
|
|
146
|
+
finally { inspectClose = false; }
|
|
147
|
+
assert.deepEqual(events, ["prepared", "close", "commit"]);
|
|
148
|
+
const interrupted = await readRuntime();
|
|
149
|
+
assert.equal(interrupted.segmentId, predecessor.segmentId);
|
|
150
|
+
assert.equal(interrupted.binding.conversationId, predecessor.binding.conversationId);
|
|
151
|
+
assert.deepEqual(interrupted.seen, predecessor.seen);
|
|
152
|
+
assert.equal(interrupted.predecessors.length, 0);
|
|
153
|
+
assert.ok(interrupted.replacement.preparedAt);
|
|
154
|
+
const successor = interrupted.replacement;
|
|
155
|
+
assert.notEqual(successor.binding.conversationId, predecessor.binding.conversationId);
|
|
156
|
+
assert.equal(successor.binding.sent, false);
|
|
157
|
+
assert.equal(successor.binding.executionId, "");
|
|
158
|
+
assert.equal(nativeIds.size, 1, "Opening a successor handle must not start a native process");
|
|
159
|
+
await assert.rejects(f.conversation.send({ messageId: "early", text: "Too early" }), { code: "conversation_replacement_pending" });
|
|
160
|
+
await assert.rejects(f.conversation.replace({ ...operation, briefing: "Changed" }), { code: "conversation_replacement_pending" });
|
|
161
|
+
await f.first.close();
|
|
162
|
+
failCommit = false;
|
|
163
|
+
const resumed = await f.runtime().open({ id: "conversation" });
|
|
164
|
+
const replaced = await resumed.replace(operation);
|
|
165
|
+
assert.equal(replaced.segmentId, successor.segmentId);
|
|
166
|
+
const ready = await readRuntime();
|
|
167
|
+
assert.equal(ready.binding.conversationId, successor.binding.conversationId);
|
|
168
|
+
assert.equal(ready.replacement, undefined);
|
|
169
|
+
assert.equal(ready.lastEngine, "");
|
|
170
|
+
assert.deepEqual(ready.seen, {});
|
|
171
|
+
assert.equal(ready.predecessors.length, 1);
|
|
172
|
+
assert.equal(ready.predecessors[0].preparedAt, successor.preparedAt);
|
|
173
|
+
assert.equal(ready.predecessors[0].binding.conversationId, predecessor.binding.conversationId);
|
|
174
|
+
assert.deepEqual(ready.predecessors[0].seen, predecessor.seen);
|
|
175
|
+
assert.equal(ready.predecessors[0].acceptedAt, undefined, "Preparation does not retire native history");
|
|
176
|
+
assert.equal(nativeIds.size, 1);
|
|
177
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
178
|
+
assert.deepEqual(await resumed.replace(operation), { ...replaced, duplicate: true });
|
|
179
|
+
await resumed.send({ messageId: "successor", text: "Continue" });
|
|
180
|
+
const delivered = await resumed.wait();
|
|
181
|
+
const accepted = await readRuntime();
|
|
182
|
+
assert.ok(accepted.predecessors[0].acceptedAt);
|
|
183
|
+
assert.equal(accepted.predecessors[0].successorConversationId, successor.binding.conversationId);
|
|
184
|
+
assert.deepEqual(delivered.conversationLog.map(turn => turn.user.text), ["Hello", "Continue"]);
|
|
185
|
+
const firstPrompt = (await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content;
|
|
186
|
+
assert.match(firstPrompt, /Keep the agreed blue clock/);
|
|
187
|
+
assert.equal(nativeIds.size, 2);
|
|
188
|
+
await resumed.send({ messageId: "after-successor", text: "Next" });
|
|
189
|
+
await resumed.wait();
|
|
190
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content, "Next");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
async function fixture(t, options = {}) {
|
|
194
|
+
const directory = await mkdtemp(path.join(tmpdir(), "jskit-claude-conversation-"));
|
|
195
|
+
const command = path.join(directory, "claude.mjs");
|
|
196
|
+
const account = path.join(directory, "claude", ".credentials.json");
|
|
197
|
+
const trace = path.join(directory, "trace.jsonl");
|
|
198
|
+
await mkdir(path.dirname(account));
|
|
199
|
+
await writeFile(account, "owner@example.test");
|
|
200
|
+
await writeFile(command, `#!${process.execPath}
|
|
201
|
+
import { createInterface } from "node:readline";
|
|
202
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
203
|
+
import path from "node:path";
|
|
204
|
+
const args = process.argv.slice(2);
|
|
205
|
+
const structuredResponse = ${JSON.stringify(options.structuredResponse || null)};
|
|
206
|
+
const option = name => args[args.indexOf(name) + 1];
|
|
207
|
+
const log = value => appendFileSync(process.env.TEST_TRACE, JSON.stringify(value) + "\\n");
|
|
208
|
+
const emit = value => process.stdout.write(JSON.stringify(value) + "\\n");
|
|
209
|
+
log({ args, routing: Object.fromEntries(["ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
210
|
+
"CLAUDE_CODE_SUBAGENT_MODEL", "CLAUDE_CODE_AUTO_COMPACT_WINDOW"].map(key => [key, process.env[key]])) });
|
|
211
|
+
if (args[0] === "auth") {
|
|
212
|
+
emit({ loggedIn: true, authMethod: "claude.ai", email: readFileSync(process.env.TEST_ACCOUNT, "utf8") });
|
|
213
|
+
process.exit(0);
|
|
214
|
+
}
|
|
215
|
+
const id = option(args.includes("--resume") ? "--resume" : "--session-id");
|
|
216
|
+
const project = path.join(process.env.CLAUDE_CONFIG_DIR, "projects", process.cwd().replace(/[^a-zA-Z0-9]/gu, "-"));
|
|
217
|
+
mkdirSync(project, { recursive: true });
|
|
218
|
+
const record = frame => appendFileSync(path.join(project, id + ".jsonl"), JSON.stringify(frame) + "\\n");
|
|
219
|
+
const controls = new Map();
|
|
220
|
+
let controlId = 0;
|
|
221
|
+
const request = value => new Promise((resolve, reject) => {
|
|
222
|
+
const id = "native-control-" + (++controlId);
|
|
223
|
+
controls.set(id, { resolve, reject });
|
|
224
|
+
emit({ type: "control_request", request_id: id, request: value });
|
|
225
|
+
});
|
|
226
|
+
createInterface({ input: process.stdin }).on("line", async line => {
|
|
227
|
+
const frame = JSON.parse(line);
|
|
228
|
+
log({ frame });
|
|
229
|
+
if (frame.type === "control_response") {
|
|
230
|
+
const pending = controls.get(frame.response.request_id); controls.delete(frame.response.request_id);
|
|
231
|
+
if (frame.response.subtype === "error") pending.reject(new Error(frame.response.error));
|
|
232
|
+
else pending.resolve(frame.response.response);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (frame.type === "control_request") {
|
|
236
|
+
if (frame.request.subtype === "initialize" && process.env.TEST_STARTUP_WAIT) return;
|
|
237
|
+
const reject = frame.request.subtype === "apply_flag_settings" && process.env.TEST_REJECT_EFFORT && frame.request.settings.effortLevel === process.env.TEST_REJECT_EFFORT;
|
|
238
|
+
emit({ type: "control_response", response: { request_id: frame.request_id,
|
|
239
|
+
subtype: reject ? "error" : "success", error: reject ? "Settings rejected" : undefined, response: {} } });
|
|
240
|
+
if (frame.request.subtype === "interrupt") emit({ type: "result", subtype: "success", terminal_reason: "aborted_streaming", result: "" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
record(frame);
|
|
244
|
+
const text = typeof frame.message.content === "string" ? frame.message.content
|
|
245
|
+
: frame.message.content.filter(part => part.type === "text").map(part => part.text).join(" ");
|
|
246
|
+
let answer = { type: "assistant", uuid: frame.uuid + "-answer", message: { content: [{ type: "text", text: "Answer: " + text }] } };
|
|
247
|
+
if (structuredResponse) answer.message.content[0].text = JSON.stringify(structuredResponse);
|
|
248
|
+
if (text === "lost") { record(answer); return; }
|
|
249
|
+
if (text.startsWith("/goal ")) {
|
|
250
|
+
const condition = text.slice(6);
|
|
251
|
+
record({ type: "attachment", timestamp: new Date().toISOString(),
|
|
252
|
+
attachment: { type: "goal_status", condition, sentinel: true, met: condition === "clear" } });
|
|
253
|
+
if (process.env.TEST_GOAL_LOST_ACK) return;
|
|
254
|
+
emit(frame);
|
|
255
|
+
if (condition === "clear") emit({ type: "result", subtype: "success", result: "" });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
emit(frame);
|
|
259
|
+
if (text === "wait") return;
|
|
260
|
+
if (text === "steering") {
|
|
261
|
+
answer.message.content[0].text = "Initial progress";
|
|
262
|
+
answer.message.id = frame.uuid + "-before";
|
|
263
|
+
record(answer); emit(answer); return;
|
|
264
|
+
}
|
|
265
|
+
if (text === "phases") {
|
|
266
|
+
emit({ type: "system", subtype: "status", status: "compacting" });
|
|
267
|
+
emit({ type: "system", subtype: "compact_boundary" });
|
|
268
|
+
}
|
|
269
|
+
if (text === "failed") { emit({ type: "result", subtype: "error_during_execution", is_error: true, errors: ["Model connection failed."] }); return; }
|
|
270
|
+
if (text === "wrapped-command") {
|
|
271
|
+
const output = await request({ subtype: "hook_callback", callback_id: "jskit-command-wrapper",
|
|
272
|
+
input: { hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "pwd", timeout: 100 } } });
|
|
273
|
+
answer.message.content[0].text = JSON.stringify(output);
|
|
274
|
+
}
|
|
275
|
+
if (text === "tools" || text === "foreign-tool") {
|
|
276
|
+
let sequence = 0;
|
|
277
|
+
const invoke = async (name, input) => {
|
|
278
|
+
const toolId = frame.uuid + "-tool-" + (++sequence);
|
|
279
|
+
const use = { type: "assistant", uuid: toolId, message: { content: [{ type: "tool_use", id: toolId, name: "mcp__application__" + name, input }] } };
|
|
280
|
+
record(use); emit(use);
|
|
281
|
+
return request({ subtype: "mcp_message", server_name: "application", message: {
|
|
282
|
+
jsonrpc: "2.0", id: sequence, method: "tools/call",
|
|
283
|
+
params: { name, arguments: input, _meta: { "claudecode/toolUseId": text === "foreign-tool" ? "foreign-id" : toolId } }
|
|
284
|
+
} });
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
await invoke("assistant_action_search", { query: "numbers" });
|
|
288
|
+
await invoke("assistant_action_contract", { actionId: "numbers.read", version: 1 });
|
|
289
|
+
const result = await invoke("assistant_action_execute", { actionId: "numbers.read", version: 1, input: {} });
|
|
290
|
+
answer.message.content[0].text = result.mcp_response.result.content[0].text;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
emit({ type: "result", subtype: "error_during_execution", is_error: true, errors: [error.message] }); return;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
emit({ type: "stream_event", event: { type: "message_start", message: { id: frame.uuid + "-stream" } } });
|
|
296
|
+
emit({ type: "stream_event", event: { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } });
|
|
297
|
+
emit({ type: "stream_event", event: { type: "content_block_delta", index: 0, delta: { text: answer.message.content[0].text.slice(0, 8) } } });
|
|
298
|
+
emit({ type: "stream_event", event: { type: "content_block_stop", index: 0 } });
|
|
299
|
+
answer.message.id = frame.uuid + "-stream";
|
|
300
|
+
const thinking = { type: "assistant", uuid: frame.uuid + "-thinking", message: { content: [{ type: "thinking", thinking: "Reasoning summary" }] } };
|
|
301
|
+
record(thinking); emit(thinking);
|
|
302
|
+
record(answer); emit(answer);
|
|
303
|
+
emit({ type: "result", subtype: "success", result: answer.message.content[0].text,
|
|
304
|
+
...(structuredResponse ? { structured_output: structuredResponse } : {}) });
|
|
305
|
+
});
|
|
306
|
+
`, { mode: 0o700 });
|
|
307
|
+
const fileStorage = createFileConversationStorage({ directory: path.join(directory, "storage") });
|
|
308
|
+
const storage = options.wrapStorage ? options.wrapStorage(fileStorage) : fileStorage;
|
|
309
|
+
const runtimes = [];
|
|
310
|
+
t.after(async () => {
|
|
311
|
+
try { for (const runtime of runtimes) await runtime.close(); }
|
|
312
|
+
finally { await rm(directory, { recursive: true, force: true }); }
|
|
313
|
+
});
|
|
314
|
+
const env = { ...process.env, ANTHROPIC_AUTH_TOKEN: "", ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "",
|
|
315
|
+
CLAUDE_CONFIG_DIR: path.join(directory, "claude"), TEST_TRACE: trace, TEST_ACCOUNT: account, ...options.environment };
|
|
316
|
+
function runtime() {
|
|
317
|
+
const value = createConversationRuntime({ engine: "claude", storage, authorize: options.authorize || (() => true), actions: options.actions,
|
|
318
|
+
connections: options.connections, attachments: options.attachments,
|
|
319
|
+
host: { workdir: directory, env, commands: { claude: command }, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
|
|
320
|
+
limits: { admissionTimeoutMs: 150, ...options.limits } });
|
|
321
|
+
runtimes.push(value);
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
const first = runtime();
|
|
325
|
+
const conversation = await first.open({ id: "conversation", configuration: options.configuration || configuration, context: options.context });
|
|
326
|
+
return { directory, storage, account, runtime, first, conversation,
|
|
327
|
+
driverOptions: { connections: options.connections,
|
|
328
|
+
host: { workdir: directory, env, commands: { claude: command }, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
|
|
329
|
+
limits: { admissionTimeoutMs: 150, ...options.limits } },
|
|
330
|
+
async trace() { return (await readFile(trace, "utf8")).trim().split("\n").map(line => JSON.parse(line)); } };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
test("Claude structured output reuses the original schema flag and restarts only when its schema changes", async t => {
|
|
334
|
+
const outputSchema = { type: "object", additionalProperties: false,
|
|
335
|
+
properties: { answer: { type: "string", maxLength: 32 } }, required: ["answer"] };
|
|
336
|
+
const f = await fixture(t, { configuration: { ...configuration, outputSchema }, structuredResponse: { answer: "Done" } });
|
|
337
|
+
await f.conversation.send(input);
|
|
338
|
+
const first = await f.conversation.wait();
|
|
339
|
+
assert.equal(first.capabilities.structuredOutput, true);
|
|
340
|
+
assert.deepEqual(first.configuration.outputSchema, outputSchema);
|
|
341
|
+
assert.equal(first.conversationLog[0].assistant.text, '{"answer":"Done"}');
|
|
342
|
+
const launches = async () => (await f.trace()).filter(row => row.args?.includes("--print"));
|
|
343
|
+
const initialArgs = (await launches())[0].args;
|
|
344
|
+
assert.deepEqual(JSON.parse(initialArgs.at(initialArgs.indexOf("--json-schema") + 1)), outputSchema);
|
|
345
|
+
const nativeId = (await f.storage.read("conversation", tx => tx.readMetadata())).runtime.binding.conversationId;
|
|
346
|
+
await f.conversation.configure({ model: "second-model" });
|
|
347
|
+
await f.conversation.send({ messageId: "same-schema", text: "Keep the schema" });
|
|
348
|
+
await f.conversation.wait();
|
|
349
|
+
assert.equal((await launches()).length, 1, "An unchanged output schema permits the existing native model update");
|
|
350
|
+
const changed = { ...outputSchema, properties: { answer: { type: "string", maxLength: 64 } } };
|
|
351
|
+
await f.conversation.configure({ outputSchema: changed });
|
|
352
|
+
await f.conversation.send({ messageId: "new-schema", text: "Use the updated schema" });
|
|
353
|
+
const last = await f.conversation.wait();
|
|
354
|
+
const processes = await launches();
|
|
355
|
+
assert.equal(processes.length, 2);
|
|
356
|
+
assert.deepEqual(JSON.parse(processes[1].args.at(processes[1].args.indexOf("--json-schema") + 1)), changed);
|
|
357
|
+
assert.equal((await f.storage.read("conversation", tx => tx.readMetadata())).runtime.binding.conversationId, nativeId);
|
|
358
|
+
assert.equal(last.conversationLog.length, 3);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("the common Claude API admits, streams, persists reasoning and deduplicates the same request", async t => {
|
|
362
|
+
const f = await fixture(t);
|
|
363
|
+
const events = [];
|
|
364
|
+
await f.conversation.subscribe(event => events.push(event));
|
|
365
|
+
const receipt = await f.conversation.send(input);
|
|
366
|
+
assert.equal(receipt.status, "accepted");
|
|
367
|
+
const snapshot = await f.conversation.wait();
|
|
368
|
+
assert.equal(snapshot.conversationLog[0].assistant.text, "Answer: Hello");
|
|
369
|
+
assert.equal(snapshot.conversationLog[0].thinking[0].text, "Reasoning summary");
|
|
370
|
+
assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "complete");
|
|
371
|
+
assert.ok(events.some(event => event.type === "message" && event.text === "Answer: "));
|
|
372
|
+
const replies = events.filter(event => event.type === "message" && event.role === "assistant");
|
|
373
|
+
// Production Claude uses temporary stream ids and native snapshot UUIDs.
|
|
374
|
+
// The discarded receiver invented a shared id that disagreed with history.
|
|
375
|
+
assert.equal(new Set(replies.map(event => event.messageId)).size, 2);
|
|
376
|
+
const partial = replies.find(event => event.status === "inProgress");
|
|
377
|
+
const complete = replies.find(event => event.status === "complete");
|
|
378
|
+
assert.notEqual(partial.messageId, complete.messageId);
|
|
379
|
+
assert.ok(events.some(event => event.type === "message-complete" && event.messageId === partial.messageId));
|
|
380
|
+
assert.equal(snapshot.conversationLog[0].assistant.messageId, complete.messageId);
|
|
381
|
+
assert.deepEqual(snapshot.conversationLog[0].commentary, []);
|
|
382
|
+
assert.deepEqual(snapshot.streaming.messages, []);
|
|
383
|
+
assert.equal((await f.conversation.send(input)).duplicate, true);
|
|
384
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("Claude reports common work and compaction phases and clears phase after settling", async t => {
|
|
388
|
+
const f = await fixture(t);
|
|
389
|
+
const phases = [];
|
|
390
|
+
await f.conversation.subscribe(event => { if (event.type === "phase") phases.push(event.phase); });
|
|
391
|
+
await f.conversation.send({ messageId: "phases", text: "phases" });
|
|
392
|
+
assert.equal((await f.conversation.wait()).phase, "");
|
|
393
|
+
assert.deepEqual(phases, ["preparing", "working", "compacting", "working", ""]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("Claude steering interrupts generation and admits a distinct message without replacing its process", async t => {
|
|
397
|
+
const f = await fixture(t);
|
|
398
|
+
const started = Promise.withResolvers();
|
|
399
|
+
const events = [];
|
|
400
|
+
await f.conversation.subscribe(event => {
|
|
401
|
+
events.push(event);
|
|
402
|
+
if (event.type === "message" && event.text === "Initial progress") started.resolve();
|
|
403
|
+
});
|
|
404
|
+
await f.conversation.send({ messageId: "before", text: "steering" });
|
|
405
|
+
await started.promise;
|
|
406
|
+
const steering = { messageId: "after", text: "Continue differently", steer: true };
|
|
407
|
+
const [one, two] = await Promise.all([f.conversation.send(steering), f.conversation.send(steering)]);
|
|
408
|
+
assert.equal(one.turnId, two.turnId);
|
|
409
|
+
const state = await f.conversation.wait();
|
|
410
|
+
assert.equal(state.error, "");
|
|
411
|
+
assert.equal(state.conversationLog.length, 2);
|
|
412
|
+
assert.equal(state.conversationLog[0].assistant.text, "Initial progress");
|
|
413
|
+
assert.equal(state.conversationLog[1].assistant.text, "Answer: Continue differently");
|
|
414
|
+
const trace = await f.trace();
|
|
415
|
+
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
416
|
+
assert.equal(trace.filter(row => row.frame?.type === "user").length, 2);
|
|
417
|
+
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 1);
|
|
418
|
+
assert.equal((await f.conversation.send(steering)).duplicate, true);
|
|
419
|
+
for (const turn of state.conversationLog) assert.equal(turn.assistant.messageId,
|
|
420
|
+
events.find(event => event.role === "assistant" && event.status === "complete" && event.turnId === turn.turnId).messageId);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("lost Claude steering acknowledgement is recovered without another native submission", async t => {
|
|
424
|
+
const f = await fixture(t);
|
|
425
|
+
await f.conversation.send({ messageId: "before", text: "wait" });
|
|
426
|
+
const input = { messageId: "lost-steering", text: "lost", steer: true };
|
|
427
|
+
await assert.rejects(f.conversation.send(input), /not acknowledged/);
|
|
428
|
+
assert.equal((await f.conversation.wait()).status, "unconfirmed");
|
|
429
|
+
assert.equal((await f.conversation.inspectDelivery({ messageId: input.messageId })).status, "accepted");
|
|
430
|
+
assert.equal((await f.conversation.send(input)).duplicate, true);
|
|
431
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 2);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("native Send retries inspect the durable prompt receipt before duplicate handling or another dispatch", async t => {
|
|
435
|
+
const f = await fixture(t);
|
|
436
|
+
const request = { messageId: "lost-receipt", text: "lost" };
|
|
437
|
+
await assert.rejects(f.conversation.send(request), /not acknowledged/);
|
|
438
|
+
await f.conversation.wait();
|
|
439
|
+
const saved = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime;
|
|
440
|
+
assert.equal(saved.version, 3);
|
|
441
|
+
assert.equal(saved.request.message, request.text);
|
|
442
|
+
assert.equal(saved.request.displayMessage, request.text);
|
|
443
|
+
assert.equal(saved.request.attempted, true);
|
|
444
|
+
assert.equal(saved.request.threadId, saved.binding.conversationId);
|
|
445
|
+
assert.equal(Object.hasOwn((await f.conversation.read()).pendingRequest, "message"), false);
|
|
446
|
+
await f.first.close();
|
|
447
|
+
const resumed = await f.runtime().open({ id: "conversation" });
|
|
448
|
+
assert.equal((await resumed.send(request)).status, "accepted");
|
|
449
|
+
const recovered = await resumed.wait();
|
|
450
|
+
assert.equal(recovered.status, "ready");
|
|
451
|
+
assert.equal(recovered.conversationLog[0].user.text, request.text);
|
|
452
|
+
assert.equal(recovered.conversationLog[0].assistant.text, "Answer: lost");
|
|
453
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
454
|
+
assert.equal((await resumed.send(request)).duplicate, true);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("native changeover keeps the prepared prompt frozen across rejection, transcript edits and retry", async t => {
|
|
458
|
+
const f = await fixture(t, { environment: { TEST_REJECT_EFFORT: "low" } });
|
|
459
|
+
await f.conversation.send(input);
|
|
460
|
+
const first = await f.conversation.wait();
|
|
461
|
+
await f.conversation.replace({ operationId: "freeze-renewal", reason: "renewal", expectedSegmentId: first.segmentId });
|
|
462
|
+
await f.conversation.configure({ effort: "low" });
|
|
463
|
+
const request = { messageId: "frozen", text: "Continue" };
|
|
464
|
+
await assert.rejects(f.conversation.send(request), /Settings rejected/);
|
|
465
|
+
await f.conversation.wait();
|
|
466
|
+
const pending = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime.request;
|
|
467
|
+
assert.equal(pending.attempted, false);
|
|
468
|
+
assert.match(pending.message, /Answer: Hello/);
|
|
469
|
+
await f.storage.write("conversation", transaction => transaction.replaceAssistant(first.conversationLog[0].turnId,
|
|
470
|
+
{ role: "assistant", messageId: first.conversationLog[0].assistant.messageId, text: "Edited after preparation", at: new Date().toISOString() }));
|
|
471
|
+
await f.conversation.configure({ effort: "high" });
|
|
472
|
+
await f.conversation.send(request);
|
|
473
|
+
await f.conversation.wait();
|
|
474
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content, pending.message);
|
|
475
|
+
await f.conversation.send({ messageId: "correction", text: "Continue again" });
|
|
476
|
+
await f.conversation.wait();
|
|
477
|
+
assert.match((await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content, /Edited after preparation/);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("an offline-upgraded native reservation is inspected without reconstructing its missing prompt", async t => {
|
|
481
|
+
const f = await fixture(t);
|
|
482
|
+
await f.conversation.send(input);
|
|
483
|
+
await f.conversation.wait();
|
|
484
|
+
const previous = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime.seen;
|
|
485
|
+
const request = { messageId: "old-receipt", text: "lost" };
|
|
486
|
+
await assert.rejects(f.conversation.send(request), /not acknowledged/);
|
|
487
|
+
await f.conversation.wait();
|
|
488
|
+
await f.first.close();
|
|
489
|
+
await f.storage.write("conversation", async transaction => {
|
|
490
|
+
const metadata = await transaction.readMetadata();
|
|
491
|
+
const saved = metadata.runtime.request;
|
|
492
|
+
metadata.runtime.version = 2;
|
|
493
|
+
delete metadata.runtime.lastEngine;
|
|
494
|
+
metadata.runtime.request = Object.fromEntries(["messageId", "text", "origin", "attachments", "at", "error"]
|
|
495
|
+
.map(name => [name, saved[name]]));
|
|
496
|
+
const conversationLog = await Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id)));
|
|
497
|
+
await transaction.writeMetadata(upgradeConversationRuntimeState({ metadata, conversationLog }).metadata);
|
|
498
|
+
});
|
|
499
|
+
const before = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime.request;
|
|
500
|
+
assert.equal(before.inspectionOnly, true);
|
|
501
|
+
assert.equal(Object.hasOwn(before, "message"), false);
|
|
502
|
+
assert.equal(Object.hasOwn(before, "seen"), false);
|
|
503
|
+
const resumed = await f.runtime().open({ id: "conversation" });
|
|
504
|
+
assert.equal((await resumed.send(request)).status, "accepted");
|
|
505
|
+
await resumed.wait();
|
|
506
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 2);
|
|
507
|
+
const saved = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime;
|
|
508
|
+
assert.equal(saved.request, undefined);
|
|
509
|
+
assert.deepEqual(saved.seen, previous, "An absent old snapshot cannot acknowledge additional history");
|
|
510
|
+
assert.equal((await resumed.read()).conversationLog[1].user.text, request.text);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("Claude reuses the process, changes compatible settings live and resumes the same history for changed instructions", async t => {
|
|
514
|
+
const f = await fixture(t);
|
|
515
|
+
await f.conversation.send(input);
|
|
516
|
+
await f.conversation.wait();
|
|
517
|
+
await f.conversation.configure({ model: "another-model", effort: "low" });
|
|
518
|
+
await f.conversation.send({ messageId: "second", text: "Second" });
|
|
519
|
+
await f.conversation.wait();
|
|
520
|
+
let starts = (await f.trace()).filter(row => row.args?.includes("--print"));
|
|
521
|
+
assert.equal(starts.length, 1);
|
|
522
|
+
await f.conversation.configure({ systemPrompt: "New instructions." });
|
|
523
|
+
await f.conversation.send({ messageId: "third", text: "Third" });
|
|
524
|
+
await f.conversation.wait();
|
|
525
|
+
starts = (await f.trace()).filter(row => row.args?.includes("--print"));
|
|
526
|
+
assert.equal(starts.length, 2);
|
|
527
|
+
assert.equal(starts[0].args[starts[0].args.indexOf("--session-id") + 1], starts[1].args[starts[1].args.indexOf("--resume") + 1]);
|
|
528
|
+
assert.equal(starts[1].args[starts[1].args.indexOf("--system-prompt") + 1], "New instructions.");
|
|
529
|
+
assert.equal(starts[1].args[starts[1].args.indexOf("--system-prompt-snapshot") + 1], "off");
|
|
530
|
+
assert.deepEqual((await f.trace()).filter(row => row.frame?.type === "user").map(row => row.frame.message.content), ["Hello", "Second", "Third"]);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("clean shutdown and reopen retain native identity, configuration and application receipts", async t => {
|
|
534
|
+
const f = await fixture(t);
|
|
535
|
+
await f.conversation.send(input);
|
|
536
|
+
await f.conversation.wait();
|
|
537
|
+
await f.first.close();
|
|
538
|
+
const binding = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
539
|
+
assert.equal(binding.executionId, "");
|
|
540
|
+
const reopened = await f.runtime().open({ id: "conversation" });
|
|
541
|
+
assert.equal((await reopened.send(input)).duplicate, true);
|
|
542
|
+
await reopened.send({ messageId: "after-restart", text: "Continue" });
|
|
543
|
+
assert.equal((await reopened.wait()).conversationLog.length, 2);
|
|
544
|
+
const starts = (await f.trace()).filter(row => row.args?.includes("--print"));
|
|
545
|
+
assert.equal(starts[1].args[starts[1].args.indexOf("--resume") + 1], binding.conversationId);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("cancel drains native work and allows a subsequent request in the same conversation", async t => {
|
|
549
|
+
const f = await fixture(t);
|
|
550
|
+
await f.conversation.send({ messageId: "waiting", text: "wait" });
|
|
551
|
+
assert.equal((await f.conversation.read()).status, "working");
|
|
552
|
+
assert.deepEqual(await f.conversation.cancel(), { stopped: true });
|
|
553
|
+
assert.equal((await f.conversation.read()).conversationLog[0].metadata.runtime.status, "cancelled");
|
|
554
|
+
const binding = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
555
|
+
assert.equal(binding.executionId, "");
|
|
556
|
+
await f.conversation.send(input);
|
|
557
|
+
assert.equal((await f.conversation.wait()).conversationLog[1].assistant.text, "Answer: Hello");
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
function applicationActions(execute) {
|
|
561
|
+
const actions = createActionCatalogue();
|
|
562
|
+
actions.register({ contributorId: "test.tools", domain: "numbers", actions: [{
|
|
563
|
+
id: "numbers.read", version: 1, kind: "query", channels: ["automation"], surfaces: ["app"],
|
|
564
|
+
permission: { require: "all", permissions: ["numbers.read"] }, idempotency: "none",
|
|
565
|
+
input: { schema: createSchema({}), mode: "replace" },
|
|
566
|
+
output: { schema: createSchema({ value: { type: "number", required: true } }), mode: "replace" }, execute
|
|
567
|
+
}] });
|
|
568
|
+
return actions;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
test("Claude uses shared discovery and durable application results across native resume", async t => {
|
|
572
|
+
let executions = 0;
|
|
573
|
+
const context = { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] };
|
|
574
|
+
const f = await fixture(t, { context, actions: applicationActions(async () => { executions++; return { value: 42 }; }) });
|
|
575
|
+
await f.conversation.send({ messageId: "tool-turn", text: "tools" });
|
|
576
|
+
const first = await f.conversation.wait();
|
|
577
|
+
assert.equal(first.conversationLog[0].metadata.runtime.status, "complete");
|
|
578
|
+
assert.equal(first.conversationLog[0].metadata.applicationTools.length, 3);
|
|
579
|
+
assert.equal(JSON.parse(first.conversationLog[0].assistant.text).result.result.value, 42);
|
|
580
|
+
assert.equal(executions, 1);
|
|
581
|
+
const started = (await f.trace()).find(row => row.args?.includes("--print"));
|
|
582
|
+
assert.equal(started.args[started.args.indexOf("--tools") + 1], "");
|
|
583
|
+
assert.deepEqual(JSON.parse(started.args[started.args.indexOf("--mcp-config") + 1]), {
|
|
584
|
+
mcpServers: { application: { type: "sdk", name: "application" } }
|
|
585
|
+
});
|
|
586
|
+
await f.first.close();
|
|
587
|
+
const resumed = await f.runtime().open({ id: "conversation", context });
|
|
588
|
+
await resumed.send({ messageId: "resumed-tools", text: "tools" });
|
|
589
|
+
assert.equal((await resumed.wait()).conversationLog[1].metadata.runtime.status, "complete");
|
|
590
|
+
assert.equal(executions, 2);
|
|
591
|
+
context.permissions = [];
|
|
592
|
+
await resumed.send({ messageId: "revoked-tools", text: "tools" });
|
|
593
|
+
const revoked = await resumed.wait();
|
|
594
|
+
assert.equal(revoked.conversationLog[2].metadata.applicationTools.at(-1).result.ok, false);
|
|
595
|
+
assert.equal(executions, 2);
|
|
596
|
+
const starts = (await f.trace()).filter(row => row.args?.includes("--print"));
|
|
597
|
+
assert.equal(starts.length, 2, "Permission changes do not replace the native process");
|
|
598
|
+
assert.equal(starts[0].args[starts[0].args.indexOf("--session-id") + 1], starts[1].args[starts[1].args.indexOf("--resume") + 1]);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("Claude rejects an application call without the preceding native tool identity", async t => {
|
|
602
|
+
let executions = 0;
|
|
603
|
+
const f = await fixture(t, {
|
|
604
|
+
actions: applicationActions(async () => { executions++; return { value: 42 }; }),
|
|
605
|
+
context: { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] }
|
|
606
|
+
});
|
|
607
|
+
await f.conversation.send({ messageId: "foreign", text: "foreign-tool" });
|
|
608
|
+
const result = await f.conversation.wait();
|
|
609
|
+
assert.equal(executions, 0);
|
|
610
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
|
|
611
|
+
assert.match(result.error, /does not match an admitted native tool use/);
|
|
612
|
+
assert.deepEqual(result.conversationLog[0].metadata.applicationTools, []);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("Claude cancellation waits for an application effect after closing the native process", async t => {
|
|
616
|
+
const entered = Promise.withResolvers(), complete = Promise.withResolvers();
|
|
617
|
+
const f = await fixture(t, {
|
|
618
|
+
actions: applicationActions(async () => { entered.resolve(); return complete.promise; }),
|
|
619
|
+
context: { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] }
|
|
620
|
+
});
|
|
621
|
+
t.after(() => complete.resolve({ value: 42 }));
|
|
622
|
+
await f.conversation.send({ messageId: "cancel-tools", text: "tools" });
|
|
623
|
+
await entered.promise;
|
|
624
|
+
let finished = false;
|
|
625
|
+
const stopping = f.conversation.cancel().then(() => { finished = true; });
|
|
626
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
627
|
+
assert.equal(finished, false);
|
|
628
|
+
complete.resolve({ value: 42 });
|
|
629
|
+
await stopping;
|
|
630
|
+
const result = await f.conversation.read();
|
|
631
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "cancelled");
|
|
632
|
+
assert.equal(result.conversationLog[0].metadata.applicationTools.at(-1).result.result.result.value, 42);
|
|
633
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("the common request deadline stops an unfinished native turn and retains its admitted history", async t => {
|
|
637
|
+
const f = await fixture(t, { limits: { timeoutMs: 2000 } });
|
|
638
|
+
await f.conversation.send({ messageId: "waiting", text: "wait" });
|
|
639
|
+
const snapshot = await f.conversation.wait();
|
|
640
|
+
assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "failed");
|
|
641
|
+
assert.match(snapshot.error, /time limit/);
|
|
642
|
+
const binding = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
643
|
+
assert.equal(binding.executionId, "");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("uncertain native delivery is reconciled from history without a second inference", async t => {
|
|
647
|
+
const f = await fixture(t);
|
|
648
|
+
await assert.rejects(f.conversation.send({ messageId: "lost-ack", text: "lost" }), error => error.delivery === "uncertain");
|
|
649
|
+
assert.equal((await f.conversation.wait()).status, "unconfirmed");
|
|
650
|
+
const binding = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime.binding;
|
|
651
|
+
const historyPath = path.join(f.directory, "claude", "projects", f.directory.replace(/[^a-zA-Z0-9]/gu, "-"), `${binding.conversationId}.jsonl`);
|
|
652
|
+
// A production Send checks the previous receipt first. Keep that receipt
|
|
653
|
+
// genuinely unavailable while proving the unknown-delivery refusal.
|
|
654
|
+
await rename(historyPath, `${historyPath}.unavailable`);
|
|
655
|
+
try { await assert.rejects(f.conversation.send(input), { code: "conversation_delivery_uncertain" }); }
|
|
656
|
+
finally { await rename(`${historyPath}.unavailable`, historyPath); }
|
|
657
|
+
const receipt = await f.conversation.inspectDelivery({ messageId: "lost-ack" });
|
|
658
|
+
assert.equal(receipt.status, "accepted");
|
|
659
|
+
assert.equal(receipt.recovered, true);
|
|
660
|
+
const snapshot = await f.conversation.read();
|
|
661
|
+
assert.equal(snapshot.conversationLog[0].assistant.text, "Answer: lost");
|
|
662
|
+
assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "interrupted");
|
|
663
|
+
assert.equal(snapshot.status, "ready");
|
|
664
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("inspecting an older accepted Claude message leaves a different pending receipt untouched", async t => {
|
|
668
|
+
const f = await fixture(t);
|
|
669
|
+
const first = await f.conversation.send(input);
|
|
670
|
+
await f.conversation.wait();
|
|
671
|
+
await assert.rejects(f.conversation.send({ messageId: "later-lost", text: "lost" }), error => error.delivery === "uncertain");
|
|
672
|
+
assert.equal((await f.conversation.wait()).status, "unconfirmed");
|
|
673
|
+
const saved = (await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime;
|
|
674
|
+
const expected = { status: "accepted", messageId: input.messageId, turnId: first.turnId, duplicate: true };
|
|
675
|
+
assert.deepEqual(await f.conversation.inspectDelivery({ messageId: input.messageId }), expected);
|
|
676
|
+
assert.deepEqual((await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime.request, saved.request);
|
|
677
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 1);
|
|
678
|
+
const historyPath = path.join(f.directory, "claude", "projects", f.directory.replace(/[^a-zA-Z0-9]/gu, "-"), `${saved.binding.conversationId}.jsonl`);
|
|
679
|
+
await rename(historyPath, `${historyPath}.unavailable`);
|
|
680
|
+
try {
|
|
681
|
+
assert.deepEqual(await f.conversation.inspectDelivery({ messageId: input.messageId }), expected);
|
|
682
|
+
assert.deepEqual((await f.storage.read("conversation", transaction => transaction.readMetadata())).runtime.request, saved.request);
|
|
683
|
+
assert.equal((await f.conversation.read()).status, "unconfirmed");
|
|
684
|
+
} finally { await rename(`${historyPath}.unavailable`, historyPath); }
|
|
685
|
+
assert.equal((await f.conversation.inspectDelivery({ messageId: "later-lost" })).recovered, true);
|
|
686
|
+
assert.equal((await f.conversation.read()).status, "ready");
|
|
687
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 2);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("accepted Claude output can be recovered after restart without claiming unproven completion", async t => {
|
|
691
|
+
const f = await fixture(t);
|
|
692
|
+
await f.conversation.send(input);
|
|
693
|
+
const finished = await f.conversation.wait();
|
|
694
|
+
await f.first.close();
|
|
695
|
+
await f.storage.write("conversation", async transaction => {
|
|
696
|
+
const turn = await transaction.readTurn("000001");
|
|
697
|
+
await transaction.updateTurnMetadata(turn.turnId, { runtime: { ...turn.metadata.runtime, status: "running" } });
|
|
698
|
+
await transaction.replaceAssistant(turn.turnId, { ...turn.assistant, text: "Partial reply" });
|
|
699
|
+
});
|
|
700
|
+
const reopened = await f.runtime().open({ id: "conversation" });
|
|
701
|
+
assert.equal((await reopened.inspectDelivery({ messageId: input.messageId })).recovered, true);
|
|
702
|
+
const state = await reopened.read();
|
|
703
|
+
assert.equal(state.conversationLog[0].assistant.text, finished.conversationLog[0].assistant.text);
|
|
704
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "interrupted");
|
|
705
|
+
await reopened.inspectDelivery({ messageId: input.messageId });
|
|
706
|
+
assert.deepEqual((await reopened.read()).conversationLog, state.conversationLog);
|
|
707
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("changed native login cannot continue another account's conversation", async t => {
|
|
711
|
+
const f = await fixture(t);
|
|
712
|
+
await f.conversation.send(input);
|
|
713
|
+
await f.conversation.wait();
|
|
714
|
+
await writeFile(f.account, "different@example.test");
|
|
715
|
+
await assert.rejects(f.conversation.send({ messageId: "second", text: "No access" }), /another Claude account/);
|
|
716
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
717
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 1);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Ported from the production provider's control-order case. The common fixture
|
|
721
|
+
// observes native JSON controls instead of the application's provider wrapper.
|
|
722
|
+
test("Claude changes provider controls before model and keeps the native conversation", async t => {
|
|
723
|
+
let apiKey = "fixture-deepseek-secret";
|
|
724
|
+
const f = await fixture(t, { connections: { resolve: async () => ({
|
|
725
|
+
providerId: "deepseek", model: "deepseek-flash", apiKey
|
|
726
|
+
}) } });
|
|
727
|
+
await f.conversation.send({ messageId: "plan", text: "Plan this" });
|
|
728
|
+
await f.conversation.wait();
|
|
729
|
+
const original = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding.conversationId);
|
|
730
|
+
await f.conversation.configure({ integrationId: "foreign", model: undefined });
|
|
731
|
+
await f.conversation.send({ messageId: "code", text: "Implement it" });
|
|
732
|
+
await f.conversation.wait();
|
|
733
|
+
let trace = await f.trace();
|
|
734
|
+
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
735
|
+
let requests = trace.filter(row => row.frame?.type === "control_request").map(row => row.frame.request);
|
|
736
|
+
assert.deepEqual(requests.slice(-2).map(({ subtype }) => subtype), ["apply_flag_settings", "set_model"]);
|
|
737
|
+
const flags = requests.at(-2).settings;
|
|
738
|
+
assert.equal(flags.env.ANTHROPIC_BASE_URL, "https://api.deepseek.com/anthropic");
|
|
739
|
+
assert.equal(flags.env.ANTHROPIC_AUTH_TOKEN, apiKey);
|
|
740
|
+
assert.equal(flags.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "deepseek-flash");
|
|
741
|
+
assert.equal(flags.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "deepseek-flash[1m]");
|
|
742
|
+
assert.equal(flags.env.CLAUDE_CODE_SUBAGENT_MODEL, "deepseek-flash");
|
|
743
|
+
assert.equal(flags.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, "786432");
|
|
744
|
+
assert.equal(requests.at(-1).model, "deepseek-flash[1m]");
|
|
745
|
+
assert.deepEqual(flags.fallbackModel, []);
|
|
746
|
+
assert.ok(!JSON.stringify(trace.filter(row => row.args)).includes(apiKey));
|
|
747
|
+
apiKey = "rotated-authorized-key";
|
|
748
|
+
await f.conversation.send({ messageId: "key", text: "Continue" });
|
|
749
|
+
await f.conversation.wait();
|
|
750
|
+
trace = await f.trace();
|
|
751
|
+
requests = trace.filter(row => row.frame?.type === "control_request").map(row => row.frame.request);
|
|
752
|
+
assert.equal(requests.at(-2).settings.env.ANTHROPIC_AUTH_TOKEN, apiKey);
|
|
753
|
+
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
754
|
+
assert.ok(!JSON.stringify(await f.storage.read("conversation", tx => tx.readMetadata())).includes(apiKey));
|
|
755
|
+
await f.conversation.configure({ integrationId: undefined, model: configuration.model });
|
|
756
|
+
await f.conversation.send({ messageId: "review", text: "Review it" });
|
|
757
|
+
await f.conversation.wait();
|
|
758
|
+
trace = await f.trace();
|
|
759
|
+
requests = trace.filter(row => row.frame?.type === "control_request").map(row => row.frame.request);
|
|
760
|
+
assert.equal(requests.at(-2).settings.env.ANTHROPIC_AUTH_TOKEN, "");
|
|
761
|
+
assert.equal(requests.at(-2).settings.env.ANTHROPIC_BASE_URL, process.env.ANTHROPIC_BASE_URL || "https://api.anthropic.com");
|
|
762
|
+
assert.equal(requests.at(-2).settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL, process.env.ANTHROPIC_DEFAULT_SONNET_MODEL || "");
|
|
763
|
+
assert.equal(requests.at(-2).settings.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || "");
|
|
764
|
+
assert.equal(requests.at(-2).settings.env.CLAUDE_CODE_SUBAGENT_MODEL, process.env.CLAUDE_CODE_SUBAGENT_MODEL || "");
|
|
765
|
+
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
766
|
+
assert.equal(await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding.conversationId), original);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
test("Claude restores explicit standalone host credentials after a selected connection", async t => {
|
|
770
|
+
const f = await fixture(t, {
|
|
771
|
+
environment: { ANTHROPIC_AUTH_TOKEN: "host-token", ANTHROPIC_BASE_URL: "http://host-provider.test" },
|
|
772
|
+
connections: { resolve: async () => ({ providerId: "deepseek", model: "deepseek-flash", apiKey: "connection-token" }) }
|
|
773
|
+
});
|
|
774
|
+
await f.conversation.configure({ integrationId: "foreign", model: undefined });
|
|
775
|
+
await f.conversation.send(input);
|
|
776
|
+
await f.conversation.wait();
|
|
777
|
+
await f.conversation.configure({ integrationId: undefined, model: configuration.model });
|
|
778
|
+
await f.conversation.send({ messageId: "host", text: "Use the host account again" });
|
|
779
|
+
await f.conversation.wait();
|
|
780
|
+
const trace = await f.trace();
|
|
781
|
+
const settings = trace.filter(row => row.frame?.request?.subtype === "apply_flag_settings").at(-1).frame.request.settings;
|
|
782
|
+
assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, "host-token");
|
|
783
|
+
assert.equal(settings.env.ANTHROPIC_BASE_URL, "http://host-provider.test");
|
|
784
|
+
assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
|
|
785
|
+
assert.equal(trace.filter(row => row.args?.[0] === "auth").length, 0);
|
|
786
|
+
assert.ok(!JSON.stringify(await f.storage.read("conversation", tx => tx.readMetadata())).includes("host-token"));
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
test("authorized Claude connections preserve native history across provider changes and process restart", async t => {
|
|
790
|
+
let apiKey = "test-foreign-key";
|
|
791
|
+
const context = { actor: { id: "owner" } };
|
|
792
|
+
const connections = { async resolve(request) {
|
|
793
|
+
assert.equal(request.context, context);
|
|
794
|
+
assert.equal(request.integrationId, "foreign");
|
|
795
|
+
return { providerId: "deepseek", model: "deepseek-flash", apiKey };
|
|
796
|
+
} };
|
|
797
|
+
const f = await fixture(t, { context, connections });
|
|
798
|
+
await f.conversation.send(input);
|
|
799
|
+
await f.conversation.wait();
|
|
800
|
+
const original = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding.conversationId);
|
|
801
|
+
const foreign = { systemPrompt: configuration.systemPrompt, integrationId: "foreign", effort: "low" };
|
|
802
|
+
await f.conversation.select({ operationId: "foreign-model", expectedSegmentId: (await f.conversation.read()).segmentId,
|
|
803
|
+
engine: "claude", configuration: foreign });
|
|
804
|
+
await f.conversation.send({ messageId: "foreign", text: "Continue" });
|
|
805
|
+
await f.conversation.wait();
|
|
806
|
+
await f.first.close();
|
|
807
|
+
const resumed = await f.runtime().open({ id: "conversation", context });
|
|
808
|
+
await resumed.send({ messageId: "restart", text: "After restart" });
|
|
809
|
+
await resumed.wait();
|
|
810
|
+
const trace = await f.trace();
|
|
811
|
+
const starts = trace.filter(row => row.args?.includes("--print"));
|
|
812
|
+
assert.equal(starts.length, 2, "Compatible provider selection keeps the live process; application restart starts its successor");
|
|
813
|
+
for (const row of starts.slice(1)) {
|
|
814
|
+
assert.equal(row.args[row.args.indexOf("--resume") + 1], original);
|
|
815
|
+
assert.equal(row.args.includes("--model"), false, "Selected provider models are installed after initialization");
|
|
816
|
+
assert.deepEqual(row.routing, starts[0].routing, "Selected connection routing is not installed in process startup");
|
|
817
|
+
}
|
|
818
|
+
const flags = trace.filter(row => row.frame?.request?.subtype === "apply_flag_settings").at(-1).frame.request.settings;
|
|
819
|
+
assert.equal(flags.env.ANTHROPIC_AUTH_TOKEN, apiKey);
|
|
820
|
+
assert.equal(flags.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, "786432");
|
|
821
|
+
assert.deepEqual(trace.filter(row => row.frame?.request?.subtype === "set_model").at(-1).frame.request,
|
|
822
|
+
{ subtype: "set_model", model: "deepseek-flash[1m]" });
|
|
823
|
+
assert.equal(trace.filter(row => row.args?.[0] === "auth").length, 1, "An API connection does not inspect the native login");
|
|
824
|
+
assert.ok(!JSON.stringify(await f.storage.read("conversation", tx => tx.readMetadata())).includes(apiKey));
|
|
825
|
+
apiKey = "rotated-authorized-key";
|
|
826
|
+
await resumed.send({ messageId: "rotated-key", text: "Continue with the rotated key" });
|
|
827
|
+
assert.equal((await resumed.wait()).conversationLog.at(-1).assistant.text, "Answer: Continue with the rotated key");
|
|
828
|
+
const rotated = await f.trace();
|
|
829
|
+
assert.equal(rotated.filter(row => row.frame?.type === "user").length, 4);
|
|
830
|
+
assert.equal(rotated.filter(row => row.args?.includes("--print")).length, 2, "Key rotation updates the existing process");
|
|
831
|
+
const rotatedStart = rotated.filter(row => row.args?.includes("--print")).at(-1);
|
|
832
|
+
assert.equal(rotatedStart.args[rotatedStart.args.indexOf("--resume") + 1], original);
|
|
833
|
+
assert.ok(!JSON.stringify(await f.storage.read("conversation", tx => tx.readMetadata())).includes(apiKey));
|
|
834
|
+
await resumed.select({ operationId: "return-native", expectedSegmentId: (await resumed.read()).segmentId,
|
|
835
|
+
engine: "claude", configuration });
|
|
836
|
+
await resumed.send({ messageId: "back", text: "Back" });
|
|
837
|
+
await resumed.wait();
|
|
838
|
+
const returned = await f.trace();
|
|
839
|
+
assert.equal(returned.filter(row => row.args?.includes("--print")).length, 2, "Returning to the native subscription also updates the live process");
|
|
840
|
+
const last = returned.filter(row => row.args?.includes("--print")).at(-1);
|
|
841
|
+
assert.equal(last.args[last.args.indexOf("--resume") + 1], original);
|
|
842
|
+
assert.deepEqual(returned.filter(row => row.frame?.request?.subtype === "set_model").at(-1).frame.request,
|
|
843
|
+
{ subtype: "set_model", model: configuration.model });
|
|
844
|
+
const restored = returned.filter(row => row.frame?.request?.subtype === "apply_flag_settings").at(-1).frame.request.settings;
|
|
845
|
+
assert.equal(restored.env.ANTHROPIC_AUTH_TOKEN, "");
|
|
846
|
+
assert.equal(restored.env.ANTHROPIC_MODEL, "");
|
|
847
|
+
assert.equal(last.routing.ANTHROPIC_MODEL, process.env.ANTHROPIC_MODEL);
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("Claude validates exact connection models and supported effort before native dispatch", async t => {
|
|
851
|
+
const f = await fixture(t, { connections: { resolve: async () => ({ providerId: "deepseek", model: "deepseek-flash", apiKey: "test-key" }) } });
|
|
852
|
+
for (const [override, message] of [[{ model: "deepseek-v4-pro" }, /differs from/], [{ effort: "medium" }, /does not support/]]) {
|
|
853
|
+
await f.conversation.select({ operationId: `unsupported-${override.model || override.effort}`,
|
|
854
|
+
expectedSegmentId: (await f.conversation.read()).segmentId, engine: "claude",
|
|
855
|
+
configuration: { systemPrompt: configuration.systemPrompt, integrationId: "foreign", ...override } });
|
|
856
|
+
await assert.rejects(f.conversation.send(input), message);
|
|
857
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 0);
|
|
858
|
+
}
|
|
859
|
+
await assert.rejects(readFile(path.join(f.directory, "trace.jsonl")), { code: "ENOENT" });
|
|
860
|
+
await assert.rejects(f.conversation.configure({ effort: "arbitrary" }), /does not support/);
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test("failed settings cannot dispatch a prompt and model errors remain failed turns", async t => {
|
|
864
|
+
const f = await fixture(t, { environment: { TEST_REJECT_EFFORT: "low" } });
|
|
865
|
+
await f.conversation.configure({ effort: "low" });
|
|
866
|
+
await assert.rejects(f.conversation.send(input), /Settings rejected/);
|
|
867
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 0);
|
|
868
|
+
await f.conversation.configure({ effort: "high" });
|
|
869
|
+
await f.conversation.send({ messageId: "failure", text: "failed" });
|
|
870
|
+
const snapshot = await f.conversation.wait();
|
|
871
|
+
assert.equal(snapshot.conversationLog[0].metadata.runtime.status, "failed");
|
|
872
|
+
assert.match(snapshot.error, /Model connection failed/);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
test("native preparation cannot dispatch after the application revokes access", async t => {
|
|
876
|
+
const local = createLocalConversationExecution();
|
|
877
|
+
let allowed = true;
|
|
878
|
+
const execution = { stop: local.stop, async start(options) {
|
|
879
|
+
const native = await local.start(options);
|
|
880
|
+
if (options.args.includes("--print")) allowed = false;
|
|
881
|
+
return native;
|
|
882
|
+
} };
|
|
883
|
+
const f = await fixture(t, { execution, authorize: () => allowed });
|
|
884
|
+
await assert.rejects(f.conversation.send(input), { code: "conversation_forbidden" });
|
|
885
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 0);
|
|
886
|
+
allowed = true;
|
|
887
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 0);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("an unconfirmed process stop blocks work, remains an error and can be retried", async t => {
|
|
891
|
+
const local = createLocalConversationExecution();
|
|
892
|
+
const nativeIds = new Set();
|
|
893
|
+
let allowStop = true;
|
|
894
|
+
const execution = {
|
|
895
|
+
async start(options) {
|
|
896
|
+
const native = await local.start(options);
|
|
897
|
+
if (options.args.includes("--print")) nativeIds.add(native.id);
|
|
898
|
+
return native;
|
|
899
|
+
},
|
|
900
|
+
stop: id => !allowStop && nativeIds.has(id) ? Promise.resolve({ scopeEmpty: false }) : local.stop(id)
|
|
901
|
+
};
|
|
902
|
+
const f = await fixture(t, { execution });
|
|
903
|
+
await f.conversation.send({ messageId: "waiting", text: "wait" });
|
|
904
|
+
allowStop = false;
|
|
905
|
+
await assert.rejects(f.conversation.cancel(), /cleanup could not be confirmed/);
|
|
906
|
+
assert.equal((await f.conversation.read()).status, "unavailable");
|
|
907
|
+
await assert.rejects(f.conversation.send(input), /cleanup could not be confirmed/);
|
|
908
|
+
allowStop = true;
|
|
909
|
+
assert.deepEqual(await f.conversation.cancel(), { stopped: true });
|
|
910
|
+
assert.equal((await f.conversation.read()).status, "ready");
|
|
911
|
+
await f.conversation.send(input);
|
|
912
|
+
assert.equal((await f.conversation.wait()).conversationLog[1].assistant.text, "Answer: Hello");
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("lost observation while idle invalidates the process and exposes unconfirmed cleanup", async t => {
|
|
916
|
+
const local = createLocalConversationExecution();
|
|
917
|
+
let nativeProcess;
|
|
918
|
+
let allowStop = true;
|
|
919
|
+
const failed = Promise.withResolvers();
|
|
920
|
+
const execution = {
|
|
921
|
+
async start(options) {
|
|
922
|
+
const native = await local.start(options);
|
|
923
|
+
if (options.args.includes("--print")) nativeProcess = native;
|
|
924
|
+
return native;
|
|
925
|
+
},
|
|
926
|
+
stop: id => !allowStop && id === nativeProcess?.id ? Promise.resolve({ scopeEmpty: false }) : local.stop(id)
|
|
927
|
+
};
|
|
928
|
+
const f = await fixture(t, { execution });
|
|
929
|
+
await f.conversation.subscribe(event => { if (event.type === "error") failed.resolve(); });
|
|
930
|
+
await f.conversation.send(input);
|
|
931
|
+
await f.conversation.wait();
|
|
932
|
+
allowStop = false;
|
|
933
|
+
nativeProcess.stdout.destroy(new Error("Connection lost."));
|
|
934
|
+
await failed.promise;
|
|
935
|
+
assert.equal((await f.conversation.read()).status, "unavailable");
|
|
936
|
+
await assert.rejects(f.conversation.send({ messageId: "blocked", text: "Must not send" }), /cleanup could not be confirmed/);
|
|
937
|
+
allowStop = true;
|
|
938
|
+
await f.conversation.cancel();
|
|
939
|
+
await f.conversation.send({ messageId: "next", text: "Continue" });
|
|
940
|
+
assert.equal((await f.conversation.wait()).conversationLog[1].assistant.text, "Answer: Continue");
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
test("cancel during native initialization drains the process without waiting for its handshake timeout", async t => {
|
|
944
|
+
const local = createLocalConversationExecution();
|
|
945
|
+
const started = Promise.withResolvers();
|
|
946
|
+
const execution = { stop: local.stop, async start(options) {
|
|
947
|
+
const native = await local.start(options);
|
|
948
|
+
if (options.args.includes("--print")) started.resolve();
|
|
949
|
+
return native;
|
|
950
|
+
} };
|
|
951
|
+
const f = await fixture(t, { execution, environment: { TEST_STARTUP_WAIT: "1" } });
|
|
952
|
+
const sending = f.conversation.send(input);
|
|
953
|
+
sending.catch(() => {});
|
|
954
|
+
await started.promise;
|
|
955
|
+
let timer;
|
|
956
|
+
try {
|
|
957
|
+
await Promise.race([f.conversation.cancel(), new Promise((_, reject) => {
|
|
958
|
+
timer = setTimeout(() => reject(new Error("Cancel waited for the native handshake timeout.")), 2000);
|
|
959
|
+
})]);
|
|
960
|
+
await assert.rejects(sending);
|
|
961
|
+
assert.equal((await f.conversation.wait()).conversationLog.length, 0);
|
|
962
|
+
const binding = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
963
|
+
assert.equal(binding.executionId, "");
|
|
964
|
+
} finally {
|
|
965
|
+
clearTimeout(timer);
|
|
966
|
+
await local.close();
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
test("Claude native goal commands use ordinary admission, pause retains the goal, and cancel stops before clear", async t => {
|
|
972
|
+
const f = await fixture(t);
|
|
973
|
+
const events = [];
|
|
974
|
+
await f.conversation.subscribe(event => events.push(event));
|
|
975
|
+
const { segmentId, capabilities } = await f.conversation.read();
|
|
976
|
+
assert.equal(capabilities.goals, true);
|
|
977
|
+
assert.equal(capabilities.goalBudgets, false);
|
|
978
|
+
assert.deepEqual(capabilities.goalCommands, {
|
|
979
|
+
set: { delivery: "message", interruptsTurn: false }, resume: { delivery: "message", interruptsTurn: false },
|
|
980
|
+
pause: { delivery: "control", interruptsTurn: true }, cancel: { delivery: "message", interruptsTurn: true }
|
|
981
|
+
});
|
|
982
|
+
const start = { action: "set", objective: "Tests pass", messageId: "set-goal", expectedSegmentId: segmentId, tokenBudget: null };
|
|
983
|
+
const started = await f.conversation.updateGoal(start);
|
|
984
|
+
assert.equal(started.status, "accepted");
|
|
985
|
+
assert.equal((await f.conversation.read()).conversationLog[0].user.text, "/goal Tests pass");
|
|
986
|
+
const goal = await f.conversation.readGoal();
|
|
987
|
+
assert.equal(goal.status, "active");
|
|
988
|
+
assert.equal((await f.conversation.updateGoal(start)).duplicate, true);
|
|
989
|
+
await assert.rejects(f.conversation.updateGoal({ ...start, objective: "Different" }), { code: "conversation_message_conflict" });
|
|
990
|
+
const action = { expectedSegmentId: segmentId, expectedGoalId: goal.id };
|
|
991
|
+
await assert.rejects(f.conversation.updateGoal({ ...action, action: "pause", expectedGoalId: "old-goal" }), /goal changed/u);
|
|
992
|
+
await assert.rejects(f.conversation.updateGoal({ ...action, action: "cancel" }), /messageId/u);
|
|
993
|
+
const paused = await f.conversation.updateGoal({ ...action, action: "pause" });
|
|
994
|
+
assert.equal(paused.status, "paused");
|
|
995
|
+
assert.equal((await f.conversation.read()).conversationLog[0].metadata.runtime.status, "cancelled");
|
|
996
|
+
const binding = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
997
|
+
assert.equal(binding.executionId, "");
|
|
998
|
+
const resumed = await f.conversation.updateGoal({ ...action, action: "resume", messageId: "resume-goal" });
|
|
999
|
+
assert.equal(resumed.status, "accepted");
|
|
1000
|
+
const resumedGoal = await f.conversation.readGoal();
|
|
1001
|
+
assert.equal(resumedGoal.status, "active");
|
|
1002
|
+
const cancel = { expectedSegmentId: segmentId, expectedGoalId: resumedGoal.id, action: "cancel", messageId: "clear-goal" };
|
|
1003
|
+
assert.equal((await f.conversation.updateGoal(cancel)).status, "accepted");
|
|
1004
|
+
await f.conversation.wait();
|
|
1005
|
+
assert.equal(await f.conversation.readGoal(), null);
|
|
1006
|
+
assert.equal((await f.conversation.updateGoal(cancel)).duplicate, true);
|
|
1007
|
+
assert.equal(events.filter(event => event.type === "accepted").length, 3);
|
|
1008
|
+
assert.equal(events.some(event => event.type === "goal" && event.goal === null), true);
|
|
1009
|
+
const trace = await f.trace();
|
|
1010
|
+
assert.deepEqual(trace.filter(row => row.frame?.type === "user").map(row => row.frame.message.content),
|
|
1011
|
+
["/goal Tests pass", "/goal Tests pass", "/goal clear"]);
|
|
1012
|
+
const starts = trace.filter(row => row.args?.includes("--print"));
|
|
1013
|
+
assert.equal(starts.length, 3);
|
|
1014
|
+
assert.equal(starts[1].args.includes("--resume"), true);
|
|
1015
|
+
assert.equal(starts[2].args.includes("--resume"), true);
|
|
1016
|
+
assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 2);
|
|
1017
|
+
await assert.rejects(f.conversation.updateGoal({ ...start, messageId: "budget", tokenBudget: 100 }), /token budget/u);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test("Claude rejects reserved goals and waits for ordinary Send to deliver a replacement briefing", async t => {
|
|
1021
|
+
const f = await fixture(t);
|
|
1022
|
+
let state = await f.conversation.read();
|
|
1023
|
+
await assert.rejects(f.conversation.updateGoal({ action: "set", objective: "clear", messageId: "reserved",
|
|
1024
|
+
expectedSegmentId: state.segmentId }), /goal condition/u);
|
|
1025
|
+
await f.conversation.send(input);
|
|
1026
|
+
state = await f.conversation.wait();
|
|
1027
|
+
await f.conversation.replace({ operationId: "renew-goal", reason: "renewal", expectedSegmentId: state.segmentId });
|
|
1028
|
+
state = await f.conversation.read();
|
|
1029
|
+
const goal = { action: "set", objective: "Tests pass", messageId: "goal-after-renewal", expectedSegmentId: state.segmentId };
|
|
1030
|
+
await assert.rejects(f.conversation.updateGoal(goal), { code: "conversation_replacement_briefing_pending" });
|
|
1031
|
+
await f.conversation.wait();
|
|
1032
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
1033
|
+
await f.conversation.send({ messageId: "catch-up", text: "Continue" });
|
|
1034
|
+
await f.conversation.wait();
|
|
1035
|
+
assert.equal((await f.conversation.updateGoal(goal)).status, "accepted");
|
|
1036
|
+
const frames = (await f.trace()).filter(row => row.frame?.type === "user");
|
|
1037
|
+
assert.match(frames[1].frame.message.content, /Conversation changeover/);
|
|
1038
|
+
assert.equal(frames[2].frame.message.content, "/goal Tests pass");
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test("Claude goal delivery remains uncertain until native history proves admission without replay", async t => {
|
|
1042
|
+
const f = await fixture(t, { environment: { TEST_GOAL_LOST_ACK: "1" } });
|
|
1043
|
+
const command = { action: "set", objective: "Tests pass", messageId: "lost-goal",
|
|
1044
|
+
expectedSegmentId: (await f.conversation.read()).segmentId };
|
|
1045
|
+
await assert.rejects(f.conversation.updateGoal(command), error => error.delivery === "uncertain");
|
|
1046
|
+
assert.equal((await f.conversation.wait()).status, "unconfirmed");
|
|
1047
|
+
await assert.rejects(f.conversation.updateGoal(command), { code: "conversation_delivery_uncertain" });
|
|
1048
|
+
assert.equal((await f.conversation.inspectDelivery({ messageId: command.messageId })).status, "accepted");
|
|
1049
|
+
assert.equal((await f.conversation.updateGoal(command)).duplicate, true);
|
|
1050
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
test("Claude goal cancellation requires authorization and confirmed cleanup before sending clear", async t => {
|
|
1055
|
+
const local = createLocalConversationExecution();
|
|
1056
|
+
const nativeIds = new Set();
|
|
1057
|
+
let allowStop = true;
|
|
1058
|
+
let allowGoal = true;
|
|
1059
|
+
const execution = {
|
|
1060
|
+
async start(options) {
|
|
1061
|
+
const native = await local.start(options);
|
|
1062
|
+
if (options.args.includes("--print")) nativeIds.add(native.id);
|
|
1063
|
+
return native;
|
|
1064
|
+
},
|
|
1065
|
+
stop: id => !allowStop && nativeIds.has(id) ? Promise.resolve({ scopeEmpty: false }) : local.stop(id)
|
|
1066
|
+
};
|
|
1067
|
+
const f = await fixture(t, { execution, authorize: ({ operation }) => operation !== "goal" || allowGoal });
|
|
1068
|
+
const { segmentId } = await f.conversation.read();
|
|
1069
|
+
await f.conversation.updateGoal({ action: "set", objective: "Tests pass", messageId: "start", expectedSegmentId: segmentId });
|
|
1070
|
+
const goal = await f.conversation.readGoal();
|
|
1071
|
+
const cancel = { action: "cancel", messageId: "clear", expectedSegmentId: segmentId, expectedGoalId: goal.id };
|
|
1072
|
+
allowGoal = false;
|
|
1073
|
+
await assert.rejects(f.conversation.updateGoal(cancel), { code: "conversation_forbidden" });
|
|
1074
|
+
assert.equal((await f.conversation.read()).status, "working");
|
|
1075
|
+
allowGoal = true;
|
|
1076
|
+
allowStop = false;
|
|
1077
|
+
await assert.rejects(f.conversation.updateGoal(cancel), /cleanup could not be confirmed/);
|
|
1078
|
+
assert.equal((await f.conversation.read()).status, "unavailable");
|
|
1079
|
+
assert.deepEqual((await f.trace()).filter(row => row.frame?.type === "user").map(row => row.frame.message.content), ["/goal Tests pass"]);
|
|
1080
|
+
allowStop = true;
|
|
1081
|
+
await f.conversation.cancel();
|
|
1082
|
+
await f.conversation.updateGoal(cancel);
|
|
1083
|
+
await f.conversation.wait();
|
|
1084
|
+
assert.equal(await f.conversation.readGoal(), null);
|
|
1085
|
+
assert.deepEqual((await f.trace()).filter(row => row.frame?.type === "user").map(row => row.frame.message.content), ["/goal Tests pass", "/goal clear"]);
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
test("Stop remains available while a Claude goal waits for native acknowledgement", async t => {
|
|
1090
|
+
const f = await fixture(t, { environment: { TEST_GOAL_LOST_ACK: "1" }, limits: { admissionTimeoutMs: 10000 } });
|
|
1091
|
+
await f.conversation.readGoal();
|
|
1092
|
+
const command = { action: "set", objective: "Tests pass", messageId: "waiting-goal",
|
|
1093
|
+
expectedSegmentId: (await f.conversation.read()).segmentId };
|
|
1094
|
+
const sending = f.conversation.updateGoal(command);
|
|
1095
|
+
const rejected = assert.rejects(sending, error => error.delivery === "uncertain");
|
|
1096
|
+
const deadline = Date.now() + 2000;
|
|
1097
|
+
while (!(await f.trace()).some(row => row.frame?.type === "user")) {
|
|
1098
|
+
if (Date.now() > deadline) throw new Error("The goal command never reached Claude.");
|
|
1099
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
1100
|
+
}
|
|
1101
|
+
let timer;
|
|
1102
|
+
try {
|
|
1103
|
+
await Promise.race([f.conversation.cancel(), new Promise((_, reject) => {
|
|
1104
|
+
timer = setTimeout(() => reject(new Error("Goal admission blocked Stop.")), 2000);
|
|
1105
|
+
})]);
|
|
1106
|
+
await rejected;
|
|
1107
|
+
const binding = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
1108
|
+
assert.equal(binding.executionId, "");
|
|
1109
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
1110
|
+
} finally { clearTimeout(timer); }
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
test("the same Claude driver reopens a verified closed conversation after retrying failed cleanup", async t => {
|
|
1114
|
+
const local = createLocalConversationExecution();
|
|
1115
|
+
const nativeIds = new Set();
|
|
1116
|
+
let allowStop = true;
|
|
1117
|
+
const execution = {
|
|
1118
|
+
async start(options) {
|
|
1119
|
+
const native = await local.start(options);
|
|
1120
|
+
if (options.args.includes("--print")) nativeIds.add(native.id);
|
|
1121
|
+
return native;
|
|
1122
|
+
},
|
|
1123
|
+
stop: id => !allowStop && nativeIds.has(id) ? Promise.resolve({ scopeEmpty: false }) : local.stop(id)
|
|
1124
|
+
};
|
|
1125
|
+
const f = await fixture(t, { execution });
|
|
1126
|
+
await f.first.close();
|
|
1127
|
+
const driver = createClaudeConversationDriver(f.driverOptions);
|
|
1128
|
+
const readBinding = () => f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
1129
|
+
const open = async () => driver.open({ binding: await readBinding(),
|
|
1130
|
+
writeBinding: binding => f.storage.write("conversation", async tx => {
|
|
1131
|
+
const metadata = await tx.readMetadata();
|
|
1132
|
+
metadata.runtime.binding = binding;
|
|
1133
|
+
await tx.writeMetadata(metadata);
|
|
1134
|
+
}),
|
|
1135
|
+
onFailure() {}
|
|
1136
|
+
});
|
|
1137
|
+
const messages = [];
|
|
1138
|
+
const run = (provider, messageId, text) => provider.run({ configuration,
|
|
1139
|
+
input: { messageId, text }, signal: new AbortController().signal,
|
|
1140
|
+
beforeDispatch() {}, accept() {}, onEvent() {}, onMessage: message => messages.push(message)
|
|
1141
|
+
});
|
|
1142
|
+
const first = await open();
|
|
1143
|
+
let reopened;
|
|
1144
|
+
try {
|
|
1145
|
+
await run(first, "before-close", "Before close");
|
|
1146
|
+
const original = await readBinding();
|
|
1147
|
+
assert.ok(original.executionId);
|
|
1148
|
+
allowStop = false;
|
|
1149
|
+
await assert.rejects(first.dispose(), /cleanup could not be confirmed/);
|
|
1150
|
+
assert.equal((await readBinding()).executionId, original.executionId);
|
|
1151
|
+
assert.equal((await f.trace()).filter(row => row.args?.includes("--print")).length, 1);
|
|
1152
|
+
allowStop = true;
|
|
1153
|
+
await first.dispose();
|
|
1154
|
+
assert.equal((await readBinding()).executionId, "");
|
|
1155
|
+
reopened = await open();
|
|
1156
|
+
await assert.rejects(run(first, "old-handle", "Must stay closed"), /closed or already working/);
|
|
1157
|
+
await run(reopened, "after-close", "After close");
|
|
1158
|
+
assert.ok(messages.some(message => message.complete && message.text === "Answer: After close"));
|
|
1159
|
+
assert.equal((await readBinding()).conversationId, original.conversationId);
|
|
1160
|
+
const launches = (await f.trace()).filter(row => row.args?.includes("--print"));
|
|
1161
|
+
assert.equal(launches.length, 2);
|
|
1162
|
+
assert.equal(launches[1].args.at(launches[1].args.indexOf("--resume") + 1), original.conversationId);
|
|
1163
|
+
assert.deepEqual((await f.trace()).filter(row => row.frame?.type === "user").map(row => row.frame.message.content),
|
|
1164
|
+
["Before close", "After close"]);
|
|
1165
|
+
await reopened.dispose();
|
|
1166
|
+
assert.equal((await readBinding()).executionId, "");
|
|
1167
|
+
} finally {
|
|
1168
|
+
allowStop = true;
|
|
1169
|
+
await first.dispose();
|
|
1170
|
+
await reopened?.dispose();
|
|
1171
|
+
await local.close();
|
|
1172
|
+
}
|
|
1173
|
+
});
|