Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 62eb8cbf42 fix(ai): restore the OpenCode 2 server username
The transport-sanitizer edit replaced the opencode username constant, so a real 2.x spawn threw ReferenceError before Basic auth. Put the constant back and lock the default client username.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:31:57 +00:00
Cursor Agentand陈大猫 f56cc29afe fix(ai): keep OpenCode transport mapping to mid-stream failures
Server-authored tool and session messages are no longer rewritten just because they match a transport string. A dead reply stream still reports the localized session-failed key.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:26:02 +00:00
Cursor Agentand陈大猫 0a50705bb5 fix(ai): drop undici transport text from OpenCode 2 details
HTTP error bodies and tool failures share the same sanitizer as session errors, so fetch failed, terminated, and other side closed never reach the chat.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:22:49 +00:00
Cursor Agentand陈大猫 24253d6406 fix(ai): keep caller OpenCode config and hide transport session errors
A profile's OPENCODE_CONFIG_CONTENT is merged under Netcatty's generated settings, and a server-reported fetch failure no longer reaches the chat as raw transport text.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:17:46 +00:00
Cursor Agentand陈大猫 17b113091c fix(ai): keep the OpenCode 2 config model as a qualified string
The selected provider/model stays the original string in OPENCODE_CONFIG_CONTENT. Session create still sends Model.Ref with id and providerID.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:10:28 +00:00
Cursor Agentand陈大猫 e47378614a fix(ai): ignore Bun version banners and hide undici transport errors
A crashed Bun binary was cached as OpenCode 1.x, and fetch or JSON-body failures still showed raw undici text. Skip Bun and Node.js runtime versions, and map those transport errors to the existing unavailable message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:02:03 +00:00
Cursor Agentand陈大猫 3a190e6a9e fix(ai): localize OpenCode 2 failures when the server dies mid-turn
A killed event stream was surfacing undici's raw "terminated" text. Map that disconnect, a closed socket, and an SSE stream that ends before the turn finishes to the existing session-failed message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:54:18 +00:00
Cursor Agentand陈大猫 c9e22779a1 fix(ai): refetch the OpenCode catalog when the settlement budget elapses during a read
A catalog read that outlasts the quiet-window budget still has to fetch the snapshot announced by an in-flight model or provider update.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:28:43 +00:00
Cursor Agentand陈大猫 7c278f1c55 fix(ai): refetch the OpenCode catalog when it changes during the first read
An update that arrives while the initial model list is in flight belongs to a newer snapshot. Record the event count before that request and fetch again when it moves.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:20:55 +00:00
Cursor Agentand陈大猫 0ce1eb88be fix(ai): refresh OpenCode version and catalog after startup races
Key the version cache by the resolved PATH binary and its mtime, so an in-place upgrade is not stuck on the previous major. While listing 2.x models, refetch after model.updated or provider.updated until the snapshot stays quiet.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:14:52 +00:00
Cursor Agentand陈大猫 ceea6f2758 fix(ai): post OpenCode 2 prompts after the event stream is open
The live /api/event stream does not replay. Wait until the server accepts the subscriber and start reading it before sending the user prompt.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:06:15 +00:00
Cursor Agentand陈大猫 6d911712a6 fix(ai): select OpenCode 2 catalog ids and keep context optional
Aliases expose provider/catalog id rather than the provider API model id. A rejected instruction entry warns and still sends the user prompt. The entry value stays the system-prompt string, which is valid JSON for that endpoint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:59:28 +00:00
Cursor Agentand陈大猫 69a8b1a0a2 fix(ai): stop the OpenCode 2 event stream when a prompt is rejected
A rejected prompt does not emit a terminal SSE event. Abort that stream and close the server before waiting, and keep HTTP "not found" responses from being reported as a missing CLI.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:52:13 +00:00
Cursor Agentand陈大猫 24f390c332 feat(ai): support OpenCode 2.x while keeping the 1.x driver
Detect the installed opencode major version. 1.x stays on the bundled SDK and the old startup banner. 2.x uses the new serve banner, HTTP Basic auth, and the session API so model lists and chat turns work.

Fixes #3799

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:46:15 +00:00
10 changed files with 2227 additions and 5 deletions

No files matched your search

+8
View File
@@ -289,6 +289,14 @@ export const enAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
'ai.mimo.title': 'MiMo Code',
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import en from './en';
import zhCN from './zh-CN';
import zhTW from './zh-TW';
const KEYS = [
'ai.opencode.v2.startupTimeout',
'ai.opencode.v2.exited',
'ai.opencode.v2.unauthorized',
'ai.opencode.v2.unavailable',
'ai.opencode.v2.sessionFailed',
'ai.opencode.v2.empty',
'ai.opencode.v2.contextSkipped',
'ai.opencode.v2.mcpSkipped',
];
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
for (const key of KEYS) {
assert.equal(typeof en[key], 'string', `en missing ${key}`);
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
assert.notEqual(en[key], zhCN[key]);
assert.notEqual(en[key], zhTW[key]);
assert.notEqual(zhCN[key], zhTW[key]);
}
});
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
const text = KEYS.map((key) => zhTW[key]).join('\n');
assert.match(text, /終端機/);
assert.match(text, /連線/);
assert.match(text, /設定/);
assert.match(text, /傳回/);
assert.equal(text.includes('终端'), false);
assert.equal(text.includes('连接'), false);
assert.equal(text.includes('设置'), false);
assert.equal(text.includes('返回'), false);
});
+8
View File
@@ -289,6 +289,14 @@ export const zhCNAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
+8
View File
@@ -280,6 +280,14 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重設',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code',
@@ -552,7 +552,7 @@ function createStopWait() {
};
}
async function runOpenCodeTurn({
async function runOpenCodeV1Turn({
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory,
}) {
@@ -937,10 +937,21 @@ function resetOpenCodeListServerPool() {
disposeOpenCodeListServer(key, entry);
}
openCodeListServers.clear();
require("./opencodeV2.cjs").resetOpenCodeV2ListServerPool();
}
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal } = {}) {
async function runOpenCodeTurn(args) {
const { shouldUseOpenCodeV2, runOpenCodeV2Turn } = require("./opencodeV2.cjs");
if (await shouldUseOpenCodeV2(args)) return runOpenCodeV2Turn(args);
return runOpenCodeV1Turn(args);
}
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal, ...rest } = {}) {
const effectiveSignal = signal || abortController?.signal;
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
const { shouldUseOpenCodeV2, listOpenCodeV2Models } = require("./opencodeV2.cjs");
const selection = { env, binPath, openCodeFactory, abortController, signal, ...rest };
if (await shouldUseOpenCodeV2(selection)) return listOpenCodeV2Models(selection);
let acquired = null;
try {
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
@@ -24,6 +24,10 @@ const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const root = ${JSON.stringify(root)};
if (process.argv.includes('--version')) {
console.log('1.19.3');
process.exit(0);
}
const profile = path.basename(process.env.HOME);
fs.appendFileSync(path.join(root, 'started'), profile + '\\n');
if (profile === 'profile-failed') process.exit(7);
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,954 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const {
buildOpenCodeBasicAuthorization,
buildOpenCodeV2ConfigContent,
mergeOpenCodeV2ConfigContent,
createOpenCodeServeStartupParser,
createOpenCodeV2Client,
detectOpenCodeCliVersion,
isOpenCodeV2Version,
listOpenCodeV2Models,
mapOpenCodeV2Catalog,
parseOpenCodeVersionOutput,
resetOpenCodeVersionCache,
runOpenCodeV2Turn,
translateOpenCodeV2Event,
waitForOpenCodeChildReady,
} = require("./opencodeV2.cjs");
const { listOpenCodeModels, resetOpenCodeListServerPool, runOpenCodeTurn } = require("./opencodeDriver.cjs");
test("parseOpenCodeVersionOutput distinguishes OpenCode 1.x from 2.x", () => {
assert.deepEqual(parseOpenCodeVersionOutput("v2.1.0\n"), { major: 2, minor: 1, patch: 0 });
assert.deepEqual(parseOpenCodeVersionOutput("2.1.0"), { major: 2, minor: 1, patch: 0 });
assert.deepEqual(parseOpenCodeVersionOutput("opencode version 1.19.3"), { major: 1, minor: 19, patch: 3 });
assert.equal(parseOpenCodeVersionOutput("v2.0.0-beta.1").major, 2);
assert.equal(parseOpenCodeVersionOutput(""), null);
assert.equal(parseOpenCodeVersionOutput("not a version"), null);
assert.equal(parseOpenCodeVersionOutput("listening on 127.0.0.1:4096"), null);
assert.equal(
parseOpenCodeVersionOutput("TypeError: Cannot read properties of undefined (reading 'split')\nNode.js v22.23.1\n"),
null,
);
assert.equal(parseOpenCodeVersionOutput("panic: failed\nBun v1.3.14 (linux x64)\n"), null);
assert.deepEqual(parseOpenCodeVersionOutput("v2.1.0\nNode.js v22.23.1\n"), { major: 2, minor: 1, patch: 0 });
assert.deepEqual(parseOpenCodeVersionOutput("v2.1.0\nBun v1.3.14\n"), { major: 2, minor: 1, patch: 0 });
assert.equal(isOpenCodeV2Version(parseOpenCodeVersionOutput("v2.1.0")), true);
assert.equal(isOpenCodeV2Version(parseOpenCodeVersionOutput("1.19.3")), false);
assert.equal(isOpenCodeV2Version(null), false);
});
test("version cache follows the PATH binary when OpenCode is replaced", async () => {
resetOpenCodeVersionCache();
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-version-cache-"));
const bin = path.join(dir, "opencode");
fs.writeFileSync(bin, "#!/bin/sh\necho v1.19.3\n", { mode: 0o755 });
try {
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH || ""}` };
const first = await detectOpenCodeCliVersion({ env });
assert.equal(first.major, 1);
fs.writeFileSync(bin, "#!/bin/sh\necho v2.1.0\n", { mode: 0o755 });
const replacedAt = new Date(Date.now() + 2000);
fs.utimesSync(bin, replacedAt, replacedAt);
const replaced = await detectOpenCodeCliVersion({ env });
assert.equal(replaced.major, 2);
} finally {
resetOpenCodeVersionCache();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("a crashed --version probe is not classified as OpenCode 2", async () => {
resetOpenCodeVersionCache();
const crash = "TypeError: Cannot read properties of undefined (reading 'split')\nNode.js v22.23.1\n";
const version = await detectOpenCodeCliVersion({
binPath: "opencode",
execFileImpl: (_command, _args, _options, callback) => {
const error = new Error("opencode --version failed");
error.code = 1;
callback(error, "", crash);
},
});
assert.equal(version, null);
assert.equal(isOpenCodeV2Version(version), false);
});
test("a Bun crash banner is not cached as OpenCode 1.x", async () => {
resetOpenCodeVersionCache();
let calls = 0;
const probe = () => detectOpenCodeCliVersion({
binPath: "opencode",
execFileImpl: (_command, _args, _options, callback) => {
calls += 1;
if (calls === 1) {
const error = new Error("opencode --version failed");
error.code = 1;
callback(error, "", "panic: failed\nBun v1.3.14 (linux x64)\n");
return;
}
callback(null, "v2.1.0\n", "");
},
});
assert.equal(await probe(), null);
assert.equal((await probe()).major, 2);
assert.equal(calls, 2);
});
test("OpenCode serve startup matches the 2.x banner and a split password line", () => {
const parser = createOpenCodeServeStartupParser();
assert.deepEqual(parser.push("server listen"), { url: null, password: null, ready: false });
assert.deepEqual(
parser.push("ing on http://127.0.0.1:4455\nser"),
{ url: "http://127.0.0.1:4455", password: null, ready: true },
);
assert.deepEqual(
parser.push("ver password zNAK.secret\n"),
{ url: "http://127.0.0.1:4455", password: "zNAK.secret", ready: true },
);
const v1 = createOpenCodeServeStartupParser();
assert.deepEqual(
v1.push("opencode server listening on http://127.0.0.1:4096\n"),
{ url: "http://127.0.0.1:4096", password: null, ready: true },
);
});
test("startup waiter captures a late password and never returns server spam", async () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 42;
let stopped = 0;
const ready = waitForOpenCodeChildReady(child, {
timeoutMs: 1000,
passwordGraceMs: 30,
stop: () => { stopped += 1; },
});
child.stdout.emit("data", Buffer.from("server listening on http://127.0.0.1:4096\n"));
child.stderr.emit("data", Buffer.from("server password printed-secret\n"));
const result = await ready;
assert.equal(result.url, "http://127.0.0.1:4096");
assert.equal(result.password, "printed-secret");
assert.equal(stopped, 0);
const timedOut = new EventEmitter();
timedOut.stdout = new EventEmitter();
timedOut.stderr = new EventEmitter();
timedOut.pid = 43;
const pending = waitForOpenCodeChildReady(timedOut, {
timeoutMs: 20,
passwordGraceMs: 500,
stop: () => {},
});
timedOut.stderr.emit("data", Buffer.from("debug server password hunter2\nstill starting\n"));
await assert.rejects(pending, (error) => {
assert.equal(error.message, "ai.opencode.v2.startupTimeout");
assert.equal(JSON.stringify(error).includes("hunter2"), false);
return true;
});
});
test("startup waiter reports an exit without the server log", async () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
const pending = waitForOpenCodeChildReady(child, { timeoutMs: 1000, passwordGraceMs: 20, stop: () => {} });
child.stderr.emit("data", Buffer.from("server password hunter2\npanic\n"));
child.emit("exit", 7);
await assert.rejects(pending, (error) => {
assert.equal(error.message, "ai.opencode.v2.exited");
assert.equal(JSON.stringify(error).includes("hunter2"), false);
assert.equal(JSON.stringify(error).includes("panic"), false);
return true;
});
});
test("startup waiter resolves with the injected password when 2.x omits the password line", async () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
const ready = waitForOpenCodeChildReady(child, { timeoutMs: 1000, passwordGraceMs: 20, stop: () => {} });
child.stdout.emit("data", Buffer.from("server listening on http://127.0.0.1:4096\n"));
const result = await ready;
assert.equal(result.url, "http://127.0.0.1:4096");
assert.equal(result.password, null);
});
test("2.x config keeps permissions and local MCP without the 1.x startup keys", () => {
const content = buildOpenCodeV2ConfigContent({
share: "disabled",
autoupdate: false,
permission: { edit: "deny", bash: "allow", read: { "*": "deny", "/tmp/**": "allow" } },
mcp: {
netcatty: { type: "local", command: ["node", "cli.js"], environment: { A: "b" }, enabled: true },
},
model: "openai/gpt-5",
});
assert.equal(content.share, "disabled");
assert.equal(content.update, "disable");
assert.equal(content.permission, undefined);
assert.equal(content.autoupdate, undefined);
assert.deepEqual(content.permissions, [
{ action: "edit", resource: "*", effect: "deny" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "deny" },
{ action: "read", resource: "/tmp/**", effect: "allow" },
]);
assert.deepEqual(content.mcp.servers.netcatty, {
type: "local",
command: ["node", "cli.js"],
environment: { A: "b" },
disabled: false,
});
assert.equal(content.model, "openai/gpt-5");
});
test("2.x config keeps caller providers and plugins", () => {
const generated = buildOpenCodeV2ConfigContent({
share: "disabled",
autoupdate: false,
model: "openai/gpt-5",
mcp: { netcatty: { type: "local", command: ["node", "cli.js"], environment: {}, enabled: true } },
});
const merged = mergeOpenCodeV2ConfigContent(generated, JSON.stringify({
username: "other",
permission: { edit: "allow" },
autoupdate: true,
model: "anthropic/claude",
providers: { local: { models: { coder: { modelID: "real" } } } },
plugins: ["opencode-provider"],
mcp: { servers: { extra: { type: "local", command: ["echo"] } } },
}));
assert.equal(merged.model, "openai/gpt-5");
assert.equal(merged.username, undefined);
assert.equal(merged.permission, undefined);
assert.equal(merged.autoupdate, undefined);
assert.deepEqual(merged.providers.local.models.coder, { modelID: "real" });
assert.deepEqual(merged.plugins, ["opencode-provider"]);
assert.deepEqual(merged.mcp.servers.extra, { type: "local", command: ["echo"] });
assert.equal(merged.mcp.servers.netcatty.type, "local");
assert.equal(mergeOpenCodeV2ConfigContent(generated, "not json"), generated);
});
test("mapOpenCodeV2Catalog uses provider and model ids from the 2.x payloads", () => {
const catalog = mapOpenCodeV2Catalog(
{ data: [
{ providerID: "openai", modelID: "gpt-5", name: "GPT-5", enabled: true },
{ providerID: "openai", modelID: "hidden", name: "Hidden", enabled: false },
] },
{ data: [{ id: "openai", name: "OpenAI" }] },
{ data: { providerID: "openai", modelID: "gpt-5", name: "GPT-5" } },
);
assert.deepEqual(catalog, {
currentModelId: "openai/gpt-5",
models: [{ id: "openai/gpt-5", name: "OpenAI GPT-5" }],
});
const aliased = mapOpenCodeV2Catalog(
{ data: [
{ id: "coding-default", modelID: "gpt-5.2", providerID: "openai", name: "Coding default", enabled: true },
{ id: "anthropic/claude-sonnet-4.5", modelID: "claude-sonnet-4.5", providerID: "openrouter", name: "Sonnet", enabled: true },
] },
{ data: [{ id: "openai", name: "OpenAI" }, { id: "openrouter", name: "OpenRouter" }] },
{ data: { id: "coding-default", modelID: "gpt-5.2", providerID: "openai", name: "Coding default" } },
);
assert.deepEqual(aliased, {
currentModelId: "openai/coding-default",
models: [
{ id: "openai/coding-default", name: "OpenAI Coding default" },
{ id: "openrouter/anthropic/claude-sonnet-4.5", name: "OpenRouter Sonnet" },
],
});
});
test("OpenCode 2 client sends Basic auth and hides unauthorized bodies", async () => {
const requests = [];
const password = "s3cret-value";
const fetchImpl = async (url, init) => {
requests.push({ url: String(url), init });
if (String(url).endsWith("/api/info")) {
return new Response(`<html>server password ${password}</html>`, { status: 401, headers: { "content-type": "text/html" } });
}
if (String(url).includes("/api/model/default")) {
return jsonResponse({ data: { providerID: "openai", modelID: "gpt-5", name: "GPT-5" } });
}
if (String(url).includes("/api/provider")) {
return jsonResponse({ data: [{ id: "openai", name: "OpenAI" }] });
}
return jsonResponse({ data: [{ providerID: "openai", modelID: "gpt-5", name: "GPT-5", enabled: true }] });
};
const client = createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password,
fetchImpl,
});
assert.equal(client.authorization, buildOpenCodeBasicAuthorization("opencode", password));
const catalog = await client.listCatalog(undefined, "/workspace");
assert.equal(catalog.currentModelId, "openai/gpt-5");
assert.equal(requests.length, 3);
for (const request of requests) {
assert.equal(request.init.headers.Authorization, client.authorization);
assert.equal(request.init.headers.authorization, undefined);
}
const modelUrl = new URL(requests.find((request) => request.url.includes("/api/model") && !request.url.includes("default")).url);
assert.equal(modelUrl.searchParams.get("location[directory]"), "/workspace");
await assert.rejects(client.info(), (error) => {
assert.equal(error.message, "ai.opencode.v2.unauthorized");
assert.equal(JSON.stringify(error).includes(password), false);
assert.equal(JSON.stringify(error).includes("<html>"), false);
return true;
});
const implicit = createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
password,
fetchImpl: async () => jsonResponse({ data: {} }),
});
assert.equal(implicit.authorization, buildOpenCodeBasicAuthorization("opencode", password));
});
test("OpenCode 2 client hides undici transport failures", async () => {
const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" });
const fetchFailed = new TypeError("fetch failed");
fetchFailed.cause = cause;
const failing = createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl: async () => { throw fetchFailed; },
});
await assert.rejects(failing.info(), (error) => {
assert.equal(error.message, "ai.opencode.v2.unavailable");
assert.equal(String(error).includes("fetch failed"), false);
assert.equal(String(error).includes("other side closed"), false);
return true;
});
const dropped = createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl: async () => ({
status: 200,
body: { cancel() {} },
text: async () => { throw new TypeError("terminated"); },
}),
});
await assert.rejects(dropped.info(), (error) => {
assert.equal(error.message, "ai.opencode.v2.unavailable");
assert.equal(String(error).includes("terminated"), false);
return true;
});
});
test("listCatalog omits the location query when no directory is set", async () => {
const urls = [];
const client = createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096/",
username: "opencode",
password: "pw",
fetchImpl: async (url) => {
urls.push(String(url));
if (String(url).includes("/api/model/default")) return jsonResponse({ data: null });
if (String(url).includes("/api/provider")) return jsonResponse({ data: [] });
return jsonResponse({ data: [] });
},
});
await client.listCatalog();
assert.equal(urls.some((url) => url.includes("location")), false);
});
test("translateOpenCodeV2Event streams text, tools, and a safe session failure", () => {
const events = [];
const emitter = {
text: (t) => events.push(["text", t]),
reasoning: (t) => events.push(["reasoning", t]),
reasoningEnd: () => events.push(["reasoningEnd"]),
toolCall: (name, input, id) => events.push(["tool", name, input, id]),
toolResult: (id, output, name) => events.push(["result", id, output, name]),
emitError: (message) => events.push(["error", message]),
};
const state = { sessionID: "ses_1" };
assert.equal(translateOpenCodeV2Event({
type: "session.text.delta",
data: { sessionID: "ses_1", delta: "Hello" },
}, emitter, state).content, true);
translateOpenCodeV2Event({
type: "session.text.delta",
data: { sessionID: "other", delta: "nope" },
}, emitter, state);
translateOpenCodeV2Event({
type: "session.tool.input.started",
data: { sessionID: "ses_1", id: "call_1", name: "bash" },
}, emitter, state);
translateOpenCodeV2Event({
type: "session.tool.called",
data: { sessionID: "ses_1", id: "call_1", input: { cmd: "ls" } },
}, emitter, state);
translateOpenCodeV2Event({
type: "session.tool.failed",
data: { sessionID: "ses_1", id: "call_1", error: { message: "command not found" } },
}, emitter, state);
const failed = translateOpenCodeV2Event({
type: "session.execution.failed",
data: { sessionID: "ses_1", error: { message: "server password hunter2\nstack" } },
}, emitter, state);
assert.equal(failed.error, true);
assert.deepEqual(events, [
["text", "Hello"],
["tool", "bash", { cmd: "ls" }, "call_1"],
["result", "call_1", "command not found", "bash"],
["error", "ai.opencode.v2.sessionFailed"],
]);
assert.equal(JSON.stringify(events).includes("hunter2"), false);
});
test("runOpenCodeV2Turn authenticates, keeps the user prompt unchanged, and streams the session", async () => {
const requests = [];
const encoder = new TextEncoder();
const sse = new ReadableStream({
start(controller) {
const send = (event) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
send({ type: "server.connected", data: {} });
send({ type: "session.reasoning.delta", data: { sessionID: "ses_1", delta: "Think" } });
send({ type: "session.reasoning.ended", data: { sessionID: "ses_1" } });
send({ type: "session.text.delta", data: { sessionID: "ses_1", delta: "Hello" } });
send({ type: "session.execution.succeeded", data: { sessionID: "ses_1" } });
controller.close();
},
});
const fetchImpl = async (url, init) => {
requests.push({ url: String(url), method: init.method, headers: init.headers, body: init.body });
const path = new URL(url).pathname;
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
if (path.includes("/instructions/")) return new Response(null, { status: 204 });
if (path === "/api/event") {
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
}
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
return jsonResponse({ data: {} });
};
const events = [];
let closed = false;
const result = await runOpenCodeV2Turn({
prompt: "hello",
systemPrompt: "netcatty context",
cwd: "/workspace",
model: "openai/gpt-5",
emitter: {
sessionId: (id) => events.push(["session", id]),
text: (t) => events.push(["text", t]),
reasoning: (t) => events.push(["reasoning", t]),
reasoningEnd: () => events.push(["reasoningEnd"]),
emitDone: () => events.push(["done"]),
emitError: (message) => events.push(["error", message]),
warning: (id, message) => events.push(["warning", id, message]),
},
openCodeV2Factory: async () => ({
client: createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl,
}),
server: { close: () => { closed = true; } },
}),
});
assert.equal(result.sessionId, "ses_1");
assert.equal(closed, true);
assert.deepEqual(events, [
["session", "ses_1"],
["reasoning", "Think"],
["reasoningEnd"],
["text", "Hello"],
["done"],
]);
const created = requests.find((request) => new URL(request.url).pathname === "/api/session");
assert.deepEqual(JSON.parse(created.body).model, { id: "gpt-5", providerID: "openai" });
const prompt = requests.find((request) => request.url.endsWith("/prompt"));
assert.equal(JSON.parse(prompt.body).text, "hello");
assert.equal(JSON.parse(prompt.body).text.includes("netcatty context"), false);
const instruction = requests.find((request) => request.url.includes("/instructions/"));
assert.equal(JSON.parse(instruction.body).value, "netcatty context");
for (const request of requests) {
assert.equal(request.headers.Authorization, buildOpenCodeBasicAuthorization("opencode", "s3cret"));
}
});
test("runOpenCodeV2Turn continues when the instruction entry is rejected", async () => {
const requests = [];
const encoder = new TextEncoder();
const sse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: "session.text.delta",
data: { sessionID: "ses_1", delta: "Hello" },
})}\n\n`));
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: "session.execution.succeeded",
data: { sessionID: "ses_1" },
})}\n\n`));
controller.close();
},
});
const fetchImpl = async (url, init) => {
requests.push({ url: String(url), method: init.method, body: init.body });
const path = new URL(url).pathname;
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
if (path.includes("/instructions/")) {
return new Response(JSON.stringify({ message: "value must be an object" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
if (path === "/api/event") {
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
}
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
return jsonResponse({ data: {} });
};
const events = [];
const result = await runOpenCodeV2Turn({
prompt: "hello",
systemPrompt: "netcatty context",
emitter: {
sessionId: (id) => events.push(["session", id]),
text: (text) => events.push(["text", text]),
emitDone: () => events.push(["done"]),
emitError: (message) => events.push(["error", message]),
warning: (id, message) => events.push(["warning", id, message]),
},
openCodeV2Factory: async () => ({
client: createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl,
}),
server: { close() {} },
}),
});
assert.equal(result.sessionId, "ses_1");
assert.deepEqual(events, [
["session", "ses_1"],
["warning", "opencode-v2-context", "ai.opencode.v2.contextSkipped"],
["text", "Hello"],
["done"],
]);
const prompt = requests.find((request) => request.url.endsWith("/prompt"));
assert.equal(JSON.parse(prompt.body).text, "hello");
const instruction = requests.find((request) => request.url.includes("/instructions/"));
assert.equal(JSON.parse(instruction.body).value, "netcatty context");
});
test("runOpenCodeV2Turn posts the prompt only after the event stream is open", async () => {
let releaseEvent;
const eventGate = new Promise((resolve) => { releaseEvent = resolve; });
const order = [];
const encoder = new TextEncoder();
const fetchImpl = async (url) => {
const path = new URL(url).pathname;
if (path === "/api/session") return jsonResponse({ data: { id: "ses_1" } });
if (path === "/api/event") {
order.push("event-start");
await eventGate;
order.push("event-open");
const sse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: "session.text.delta",
data: { sessionID: "ses_1", delta: "Hi" },
})}\n\n`));
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: "session.execution.succeeded",
data: { sessionID: "ses_1" },
})}\n\n`));
controller.close();
},
});
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
}
if (path.endsWith("/prompt")) {
order.push("prompt");
return jsonResponse({ data: { id: "msg_1" } });
}
return new Response(null, { status: 204 });
};
const events = [];
const running = runOpenCodeV2Turn({
prompt: "hello",
emitter: {
sessionId: () => {},
text: (text) => events.push(text),
emitDone: () => events.push("done"),
emitError: (message) => events.push(["error", message]),
},
openCodeV2Factory: async () => ({
client: createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl,
}),
server: { close() {} },
}),
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(order, ["event-start"]);
releaseEvent();
await running;
assert.deepEqual(order, ["event-start", "event-open", "prompt"]);
assert.deepEqual(events, ["Hi", "done"]);
});
test("runOpenCodeV2Turn reports a rejected prompt without waiting on the event stream", { timeout: 2000 }, async () => {
let releaseEvent;
const eventOpen = new Promise((resolve) => { releaseEvent = resolve; });
let streamCancelled = false;
const sse = new ReadableStream({
start() {},
cancel() { streamCancelled = true; },
});
const fetchImpl = async (url, init) => {
const path = new URL(url).pathname;
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
if (path === "/api/event") {
releaseEvent();
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
}
if (path.endsWith("/prompt")) {
await eventOpen;
return new Response(JSON.stringify({ message: "model not found" }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
return new Response(null, { status: 204 });
};
const events = [];
let closed = false;
const result = await runOpenCodeV2Turn({
prompt: "hello",
emitter: {
sessionId: (id) => events.push(["session", id]),
text: () => {},
emitDone: () => events.push(["done"]),
emitError: (message) => events.push(["error", message]),
},
openCodeV2Factory: async () => ({
client: createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl,
}),
server: { close: () => { closed = true; } },
}),
});
assert.equal(result.sessionId, "ses_1");
assert.equal(closed, true);
assert.equal(streamCancelled, true);
assert.deepEqual(events, [
["session", "ses_1"],
["error", "model not found"],
]);
});
test("runOpenCodeV2Turn maps a mid-stream server disconnect to a localized failure", async () => {
const encoder = new TextEncoder();
const disconnect = new TypeError("terminated");
disconnect.cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" });
let sent = false;
const sse = new ReadableStream({
pull(controller) {
if (!sent) {
sent = true;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: "session.text.delta",
data: { sessionID: "ses_1", delta: "Hel" },
})}\n\n`));
return;
}
controller.error(disconnect);
},
});
const fetchImpl = async (url, init) => {
const path = new URL(url).pathname;
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
if (path === "/api/event") {
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
}
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
return new Response(null, { status: 204 });
};
const events = [];
const result = await runOpenCodeV2Turn({
prompt: "hello",
emitter: {
sessionId: (id) => events.push(["session", id]),
text: (text) => events.push(["text", text]),
emitDone: () => events.push(["done"]),
emitError: (message) => events.push(["error", message]),
},
openCodeV2Factory: async () => ({
client: createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl,
}),
server: { close() {} },
}),
});
assert.equal(result.sessionId, "ses_1");
assert.deepEqual(events, [
["session", "ses_1"],
["text", "Hel"],
["error", "ai.opencode.v2.sessionFailed"],
]);
assert.equal(JSON.stringify(events).includes("terminated"), false);
assert.equal(JSON.stringify(events).includes("other side closed"), false);
});
test("runOpenCodeV2Turn maps an event stream that ends before the turn finishes", async () => {
const encoder = new TextEncoder();
const sse = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: "session.text.delta",
data: { sessionID: "ses_1", delta: "Hel" },
})}\n\n`));
controller.close();
},
});
const fetchImpl = async (url, init) => {
const path = new URL(url).pathname;
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
if (path === "/api/event") {
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
}
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
return new Response(null, { status: 204 });
};
const events = [];
await runOpenCodeV2Turn({
prompt: "hello",
emitter: {
sessionId: (id) => events.push(["session", id]),
text: (text) => events.push(["text", text]),
emitDone: () => events.push(["done"]),
emitError: (message) => events.push(["error", message]),
},
openCodeV2Factory: async () => ({
client: createOpenCodeV2Client({
baseUrl: "http://127.0.0.1:4096",
username: "opencode",
password: "s3cret",
fetchImpl,
}),
server: { close() {} },
}),
});
assert.deepEqual(events, [
["session", "ses_1"],
["text", "Hel"],
["error", "ai.opencode.v2.sessionFailed"],
]);
});
test("listOpenCodeModels stays on the 1.x factory for OpenCode 1.x", async () => {
resetOpenCodeListServerPool();
let v1 = false;
const models = await listOpenCodeModels({
versionProbe: async () => ({ major: 1, minor: 19, patch: 3 }),
openCodeFactory: async () => {
v1 = true;
return {
client: { config: { providers: async () => ({ providers: [], default: {} }) } },
server: { close() {} },
};
},
openCodeV2Factory: async () => {
throw new Error("v2 catalog should not run");
},
});
assert.equal(v1, true);
assert.deepEqual(models.models, []);
resetOpenCodeListServerPool();
});
test("listOpenCodeV2Models refetches the catalog after model.updated", async () => {
let updated = false;
const result = await listOpenCodeV2Models({
openCodeV2Factory: async () => ({
client: {
async listCatalog() {
if (!updated) {
return { currentModelId: null, models: [{ id: "opencode/big-pickle", name: "builtin" }] };
}
return {
currentModelId: "custom/coder",
models: [
{ id: "opencode/big-pickle", name: "builtin" },
{ id: "custom/coder", name: "Coder" },
],
};
},
async openEventStream() {
return (async function* partialCatalog() {
await new Promise((resolve) => setTimeout(resolve, 30));
updated = true;
yield { type: "model.updated", data: {} };
await new Promise(() => {});
})();
},
},
server: { close() {} },
}),
});
assert.deepEqual(result.models.map((model) => model.id), ["opencode/big-pickle", "custom/coder"]);
assert.equal(result.currentModelId, "custom/coder");
});
test("listOpenCodeV2Models refetches when model.updated arrives during the first catalog read", async () => {
let releaseFirst;
const firstCatalog = new Promise((resolve) => { releaseFirst = resolve; });
let calls = 0;
const result = await listOpenCodeV2Models({
openCodeV2Factory: async () => ({
client: {
async listCatalog() {
calls += 1;
if (calls === 1) {
await firstCatalog;
return { currentModelId: null, models: [{ id: "opencode/big-pickle", name: "builtin" }] };
}
return {
currentModelId: "custom/coder",
models: [
{ id: "opencode/big-pickle", name: "builtin" },
{ id: "custom/coder", name: "Coder" },
],
};
},
async openEventStream() {
return (async function* updateDuringFetch() {
await new Promise((resolve) => setTimeout(resolve, 20));
yield { type: "model.updated", data: {} };
releaseFirst();
await new Promise(() => {});
})();
},
},
server: { close() {} },
}),
});
assert.ok(calls >= 2);
assert.equal(result.currentModelId, "custom/coder");
assert.deepEqual(result.models.map((model) => model.id), ["opencode/big-pickle", "custom/coder"]);
});
test("listOpenCodeV2Models refetches when the settlement budget elapses during the first read", async () => {
let releaseFirst;
const firstCatalog = new Promise((resolve) => { releaseFirst = resolve; });
let calls = 0;
const result = await listOpenCodeV2Models({
catalogSettle: { maxMs: 30, quietMs: 5000, pollMs: 10 },
openCodeV2Factory: async () => ({
client: {
async listCatalog() {
calls += 1;
if (calls === 1) {
await firstCatalog;
return { currentModelId: null, models: [{ id: "opencode/big-pickle", name: "builtin" }] };
}
return {
currentModelId: "custom/coder",
models: [
{ id: "opencode/big-pickle", name: "builtin" },
{ id: "custom/coder", name: "Coder" },
],
};
},
async openEventStream() {
return (async function* updateAfterDeadline() {
await new Promise((resolve) => setTimeout(resolve, 50));
yield { type: "provider.updated", data: {} };
releaseFirst();
await new Promise(() => {});
})();
},
},
server: { close() {} },
}),
});
assert.ok(calls >= 2);
assert.equal(result.currentModelId, "custom/coder");
});
test("listOpenCodeModels uses the 2.x catalog when the binary is 2.x", async () => {
const models = await listOpenCodeModels({
versionProbe: async () => parseOpenCodeVersionOutput("v2.1.0"),
openCodeV2Factory: async () => ({
client: {
listCatalog: async () => ({
currentModelId: "openai/gpt-5",
models: [{ id: "openai/gpt-5", name: "OpenAI GPT-5" }],
}),
},
server: { close() {} },
}),
});
assert.equal(models.currentModelId, "openai/gpt-5");
});
test("runOpenCodeTurn keeps the injected 1.x factory when a factory is supplied", async () => {
const events = [];
await runOpenCodeTurn({
prompt: "hi",
versionProbe: async () => ({ major: 2, minor: 1, patch: 0 }),
emitter: {
sessionId: () => {},
text: (t) => events.push(t),
emitDone: () => events.push("done"),
emitError: (message) => events.push(message),
},
openCodeFactory: async () => ({
client: {
global: { event: async () => ({ stream: (async function* empty() {})() }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
},
server: { close() {} },
}),
});
assert.ok(events.includes("ai.opencode.v2.empty") === false);
assert.ok(events.includes("done") || events.some((event) => String(event).includes("empty")));
});
test("listOpenCodeV2Models returns an empty catalog when the caller already aborted", async () => {
const controller = new AbortController();
controller.abort();
const models = await listOpenCodeV2Models({
signal: controller.signal,
openCodeV2Factory: async () => { throw new Error("should not connect"); },
});
assert.deepEqual(models, { currentModelId: null, models: [] });
});
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
@@ -4,7 +4,7 @@ import type { AISession, AgentActivity, ChatMessage, ExternalAgentConfig } from
import { SessionStateStore } from '../sessionState';
import { ToolOutputStore } from '../toolOutputStore';
import { ToolResultDedup } from '../toolResultDedup';
import { externalSdkTurnDriver } from './externalSdkTurnDriver';
import { externalSdkTurnDriver, localizeSdkAgentMessage } from './externalSdkTurnDriver';
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
import type { TurnUiCallbacks } from './types';
@@ -16,6 +16,16 @@ async function waitFor(predicate: () => boolean): Promise<void> {
throw new Error('condition not reached');
}
test('OpenCode 2 error keys are localized and other errors stay unchanged', () => {
const translate = (key: string) => key === 'ai.opencode.v2.startupTimeout' ? '未能及时就绪' : key;
assert.equal(
localizeSdkAgentMessage('ai.opencode.v2.startupTimeout', translate),
'未能及时就绪',
);
assert.equal(localizeSdkAgentMessage('server password hunter2', translate), 'server password hunter2');
assert.equal(localizeSdkAgentMessage('ai.opencode.v2.startupTimeout'), 'ai.opencode.v2.startupTimeout');
});
test('upsertAgentActivity replaces streaming updates by stable item id', () => {
const running: AgentActivity = {
id: 'plan-1',
@@ -25,6 +25,17 @@ import {
registerCodebuddyElicitation,
} from '../../shared/codebuddyElicitations';
const OPENCODE_V2_MESSAGE_KEY = /^ai\.opencode\.v2\.[A-Za-z0-9]+$/;
export function localizeSdkAgentMessage(
message: string,
translate?: (key: string) => string,
): string {
if (!translate || !OPENCODE_V2_MESSAGE_KEY.test(message)) return message;
const localized = translate(message);
return localized || message;
}
interface LiveExternalTurn {
requestId: string;
sessionId: string;
@@ -333,7 +344,8 @@ async function runExternalTurn(
},
onWarning: (activity) => {
flushTextBeforeNonTextEvent();
runOrBufferUiOperation(() => updateActivity(activity));
const message = localizeSdkAgentMessage(activity.message, ui.translate);
runOrBufferUiOperation(() => updateActivity({ ...activity, message }));
},
onUsage: (usage: AgentUsage) => {
flushTextBeforeNonTextEvent();
@@ -391,7 +403,7 @@ async function runExternalTurn(
},
onError: (error: string) => {
flushTextBeforeNonTextEvent();
ui.reportStreamError(sessionId, signal, error);
ui.reportStreamError(sessionId, signal, localizeSdkAgentMessage(error, ui.translate));
ui.setStreamingForScope(sessionId, false);
},
onDone: () => {