@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,789 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { createConversationRuntime, createFileConversationStorage } from "../src/server/conversation/index.js";
|
|
8
|
+
import { createLocalConversationExecution } from "../src/server/conversation/localExecution.js";
|
|
9
|
+
import { createOpenCodeConversationDriver } from "../src/server/conversation/providers/opencodeDriver.js";
|
|
10
|
+
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
11
|
+
import { createSchema } from "json-rest-schema";
|
|
12
|
+
|
|
13
|
+
const configuration = { systemPrompt: "Fresh instructions", integrationId: "assistant" };
|
|
14
|
+
const input = { messageId: "first", text: "Hello" };
|
|
15
|
+
|
|
16
|
+
test("OpenCode exposes the durable native identity before dispatch and respects a rejected gate", async t => {
|
|
17
|
+
const f = await fixture(t);
|
|
18
|
+
await f.first.close();
|
|
19
|
+
const driver = createOpenCodeConversationDriver(f.driverOptions);
|
|
20
|
+
const readBinding = () => f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
21
|
+
const provider = await driver.open({ binding: await readBinding(),
|
|
22
|
+
writeBinding: binding => f.storage.write("conversation", async tx => {
|
|
23
|
+
const metadata = await tx.readMetadata();
|
|
24
|
+
metadata.runtime.binding = binding;
|
|
25
|
+
await tx.writeMetadata(metadata);
|
|
26
|
+
}),
|
|
27
|
+
onFailure() {}
|
|
28
|
+
});
|
|
29
|
+
const blocked = new Error("Dispatch stopped by the persisted delivery gate.");
|
|
30
|
+
let checked = false;
|
|
31
|
+
try {
|
|
32
|
+
await assert.rejects(provider.run({ configuration, input, signal: new AbortController().signal,
|
|
33
|
+
async beforeDispatch(identity) {
|
|
34
|
+
assert.deepEqual(identity, { threadId: (await readBinding()).sessionId });
|
|
35
|
+
assert.ok(identity.threadId);
|
|
36
|
+
checked = true;
|
|
37
|
+
throw blocked;
|
|
38
|
+
},
|
|
39
|
+
accept() { assert.fail("A rejected dispatch must not admit the message."); },
|
|
40
|
+
onMessage() {}, onEvent() {}
|
|
41
|
+
}), error => error === blocked);
|
|
42
|
+
assert.equal(checked, true);
|
|
43
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 0);
|
|
44
|
+
} finally { await provider.dispose(); }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("OpenCode wraps native commands only for the bound conversation", async t => {
|
|
48
|
+
const f = await fixture(t, { nativeTools: true, commandWrapper: "/host/command wrapper" });
|
|
49
|
+
await f.conversation.send({ messageId: "command", text: "wrapped-command" });
|
|
50
|
+
const state = await f.conversation.wait();
|
|
51
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "complete", state.error);
|
|
52
|
+
assert.deepEqual(JSON.parse(state.conversationLog[0].assistant.text), {
|
|
53
|
+
args: { command: "'/host/command wrapper' 'pwd'", description: "Current directory" }, foreignRejected: true
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("OpenCode receives inline authorized images without granting filesystem access", async t => {
|
|
58
|
+
const image = Buffer.from("authorized-image");
|
|
59
|
+
const receipt = { attachmentId: "picture", fileName: "picture.png", size: image.length };
|
|
60
|
+
const f = await fixture(t, { attachments: { resolve: async () => ({
|
|
61
|
+
attachments: [receipt], content: [{ type: "image", image, mediaType: "image/png" }]
|
|
62
|
+
}) } });
|
|
63
|
+
await f.conversation.send({ ...input, attachmentIds: [receipt.attachmentId] });
|
|
64
|
+
const state = await f.conversation.wait();
|
|
65
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "complete", state.error);
|
|
66
|
+
assert.deepEqual(state.conversationLog[0].user.attachments, [receipt]);
|
|
67
|
+
const trace = await f.trace();
|
|
68
|
+
assert.deepEqual(trace.find(row => row.url?.endsWith("/prompt_async")).body.parts, [
|
|
69
|
+
{ type: "text", text: input.text }, { type: "file", mime: "image/png", url: `data:image/png;base64,${image.toString("base64")}` }
|
|
70
|
+
]);
|
|
71
|
+
assert.equal(trace.some(row => row.url?.includes("/permission")), false);
|
|
72
|
+
assert.equal((await f.conversation.send({ ...input, attachmentIds: [receipt.attachmentId] })).duplicate, true);
|
|
73
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
async function fixture(t, options = {}) {
|
|
77
|
+
const directory = await mkdtemp(path.join(tmpdir(), "jskit-opencode-conversation-"));
|
|
78
|
+
const command = path.join(directory, "opencode.mjs");
|
|
79
|
+
const trace = path.join(directory, "trace.jsonl");
|
|
80
|
+
await writeFile(command, `#!${process.execPath}
|
|
81
|
+
import { createServer } from "node:http";
|
|
82
|
+
import { appendFileSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
83
|
+
import { randomUUID } from "node:crypto";
|
|
84
|
+
const log = value => appendFileSync(${JSON.stringify(trace)}, JSON.stringify(value) + "\\n");
|
|
85
|
+
const configuration = JSON.parse(process.env.OPENCODE_CONFIG_CONTENT);
|
|
86
|
+
const hooks = await (await import(configuration.plugin[0])).default();
|
|
87
|
+
const file = process.env.OPENCODE_DB;
|
|
88
|
+
const sessions = new Map((existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : []).map(session => [session.id, session]));
|
|
89
|
+
const save = () => writeFileSync(file, JSON.stringify([...sessions.values()]));
|
|
90
|
+
const streams = new Set();
|
|
91
|
+
const assistantResponses = ${JSON.stringify(options.assistantResponses || [])};
|
|
92
|
+
const busy = new Set();
|
|
93
|
+
let sequence = [...sessions.values()].reduce((sum, session) => sum + session.messages.length, 0);
|
|
94
|
+
const row = (role, id, parts, extra = {}) => ({ info: { role, id, time: { created: ++sequence }, ...extra }, parts });
|
|
95
|
+
const server = createServer(async (req, res) => {
|
|
96
|
+
let source = "";
|
|
97
|
+
for await (const chunk of req) source += chunk;
|
|
98
|
+
const body = source ? JSON.parse(source) : {};
|
|
99
|
+
const pieces = req.url.split("/");
|
|
100
|
+
const sessionId = pieces[1] === "api" ? pieces[3] : pieces[1] === "session" ? pieces[2] : "";
|
|
101
|
+
let session = sessions.get(sessionId);
|
|
102
|
+
log({ method: req.method, url: req.url, ...(req.url.startsWith("/auth/") ? {} : { body }) });
|
|
103
|
+
if (req.headers.authorization !== "Basic " + Buffer.from("opencode:" + process.env.OPENCODE_SERVER_PASSWORD).toString("base64")) {
|
|
104
|
+
res.writeHead(401); res.end(); return;
|
|
105
|
+
}
|
|
106
|
+
const reply = (value, status = 200) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(value)); };
|
|
107
|
+
if (req.url === "/global/health") {
|
|
108
|
+
if (${Boolean(options.startupWait)}) return;
|
|
109
|
+
return reply({ healthy: true, version: "1.18.31" });
|
|
110
|
+
}
|
|
111
|
+
if (req.url.startsWith("/auth/")) return reply(true);
|
|
112
|
+
if (req.url === "/path") return reply({ directory: req.headers["x-opencode-directory"] });
|
|
113
|
+
if (req.url === "/provider") return reply({ all: Object.entries(configuration.provider).map(([id, provider]) => ({
|
|
114
|
+
id, models: Object.fromEntries(Object.entries(provider.models || { "test-model": {} }).map(([id, model]) => [id, { ...model, id, variants: { low: {}, high: {} } }]))
|
|
115
|
+
})) });
|
|
116
|
+
if (req.url === "/api/session" && req.method === "POST") {
|
|
117
|
+
if (!body.model?.id || body.location?.directory !== process.cwd()) return reply({ message: "Invalid native session parameters" }, 422);
|
|
118
|
+
session = { id: "ses_" + randomUUID().replaceAll("-", ""), messages: [] }; sessions.set(session.id, session); save(); return reply({ data: { id: session.id } });
|
|
119
|
+
}
|
|
120
|
+
if (req.url.startsWith("/api/session/")) return session ? reply({ data: { id: session.id } }) : reply({ message: "Missing session" }, 404);
|
|
121
|
+
if (pieces[1] === "session" && pieces.length === 3 && pieces[2].startsWith("ses_")) {
|
|
122
|
+
if (!session) return reply({ message: "Missing session" }, 404);
|
|
123
|
+
if (req.method === "PATCH") { session.permission = body.permission; save(); }
|
|
124
|
+
return reply({ id: session.id, permission: session.permission || [] });
|
|
125
|
+
}
|
|
126
|
+
if (req.url === "/session/status") return reply(Object.fromEntries([...busy].map(id => [id, { type: "busy" }])));
|
|
127
|
+
if (req.url.endsWith("/abort")) { busy.delete(session.id); return reply(true); }
|
|
128
|
+
if (req.url === "/event") {
|
|
129
|
+
if (${Boolean(options.eventWait)}) return;
|
|
130
|
+
res.writeHead(200, { "content-type": "text/event-stream" }); res.flushHeaders();
|
|
131
|
+
streams.add(res); req.on("close", () => streams.delete(res)); return;
|
|
132
|
+
}
|
|
133
|
+
if (req.url.includes("/message")) {
|
|
134
|
+
if (${Boolean(options.historyUnavailable)}) return reply({ message: "Native history unavailable" }, 503);
|
|
135
|
+
return reply(session.messages);
|
|
136
|
+
}
|
|
137
|
+
if (req.url.endsWith("/prompt_async")) {
|
|
138
|
+
const text = body.parts[0].text;
|
|
139
|
+
if (text === "rejected") return reply({ message: "Native request rejected" }, 422);
|
|
140
|
+
if (!${Boolean(options.unobservedInput)}) session.messages.push(row("user", body.messageID, body.parts));
|
|
141
|
+
save();
|
|
142
|
+
busy.add(session.id);
|
|
143
|
+
if (text === "lost") return;
|
|
144
|
+
reply(null, 204);
|
|
145
|
+
if (text === "wait") return;
|
|
146
|
+
if (text === "steering") {
|
|
147
|
+
session.messages.push(row("assistant", randomUUID(), [{ type: "text", text: "Initial progress" }], { parentID: body.messageID }));
|
|
148
|
+
save(); return;
|
|
149
|
+
}
|
|
150
|
+
if (text === "phases") {
|
|
151
|
+
for (const [type, timeout] of [["retry", 10], ["busy", 25]]) setTimeout(() => {
|
|
152
|
+
for (const stream of streams) stream.write("data: " + JSON.stringify({ type: "session.status",
|
|
153
|
+
properties: { sessionID: session.id, status: { type } } }) + "\\n\\n");
|
|
154
|
+
}, timeout);
|
|
155
|
+
}
|
|
156
|
+
if (text === "disconnect") { for (const stream of streams) stream.end(); return; }
|
|
157
|
+
if (text === "failed") {
|
|
158
|
+
busy.delete(session.id);
|
|
159
|
+
session.messages.push(row("assistant", randomUUID(), [], { error: { data: { message: "Model unavailable" } } })); save(); return;
|
|
160
|
+
}
|
|
161
|
+
const response = assistantResponses.shift();
|
|
162
|
+
if (response) {
|
|
163
|
+
session.messages.push(row("assistant", randomUUID(), response.content || [{ type: "text", text: response.text }], {
|
|
164
|
+
parentID: body.messageID, finish: "stop"
|
|
165
|
+
}));
|
|
166
|
+
busy.delete(session.id); save(); return;
|
|
167
|
+
}
|
|
168
|
+
let answerText = "Answer: " + text;
|
|
169
|
+
if (text === "wrapped-command") {
|
|
170
|
+
const output = { args: { command: "pwd", description: "Current directory" } };
|
|
171
|
+
await hooks["tool.execute.before"]({ tool: "bash", sessionID: session.id }, output);
|
|
172
|
+
try { await hooks["tool.execute.before"]({ tool: "bash", sessionID: "unowned" }, { args: { command: "pwd" } }); }
|
|
173
|
+
catch { output.foreignRejected = true; }
|
|
174
|
+
answerText = JSON.stringify(output);
|
|
175
|
+
}
|
|
176
|
+
if (text === "tools" || text === "foreign-tool") {
|
|
177
|
+
const invoke = async (name, input) => {
|
|
178
|
+
const id = randomUUID();
|
|
179
|
+
const part = { type: "tool", callID: id, tool: name, state: { status: "running", input } };
|
|
180
|
+
const message = row("assistant", randomUUID(), [part], { parentID: body.messageID, finish: "tool-calls" });
|
|
181
|
+
session.messages.push(message); save();
|
|
182
|
+
await hooks["tool.execute.before"]({ tool: name, sessionID: session.id });
|
|
183
|
+
const result = await hooks.tool[name].execute(input, { sessionID: session.id, messageID: message.info.id,
|
|
184
|
+
callID: text === "foreign-tool" ? "foreign-id" : id, abort: new AbortController().signal });
|
|
185
|
+
part.state = { ...part.state, status: "completed", output: result }; save();
|
|
186
|
+
return result;
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
await invoke("assistant_action_search", { query: "numbers" });
|
|
190
|
+
await invoke("assistant_action_contract", { actionId: "numbers.read", version: 1 });
|
|
191
|
+
answerText = await invoke("assistant_action_execute", { actionId: "numbers.read", version: 1, input: {} });
|
|
192
|
+
} catch (error) {
|
|
193
|
+
busy.delete(session.id);
|
|
194
|
+
session.messages.push(row("assistant", randomUUID(), [], { error: { data: { message: error.message } } })); save(); return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (text === "compact") {
|
|
198
|
+
session.messages.push(row("user", randomUUID(), [{ type: "compaction", auto: true }]));
|
|
199
|
+
session.messages.push(row("assistant", randomUUID(), [{ type: "text", text: "Native summary" }], { summary: true, finish: "stop" }));
|
|
200
|
+
session.messages.push(row("user", randomUUID(), [{ type: "text", text: "Continue" }]));
|
|
201
|
+
await hooks.event({ event: { type: "session.compacted", properties: { sessionID: session.id } } });
|
|
202
|
+
}
|
|
203
|
+
const output = { system: ["ambient instructions"] };
|
|
204
|
+
await hooks["experimental.chat.system.transform"]({ sessionID: session.id }, output);
|
|
205
|
+
log({ system: output.system, sessionId: session.id });
|
|
206
|
+
const answer = row("assistant", randomUUID(), [{ type: "reasoning", text: "Reasoning summary" }, { type: "text", text: "Answer: " }]);
|
|
207
|
+
session.messages.push(answer); save();
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
answer.parts[1].text = answerText;
|
|
210
|
+
answer.info.finish = "stop"; answer.info.time.completed = Date.now(); save();
|
|
211
|
+
busy.delete(session.id);
|
|
212
|
+
}, 100);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
reply({ message: "Unexpected route" }, 404);
|
|
216
|
+
});
|
|
217
|
+
log({ configuration, isolated: process.env.OPENCODE_DISABLE_PROJECT_CONFIG,
|
|
218
|
+
inheritedSecret: process.env.UNRELATED_SECRET || null });
|
|
219
|
+
server.listen(Number(process.argv[process.argv.indexOf("--port") + 1]), "127.0.0.1");
|
|
220
|
+
`, { mode: 0o700 });
|
|
221
|
+
const storage = createFileConversationStorage({ directory: path.join(directory, "storage") });
|
|
222
|
+
let key = "test-key";
|
|
223
|
+
const runtimes = [];
|
|
224
|
+
t.after(async () => {
|
|
225
|
+
try { for (const runtime of runtimes) await runtime.close(); }
|
|
226
|
+
finally { await rm(directory, { recursive: true, force: true }); }
|
|
227
|
+
});
|
|
228
|
+
const runtime = () => {
|
|
229
|
+
const value = createConversationRuntime({ engine: "opencode", storage, authorize: options.authorize || (() => true), actions: options.actions, attachments: options.attachments,
|
|
230
|
+
connections: { resolve: options.resolve || (async () => ({ providerId: "test", model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
|
|
231
|
+
modelLimits: { context: 32000, output: 4000 }, apiKey: key })) },
|
|
232
|
+
host: { workdir: directory, env: { ...process.env, UNRELATED_SECRET: "must-not-inherit" },
|
|
233
|
+
commands: { opencode: command }, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
|
|
234
|
+
limits: { admissionTimeoutMs: 250, ...options.limits } });
|
|
235
|
+
runtimes.push(value);
|
|
236
|
+
return value;
|
|
237
|
+
};
|
|
238
|
+
const first = runtime();
|
|
239
|
+
const conversation = await first.open({ id: "conversation", configuration: options.configuration || configuration, context: options.context });
|
|
240
|
+
return { directory, storage, first, runtime, conversation, setKey: value => { key = value; },
|
|
241
|
+
driverOptions: {
|
|
242
|
+
connections: { resolve: options.resolve || (async () => ({ providerId: "test", model: "test-model", sdkPackage: "@ai-sdk/openai-compatible",
|
|
243
|
+
modelLimits: { context: 32000, output: 4000 }, apiKey: key })) },
|
|
244
|
+
host: { workdir: directory, env: { ...process.env, UNRELATED_SECRET: "must-not-inherit" },
|
|
245
|
+
commands: { opencode: command }, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
|
|
246
|
+
limits: { admissionTimeoutMs: 250, ...options.limits }
|
|
247
|
+
},
|
|
248
|
+
binding: () => storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding),
|
|
249
|
+
async trace() { return (await readFile(trace, "utf8")).trim().split("\n").map(line => JSON.parse(line)); } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
test("OpenCode structured output shares the original prompt and completed-result normalization across reopen", async t => {
|
|
253
|
+
const outputSchema = { type: "object", additionalProperties: false,
|
|
254
|
+
properties: { answer: { type: "string", maxLength: 32 } }, required: ["answer"] };
|
|
255
|
+
const expected = '{"answer":"Done"}';
|
|
256
|
+
const f = await fixture(t, { configuration: { ...configuration, outputSchema },
|
|
257
|
+
assistantResponses: [{ text: `\x60\x60\x60json\n${expected}\n\x60\x60\x60` }] });
|
|
258
|
+
const events = [];
|
|
259
|
+
await f.conversation.subscribe(event => events.push(event));
|
|
260
|
+
await f.conversation.send(input);
|
|
261
|
+
const result = await f.conversation.wait();
|
|
262
|
+
assert.equal(result.capabilities.structuredOutput, true);
|
|
263
|
+
assert.equal(result.conversationLog[0].user.text, input.text, "Schema framing is native context, never authored history");
|
|
264
|
+
assert.equal(result.conversationLog[0].assistant.text, expected);
|
|
265
|
+
const prompt = (await f.trace()).find(row => row.url?.endsWith("/prompt_async")).body.parts[0].text;
|
|
266
|
+
assert.equal(prompt, [input.text, "", "Return only one JSON value matching this JSON Schema. Do not wrap it in Markdown code fences:",
|
|
267
|
+
JSON.stringify(outputSchema)].join("\n"));
|
|
268
|
+
assert.deepEqual(events.filter(event => event.type === "message" && event.role === "assistant" && event.status === "complete").map(event => event.text), [expected]);
|
|
269
|
+
await f.first.close();
|
|
270
|
+
const reopened = await f.runtime().open({ id: "conversation" });
|
|
271
|
+
const saved = await reopened.read();
|
|
272
|
+
assert.deepEqual(saved.configuration.outputSchema, outputSchema);
|
|
273
|
+
assert.equal(saved.conversationLog[0].assistant.text, expected);
|
|
274
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("OpenCode structured output enforces the raw output bound before removing JSON fences", async t => {
|
|
278
|
+
const f = await fixture(t, { configuration: { ...configuration, outputSchema: { type: "string", maxLength: 1 } },
|
|
279
|
+
limits: { maxOutputCharacters: 8 }, assistantResponses: [{ text: '\x60\x60\x60json\n"x"\n\x60\x60\x60' }] });
|
|
280
|
+
await f.conversation.send(input);
|
|
281
|
+
const result = await f.conversation.wait();
|
|
282
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
|
|
283
|
+
assert.match(result.error, /output exceeded the configured limit/);
|
|
284
|
+
assert.equal(result.conversationLog[0].assistant, null);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("OpenCode structured output counts its schema framing before native dispatch", async t => {
|
|
288
|
+
const f = await fixture(t, { configuration: { ...configuration, outputSchema: { type: "string", maxLength: 1 } },
|
|
289
|
+
limits: { maxInputCharacters: 20 } });
|
|
290
|
+
await assert.rejects(f.conversation.send(input), /structured input exceeded the configured limit/);
|
|
291
|
+
assert.equal((await f.trace()).some(row => row.url?.endsWith("/prompt_async")), false);
|
|
292
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("OpenCode admits once, streams and saves native reasoning through the common runtime", async t => {
|
|
296
|
+
const f = await fixture(t);
|
|
297
|
+
const events = [];
|
|
298
|
+
await f.conversation.subscribe(event => events.push(event));
|
|
299
|
+
assert.equal((await f.conversation.send(input)).status, "accepted");
|
|
300
|
+
const state = await f.conversation.wait();
|
|
301
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "complete");
|
|
302
|
+
assert.equal(state.conversationLog[0].assistant.text, "Answer: Hello");
|
|
303
|
+
assert.equal(state.conversationLog[0].thinking[0].text, "Reasoning summary");
|
|
304
|
+
assert.ok(events.some(event => event.type === "message" && event.text === "Answer: "));
|
|
305
|
+
const replies = events.filter(event => event.type === "message" && event.role === "assistant");
|
|
306
|
+
assert.equal(new Set(replies.map(event => event.messageId)).size, 1);
|
|
307
|
+
assert.equal(state.conversationLog[0].assistant.messageId, replies[0].messageId);
|
|
308
|
+
assert.equal((await f.conversation.send(input)).duplicate, true);
|
|
309
|
+
const trace = await f.trace();
|
|
310
|
+
assert.equal(trace.filter(item => item.url?.endsWith("/prompt_async")).length, 1);
|
|
311
|
+
assert.equal(trace.find(item => item.url === "/api/session").body.model.id, "test-model");
|
|
312
|
+
assert.equal(trace.find(item => item.url?.endsWith("/prompt_async")).body.model.modelID, "test-model");
|
|
313
|
+
assert.equal(trace[0].inheritedSecret, null);
|
|
314
|
+
assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask" });
|
|
315
|
+
assert.match(trace[0].configuration.plugin[0], /openCodePlugin\.js$/u);
|
|
316
|
+
assert.deepEqual(trace[0].configuration.provider.test.models["test-model"].limit, { context: 32000, output: 4000 });
|
|
317
|
+
assert.equal(trace[0].configuration.provider.test.models["test-model"].id, "test-model");
|
|
318
|
+
assert.equal(trace[0].isolated, "1");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("independent common conversations share one OpenCode server and keep separate native histories", async t => {
|
|
322
|
+
const f = await fixture(t);
|
|
323
|
+
const second = await f.first.open({ id: "second-conversation", configuration: { ...configuration, systemPrompt: "Second instructions" } });
|
|
324
|
+
await f.conversation.send({ messageId: "first-work", text: "wait" });
|
|
325
|
+
await second.send({ messageId: "second-work", text: "Hello second" });
|
|
326
|
+
const result = await second.wait();
|
|
327
|
+
assert.equal(result.conversationLog[0].assistant.text, "Answer: Hello second");
|
|
328
|
+
let trace = await f.trace();
|
|
329
|
+
assert.equal(trace.filter(item => item.configuration).length, 1);
|
|
330
|
+
const secondBinding = await f.storage.read("second-conversation", async tx => (await tx.readMetadata()).runtime.binding);
|
|
331
|
+
assert.notEqual((await f.binding()).sessionId, secondBinding.sessionId);
|
|
332
|
+
assert.equal((await f.binding()).databasePath, secondBinding.databasePath);
|
|
333
|
+
await f.conversation.cancel();
|
|
334
|
+
assert.equal((await f.conversation.wait()).conversationLog[0].metadata.runtime.status, "cancelled");
|
|
335
|
+
await f.conversation.dispose();
|
|
336
|
+
await second.send({ messageId: "second-more", text: "Continue second" });
|
|
337
|
+
assert.equal((await second.wait()).conversationLog.length, 2);
|
|
338
|
+
trace = await f.trace();
|
|
339
|
+
assert.equal(trace.filter(item => item.configuration).length, 1);
|
|
340
|
+
assert.deepEqual(trace.filter(item => item.system).map(item => item.system), [["Second instructions"], ["Second instructions"]]);
|
|
341
|
+
await f.first.close();
|
|
342
|
+
const resumed = await f.runtime().open({ id: "second-conversation" });
|
|
343
|
+
await resumed.send({ messageId: "after-restart", text: "Resume second" });
|
|
344
|
+
assert.equal((await resumed.wait()).conversationLog.length, 3);
|
|
345
|
+
assert.equal((await f.storage.read("second-conversation", async tx => (await tx.readMetadata()).runtime.binding)).sessionId, secondBinding.sessionId);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("OpenCode reports the same work and retry phases as the other drivers", async t => {
|
|
349
|
+
const f = await fixture(t);
|
|
350
|
+
const phases = [];
|
|
351
|
+
await f.conversation.subscribe(event => { if (event.type === "phase") phases.push(event.phase); });
|
|
352
|
+
await f.conversation.send({ messageId: "phases", text: "phases" });
|
|
353
|
+
assert.equal((await f.conversation.wait()).phase, "");
|
|
354
|
+
assert.deepEqual(phases, ["preparing", "working", "retrying", "working", ""]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("disposing an unopened restored binding does not stop its recorded shared server", async t => {
|
|
358
|
+
const f = await fixture(t);
|
|
359
|
+
await f.conversation.send(input);
|
|
360
|
+
await f.conversation.wait();
|
|
361
|
+
const saved = await f.binding();
|
|
362
|
+
const peer = await f.first.open({ id: "peer", configuration });
|
|
363
|
+
await peer.send({ messageId: "peer-work", text: "wait" });
|
|
364
|
+
await f.conversation.dispose();
|
|
365
|
+
// A restart can leave the old server reference in the durable binding.
|
|
366
|
+
// Restoring it grants neither a new acquisition nor ownership of peers.
|
|
367
|
+
await f.storage.write("conversation", async tx => {
|
|
368
|
+
const metadata = await tx.readMetadata();
|
|
369
|
+
metadata.runtime.binding = saved;
|
|
370
|
+
await tx.writeMetadata(metadata);
|
|
371
|
+
});
|
|
372
|
+
const restored = await f.first.open({ id: "conversation" });
|
|
373
|
+
await restored.dispose();
|
|
374
|
+
assert.equal((await peer.read()).status, "working");
|
|
375
|
+
assert.equal((await f.trace()).filter(row => row.configuration).length, 1);
|
|
376
|
+
await peer.cancel();
|
|
377
|
+
assert.equal((await peer.wait()).conversationLog[0].metadata.runtime.status, "cancelled");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("the production reasoning-only recovery keeps one common turn and preserves its reasoning", async t => {
|
|
381
|
+
const f = await fixture(t, { assistantResponses: [{
|
|
382
|
+
content: [{ id: "reasoning-only-part", text: "The command completed and the result is 42.", type: "reasoning" }],
|
|
383
|
+
text: ""
|
|
384
|
+
}, { text: "The result is 42." }] });
|
|
385
|
+
await f.conversation.send({ messageId: "client-message-reasoning-only", text: "Run the command and tell me its result." });
|
|
386
|
+
const state = await f.conversation.wait();
|
|
387
|
+
assert.equal(state.conversationLog.length, 1);
|
|
388
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "complete", state.error);
|
|
389
|
+
assert.equal(state.conversationLog[0].thinking[0].text, "The command completed and the result is 42.");
|
|
390
|
+
assert.equal(state.conversationLog[0].assistant.text, "The result is 42.");
|
|
391
|
+
const prompts = (await f.trace()).filter(row => row.url?.endsWith("/prompt_async"));
|
|
392
|
+
assert.equal(prompts.length, 2);
|
|
393
|
+
assert.match(prompts[1].body.parts[0].text, /previous response ended without a user-facing final answer/u);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("the production recovery stops after two reasoning-only completions through the common runtime", async t => {
|
|
397
|
+
const reasoningOnly = id => ({
|
|
398
|
+
content: [{ id, text: "I have the result but did not emit a final answer.", type: "reasoning" }], text: ""
|
|
399
|
+
});
|
|
400
|
+
const f = await fixture(t, { assistantResponses: [reasoningOnly("reasoning-only-first"), reasoningOnly("reasoning-only-second")] });
|
|
401
|
+
await f.conversation.send({ messageId: "client-message-reasoning-only-twice", text: "Run the command and tell me its result." });
|
|
402
|
+
const state = await f.conversation.wait();
|
|
403
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 2);
|
|
404
|
+
assert.equal(state.conversationLog.length, 1);
|
|
405
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "failed");
|
|
406
|
+
assert.equal(state.error, "OpenCode finished without a user-facing final response. Please send your message again.");
|
|
407
|
+
assert.equal(state.conversationLog[0].assistant, null);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("OpenCode steering follows the newly admitted input in the same native conversation", async t => {
|
|
411
|
+
const f = await fixture(t);
|
|
412
|
+
const started = Promise.withResolvers();
|
|
413
|
+
const events = [];
|
|
414
|
+
await f.conversation.subscribe(event => {
|
|
415
|
+
events.push(event);
|
|
416
|
+
if (event.type === "message" && event.text === "Initial progress") started.resolve();
|
|
417
|
+
});
|
|
418
|
+
await f.conversation.send({ messageId: "before", text: "steering" });
|
|
419
|
+
await started.promise;
|
|
420
|
+
const steering = { messageId: "after", text: "Continue differently", steer: true };
|
|
421
|
+
const [one, two] = await Promise.all([f.conversation.send(steering), f.conversation.send(steering)]);
|
|
422
|
+
assert.equal(one.turnId, two.turnId);
|
|
423
|
+
const state = await f.conversation.wait();
|
|
424
|
+
assert.equal(state.error, "");
|
|
425
|
+
assert.equal(state.conversationLog.length, 2);
|
|
426
|
+
assert.equal(state.conversationLog[0].assistant.text, "Initial progress");
|
|
427
|
+
assert.equal(state.conversationLog[1].assistant.text, "Answer: Continue differently");
|
|
428
|
+
for (const turn of state.conversationLog) assert.equal(turn.assistant.messageId,
|
|
429
|
+
events.find(event => event.role === "assistant" && event.turnId === turn.turnId).messageId);
|
|
430
|
+
const trace = await f.trace();
|
|
431
|
+
assert.equal(trace.filter(row => row.configuration).length, 1);
|
|
432
|
+
assert.equal(trace.filter(row => row.url === "/api/session").length, 1);
|
|
433
|
+
assert.equal(trace.filter(row => row.url?.endsWith("/prompt_async")).length, 2);
|
|
434
|
+
assert.equal((await f.conversation.send(steering)).duplicate, true);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("OpenCode steering rejection does not abandon the existing turn", async t => {
|
|
438
|
+
const f = await fixture(t);
|
|
439
|
+
await f.conversation.send({ messageId: "before", text: "wait" });
|
|
440
|
+
await assert.rejects(f.conversation.send({ messageId: "rejected", text: "rejected", steer: true }), /Native request rejected/);
|
|
441
|
+
assert.equal((await f.conversation.read()).pendingRequest, null);
|
|
442
|
+
await f.conversation.send({ messageId: "after", text: "Continue", steer: true });
|
|
443
|
+
assert.equal((await f.conversation.wait()).conversationLog.length, 2);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("lost OpenCode steering acknowledgement retains a recoverable receipt without resending", async t => {
|
|
447
|
+
const f = await fixture(t);
|
|
448
|
+
await f.conversation.send({ messageId: "before", text: "wait" });
|
|
449
|
+
const steering = { messageId: "lost-steering", text: "lost", steer: true };
|
|
450
|
+
await assert.rejects(f.conversation.send(steering));
|
|
451
|
+
assert.equal((await f.conversation.wait()).status, "unconfirmed");
|
|
452
|
+
assert.equal((await f.conversation.inspectDelivery({ messageId: steering.messageId })).status, "accepted");
|
|
453
|
+
assert.equal((await f.conversation.send(steering)).duplicate, true);
|
|
454
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 2);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("OpenCode validates common model and effort fields against its exact native catalogue", async t => {
|
|
458
|
+
const f = await fixture(t);
|
|
459
|
+
await f.conversation.configure({ model: "test-model", effort: "high" });
|
|
460
|
+
await f.conversation.send(input);
|
|
461
|
+
await f.conversation.wait();
|
|
462
|
+
assert.equal((await f.trace()).find(row => row.url?.endsWith("/prompt_async")).body.variant, "high");
|
|
463
|
+
await f.conversation.configure({ effort: "medium" });
|
|
464
|
+
await assert.rejects(f.conversation.send({ messageId: "unsupported", text: "No" }), /does not support/);
|
|
465
|
+
await f.conversation.configure({ model: "different-model" });
|
|
466
|
+
await assert.rejects(f.conversation.send({ messageId: "mismatch", text: "No" }), /differs from/);
|
|
467
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
|
|
468
|
+
const retained = await f.binding();
|
|
469
|
+
await f.conversation.configure({ model: "test-model", effort: "low" });
|
|
470
|
+
await f.conversation.send({ messageId: "changed-effort", text: "Continue with less reasoning" });
|
|
471
|
+
await f.conversation.wait();
|
|
472
|
+
const trace = await f.trace();
|
|
473
|
+
const sessionPath = `/api/session/${retained.sessionId}`;
|
|
474
|
+
assert.deepEqual(trace.filter(row => row.url?.startsWith(sessionPath)).map(row => [row.method, row.url, row.body]), [
|
|
475
|
+
["GET", sessionPath, {}],
|
|
476
|
+
["POST", `${sessionPath}/model`, { model: { id: "test-model", providerID: "test", variant: "low" } }],
|
|
477
|
+
["POST", `${sessionPath}/agent`, { agent: trace.find(row => row.url === "/api/session").body.agent }]
|
|
478
|
+
]);
|
|
479
|
+
assert.equal((await f.binding()).sessionId, retained.sessionId);
|
|
480
|
+
assert.equal((await f.binding()).databasePath, retained.databasePath);
|
|
481
|
+
assert.equal(trace.filter(row => row.url === "/api/session").length, 1);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
function applicationActions(execute) {
|
|
485
|
+
const actions = createActionCatalogue();
|
|
486
|
+
actions.register({ contributorId: "test.tools", domain: "numbers", actions: [{
|
|
487
|
+
id: "numbers.read", version: 1, kind: "query", channels: ["automation"], surfaces: ["app"],
|
|
488
|
+
permission: { require: "all", permissions: ["numbers.read"] }, idempotency: "none",
|
|
489
|
+
input: { schema: createSchema({}), mode: "replace" },
|
|
490
|
+
output: { schema: createSchema({ value: { type: "number", required: true } }), mode: "replace" }, execute
|
|
491
|
+
}] });
|
|
492
|
+
return actions;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
test("OpenCode shares discovery, saved results and current permissions across native resume", async t => {
|
|
496
|
+
let executions = 0;
|
|
497
|
+
const context = { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] };
|
|
498
|
+
const f = await fixture(t, { context, actions: applicationActions(async () => { executions++; return { value: 42 }; }) });
|
|
499
|
+
await f.conversation.send({ messageId: "tool-turn", text: "tools" });
|
|
500
|
+
const first = await f.conversation.wait();
|
|
501
|
+
assert.equal(first.conversationLog[0].metadata.runtime.status, "complete", first.error);
|
|
502
|
+
assert.equal(first.conversationLog[0].metadata.applicationTools.length, 3);
|
|
503
|
+
assert.equal(JSON.parse(first.conversationLog[0].assistant.text).result.result.value, 42);
|
|
504
|
+
assert.equal(executions, 1);
|
|
505
|
+
const trace = await f.trace();
|
|
506
|
+
assert.deepEqual(trace[0].configuration.agent[trace.find(item => item.url?.endsWith("/prompt_async")).body.agent].permission, { "*": "ask", assistant_action_search: "allow",
|
|
507
|
+
assistant_action_contract: "allow", assistant_action_execute: "allow" });
|
|
508
|
+
const original = await f.binding();
|
|
509
|
+
await f.first.close();
|
|
510
|
+
const resumed = await f.runtime().open({ id: "conversation", context });
|
|
511
|
+
await resumed.send({ messageId: "resumed-tools", text: "tools" });
|
|
512
|
+
assert.equal((await resumed.wait()).conversationLog[1].metadata.runtime.status, "complete");
|
|
513
|
+
assert.equal(executions, 2);
|
|
514
|
+
context.permissions = [];
|
|
515
|
+
await resumed.send({ messageId: "revoked-tools", text: "tools" });
|
|
516
|
+
const revoked = await resumed.wait();
|
|
517
|
+
assert.equal(revoked.conversationLog[2].metadata.applicationTools.at(-1).result.ok, false);
|
|
518
|
+
assert.equal(executions, 2);
|
|
519
|
+
assert.equal((await f.binding()).sessionId, original.sessionId);
|
|
520
|
+
assert.equal((await f.trace()).filter(item => item.configuration).length, 2);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
test("OpenCode rejects calls that do not match the admitted native tool identity", async t => {
|
|
524
|
+
let executions = 0;
|
|
525
|
+
const f = await fixture(t, {
|
|
526
|
+
actions: applicationActions(async () => { executions++; return { value: 42 }; }),
|
|
527
|
+
context: { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] }
|
|
528
|
+
});
|
|
529
|
+
await f.conversation.send({ messageId: "foreign", text: "foreign-tool" });
|
|
530
|
+
const result = await f.conversation.wait();
|
|
531
|
+
assert.equal(executions, 0);
|
|
532
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
|
|
533
|
+
assert.match(result.error, /does not match an admitted native tool use/);
|
|
534
|
+
assert.deepEqual(result.conversationLog[0].metadata.applicationTools, []);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("OpenCode stops after an uncertain application effect and retains its receipt", async t => {
|
|
538
|
+
let executions = 0;
|
|
539
|
+
const f = await fixture(t, {
|
|
540
|
+
actions: applicationActions(async () => {
|
|
541
|
+
executions++;
|
|
542
|
+
throw Object.assign(new Error("Acknowledgement lost"), { statusCode: 503 });
|
|
543
|
+
}),
|
|
544
|
+
context: { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] }
|
|
545
|
+
});
|
|
546
|
+
await f.conversation.send({ messageId: "uncertain", text: "tools" });
|
|
547
|
+
const result = await f.conversation.wait();
|
|
548
|
+
assert.equal(executions, 1);
|
|
549
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "failed");
|
|
550
|
+
assert.equal(result.conversationLog[0].metadata.applicationTools.at(-1).status, "unknown");
|
|
551
|
+
assert.match(result.error, /Inspect its target/);
|
|
552
|
+
assert.equal((await f.trace()).filter(item => item.url?.endsWith("/prompt_async")).length, 1);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("intermediate tool messages cannot finish a turn and cancellation drains an invoked action", async t => {
|
|
556
|
+
const entered = Promise.withResolvers(), complete = Promise.withResolvers();
|
|
557
|
+
const f = await fixture(t, {
|
|
558
|
+
actions: applicationActions(async () => { entered.resolve(); return complete.promise; }),
|
|
559
|
+
context: { actor: { id: "owner" }, surface: "app", permissions: ["numbers.read"] }
|
|
560
|
+
});
|
|
561
|
+
t.after(() => complete.resolve({ value: 42 }));
|
|
562
|
+
await f.conversation.send({ messageId: "cancel-tools", text: "tools" });
|
|
563
|
+
await entered.promise;
|
|
564
|
+
// Allow several native completion polls to see the finished tool-call message.
|
|
565
|
+
await delay(650);
|
|
566
|
+
assert.equal((await f.conversation.read()).status, "working");
|
|
567
|
+
let finished = false;
|
|
568
|
+
const stopping = f.conversation.cancel().then(() => { finished = true; });
|
|
569
|
+
await delay(50);
|
|
570
|
+
assert.equal(finished, false);
|
|
571
|
+
complete.resolve({ value: 42 });
|
|
572
|
+
await stopping;
|
|
573
|
+
const result = await f.conversation.read();
|
|
574
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "cancelled");
|
|
575
|
+
assert.equal(result.conversationLog[0].metadata.applicationTools.at(-1).result.result.result.value, 42);
|
|
576
|
+
assert.equal((await f.trace()).filter(item => item.url?.endsWith("/prompt_async")).length, 1);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("updated instructions replace the native system lane and survive compaction and process restart", async t => {
|
|
580
|
+
const f = await fixture(t);
|
|
581
|
+
await f.conversation.send(input);
|
|
582
|
+
await f.conversation.wait();
|
|
583
|
+
const session = (await f.binding()).sessionId;
|
|
584
|
+
await f.conversation.configure({ systemPrompt: "Changed instructions" });
|
|
585
|
+
await f.conversation.send({ messageId: "second", text: "compact" });
|
|
586
|
+
assert.equal((await f.conversation.wait()).conversationLog[1].assistant.text, "Answer: compact");
|
|
587
|
+
assert.equal((await f.trace()).filter(item => item.configuration).length, 1, "prompt updates do not replace this native process");
|
|
588
|
+
assert.equal((await f.trace()).filter(item => item.url?.startsWith(`/api/session/${session}`)).length, 0,
|
|
589
|
+
"the established native selection does not repeat read/model/agent controls");
|
|
590
|
+
await f.first.close();
|
|
591
|
+
const resumed = await f.runtime().open({ id: "conversation" });
|
|
592
|
+
await resumed.send({ messageId: "third", text: "Again" });
|
|
593
|
+
assert.equal((await resumed.wait()).conversationLog.length, 3);
|
|
594
|
+
assert.equal((await f.binding()).sessionId, session);
|
|
595
|
+
assert.deepEqual((await f.trace()).filter(item => item.system).map(item => item.system), [
|
|
596
|
+
["Fresh instructions"], ["Changed instructions"], ["Changed instructions"]
|
|
597
|
+
]);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("cancel drains the owned OpenCode process before another native turn", async t => {
|
|
601
|
+
const f = await fixture(t);
|
|
602
|
+
await f.conversation.send({ messageId: "waiting", text: "wait" });
|
|
603
|
+
await f.conversation.cancel();
|
|
604
|
+
assert.equal((await f.conversation.wait()).conversationLog[0].metadata.runtime.status, "cancelled");
|
|
605
|
+
assert.equal((await f.binding()).executionId, "");
|
|
606
|
+
await f.conversation.send(input);
|
|
607
|
+
assert.equal((await f.conversation.wait()).conversationLog[1].assistant.text, "Answer: Hello");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("a lost native acknowledgement is inspected by exact message identity without resending", async t => {
|
|
611
|
+
const f = await fixture(t);
|
|
612
|
+
await assert.rejects(f.conversation.send({ messageId: "lost-id", text: "lost" }));
|
|
613
|
+
assert.equal((await f.conversation.read()).status, "unconfirmed");
|
|
614
|
+
const receipt = await f.conversation.inspectDelivery({ messageId: "lost-id" });
|
|
615
|
+
assert.equal(receipt.recovered, true);
|
|
616
|
+
assert.equal((await f.conversation.read()).conversationLog[0].metadata.runtime.status, "interrupted");
|
|
617
|
+
assert.equal((await f.trace()).filter(item => item.url?.endsWith("/prompt_async")).length, 1);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("unavailable or absent native admission remains unknown without replaying the request", async t => {
|
|
621
|
+
for (const options of [{ historyUnavailable: true }, { unobservedInput: true }]) {
|
|
622
|
+
const f = await fixture(t, options);
|
|
623
|
+
await assert.rejects(f.conversation.send({ messageId: "lost-id", text: "lost" }));
|
|
624
|
+
assert.deepEqual(await f.conversation.inspectDelivery({ messageId: "lost-id" }), {
|
|
625
|
+
status: "unknown", messageId: "lost-id"
|
|
626
|
+
});
|
|
627
|
+
assert.equal((await f.conversation.read()).status, "unconfirmed");
|
|
628
|
+
await assert.rejects(f.conversation.send({ messageId: "retry", text: "Hello" }), /uncertain delivery/);
|
|
629
|
+
assert.equal((await f.trace()).filter(item => item.url?.endsWith("/prompt_async")).length, 1);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("accepted OpenCode output can be recovered after restart without repeating inference", async t => {
|
|
634
|
+
const f = await fixture(t);
|
|
635
|
+
await f.conversation.send(input);
|
|
636
|
+
const finished = await f.conversation.wait();
|
|
637
|
+
await f.first.close();
|
|
638
|
+
await f.storage.write("conversation", async transaction => {
|
|
639
|
+
const turn = await transaction.readTurn("000001");
|
|
640
|
+
await transaction.updateTurnMetadata(turn.turnId, { runtime: { ...turn.metadata.runtime, status: "running" } });
|
|
641
|
+
await transaction.replaceAssistant(turn.turnId, { ...turn.assistant, text: "Partial reply" });
|
|
642
|
+
});
|
|
643
|
+
const reopened = await f.runtime().open({ id: "conversation" });
|
|
644
|
+
assert.equal((await reopened.inspectDelivery({ messageId: input.messageId })).recovered, true);
|
|
645
|
+
const state = await reopened.read();
|
|
646
|
+
assert.equal(state.conversationLog[0].assistant.text, finished.conversationLog[0].assistant.text);
|
|
647
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "interrupted");
|
|
648
|
+
await reopened.inspectDelivery({ messageId: input.messageId });
|
|
649
|
+
assert.deepEqual((await reopened.read()).conversationLog, state.conversationLog);
|
|
650
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("native rejection and an admitted model failure retain different admission outcomes", async t => {
|
|
654
|
+
const f = await fixture(t);
|
|
655
|
+
await assert.rejects(f.conversation.send({ messageId: "bad", text: "rejected" }), /Native request rejected/);
|
|
656
|
+
assert.equal((await f.conversation.read()).status, "ready");
|
|
657
|
+
await f.conversation.send({ messageId: "failed", text: "failed" });
|
|
658
|
+
const state = await f.conversation.wait();
|
|
659
|
+
assert.equal(state.conversationLog.length, 1);
|
|
660
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "failed");
|
|
661
|
+
assert.match(state.error, /Model unavailable/);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("OpenCode refuses a different resolved account before dispatch", async t => {
|
|
665
|
+
const f = await fixture(t);
|
|
666
|
+
await f.conversation.send(input);
|
|
667
|
+
await f.conversation.wait();
|
|
668
|
+
f.setKey("another-account");
|
|
669
|
+
await assert.rejects(f.conversation.send({ messageId: "second", text: "Again" }), /another OpenCode account/);
|
|
670
|
+
assert.equal((await f.trace()).filter(item => item.url?.endsWith("/prompt_async")).length, 1);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("loss of the event connection fails accepted work and proves process cleanup", async t => {
|
|
674
|
+
const f = await fixture(t);
|
|
675
|
+
await f.conversation.send({ messageId: "disconnect", text: "disconnect" });
|
|
676
|
+
assert.equal((await f.conversation.wait()).conversationLog[0].metadata.runtime.status, "failed");
|
|
677
|
+
assert.equal((await f.binding()).executionId, "");
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("event readiness failure does not submit a native message", async t => {
|
|
681
|
+
const f = await fixture(t, { eventWait: true });
|
|
682
|
+
await assert.rejects(f.conversation.send(input), /admission deadline/);
|
|
683
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 0);
|
|
684
|
+
assert.equal((await f.trace()).filter(item => item.url?.endsWith("/prompt_async")).length, 0);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("cancel interrupts OpenCode startup instead of waiting for the native health deadline", async t => {
|
|
688
|
+
const f = await fixture(t, { startupWait: true });
|
|
689
|
+
const sending = f.conversation.send(input);
|
|
690
|
+
sending.catch(() => {});
|
|
691
|
+
const deadline = Date.now() + 3000;
|
|
692
|
+
while (!(await f.trace().catch(() => [])).some(item => item.url === "/global/health")) {
|
|
693
|
+
if (Date.now() > deadline) throw new Error("OpenCode did not begin startup.");
|
|
694
|
+
await delay(20);
|
|
695
|
+
}
|
|
696
|
+
const started = Date.now();
|
|
697
|
+
await f.conversation.cancel();
|
|
698
|
+
await assert.rejects(sending);
|
|
699
|
+
assert.ok(Date.now() - started < 1500);
|
|
700
|
+
assert.equal((await f.binding()).executionId, "");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("unconfirmed cleanup blocks replacement until an explicit cleanup retry succeeds", async t => {
|
|
704
|
+
const execution = createLocalConversationExecution();
|
|
705
|
+
let allowStop = false;
|
|
706
|
+
t.after(() => { allowStop = true; });
|
|
707
|
+
const f = await fixture(t, { execution: { start: execution.start, stop: id => allowStop ? execution.stop(id) : { scopeEmpty: false } } });
|
|
708
|
+
t.after(() => execution.close());
|
|
709
|
+
await f.conversation.send({ messageId: "waiting", text: "wait" });
|
|
710
|
+
await assert.rejects(f.conversation.cancel(), /cleanup could not be confirmed/);
|
|
711
|
+
await assert.rejects(f.conversation.send(input), /cleanup could not be confirmed/);
|
|
712
|
+
allowStop = true;
|
|
713
|
+
await f.conversation.cancel();
|
|
714
|
+
await f.conversation.send(input);
|
|
715
|
+
assert.equal((await f.conversation.wait()).conversationLog.at(-1).assistant.text, "Answer: Hello");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("unconfirmed OpenCode startup cleanup stays owned and Stop retries that exact execution", async t => {
|
|
719
|
+
const execution = createLocalConversationExecution();
|
|
720
|
+
let allowStop = false;
|
|
721
|
+
t.after(() => { allowStop = true; });
|
|
722
|
+
const stoppedIds = [];
|
|
723
|
+
const f = await fixture(t, { startupWait: true, execution: { start: execution.start, stop(id, options) {
|
|
724
|
+
stoppedIds.push(id);
|
|
725
|
+
return allowStop ? execution.stop(id, options) : { scopeEmpty: false };
|
|
726
|
+
} } });
|
|
727
|
+
t.after(() => execution.close());
|
|
728
|
+
const sending = f.conversation.send(input);
|
|
729
|
+
sending.catch(() => {});
|
|
730
|
+
const deadline = Date.now() + 3000;
|
|
731
|
+
while (!(await f.trace().catch(() => [])).some(item => item.url === "/global/health")) {
|
|
732
|
+
if (Date.now() > deadline) throw new Error("OpenCode did not begin startup.");
|
|
733
|
+
await delay(20);
|
|
734
|
+
}
|
|
735
|
+
await assert.rejects(f.conversation.cancel(), /cleanup could not be confirmed/);
|
|
736
|
+
await assert.rejects(sending);
|
|
737
|
+
const saved = await f.binding();
|
|
738
|
+
assert.ok(saved.executionId);
|
|
739
|
+
await assert.rejects(f.conversation.cancel(), /cleanup could not be confirmed/);
|
|
740
|
+
await assert.rejects(f.conversation.send(input), /cleanup could not be confirmed/);
|
|
741
|
+
assert.equal((await f.trace()).filter(row => row.configuration).length, 1);
|
|
742
|
+
allowStop = true;
|
|
743
|
+
await f.conversation.cancel();
|
|
744
|
+
assert.equal((await f.binding()).executionId, "");
|
|
745
|
+
assert.equal((await f.conversation.read()).status, "ready");
|
|
746
|
+
assert.ok(stoppedIds.length >= 3);
|
|
747
|
+
assert.deepEqual([...new Set(stoppedIds)], [saved.executionId]);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test("access revoked during connection preparation prevents native dispatch", async t => {
|
|
751
|
+
let allowed = true;
|
|
752
|
+
const f = await fixture(t, { authorize: () => allowed, resolve: async () => {
|
|
753
|
+
allowed = false;
|
|
754
|
+
return { providerId: "test", model: "test-model", apiKey: "test", sdkPackage: "@ai-sdk/openai-compatible" };
|
|
755
|
+
} });
|
|
756
|
+
await assert.rejects(f.conversation.send(input), /not available/);
|
|
757
|
+
assert.equal((await f.trace()).filter(item => item.url?.endsWith("/prompt_async")).length, 0);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("OpenCode common dispatch preserves original local image input and exact conversation directory permission", async t => {
|
|
761
|
+
const localFiles = [
|
|
762
|
+
{ attachmentId: "image", fileName: "a b.png", path: "/session/artifacts/a b.png", contentType: "image/png", size: 16, reference: "[Image #1]" },
|
|
763
|
+
{ attachmentId: "file", fileName: "data.csv", path: "/session/artifacts/data.csv", contentType: "application/octet-stream", size: 24, reference: "[File #1]" }
|
|
764
|
+
];
|
|
765
|
+
const attachments = localFiles.map(({ attachmentId, fileName, size, reference }) => ({ attachmentId, fileName, size, reference }));
|
|
766
|
+
const f = await fixture(t, { attachments: { resolve: async () => ({ attachments, localFiles }) } });
|
|
767
|
+
const request = { messageId: "local-files", text: "Inspect [Image #1] and [File #1]", attachmentIds: ["image", "file"] };
|
|
768
|
+
await f.conversation.send({ ...request, localFiles: [{ path: "/etc/passwd" }], attachmentManifest: "forged" });
|
|
769
|
+
const state = await f.conversation.wait();
|
|
770
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, "complete", state.error);
|
|
771
|
+
assert.equal(state.conversationLog[0].user.text, request.text);
|
|
772
|
+
assert.deepEqual(state.conversationLog[0].user.attachments, attachments);
|
|
773
|
+
assert.doesNotMatch(JSON.stringify(state.conversationLog[0].user), /session\/artifacts|contentType|forged|etc\/passwd/);
|
|
774
|
+
const trace = await f.trace();
|
|
775
|
+
const promptIndex = trace.findIndex(row => row.url?.endsWith("/prompt_async"));
|
|
776
|
+
assert.deepEqual(trace[promptIndex].body.parts, [
|
|
777
|
+
{ type: "text", text: '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"' },
|
|
778
|
+
{ type: "file", mime: "image/png", filename: "a b.png", url: "file:///session/artifacts/a%20b.png" }
|
|
779
|
+
]);
|
|
780
|
+
const sessionId = (await f.binding()).sessionId;
|
|
781
|
+
const permissionIndex = trace.findIndex(row => row.method === "PATCH" && row.url === `/session/${sessionId}`);
|
|
782
|
+
assert.ok(permissionIndex >= 0 && permissionIndex < promptIndex);
|
|
783
|
+
assert.deepEqual(trace[permissionIndex].body.permission, [{ permission: "external_directory", pattern: "/session/artifacts/*", action: "allow" }]);
|
|
784
|
+
assert.equal((await f.conversation.send(request)).duplicate, true);
|
|
785
|
+
assert.equal((await f.trace()).filter(row => row.url?.endsWith("/prompt_async")).length, 1);
|
|
786
|
+
await f.conversation.send({ ...request, messageId: "local-again" });
|
|
787
|
+
await f.conversation.wait();
|
|
788
|
+
assert.equal((await f.trace()).filter(row => row.method === "PATCH" && row.url === `/session/${sessionId}`).length, 1);
|
|
789
|
+
});
|