@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,515 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
OPENCODE_RESPONSE_LIMIT_BYTES,
|
|
6
|
+
createOpenCodeServerClient
|
|
7
|
+
} from "../src/server/conversation/openCodeClient.js";
|
|
8
|
+
|
|
9
|
+
function jsonResponse(value, status = 200) {
|
|
10
|
+
return new Response(JSON.stringify(value), {
|
|
11
|
+
headers: { "content-type": "application/json" },
|
|
12
|
+
status
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("OpenCode directory readiness uses the same authenticated directory as native Stop", async () => {
|
|
17
|
+
const requests = [];
|
|
18
|
+
const client = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:4096", password: "private",
|
|
19
|
+
fetchImpl: async (url, init) => {
|
|
20
|
+
requests.push({ path: new URL(url).pathname, headers: init.headers, signal: init.signal });
|
|
21
|
+
return jsonResponse(new URL(url).pathname === "/path" ? { directory: "/work/selected" } : true);
|
|
22
|
+
}
|
|
23
|
+
}).forDirectory("/work/selected");
|
|
24
|
+
const stopSignal = new AbortController().signal;
|
|
25
|
+
await client.prepareDirectory();
|
|
26
|
+
assert.equal(await client.interrupt("retained", { signal: stopSignal }), true);
|
|
27
|
+
assert.deepEqual(requests.map(row => row.path), ["/path", "/session/retained/abort"]);
|
|
28
|
+
assert.equal(requests[0].headers.authorization, requests[1].headers.authorization);
|
|
29
|
+
assert.equal(requests[0].headers["x-opencode-directory"], "/work/selected");
|
|
30
|
+
assert.equal(requests[1].headers["x-opencode-directory"], "/work/selected");
|
|
31
|
+
assert.ok(requests[0].signal instanceof AbortSignal);
|
|
32
|
+
assert.equal(requests[1].signal, stopSignal);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("OpenCode directory readiness rejects cancellation and missing native confirmation", async () => {
|
|
36
|
+
const client = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:4096",
|
|
37
|
+
fetchImpl: async () => jsonResponse({ healthy: true }) });
|
|
38
|
+
await assert.rejects(client.prepareDirectory(), { code: "assistant_opencode_directory_unavailable" });
|
|
39
|
+
const failure = new Error("Closed during native preparation");
|
|
40
|
+
await assert.rejects(client.prepareDirectory({ signal: AbortSignal.abort(failure) }), error => error === failure);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("OpenCode attachment access persists on its conversation, preserves other permissions and does not grow on retries", async () => {
|
|
44
|
+
const initial = [{ permission: "read", pattern: "*.env", action: "deny" }];
|
|
45
|
+
let permission = [...initial];
|
|
46
|
+
const requests = [];
|
|
47
|
+
const client = createOpenCodeServerClient({
|
|
48
|
+
allowAttachmentDirectories: true,
|
|
49
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
50
|
+
fetchImpl: async (url, init) => {
|
|
51
|
+
const body = init.body ? JSON.parse(init.body) : null;
|
|
52
|
+
requests.push({ path: new URL(url).pathname, method: init.method, body });
|
|
53
|
+
if (init.method === "PATCH") permission = body.permission;
|
|
54
|
+
return jsonResponse({ permission });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
const attachments = [
|
|
58
|
+
{ path: "/sessions/one/artifacts/attachments/id-1/file", fileName: "image.png", contentType: "image/png" },
|
|
59
|
+
{ path: "/sessions/one/artifacts/attachments/id-2/file", fileName: "data.csv", contentType: "application/octet-stream" }
|
|
60
|
+
];
|
|
61
|
+
await client.prompt("ses_one", { prompt: { text: "Inspect" }, attachments });
|
|
62
|
+
assert.deepEqual(permission, [...initial, ...attachments.map((attachment) => ({
|
|
63
|
+
permission: "external_directory", pattern: attachment.path.replace(/file$/u, "*"), action: "allow"
|
|
64
|
+
}))]);
|
|
65
|
+
assert.deepEqual(requests.map(({ method, path }) => [method, path]), [
|
|
66
|
+
["GET", "/session/ses_one"], ["PATCH", "/session/ses_one"], ["POST", "/session/ses_one/prompt_async"]
|
|
67
|
+
]);
|
|
68
|
+
await client.prompt("ses_one", { prompt: { text: "Retry" }, attachments });
|
|
69
|
+
assert.equal(requests.filter(({ method }) => method === "PATCH").length, 1);
|
|
70
|
+
requests.length = 0;
|
|
71
|
+
await client.prompt("ses_one", { prompt: { text: "Reopen the earlier file" } });
|
|
72
|
+
assert.equal(requests.length, 1);
|
|
73
|
+
assert.equal(permission.length, 3);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("OpenCode does not send attachments if their native access cannot be configured", async () => {
|
|
77
|
+
const requests = [];
|
|
78
|
+
const client = createOpenCodeServerClient({
|
|
79
|
+
allowAttachmentDirectories: true,
|
|
80
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
81
|
+
fetchImpl: async (_url, init) => {
|
|
82
|
+
requests.push(init.method);
|
|
83
|
+
return init.method === "GET" ? jsonResponse({ permission: [] }) : jsonResponse({ message: "Cannot save permissions" }, 500);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
await assert.rejects(client.prompt("ses_one", {
|
|
87
|
+
prompt: { text: "Inspect" },
|
|
88
|
+
attachments: [{ path: "/sessions/one/artifacts/attachments/id/file", fileName: "data.csv" }]
|
|
89
|
+
}), /Cannot save permissions/u);
|
|
90
|
+
assert.deepEqual(requests, ["GET", "PATCH"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("OpenCode client accepts only loopback HTTP origins", () => {
|
|
94
|
+
for (const baseUrl of [
|
|
95
|
+
"https://127.0.0.1:4096",
|
|
96
|
+
"http://192.0.2.10:4096",
|
|
97
|
+
"http://example.com:4096"
|
|
98
|
+
]) {
|
|
99
|
+
assert.throws(
|
|
100
|
+
() => createOpenCodeServerClient({
|
|
101
|
+
allowAttachmentDirectories: true,
|
|
102
|
+
baseUrl
|
|
103
|
+
}),
|
|
104
|
+
/loopback HTTP server/u
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
assert.doesNotThrow(() => createOpenCodeServerClient({
|
|
108
|
+
allowAttachmentDirectories: true,
|
|
109
|
+
baseUrl: "http://127.0.0.1:4096"
|
|
110
|
+
}));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("OpenCode provider reads allowlist catalogue metadata before it can be cached", async () => {
|
|
114
|
+
const secret = "provider-secret-canary";
|
|
115
|
+
const client = createOpenCodeServerClient({
|
|
116
|
+
allowAttachmentDirectories: true,
|
|
117
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
118
|
+
fetchImpl: async () => jsonResponse({
|
|
119
|
+
all: [{
|
|
120
|
+
api: { url: `https://${secret}.example` },
|
|
121
|
+
headers: { authorization: secret },
|
|
122
|
+
id: "zai",
|
|
123
|
+
key: secret,
|
|
124
|
+
env: ["ZAI_API_KEY"],
|
|
125
|
+
models: {
|
|
126
|
+
"glm-4.7-flash": {
|
|
127
|
+
api: { headers: { authorization: secret }, url: `https://${secret}.example/v1` },
|
|
128
|
+
capabilities: {
|
|
129
|
+
attachment: true,
|
|
130
|
+
input: { image: true, secret },
|
|
131
|
+
output: { text: true },
|
|
132
|
+
reasoning: true,
|
|
133
|
+
toolcall: true
|
|
134
|
+
},
|
|
135
|
+
family: "glm",
|
|
136
|
+
headers: { authorization: secret },
|
|
137
|
+
id: "glm-4.7-flash",
|
|
138
|
+
limit: { context: 204800, output: 131072, secret },
|
|
139
|
+
name: "GLM Flash",
|
|
140
|
+
options: { apiKey: secret },
|
|
141
|
+
release_date: "2026-08-01",
|
|
142
|
+
status: "active",
|
|
143
|
+
variants: { high: { apiKey: secret } }
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
name: "Z.AI",
|
|
147
|
+
options: { apiKey: secret },
|
|
148
|
+
source: "api"
|
|
149
|
+
}],
|
|
150
|
+
apiKey: secret,
|
|
151
|
+
connected: ["zai"],
|
|
152
|
+
default: { zai: "glm-4.7-flash" }
|
|
153
|
+
}),
|
|
154
|
+
password: "bridge-password"
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const providers = await client.providers();
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(providers, {
|
|
160
|
+
all: [{
|
|
161
|
+
apiKeyCompatible: true,
|
|
162
|
+
id: "zai",
|
|
163
|
+
models: {
|
|
164
|
+
"glm-4.7-flash": {
|
|
165
|
+
capabilities: {
|
|
166
|
+
attachment: true,
|
|
167
|
+
input: { image: true },
|
|
168
|
+
output: { text: true },
|
|
169
|
+
reasoning: true,
|
|
170
|
+
toolcall: true
|
|
171
|
+
},
|
|
172
|
+
family: "glm",
|
|
173
|
+
id: "glm-4.7-flash",
|
|
174
|
+
limit: { context: 204800, output: 131072 },
|
|
175
|
+
name: "GLM Flash",
|
|
176
|
+
release_date: "2026-08-01",
|
|
177
|
+
status: "active",
|
|
178
|
+
variants: { high: {} }
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
name: "Z.AI"
|
|
182
|
+
}],
|
|
183
|
+
default: { zai: "glm-4.7-flash" }
|
|
184
|
+
});
|
|
185
|
+
assert.equal(JSON.stringify(providers).includes(secret), false);
|
|
186
|
+
assert.equal(Object.hasOwn(providers, "connected"), false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("OpenCode provider reads expose only API-key compatibility from raw env metadata", async () => {
|
|
190
|
+
const provider = (id, env) => ({ env, id, models: {}, name: id });
|
|
191
|
+
const client = createOpenCodeServerClient({
|
|
192
|
+
allowAttachmentDirectories: true,
|
|
193
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
194
|
+
fetchImpl: async () => jsonResponse({
|
|
195
|
+
all: [
|
|
196
|
+
provider("azure", ["AZURE_RESOURCE_NAME", "AZURE_API_KEY"]),
|
|
197
|
+
provider("cloudflare-workers-ai", [
|
|
198
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
199
|
+
"CLOUDFLARE_API_TOKEN"
|
|
200
|
+
]),
|
|
201
|
+
provider("google", ["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_PROJECT"]),
|
|
202
|
+
provider("amazon-bedrock", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]),
|
|
203
|
+
provider("ordinary", ["ORDINARY_API_KEY"])
|
|
204
|
+
],
|
|
205
|
+
default: {}
|
|
206
|
+
}),
|
|
207
|
+
password: "bridge-password"
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const providers = await client.providers();
|
|
211
|
+
|
|
212
|
+
assert.deepEqual(providers.all.map(({ apiKeyCompatible, id }) => ({
|
|
213
|
+
apiKeyCompatible,
|
|
214
|
+
id
|
|
215
|
+
})), [
|
|
216
|
+
{ apiKeyCompatible: false, id: "azure" },
|
|
217
|
+
{ apiKeyCompatible: false, id: "cloudflare-workers-ai" },
|
|
218
|
+
{ apiKeyCompatible: true, id: "google" },
|
|
219
|
+
{ apiKeyCompatible: true, id: "amazon-bedrock" },
|
|
220
|
+
{ apiKeyCompatible: true, id: "ordinary" }
|
|
221
|
+
]);
|
|
222
|
+
assert.equal(providers.all.length, 5);
|
|
223
|
+
assert.equal(JSON.stringify(providers).includes("AZURE_API_KEY"), false);
|
|
224
|
+
assert.equal(JSON.stringify(providers).includes("AWS_SECRET_ACCESS_KEY"), false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("OpenCode provider reads reject malformed or empty catalogues", async () => {
|
|
228
|
+
for (const value of [null, {}, { all: {} }, { all: [] }, { all: [null] }]) {
|
|
229
|
+
const client = createOpenCodeServerClient({
|
|
230
|
+
allowAttachmentDirectories: true,
|
|
231
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
232
|
+
fetchImpl: async () => jsonResponse(value),
|
|
233
|
+
password: "bridge-password"
|
|
234
|
+
});
|
|
235
|
+
await assert.rejects(
|
|
236
|
+
() => client.providers(),
|
|
237
|
+
(error) => error?.code === "assistant_opencode_catalog_invalid"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("OpenCode project clients scope every request to one directory", async () => {
|
|
243
|
+
const requests = [];
|
|
244
|
+
const client = createOpenCodeServerClient({
|
|
245
|
+
allowAttachmentDirectories: true,
|
|
246
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
247
|
+
fetchImpl: async (url, options = {}) => {
|
|
248
|
+
requests.push({ headers: { ...options.headers }, url: String(url) });
|
|
249
|
+
return jsonResponse(new URL(url).pathname === "/api/session"
|
|
250
|
+
? { data: { id: "ses_scoped" } }
|
|
251
|
+
: true);
|
|
252
|
+
},
|
|
253
|
+
password: "bridge-password"
|
|
254
|
+
}).forDirectory("/workspace/project-one");
|
|
255
|
+
|
|
256
|
+
await client.createSession({ id: "ses_scoped" });
|
|
257
|
+
await client.readSession("ses_scoped");
|
|
258
|
+
await client.interrupt("ses_scoped");
|
|
259
|
+
|
|
260
|
+
assert.deepEqual(
|
|
261
|
+
requests.map(({ headers }) => headers["x-opencode-directory"]),
|
|
262
|
+
["/workspace/project-one", "/workspace/project-one", "/workspace/project-one"]
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("OpenCode client combines durable v2 sessions with stable execution routes and isolates provider keys", async () => {
|
|
267
|
+
const requests = [];
|
|
268
|
+
const client = createOpenCodeServerClient({
|
|
269
|
+
allowAttachmentDirectories: true,
|
|
270
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
271
|
+
fetchImpl: async (url, options = {}) => {
|
|
272
|
+
requests.push({
|
|
273
|
+
body: options.body,
|
|
274
|
+
headers: { ...options.headers },
|
|
275
|
+
method: options.method,
|
|
276
|
+
url: String(url)
|
|
277
|
+
});
|
|
278
|
+
const pathname = new URL(url).pathname;
|
|
279
|
+
if (pathname === "/api/session") {
|
|
280
|
+
return jsonResponse({ data: { id: "ses_created" } });
|
|
281
|
+
}
|
|
282
|
+
if (pathname.endsWith("/prompt_async")) {
|
|
283
|
+
return new Response(null, { status: 204 });
|
|
284
|
+
}
|
|
285
|
+
if (pathname.endsWith("/message")) {
|
|
286
|
+
return jsonResponse([{
|
|
287
|
+
info: {
|
|
288
|
+
finish: "stop",
|
|
289
|
+
id: "msg_assistant",
|
|
290
|
+
role: "assistant",
|
|
291
|
+
time: { completed: 2, created: 1 }
|
|
292
|
+
},
|
|
293
|
+
parts: [{ id: "prt_text", text: "hello", type: "text" }]
|
|
294
|
+
}]);
|
|
295
|
+
}
|
|
296
|
+
return jsonResponse(true);
|
|
297
|
+
},
|
|
298
|
+
password: "bridge-password"
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await client.authenticateApiKey("deepseek", "deepseek-private-key");
|
|
302
|
+
assert.deepEqual(await client.createSession({ id: "ses_created" }), { id: "ses_created" });
|
|
303
|
+
assert.deepEqual(await client.prompt("ses/needs encoding", {
|
|
304
|
+
agent: "build",
|
|
305
|
+
id: "msg_admitted",
|
|
306
|
+
model: { id: "deepseek-chat", providerID: "deepseek", variant: "high" },
|
|
307
|
+
prompt: { text: " hello\n" }
|
|
308
|
+
}), {
|
|
309
|
+
delivery: "",
|
|
310
|
+
id: "msg_admitted",
|
|
311
|
+
sessionID: "ses/needs encoding"
|
|
312
|
+
});
|
|
313
|
+
assert.deepEqual(await client.messages("ses_created", { limit: 10 }), {
|
|
314
|
+
data: [{
|
|
315
|
+
content: [{ id: "prt_text", text: "hello", type: "text" }],
|
|
316
|
+
finish: "stop",
|
|
317
|
+
id: "msg_assistant",
|
|
318
|
+
role: "assistant",
|
|
319
|
+
time: { completed: 2, created: 1 },
|
|
320
|
+
type: "assistant"
|
|
321
|
+
}]
|
|
322
|
+
});
|
|
323
|
+
await client.interrupt("ses_created");
|
|
324
|
+
await client.switchAgent("ses_created", "build");
|
|
325
|
+
await client.switchModel("ses_created", { id: "deepseek-chat", providerID: "deepseek" });
|
|
326
|
+
|
|
327
|
+
assert.deepEqual(requests.map(({ method, url }) => ({
|
|
328
|
+
method,
|
|
329
|
+
path: new URL(url).pathname
|
|
330
|
+
})), [
|
|
331
|
+
{ method: "PUT", path: "/auth/deepseek" },
|
|
332
|
+
{ method: "POST", path: "/api/session" },
|
|
333
|
+
{ method: "POST", path: "/session/ses%2Fneeds%20encoding/prompt_async" },
|
|
334
|
+
{ method: "GET", path: "/session/ses_created/message" },
|
|
335
|
+
{ method: "POST", path: "/session/ses_created/abort" },
|
|
336
|
+
{ method: "POST", path: "/api/session/ses_created/agent" },
|
|
337
|
+
{ method: "POST", path: "/api/session/ses_created/model" }
|
|
338
|
+
]);
|
|
339
|
+
assert.deepEqual(JSON.parse(requests[0].body), {
|
|
340
|
+
key: "deepseek-private-key",
|
|
341
|
+
type: "api"
|
|
342
|
+
});
|
|
343
|
+
assert.deepEqual(JSON.parse(requests[2].body), {
|
|
344
|
+
agent: "build",
|
|
345
|
+
messageID: "msg_admitted",
|
|
346
|
+
model: { modelID: "deepseek-chat", providerID: "deepseek" },
|
|
347
|
+
parts: [{ text: " hello\n", type: "text" }],
|
|
348
|
+
variant: "high"
|
|
349
|
+
});
|
|
350
|
+
assert.equal(requests.slice(1).some((request) => (
|
|
351
|
+
JSON.stringify(request).includes("deepseek-private-key")
|
|
352
|
+
)), false);
|
|
353
|
+
assert.equal(new Set(requests.map((request) => request.headers.authorization)).size, 1);
|
|
354
|
+
assert.match(requests[0].headers.authorization, /^Basic /u);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("OpenCode client rejects oversized responses before parsing them", async () => {
|
|
358
|
+
const client = createOpenCodeServerClient({
|
|
359
|
+
allowAttachmentDirectories: true,
|
|
360
|
+
baseUrl: "http://localhost:4096",
|
|
361
|
+
fetchImpl: async () => new Response("x".repeat(OPENCODE_RESPONSE_LIMIT_BYTES + 1)),
|
|
362
|
+
password: "password"
|
|
363
|
+
});
|
|
364
|
+
await assert.rejects(
|
|
365
|
+
() => client.health(),
|
|
366
|
+
(error) => error?.code === "assistant_opencode_response_too_large"
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("OpenCode client decodes bounded server-sent events", async () => {
|
|
371
|
+
const source = [
|
|
372
|
+
": heartbeat",
|
|
373
|
+
"id: evt_1",
|
|
374
|
+
"event: session.updated",
|
|
375
|
+
'data: {"sessionID":"ses_1"}',
|
|
376
|
+
"",
|
|
377
|
+
"id: evt_2",
|
|
378
|
+
'data: "{\\"status\\":\\"idle\\"}"',
|
|
379
|
+
""
|
|
380
|
+
].join("\n");
|
|
381
|
+
let eventUrl = "";
|
|
382
|
+
const client = createOpenCodeServerClient({
|
|
383
|
+
allowAttachmentDirectories: true,
|
|
384
|
+
baseUrl: "http://[::1]:4096",
|
|
385
|
+
fetchImpl: async (url) => {
|
|
386
|
+
eventUrl = String(url);
|
|
387
|
+
return new Response(source, {
|
|
388
|
+
headers: { "content-type": "text/event-stream" }
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
password: "password"
|
|
392
|
+
});
|
|
393
|
+
const events = [];
|
|
394
|
+
let ready = false;
|
|
395
|
+
for await (const event of client.events("ses_1", { onReady: () => { ready = true; } })) {
|
|
396
|
+
assert.equal(ready, true);
|
|
397
|
+
events.push(event);
|
|
398
|
+
}
|
|
399
|
+
assert.deepEqual(events, [
|
|
400
|
+
{
|
|
401
|
+
data: { sessionID: "ses_1" },
|
|
402
|
+
event: "session.updated",
|
|
403
|
+
id: "evt_1"
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
data: { status: "idle" },
|
|
407
|
+
event: "message",
|
|
408
|
+
id: "evt_2"
|
|
409
|
+
}
|
|
410
|
+
]);
|
|
411
|
+
assert.equal(new URL(eventUrl).pathname, "/event");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("OpenCode session status reads native busy state and treats an omitted session as idle", async () => {
|
|
415
|
+
const client = createOpenCodeServerClient({
|
|
416
|
+
allowAttachmentDirectories: true,
|
|
417
|
+
baseUrl: "http://127.0.0.1:4096",
|
|
418
|
+
fetchImpl: async (url) => {
|
|
419
|
+
assert.equal(new URL(url).pathname, "/session/status");
|
|
420
|
+
return jsonResponse({ ses_busy: { type: "busy" } });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
assert.deepEqual(await client.sessionStatus("ses_busy"), { type: "busy" });
|
|
424
|
+
assert.deepEqual(await client.sessionStatus("ses_other"), { type: "idle" });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
test("OpenCode retained storage validates complete inventories and bounded cancellable pages without opening archived source", async () => {
|
|
429
|
+
const password = "storage-proof";
|
|
430
|
+
const pageRequests = [];
|
|
431
|
+
const permissionUpdates = [];
|
|
432
|
+
const existingPermission = { permission: "bash", pattern: "*", action: "ask" };
|
|
433
|
+
let conversationPermission = [existingPermission];
|
|
434
|
+
let messageResponse = () => new Response("[]");
|
|
435
|
+
let inventoryRows = [{ id: "ses_child", parentID: "ses_parent", directory: "/archived/source" }];
|
|
436
|
+
const client = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:12345", password, directory: "/deleted/worktree",
|
|
437
|
+
fetchImpl: async (url, options) => {
|
|
438
|
+
assert.equal(options.headers["x-opencode-directory"], undefined, "Storage must not initialize the archived source directory");
|
|
439
|
+
if (new URL(url).pathname.endsWith("/message")) {
|
|
440
|
+
assert.equal(options.headers.authorization, `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`);
|
|
441
|
+
assert.equal(options.method, "GET");
|
|
442
|
+
pageRequests.push(new URL(url));
|
|
443
|
+
return messageResponse(options);
|
|
444
|
+
}
|
|
445
|
+
if (new URL(url).pathname === "/experimental/session") {
|
|
446
|
+
assert.deepEqual(Object.fromEntries(new URL(url).searchParams), { directory: "/archived/source", limit: "1001" });
|
|
447
|
+
}
|
|
448
|
+
if (new URL(url).pathname === "/session/ses_parent") {
|
|
449
|
+
assert.equal(options.headers.authorization, `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`);
|
|
450
|
+
if (options.method === "PATCH") {
|
|
451
|
+
assert.equal(options.headers["content-type"], "application/json");
|
|
452
|
+
conversationPermission = JSON.parse(options.body).permission;
|
|
453
|
+
permissionUpdates.push(conversationPermission);
|
|
454
|
+
return new Response(null, { status: 204 });
|
|
455
|
+
}
|
|
456
|
+
return new Response(JSON.stringify({ id: "ses_parent", directory: "/archived/source", permission: conversationPermission }), { status: 200 });
|
|
457
|
+
}
|
|
458
|
+
if (new URL(url).pathname.startsWith("/session") || new URL(url).pathname === "/experimental/session") {
|
|
459
|
+
assert.equal(options.headers.authorization, `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`);
|
|
460
|
+
assert.equal(options.method, "GET");
|
|
461
|
+
return new Response(JSON.stringify(inventoryRows), { status: 200 });
|
|
462
|
+
}
|
|
463
|
+
throw new Error("Unexpected storage request");
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
assert.deepEqual(await client.readConversationStorage("ses_parent"), { id: "ses_parent", directory: "/archived/source", permission: [existingPermission] });
|
|
467
|
+
const terminalAttachment = { path: "/session-artifacts/attachments/file-1/file" };
|
|
468
|
+
await client.allowConversationAttachments("ses_parent", [terminalAttachment]);
|
|
469
|
+
assert.deepEqual(permissionUpdates, [[existingPermission, {
|
|
470
|
+
permission: "external_directory", pattern: "/session-artifacts/attachments/file-1/*", action: "allow"
|
|
471
|
+
}]]);
|
|
472
|
+
await client.allowConversationAttachments("ses_parent", [terminalAttachment]);
|
|
473
|
+
assert.equal(permissionUpdates.length, 1);
|
|
474
|
+
await assert.rejects(client.allowConversationAttachments("../credentials", [terminalAttachment]), /Invalid/);
|
|
475
|
+
await assert.rejects(client.readConversationStorage("../credentials"), /Invalid/);
|
|
476
|
+
assert.deepEqual(await client.listConversationChildren("ses_parent"), inventoryRows);
|
|
477
|
+
assert.deepEqual(await client.listConversationsForDirectory("/archived/source"), inventoryRows);
|
|
478
|
+
await assert.rejects(client.listConversationChildren("../credentials"), /Invalid/);
|
|
479
|
+
inventoryRows = [{ id: "ses_child", parentID: "ses_foreign", directory: "/archived/source" }];
|
|
480
|
+
await assert.rejects(client.listConversationChildren("ses_parent"), /invalid child inventory/);
|
|
481
|
+
inventoryRows = Array.from({ length: 1001 }, (_, index) => ({ id: `ses_${index}`, directory: "/archived/source" }));
|
|
482
|
+
await assert.rejects(client.listConversationsForDirectory("/archived/source"), /incomplete or invalid/);
|
|
483
|
+
assert.deepEqual(await client.readConversationStoragePage("ses_parent"), { data: [], nextCursor: null });
|
|
484
|
+
messageResponse = () => new Response("[]", { headers: { "x-next-cursor": "opaque+/=" } });
|
|
485
|
+
assert.equal((await client.readConversationStoragePage("ses_parent", { before: "opaque+/=" })).nextCursor, "opaque+/=");
|
|
486
|
+
assert.equal(pageRequests[0].pathname, "/session/ses_parent/message");
|
|
487
|
+
assert.deepEqual(Object.fromEntries(pageRequests[0].searchParams), { limit: "1" });
|
|
488
|
+
assert.deepEqual(Object.fromEntries(pageRequests[1].searchParams), { limit: "1", before: "opaque+/=" });
|
|
489
|
+
await assert.rejects(client.readConversationStoragePage("../credentials"), /Invalid/);
|
|
490
|
+
await assert.rejects(client.readConversationStoragePage("ses_parent", { signal: AbortSignal.abort() }), { name: "AbortError" });
|
|
491
|
+
assert.equal(pageRequests.length, 2);
|
|
492
|
+
const largeMessage = { info: { id: "msg_large", role: "user" }, parts: [{ type: "text", text: "x".repeat(3 * 1024 * 1024) }] };
|
|
493
|
+
messageResponse = () => new Response(JSON.stringify([largeMessage]));
|
|
494
|
+
assert.deepEqual(await client.readConversationStoragePage("ses_parent"), { data: [largeMessage], nextCursor: null });
|
|
495
|
+
messageResponse = () => new Response("[]", { headers: { link: '<http://localhost/session/ses_parent/message?before=opaque>; rel="next"' } });
|
|
496
|
+
await assert.rejects(client.readConversationStoragePage("ses_parent"), /continuation cursor/);
|
|
497
|
+
let bodyCancelled = false;
|
|
498
|
+
messageResponse = () => new Response(new ReadableStream({
|
|
499
|
+
pull(controller) { controller.enqueue(new Uint8Array(1024 * 1024)); },
|
|
500
|
+
cancel() { bodyCancelled = true; }
|
|
501
|
+
}));
|
|
502
|
+
await assert.rejects(client.readConversationStoragePage("ses_parent"), { code: "assistant_opencode_response_too_large" });
|
|
503
|
+
assert.equal(bodyCancelled, true);
|
|
504
|
+
const started = Promise.withResolvers();
|
|
505
|
+
const cancellation = new AbortController();
|
|
506
|
+
messageResponse = ({ signal }) => new Promise((_resolve, reject) => {
|
|
507
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
508
|
+
started.resolve();
|
|
509
|
+
});
|
|
510
|
+
const cancelledPage = client.readConversationStoragePage("ses_parent", { signal: cancellation.signal });
|
|
511
|
+
await started.promise;
|
|
512
|
+
cancellation.abort();
|
|
513
|
+
await assert.rejects(cancelledPage, { name: "AbortError" });
|
|
514
|
+
|
|
515
|
+
});
|