@jskit-ai/assistant-core 0.1.199 → 0.1.201
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,238 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
|
|
4
|
+
import { codexLocalImageInput, codexTurnInput } from "../src/server/conversation/codexProvider.js";
|
|
5
|
+
import { createConversationAttachmentReader } from "../src/server/conversation/attachments.js";
|
|
6
|
+
import { conversationAttachmentManifest, prepareConversationAttachmentMessage } from "../src/shared/conversation/attachments.js";
|
|
7
|
+
|
|
8
|
+
const image = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN7sAAAAASUVORK5CYII=", "base64");
|
|
9
|
+
const receipt = { attachmentId: "picture", fileName: "picture.png", size: image.length };
|
|
10
|
+
const input = { messageId: "first", text: "Describe this", attachmentIds: [receipt.attachmentId] };
|
|
11
|
+
const context = { subject: "owner" };
|
|
12
|
+
const configuration = { integrationId: "model", systemPrompt: "Be concise." };
|
|
13
|
+
const resolution = () => ({ attachments: [{ ...receipt, privatePath: "/private/upload" }], content: [{ type: "image", image, mediaType: "image/png" }] });
|
|
14
|
+
|
|
15
|
+
const localFiles = [
|
|
16
|
+
{ attachmentId: "picture", fileName: "a b.png", size: 10 * 1024 * 1024, reference: "[Image #1]",
|
|
17
|
+
contentType: "image/png", path: "/session/artifacts/a b.png" },
|
|
18
|
+
{ attachmentId: "data", fileName: "data.csv", size: 12, reference: "[File #1]",
|
|
19
|
+
contentType: "application/octet-stream", path: "/session/artifacts/data.csv" }
|
|
20
|
+
];
|
|
21
|
+
const localReceipts = localFiles.map(({ attachmentId, fileName, size, reference }) => ({ attachmentId, fileName, size, reference }));
|
|
22
|
+
const localResolution = () => ({ attachments: structuredClone(localReceipts), localFiles: structuredClone(localFiles) });
|
|
23
|
+
|
|
24
|
+
function localReader({ resolve = localResolution, types = ["text", "image", "localFile"], authorize = async () => {}, maximumBytes = 1024 } = {}) {
|
|
25
|
+
return createConversationAttachmentReader({ attachments: { resolve }, context, conversationId: "one",
|
|
26
|
+
signal: new AbortController().signal, authorize, types, maximumBytes });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("prepared local files keep the original manifest, authored message and visible receipts", () => {
|
|
30
|
+
const prepared = prepareConversationAttachmentMessage({ message: "Inspect [Image #1] and [File #1]",
|
|
31
|
+
displayMessage: "My authored request", messageId: "prepared" }, localFiles, localReceipts);
|
|
32
|
+
assert.equal(prepared.message, 'Inspect [Image #1] and [File #1]\n\nAttached files:\n[Image #1] "a b.png": "/session/artifacts/a b.png"\n[File #1] "data.csv": "/session/artifacts/data.csv"');
|
|
33
|
+
assert.equal(prepared.displayMessage, "My authored request");
|
|
34
|
+
assert.equal(prepared.messageId, "prepared");
|
|
35
|
+
assert.equal(prepared.attachments, localFiles);
|
|
36
|
+
assert.equal(prepared.displayAttachments, localReceipts);
|
|
37
|
+
assert.equal(prepareConversationAttachmentMessage({ prompt: "Fallback" }, localFiles, localReceipts).displayMessage, "Fallback");
|
|
38
|
+
assert.equal(prepareConversationAttachmentMessage({ message: "", displayMessage: "" }, localFiles, localReceipts).displayMessage, "");
|
|
39
|
+
assert.deepEqual(prepareConversationAttachmentMessage({ prompt: "Unchanged" }, [], []), {
|
|
40
|
+
prompt: "Unchanged", attachments: [], displayAttachments: []
|
|
41
|
+
});
|
|
42
|
+
assert.equal(conversationAttachmentManifest([{ reference: "[File #1]", fileName: 'a"b\n.csv', path: '/session/a"b\n.csv' }]),
|
|
43
|
+
'\n\nAttached files:\n[File #1] "a\\"b\\n.csv": "/session/a\\"b\\n.csv"');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("authorized local files retain native paths only in transient dispatch input", async () => {
|
|
47
|
+
const calls = [];
|
|
48
|
+
const read = localReader({ resolve: async request => { calls.push(request); return localResolution(); } });
|
|
49
|
+
const files = await read(localReceipts);
|
|
50
|
+
assert.deepEqual(calls[0].attachmentIds, ["picture", "data"]);
|
|
51
|
+
assert.equal(calls[0].context, context);
|
|
52
|
+
assert.equal(calls[0].conversationId, "one");
|
|
53
|
+
assert.deepEqual(files.attachments, localReceipts);
|
|
54
|
+
assert.deepEqual(files.localFiles, localFiles);
|
|
55
|
+
assert.deepEqual(files.content, []);
|
|
56
|
+
assert.equal(files.attachmentManifest, '\n\nAttached files:\n[Image #1] "a b.png": "/session/artifacts/a b.png"\n[File #1] "data.csv": "/session/artifacts/data.csv"');
|
|
57
|
+
assert.doesNotMatch(JSON.stringify(files.attachments), /session\/artifacts|contentType|path/);
|
|
58
|
+
assert.equal(files.localFiles[0].size, 10 * 1024 * 1024, "Native files keep the host upload limit rather than the byte-image memory limit");
|
|
59
|
+
assert.deepEqual(await read([]), { attachments: [], content: [], localFiles: [], attachmentManifest: "" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("local file resolution requires the exact requested receipts and native path", async () => {
|
|
63
|
+
for (const change of [
|
|
64
|
+
result => { result.localFiles.reverse(); },
|
|
65
|
+
result => { result.localFiles[0].attachmentId = "foreign"; },
|
|
66
|
+
result => { result.localFiles[0].fileName = "foreign.png"; },
|
|
67
|
+
result => { result.localFiles[0].size++; },
|
|
68
|
+
result => { result.localFiles[0].reference = "[Image #2]"; },
|
|
69
|
+
result => { result.localFiles[0].path = "../foreign.png"; },
|
|
70
|
+
result => { result.localFiles[0].path = "https://untrusted.invalid/image.png"; },
|
|
71
|
+
result => { result.localFiles[0].path += "\0"; },
|
|
72
|
+
result => { result.localFiles[0].contentType = "invalid"; },
|
|
73
|
+
result => { result.localFiles.pop(); },
|
|
74
|
+
result => { result.content = [{ type: "image", image, mediaType: "image/png" }]; }
|
|
75
|
+
]) {
|
|
76
|
+
const result = localResolution();
|
|
77
|
+
change(result);
|
|
78
|
+
await assert.rejects(localReader({ resolve: async () => result })(localReceipts), { code: "conversation_invalid_attachments" });
|
|
79
|
+
}
|
|
80
|
+
await assert.rejects(localReader({ types: ["text", "image"] })(localReceipts), /cannot accept local attachment files/);
|
|
81
|
+
await assert.rejects(localReader({ maximumBytes: 1 })(localReceipts), /byte limit/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("local file authorization is checked again after resolution", async () => {
|
|
85
|
+
let allowed = true;
|
|
86
|
+
const read = localReader({
|
|
87
|
+
authorize: async () => { if (!allowed) throw new Error("Attachment access denied"); },
|
|
88
|
+
resolve: async () => { allowed = false; return localResolution(); }
|
|
89
|
+
});
|
|
90
|
+
await assert.rejects(read(localReceipts), /Attachment access denied/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("API rejects resolver-only native files before admission or dispatch", async t => {
|
|
94
|
+
const f = await fixture(t, { resolve: localResolution });
|
|
95
|
+
await assert.rejects(f.conversation.send({ messageId: "native-only", text: "Inspect", attachmentIds: ["picture", "data"] }),
|
|
96
|
+
/cannot accept local attachment files/);
|
|
97
|
+
assert.equal(f.requests.length, 0);
|
|
98
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("Codex translates resolved images into native input and leaves other files as path references", () => {
|
|
102
|
+
const attachments = [
|
|
103
|
+
{ attachmentId: "image", fileName: "a b.png", path: "/session/artifacts/a b.png", contentType: "image/png" },
|
|
104
|
+
{ attachmentId: "file", fileName: "data.csv", path: "/session/artifacts/data.csv", contentType: "application/octet-stream" }
|
|
105
|
+
];
|
|
106
|
+
const codexInput = codexTurnInput([
|
|
107
|
+
"Inspect [Image #1] and [File #1]",
|
|
108
|
+
...codexLocalImageInput(attachments)
|
|
109
|
+
]);
|
|
110
|
+
assert.deepEqual(codexInput, [
|
|
111
|
+
{ type: "text", text: "Inspect [Image #1] and [File #1]", text_elements: [] },
|
|
112
|
+
{ type: "localImage", path: attachments[0].path }
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
async function fixture(t, { storage = createMemoryConversationStorage(), resolve = resolution, limits, authorize = () => true } = {}) {
|
|
117
|
+
const requests = [];
|
|
118
|
+
const reads = [];
|
|
119
|
+
const runtime = createConversationRuntime({ storage, authorize, limits,
|
|
120
|
+
connections: { resolve: async () => ({ providerId: "test", model: "test", sdkPackage: "@ai-sdk/openai-compatible",
|
|
121
|
+
apiKey: "test", baseURL: "https://provider.invalid/v1" }) },
|
|
122
|
+
attachments: { resolve: async request => { reads.push(request); return resolve(request); } },
|
|
123
|
+
fetch: async (url, request) => {
|
|
124
|
+
requests.push(JSON.parse(request.body));
|
|
125
|
+
return new Response('data: {"choices":[{"delta":{"content":"A picture."}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
|
126
|
+
{ headers: { "content-type": "text/event-stream" } });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
t.after(() => runtime.close());
|
|
130
|
+
const conversation = await runtime.open({ id: "one", context, configuration });
|
|
131
|
+
return { runtime, conversation, storage, requests, reads };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("authorized image bytes reach the API once and only safe receipts enter stored messages", async t => {
|
|
135
|
+
const f = await fixture(t);
|
|
136
|
+
assert.equal(f.conversation.capabilities.attachments, true);
|
|
137
|
+
const [first, duplicate] = await Promise.all([f.conversation.send(input), f.conversation.send(input)]);
|
|
138
|
+
assert.equal(first.turnId, duplicate.turnId);
|
|
139
|
+
const state = await f.conversation.wait();
|
|
140
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "complete", state.error);
|
|
141
|
+
assert.equal(f.requests.length, 1);
|
|
142
|
+
assert.equal(f.reads.length, 1);
|
|
143
|
+
assert.equal(f.reads[0].context, context);
|
|
144
|
+
assert.equal(f.reads[0].conversationId, "one");
|
|
145
|
+
assert.deepEqual(f.reads[0].attachmentIds, [receipt.attachmentId]);
|
|
146
|
+
assert.deepEqual(f.requests[0].messages.at(-1).content, [
|
|
147
|
+
{ type: "text", text: input.text }, { type: "image_url", image_url: { url: `data:image/png;base64,${image.toString("base64")}` } }
|
|
148
|
+
]);
|
|
149
|
+
assert.deepEqual(state.conversationLog[0].user.attachments, [receipt]);
|
|
150
|
+
assert.doesNotMatch(JSON.stringify(state), /privatePath|private\/upload|iVBOR/);
|
|
151
|
+
assert.equal((await f.conversation.send(input)).duplicate, true);
|
|
152
|
+
assert.equal(f.reads.length, 1, "An accepted retry returns its receipt without rereading files or resending");
|
|
153
|
+
await assert.rejects(f.conversation.send({ ...input, attachmentIds: ["different"] }), { code: "conversation_message_conflict" });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("API history and continuity reauthorize files after reopen and renewal", async t => {
|
|
157
|
+
let allowed = true;
|
|
158
|
+
const resolve = () => {
|
|
159
|
+
if (!allowed) throw new Error("This identity no longer has attachment access");
|
|
160
|
+
return resolution();
|
|
161
|
+
};
|
|
162
|
+
const f = await fixture(t, { resolve });
|
|
163
|
+
await f.conversation.send(input);
|
|
164
|
+
const first = await f.conversation.wait();
|
|
165
|
+
await f.runtime.close();
|
|
166
|
+
const resumed = await fixture(t, { storage: f.storage, resolve });
|
|
167
|
+
await resumed.conversation.send({ messageId: "second", text: "What was in it?" });
|
|
168
|
+
await resumed.conversation.wait();
|
|
169
|
+
assert.equal(resumed.requests[0].messages[1].content[1].type, "image_url");
|
|
170
|
+
await resumed.conversation.replace({ operationId: "renew", reason: "renewal", expectedSegmentId: first.segmentId });
|
|
171
|
+
await resumed.conversation.send({ messageId: "third", text: "Look again" });
|
|
172
|
+
await resumed.conversation.wait();
|
|
173
|
+
assert.equal(resumed.requests[1].messages.at(-1).content[1].type, "image_url");
|
|
174
|
+
allowed = false;
|
|
175
|
+
await assert.rejects(resumed.conversation.send({ messageId: "denied", text: "Look again" }), /attachment access/);
|
|
176
|
+
assert.equal(resumed.requests.length, 2);
|
|
177
|
+
assert.equal((await resumed.conversation.read()).conversationLog.length, 3);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("an image-only request is accepted without invented user text", async t => {
|
|
181
|
+
const f = await fixture(t);
|
|
182
|
+
await f.conversation.send({ ...input, text: "" });
|
|
183
|
+
const state = await f.conversation.wait();
|
|
184
|
+
assert.equal(f.requests[0].messages.at(-1).content.length, 1);
|
|
185
|
+
assert.equal(f.requests[0].messages.at(-1).content[0].type, "image_url");
|
|
186
|
+
assert.equal(state.conversationLog[0].user.text, "");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("denied files and malformed resolver content never admit a message or dispatch a model request", async t => {
|
|
190
|
+
const cases = [
|
|
191
|
+
() => { throw new Error("Attachment access denied"); },
|
|
192
|
+
() => ({ ...resolution(), attachments: [{ ...receipt, attachmentId: "someone-elses-file" }] }),
|
|
193
|
+
() => ({ ...resolution(), content: [] }),
|
|
194
|
+
() => ({ ...resolution(), content: [{ type: "image", image: "https://untrusted.invalid/image.png", mediaType: "image/png" }] }),
|
|
195
|
+
() => ({ ...resolution(), content: [{ type: "image", image, mediaType: "image/unknown" }] }),
|
|
196
|
+
() => ({ ...resolution(), content: [{ type: "file", data: "/private/upload", mediaType: "application/pdf" }] })
|
|
197
|
+
];
|
|
198
|
+
for (const resolve of cases) {
|
|
199
|
+
const f = await fixture(t, { resolve });
|
|
200
|
+
await assert.rejects(f.conversation.send(input));
|
|
201
|
+
assert.equal(f.requests.length, 0);
|
|
202
|
+
const state = await f.conversation.read();
|
|
203
|
+
assert.equal(state.pendingRequest, null);
|
|
204
|
+
assert.equal(state.conversationLog.length, 0);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("invalid IDs and caller-supplied attachment content do not reach the resolver", async t => {
|
|
209
|
+
const f = await fixture(t);
|
|
210
|
+
for (const attachmentIds of [null, "picture", [""], ["picture", "picture"], [{}], Array.from({ length: 11 }, (_, i) => String(i))]) {
|
|
211
|
+
await assert.rejects(f.conversation.send({ ...input, attachmentIds }), { code: "conversation_invalid_attachments" });
|
|
212
|
+
}
|
|
213
|
+
await assert.rejects(f.conversation.send({ ...input, attachments: [resolution()] }), { code: "conversation_unsupported" });
|
|
214
|
+
assert.equal(f.reads.length, 0);
|
|
215
|
+
assert.equal(f.requests.length, 0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("attachment bytes are bounded across current input and restored history", async t => {
|
|
219
|
+
const f = await fixture(t, { limits: { maxAttachmentBytes: image.length } });
|
|
220
|
+
await f.conversation.send(input);
|
|
221
|
+
await f.conversation.wait();
|
|
222
|
+
await assert.rejects(f.conversation.send({ ...input, messageId: "second" }), /byte limit/);
|
|
223
|
+
assert.equal(f.requests.length, 1);
|
|
224
|
+
const text = await fixture(t, { limits: { maxAttachmentBytes: 3 }, resolve: () => ({
|
|
225
|
+
attachments: [receipt], content: [{ type: "text", text: "Too long" }]
|
|
226
|
+
}) });
|
|
227
|
+
await assert.rejects(text.conversation.send(input), /byte limit/);
|
|
228
|
+
assert.equal(text.requests.length, 0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("authorization revoked during file resolution prevents dispatch", async t => {
|
|
232
|
+
let allowed = true;
|
|
233
|
+
const f = await fixture(t, { authorize: () => allowed, resolve: () => { allowed = false; return resolution(); } });
|
|
234
|
+
await assert.rejects(f.conversation.send(input), { code: "conversation_forbidden" });
|
|
235
|
+
assert.equal(f.requests.length, 0);
|
|
236
|
+
allowed = true;
|
|
237
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 0);
|
|
238
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { nativeCommandHook, wrapNativeCommand } from "../src/server/conversation/commandWrapper.js";
|
|
9
|
+
import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
|
|
10
|
+
|
|
11
|
+
test("a command wrapper receives exactly one original argument, including shell syntax", async t => {
|
|
12
|
+
const directory = await mkdtemp(path.join(tmpdir(), "jskit-command-wrapper-"));
|
|
13
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
14
|
+
const wrapper = path.join(directory, "wrapper's executable");
|
|
15
|
+
await writeFile(wrapper, `#!${process.execPath}\nimport { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.RECEIPT, JSON.stringify(process.argv.slice(2)));\n`, { mode: 0o700 });
|
|
16
|
+
const command = "printf '%s' \"$SECRET\"\n$(touch escaped) `touch escaped` '🌏'";
|
|
17
|
+
execFileSync("sh", ["-c", wrapNativeCommand(wrapper, command)], { cwd: directory, env: { ...process.env, RECEIPT: path.join(directory, "receipt") } });
|
|
18
|
+
assert.deepEqual(JSON.parse(await readFile(path.join(directory, "receipt"), "utf8")), [command]);
|
|
19
|
+
await assert.rejects(readFile(path.join(directory, "escaped")), { code: "ENOENT" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("native command hooks preserve tool options and deny malformed commands", () => {
|
|
23
|
+
const input = { hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "pwd", timeout: 500, description: "Current directory" } };
|
|
24
|
+
const output = nativeCommandHook("/host/wrapper", input).hookSpecificOutput;
|
|
25
|
+
assert.equal(output.updatedInput.command, "'/host/wrapper' 'pwd'");
|
|
26
|
+
assert.equal(output.updatedInput.timeout, 500);
|
|
27
|
+
assert.equal(output.updatedInput.description, "Current directory");
|
|
28
|
+
for (const command of [null, "", " ", "pwd\0bad"]) {
|
|
29
|
+
assert.equal(nativeCommandHook("/host/wrapper", { ...input, tool_input: { command } }).hookSpecificOutput.permissionDecision, "deny");
|
|
30
|
+
}
|
|
31
|
+
assert.throws(() => nativeCommandHook("/host/wrapper", { ...input, tool_name: "Read" }), /unexpected native tool/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("the native hook executable fails closed on malformed or oversized input", () => {
|
|
35
|
+
const executable = fileURLToPath(new URL("../src/server/conversation/commandHook.js", import.meta.url));
|
|
36
|
+
for (const input of ["invalid JSON", "x".repeat(2 * 1024 * 1024 + 1)]) {
|
|
37
|
+
const output = JSON.parse(execFileSync(process.execPath, [executable, "/host/wrapper"], { input, encoding: "utf8" }));
|
|
38
|
+
assert.equal(output.hookSpecificOutput.permissionDecision, "deny");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a required command wrapper cannot be silently ignored by an incompatible host", async () => {
|
|
43
|
+
for (const host of [{ commandWrapper: "relative", nativeTools: true }, { commandWrapper: "/host/wrapper" }, { commandWrapper: "/host/wrapper", nativeTools: true }]) {
|
|
44
|
+
const runtime = createConversationRuntime({ storage: createMemoryConversationStorage(), authorize: () => true, host });
|
|
45
|
+
await assert.rejects(runtime.open({ id: "wrapper", configuration: { integrationId: "unused" } }), /commandWrapper|requires native/);
|
|
46
|
+
await runtime.close();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createConversationRuntime, createMemoryConversationStorage, createFileConversationStorage, normalizeConversationConfiguration } from "../src/server/conversation/index.js";
|
|
7
|
+
import { validateConversationConfiguration } from "../src/server/conversation/configuration.js";
|
|
8
|
+
|
|
9
|
+
test("configuration shorthand retains native login, explicit connection defaults and JSON-stable optional fields", () => {
|
|
10
|
+
const input = Object.freeze({ systemPrompt: "Answer briefly.", integrationId: undefined,
|
|
11
|
+
model: undefined, effort: undefined, outputSchema: undefined });
|
|
12
|
+
for (const engine of ["api", "opencode", "claude", "codex"]) {
|
|
13
|
+
const configuration = normalizeConversationConfiguration(input, { engine, defaultIntegrationId: "assistant" });
|
|
14
|
+
assert.deepEqual(configuration, engine === "api" || engine === "opencode"
|
|
15
|
+
? { systemPrompt: input.systemPrompt, integrationId: "assistant" } : { systemPrompt: input.systemPrompt });
|
|
16
|
+
assert.deepEqual(JSON.parse(JSON.stringify(configuration)), configuration);
|
|
17
|
+
const explicit = { ...input, integrationId: "authorized-choice", model: "exact-model-id", effort: "high" };
|
|
18
|
+
assert.deepEqual(normalizeConversationConfiguration(explicit, { engine, defaultIntegrationId: "assistant" }), {
|
|
19
|
+
systemPrompt: input.systemPrompt, integrationId: "authorized-choice", model: "exact-model-id", effort: "high"
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
assert.equal(Object.hasOwn(input, "integrationId"), true);
|
|
23
|
+
assert.equal(input.integrationId, undefined);
|
|
24
|
+
const withoutDefault = normalizeConversationConfiguration(input, { engine: "api" });
|
|
25
|
+
assert.deepEqual(withoutDefault, { systemPrompt: input.systemPrompt });
|
|
26
|
+
assert.throws(() => validateConversationConfiguration(withoutDefault, {
|
|
27
|
+
engine: "API", connectionRequired: true, connections: { resolve() {} }
|
|
28
|
+
}), /requires an authorized integrationId/);
|
|
29
|
+
const invalid = normalizeConversationConfiguration({ systemPrompt: input.systemPrompt, integrationId: "" }, {
|
|
30
|
+
engine: "api", defaultIntegrationId: "assistant"
|
|
31
|
+
});
|
|
32
|
+
assert.equal(invalid.integrationId, "");
|
|
33
|
+
assert.throws(() => validateConversationConfiguration(invalid), /configuration requires/);
|
|
34
|
+
const unsupported = normalizeConversationConfiguration({ systemPrompt: input.systemPrompt, unexpected: undefined });
|
|
35
|
+
assert.equal(Object.hasOwn(unsupported, "unexpected"), true);
|
|
36
|
+
assert.throws(() => validateConversationConfiguration(unsupported), /configuration requires/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
async function fixture(t, { file = false } = {}) {
|
|
40
|
+
const directory = await mkdtemp(join(tmpdir(), "conversation-configuration-"));
|
|
41
|
+
const storage = file ? createFileConversationStorage({ directory: join(directory, "conversations") }) : createMemoryConversationStorage();
|
|
42
|
+
const runtimes = [];
|
|
43
|
+
t.after(async () => {
|
|
44
|
+
try { for (const runtime of runtimes) await runtime.close(); }
|
|
45
|
+
finally { await rm(directory, { recursive: true, force: true }); }
|
|
46
|
+
});
|
|
47
|
+
function runtime(options = {}) {
|
|
48
|
+
const value = createConversationRuntime({ storage, defaultIntegrationId: "assistant", authorize: () => true,
|
|
49
|
+
connections: { resolve() { assert.fail("Configuration must not resolve credentials or run inference."); } },
|
|
50
|
+
fetch() { assert.fail("Configuration must not issue a provider request."); },
|
|
51
|
+
host: { workdir: directory, env: { HOME: directory }, execution: {
|
|
52
|
+
start() { assert.fail("Configuration must not start a native process."); },
|
|
53
|
+
stop() { assert.fail("An unopened native conversation has no process to stop."); }
|
|
54
|
+
} }, ...options });
|
|
55
|
+
runtimes.push(value);
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
return { runtime, storage };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("runtime normalizes open and configure inputs with only an explicit connection default", async t => {
|
|
62
|
+
const f = await fixture(t);
|
|
63
|
+
const runtime = f.runtime();
|
|
64
|
+
const input = Object.freeze({ systemPrompt: "Answer briefly.", integrationId: undefined,
|
|
65
|
+
model: undefined, effort: undefined, outputSchema: undefined });
|
|
66
|
+
const expected = { systemPrompt: input.systemPrompt, integrationId: "assistant" };
|
|
67
|
+
const conversation = await runtime.open({ id: "one", configuration: input });
|
|
68
|
+
assert.deepEqual((await conversation.read()).configuration, expected);
|
|
69
|
+
assert.deepEqual((await (await runtime.open({ id: "one", configuration: input })).read()).configuration, expected);
|
|
70
|
+
assert.deepEqual(await conversation.configure({ integrationId: "explicit", model: "exact-model-id", effort: "high" }), {
|
|
71
|
+
systemPrompt: input.systemPrompt, integrationId: "explicit", model: "exact-model-id", effort: "high"
|
|
72
|
+
});
|
|
73
|
+
assert.deepEqual(await conversation.configure({ integrationId: undefined, model: undefined, effort: undefined }), expected);
|
|
74
|
+
await assert.rejects(conversation.configure({ integrationId: "" }), /configuration requires/);
|
|
75
|
+
await assert.rejects(conversation.configure({ unexpected: undefined }), /configuration requires/);
|
|
76
|
+
assert.deepEqual((await conversation.read()).configuration, expected);
|
|
77
|
+
assert.equal(Object.hasOwn(input, "integrationId"), true);
|
|
78
|
+
assert.equal(input.integrationId, undefined);
|
|
79
|
+
await assert.rejects(f.runtime({ defaultIntegrationId: undefined }).open({ id: "without-default", configuration: input }),
|
|
80
|
+
/requires an authorized integrationId/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("runtime normalization uses the saved engine on reopen and never rewrites saved configuration", async t => {
|
|
84
|
+
const f = await fixture(t);
|
|
85
|
+
for (const engine of ["api", "claude"]) {
|
|
86
|
+
const options = { engine: engine === "api" ? "claude" : "api" };
|
|
87
|
+
const input = { systemPrompt: "Keep this configuration.", integrationId: undefined,
|
|
88
|
+
model: undefined, effort: undefined, outputSchema: undefined };
|
|
89
|
+
const expected = { systemPrompt: input.systemPrompt, ...(engine === "api" ? { integrationId: "assistant" } : {}) };
|
|
90
|
+
const first = f.runtime(options);
|
|
91
|
+
await first.open({ id: engine, engine, configuration: input });
|
|
92
|
+
await first.close();
|
|
93
|
+
const second = f.runtime(options);
|
|
94
|
+
const reopened = await second.open({ id: engine, configuration: input });
|
|
95
|
+
assert.equal((await reopened.read()).engine, engine);
|
|
96
|
+
assert.deepEqual((await reopened.read()).configuration, expected);
|
|
97
|
+
await reopened.configure({ integrationId: "explicit", model: "exact-model-id", effort: "high" });
|
|
98
|
+
assert.deepEqual(await reopened.configure({ integrationId: undefined, model: undefined, effort: undefined }), expected);
|
|
99
|
+
await second.close();
|
|
100
|
+
await f.storage.write(engine, async transaction => {
|
|
101
|
+
const metadata = await transaction.readMetadata();
|
|
102
|
+
metadata.runtime.configuration.model = undefined;
|
|
103
|
+
await transaction.writeMetadata(metadata);
|
|
104
|
+
});
|
|
105
|
+
const saved = await f.storage.read(engine, transaction => transaction.readMetadata());
|
|
106
|
+
const restored = await f.runtime(options).open({ id: engine });
|
|
107
|
+
assert.deepEqual((await restored.read()).configuration, saved.runtime.configuration);
|
|
108
|
+
assert.equal(Object.hasOwn((await restored.read()).configuration, "model"), true);
|
|
109
|
+
assert.deepEqual(await f.storage.read(engine, transaction => transaction.readMetadata()), saved);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("runtime normalizes destination settings and retains file-backed selection and replacement retries", async t => {
|
|
114
|
+
const f = await fixture(t, { file: true });
|
|
115
|
+
const first = f.runtime();
|
|
116
|
+
let conversation = await first.open({ id: "one", configuration: { systemPrompt: "Initial instructions." } });
|
|
117
|
+
const selection = { operationId: "native", expectedSegmentId: (await conversation.read()).segmentId, engine: "claude",
|
|
118
|
+
configuration: { systemPrompt: "Native instructions.", integrationId: undefined, model: "exact-model-id", effort: "high" } };
|
|
119
|
+
const selected = await conversation.select(selection);
|
|
120
|
+
assert.equal((await conversation.read()).engine, "claude");
|
|
121
|
+
assert.deepEqual((await conversation.read()).configuration, {
|
|
122
|
+
systemPrompt: "Native instructions.", model: "exact-model-id", effort: "high"
|
|
123
|
+
});
|
|
124
|
+
assert.deepEqual((await f.storage.read("one", transaction => transaction.readMetadata())).runtime.predecessors.at(-1).replacement,
|
|
125
|
+
{ ...selection, configuration: { systemPrompt: "Native instructions.", model: "exact-model-id", effort: "high" }, operation: "select" });
|
|
126
|
+
await first.close();
|
|
127
|
+
const second = f.runtime({ defaultIntegrationId: "next-default" });
|
|
128
|
+
conversation = await second.open({ id: "one" });
|
|
129
|
+
assert.equal((await conversation.select(selection)).duplicate, true);
|
|
130
|
+
await assert.rejects(conversation.select({ ...selection, configuration: { ...selection.configuration, model: "changed-model" } }),
|
|
131
|
+
{ code: "conversation_replacement_conflict" });
|
|
132
|
+
const replacement = { operationId: "api", expectedSegmentId: selected.segmentId, reason: "engine-change", engine: "api",
|
|
133
|
+
configuration: { systemPrompt: "API instructions.", integrationId: undefined, model: undefined, effort: undefined } };
|
|
134
|
+
await conversation.replace(replacement);
|
|
135
|
+
assert.equal((await conversation.read()).engine, "api");
|
|
136
|
+
assert.deepEqual((await conversation.read()).configuration, { systemPrompt: "API instructions.", integrationId: "next-default" });
|
|
137
|
+
assert.deepEqual((await f.storage.read("one", transaction => transaction.readMetadata())).runtime.predecessors.at(-1).replacement,
|
|
138
|
+
{ ...replacement, configuration: { systemPrompt: "API instructions." }, operation: "replace" });
|
|
139
|
+
await second.close();
|
|
140
|
+
conversation = await f.runtime({ defaultIntegrationId: "later-default" }).open({ id: "one" });
|
|
141
|
+
assert.equal((await conversation.replace(replacement)).duplicate, true);
|
|
142
|
+
await assert.rejects(conversation.replace({ ...replacement, configuration: { ...replacement.configuration, integrationId: "changed" } }),
|
|
143
|
+
{ code: "conversation_replacement_conflict" });
|
|
144
|
+
assert.deepEqual((await conversation.read()).configuration, { systemPrompt: "API instructions.", integrationId: "next-default" });
|
|
145
|
+
assert.deepEqual((await conversation.read()).conversationLog, []);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("file-backed pending replacement keeps its admitted configuration across a new runtime default", async t => {
|
|
149
|
+
const f = await fixture(t, { file: true });
|
|
150
|
+
const interruptedStorage = { ...f.storage, write: (id, callback) => f.storage.write(id, async transaction => {
|
|
151
|
+
const previous = await transaction.readMetadata();
|
|
152
|
+
const value = await callback(transaction);
|
|
153
|
+
const next = await transaction.readMetadata();
|
|
154
|
+
if (previous.runtime?.replacement && !next.runtime?.replacement) throw new Error("Storage unavailable during replacement commit.");
|
|
155
|
+
return value;
|
|
156
|
+
}) };
|
|
157
|
+
const first = f.runtime({ storage: interruptedStorage });
|
|
158
|
+
const conversation = await first.open({ id: "one", configuration: { systemPrompt: "Initial instructions." } });
|
|
159
|
+
const input = { operationId: "pending", expectedSegmentId: (await conversation.read()).segmentId, reason: "model-change",
|
|
160
|
+
engine: undefined, briefing: undefined,
|
|
161
|
+
configuration: { systemPrompt: "Replacement instructions.", integrationId: undefined, model: undefined, effort: undefined } };
|
|
162
|
+
await assert.rejects(conversation.replace(input), /Storage unavailable during replacement commit/);
|
|
163
|
+
const saved = await f.storage.read("one", transaction => transaction.readMetadata());
|
|
164
|
+
assert.deepEqual(saved.runtime.replacement.configuration, { systemPrompt: "Replacement instructions.", integrationId: "assistant" });
|
|
165
|
+
assert.deepEqual(saved.runtime.replacement.request.configuration, { systemPrompt: "Replacement instructions." });
|
|
166
|
+
await first.close();
|
|
167
|
+
const resumed = await f.runtime({ defaultIntegrationId: "later-default" }).open({ id: "one" });
|
|
168
|
+
await assert.rejects(resumed.replace({ ...input, configuration: { ...input.configuration, systemPrompt: "Different instructions." } }),
|
|
169
|
+
{ code: "conversation_replacement_pending" });
|
|
170
|
+
assert.deepEqual(await f.storage.read("one", transaction => transaction.readMetadata()), saved);
|
|
171
|
+
await resumed.replace(input);
|
|
172
|
+
assert.deepEqual((await resumed.read()).configuration, { systemPrompt: "Replacement instructions.", integrationId: "assistant" });
|
|
173
|
+
assert.equal((await resumed.replace(input)).duplicate, true);
|
|
174
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { nativeConversationStoragePolicy } from "../src/server/conversation/nativeStorage.js";
|
|
4
|
+
|
|
5
|
+
test("native storage policy distinguishes locked deletion from host writer exclusion", () => {
|
|
6
|
+
assert.equal(nativeConversationStoragePolicy("codex").requiresWriterExclusion, false);
|
|
7
|
+
assert.equal(nativeConversationStoragePolicy("claude").requiresWriterExclusion, true);
|
|
8
|
+
assert.equal(nativeConversationStoragePolicy("claude").allowsManagedServer, false);
|
|
9
|
+
assert.equal(nativeConversationStoragePolicy("opencode").requiresWriterExclusion, true);
|
|
10
|
+
assert.equal(nativeConversationStoragePolicy("opencode").allowsManagedServer, true);
|
|
11
|
+
for (const engine of ["api", "unknown", "toString", "__proto__", undefined]) {
|
|
12
|
+
assert.throws(() => nativeConversationStoragePolicy(engine), /Unsupported/u);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("native writer recognition handles executables and node entry points without matching similar names", () => {
|
|
17
|
+
const claude = nativeConversationStoragePolicy("claude");
|
|
18
|
+
assert.ok(claude.matchesProcess({ executable: "/tools/claude", commandLine: "native\0--resume\0" }));
|
|
19
|
+
assert.ok(claude.matchesProcess({ executable: "/usr/bin/node", commandLine: "node\0/tools/node_modules/@anthropic-ai/claude-code/cli.js\0" }));
|
|
20
|
+
assert.equal(claude.matchesProcess({ executable: "/tools/claude-helper", commandLine: "node\0/notes/claude.txt\0" }), false);
|
|
21
|
+
const opencode = nativeConversationStoragePolicy("opencode");
|
|
22
|
+
assert.ok(opencode.matchesProcess({ executable: "/tools/opencode", commandLine: "opencode\0serve\0" }));
|
|
23
|
+
assert.ok(opencode.matchesProcess({ executable: "/usr/bin/bun", commandLine: "bun\0/tools/opencode\0serve\0" }));
|
|
24
|
+
assert.equal(opencode.matchesProcess({ executable: "/tools/opencode-helper", commandLine: "node\0/notes/opencode.txt\0" }), false);
|
|
25
|
+
});
|