5887 lines
192 KiB
TypeScript
5887 lines
192 KiB
TypeScript
import { EventEmitter } from "node:events";
|
||
import { createHash } from "node:crypto";
|
||
import { createServer } from "node:http";
|
||
import {
|
||
mkdir,
|
||
mkdtemp,
|
||
readFile,
|
||
rm,
|
||
stat,
|
||
writeFile,
|
||
} from "node:fs/promises";
|
||
import { tmpdir } from "node:os";
|
||
import { join, resolve } from "node:path";
|
||
import { PassThrough } from "node:stream";
|
||
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
|
||
import type spawn from "cross-spawn";
|
||
import { describe, expect, it, vi } from "vitest";
|
||
import type { KnowledgeMcpGateway } from "./knowledge-mcp-gateway";
|
||
import type { RuntimeEvent } from "./runtime";
|
||
import { DesktopDiagnostics, type DesktopDiagnosticFailureObserver } from "../desktop-diagnostics";
|
||
import {
|
||
createTemporaryMcpName,
|
||
OpenCodeRuntime,
|
||
type OpenCodeRuntimeDependencies,
|
||
} from "./opencode-runtime";
|
||
|
||
type SpawnedProcess = ReturnType<typeof spawn>;
|
||
|
||
function fakeChild(pid = 42): SpawnedProcess {
|
||
const child = new EventEmitter() as EventEmitter & {
|
||
stdout: PassThrough;
|
||
stderr: PassThrough;
|
||
exitCode: number | null;
|
||
killed: boolean;
|
||
pid: number;
|
||
kill: ReturnType<typeof vi.fn>;
|
||
unref: ReturnType<typeof vi.fn>;
|
||
};
|
||
child.stdout = new PassThrough();
|
||
child.stderr = new PassThrough();
|
||
child.exitCode = null;
|
||
child.killed = false;
|
||
child.pid = pid;
|
||
child.kill = vi.fn(() => {
|
||
child.killed = true;
|
||
queueMicrotask(() => {
|
||
child.exitCode = 0;
|
||
child.emit("close", 0, null);
|
||
});
|
||
return true;
|
||
});
|
||
child.unref = vi.fn(() => child);
|
||
return child as unknown as SpawnedProcess;
|
||
}
|
||
|
||
function fakeClient() {
|
||
return {
|
||
session: {
|
||
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
|
||
},
|
||
} as unknown as ReturnType<typeof createOpencodeClient>;
|
||
}
|
||
|
||
function stderrOf(child: SpawnedProcess): PassThrough {
|
||
return child.stderr as PassThrough;
|
||
}
|
||
|
||
function closeChild(child: SpawnedProcess, code: number): void {
|
||
(child as unknown as { exitCode: number | null }).exitCode = code;
|
||
child.emit("close", code, null);
|
||
}
|
||
|
||
function options(
|
||
overrides: Partial<ConstructorParameters<typeof OpenCodeRuntime>[0]> = {},
|
||
): ConstructorParameters<typeof OpenCodeRuntime>[0] {
|
||
return {
|
||
embedded: true,
|
||
binaryPath: "",
|
||
configPath: "",
|
||
defaultWorkspace: process.cwd(),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function dependencies(
|
||
child: SpawnedProcess,
|
||
overrides: Partial<OpenCodeRuntimeDependencies> = {},
|
||
): {
|
||
deps: Partial<OpenCodeRuntimeDependencies>;
|
||
spawnMock: ReturnType<typeof vi.fn>;
|
||
detectBinary: ReturnType<typeof vi.fn>;
|
||
validateBinary: ReturnType<typeof vi.fn>;
|
||
createClient: ReturnType<typeof vi.fn>;
|
||
checkServerHealth: ReturnType<typeof vi.fn>;
|
||
} {
|
||
const spawnMock = vi.fn(() => child);
|
||
const detectBinary = vi.fn().mockResolvedValue({
|
||
path: "opencode",
|
||
detail: "OpenCode CLI 已就绪",
|
||
});
|
||
const validateBinary = vi.fn().mockResolvedValue({
|
||
path: "opencode",
|
||
detail: "OpenCode CLI 已就绪",
|
||
});
|
||
const createClient = vi.fn(() => fakeClient());
|
||
const checkServerHealth = vi.fn().mockResolvedValue(true);
|
||
return {
|
||
deps: {
|
||
spawn: spawnMock as unknown as typeof spawn,
|
||
detectBinary,
|
||
validateBinary,
|
||
createClient: createClient as unknown as typeof createOpencodeClient,
|
||
checkServerHealth,
|
||
platform: "linux",
|
||
startupTimeoutMs: 100,
|
||
...overrides,
|
||
},
|
||
spawnMock,
|
||
detectBinary,
|
||
validateBinary,
|
||
createClient,
|
||
checkServerHealth,
|
||
};
|
||
}
|
||
|
||
function permissionEvent(
|
||
overrides: Record<string, unknown> = {},
|
||
): Record<string, unknown> {
|
||
return {
|
||
id: "event-1",
|
||
type: "permission.asked",
|
||
properties: {
|
||
id: "permission-1",
|
||
sessionID: "session-1",
|
||
permission: "bash",
|
||
patterns: ["npm test"],
|
||
metadata: { command: "npm test" },
|
||
always: ["npm test"],
|
||
tool: {
|
||
messageID: "message-1",
|
||
callID: "call-1",
|
||
},
|
||
...overrides,
|
||
},
|
||
};
|
||
}
|
||
|
||
function completedToolEvent(
|
||
callId = "call-1",
|
||
tool = "bash",
|
||
): Record<string, unknown> {
|
||
return {
|
||
id: `event-tool-${callId}`,
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: `part-${callId}`,
|
||
callID: callId,
|
||
type: "tool",
|
||
tool,
|
||
state: {
|
||
status: "completed",
|
||
input: {
|
||
command: "npm test",
|
||
token: "visible-token",
|
||
},
|
||
output: "Tests passed\nAuthorization: Bearer secret-token",
|
||
},
|
||
},
|
||
},
|
||
};
|
||
}
|
||
|
||
function runClient(events: Record<string, unknown>[]) {
|
||
const callOrder: string[] = [];
|
||
const permissionReply = vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
});
|
||
const questionReply = vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
});
|
||
const questionReject = vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
});
|
||
const client = {
|
||
session: {
|
||
get: vi.fn().mockResolvedValue({ data: { id: "unrelated-session" } }),
|
||
list: vi.fn().mockResolvedValue({ data: [], error: undefined }),
|
||
create: vi.fn().mockResolvedValue({
|
||
data: { id: "session-1" },
|
||
error: undefined,
|
||
}),
|
||
update: vi.fn().mockResolvedValue({
|
||
data: { id: "session-1" },
|
||
error: undefined,
|
||
}),
|
||
promptAsync: vi.fn().mockImplementation(async () => {
|
||
callOrder.push("prompt");
|
||
return { data: true, error: undefined };
|
||
}),
|
||
abort: vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
}),
|
||
delete: vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
}),
|
||
deleteMessage: vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
}),
|
||
},
|
||
event: {
|
||
subscribe: vi.fn().mockImplementation(async () => {
|
||
callOrder.push("subscribe");
|
||
return {
|
||
stream: (async function* () {
|
||
for (const event of events) {
|
||
yield event;
|
||
}
|
||
})(),
|
||
};
|
||
}),
|
||
},
|
||
permission: {
|
||
reply: permissionReply,
|
||
},
|
||
question: {
|
||
reply: questionReply,
|
||
reject: questionReject,
|
||
},
|
||
mcp: {
|
||
add: vi.fn().mockImplementation(async (input: { name: string }) => ({
|
||
data: {
|
||
[input.name]: { status: "connected" },
|
||
},
|
||
error: undefined,
|
||
response: { status: 200 },
|
||
})),
|
||
disconnect: vi.fn().mockResolvedValue({ data: true, error: undefined }),
|
||
},
|
||
tool: {
|
||
ids: vi.fn().mockResolvedValue({
|
||
data: ["read", "write", "bash", "task"],
|
||
error: undefined,
|
||
}),
|
||
},
|
||
} as unknown as ReturnType<typeof createOpencodeClient>;
|
||
return {
|
||
client,
|
||
callOrder,
|
||
permissionReply,
|
||
questionReply,
|
||
questionReject,
|
||
session: client.session,
|
||
event: client.event,
|
||
tool: client.tool,
|
||
};
|
||
}
|
||
|
||
function embeddedRuntime(
|
||
client: ReturnType<typeof createOpencodeClient>,
|
||
overrides: Partial<ConstructorParameters<typeof OpenCodeRuntime>[0]> = {},
|
||
dependencyOverrides: Partial<OpenCodeRuntimeDependencies> = {},
|
||
): OpenCodeRuntime {
|
||
const child = fakeChild();
|
||
const { deps } = dependencies(child, {
|
||
...dependencyOverrides,
|
||
createClient: vi.fn(() => client) as unknown as typeof createOpencodeClient,
|
||
});
|
||
return new OpenCodeRuntime(options(overrides), deps);
|
||
}
|
||
|
||
async function collectRun(
|
||
runtime: OpenCodeRuntime,
|
||
workMode: "ask" | "execute" = "execute",
|
||
) {
|
||
const events = [];
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "test",
|
||
workMode,
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
events.push(event);
|
||
}
|
||
return events;
|
||
}
|
||
|
||
describe("OpenCodeRuntime embedded launcher", () => {
|
||
it('attributes todos to the current parent message and running call in reused sessions', async () => {
|
||
const setup = runClient([]);
|
||
let previousMessage = 'old-message';
|
||
const items = [{ content: 'task', status: 'pending', priority: 'low' }];
|
||
vi.mocked(setup.event.subscribe).mockImplementation(async () => ({
|
||
stream: (async function* () {
|
||
const current = vi.mocked(setup.session.promptAsync).mock.calls.at(-1)![0]!.messageID!;
|
||
const update = (parentID: string) => ({ type: 'message.updated', properties: { sessionID: 'session-1', info: { id: parentID + '-assistant', sessionID: 'session-1', role: 'assistant', parentID, time: { created: Date.now() } } } });
|
||
const tool = (parentID: string, todos: unknown[], status = 'running', callID = 'call') => ({ type: 'message.part.updated', properties: { sessionID: 'session-1', part: { id: 'part', callID, sessionID: 'session-1', messageID: parentID + '-assistant', type: 'tool', tool: 'todowrite', state: { status, input: { todos } } } } });
|
||
const todo = (sessionID: string, todos: unknown[]) => ({ type: 'todo.updated', properties: { sessionID, todos } });
|
||
yield update(previousMessage);
|
||
yield tool(previousMessage, items);
|
||
yield todo('session-1', items);
|
||
yield update(current);
|
||
yield tool(current, [], 'pending');
|
||
yield todo('session-1', items);
|
||
yield tool(current, items);
|
||
yield todo('child-session', items);
|
||
yield todo('session-1', [{ content: 'late old task', status: 'pending' }]);
|
||
yield todo('session-1', items);
|
||
yield completedToolEvent('call', 'todowrite');
|
||
yield tool(current, [], 'running', 'clear');
|
||
yield todo('session-1', []);
|
||
yield completedToolEvent('clear', 'todowrite');
|
||
previousMessage = current;
|
||
yield { type: 'session.idle', properties: { sessionID: 'session-1' } };
|
||
})()
|
||
}) as never);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
try {
|
||
for (let round = 0; round < 2; round++) {
|
||
const events = await collectRun(runtime);
|
||
expect(events.filter(event => event.type === 'checklist')).toEqual([
|
||
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items } },
|
||
{ type: 'checklist', requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef', checklist: { source: 'opencode', items: [] } }
|
||
]);
|
||
}
|
||
} finally { await runtime.dispose(); }
|
||
});
|
||
it.each(["native", "profile"] as const)(
|
||
"disables unused automatic Git snapshots with %s model configuration",
|
||
async (configuration) => {
|
||
const { deps, spawnMock } = dependencies(fakeChild());
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
modelProfile: configuration === "profile"
|
||
? {
|
||
id: "00000000-0000-4000-8000-000000000011",
|
||
name: "Test model",
|
||
baseUrl: "https://model.example/v1",
|
||
modelName: "test-model",
|
||
protocol: "openai-responses",
|
||
authentication: "none",
|
||
supportsImageInput: false,
|
||
}
|
||
: undefined,
|
||
}),
|
||
deps,
|
||
);
|
||
try {
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
const config = JSON.parse(
|
||
spawnMock.mock.calls[0]?.[2]?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||
);
|
||
expect(config.snapshot).toBe(false);
|
||
expect(config.permission["*"]).toBe("allow");
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
},
|
||
);
|
||
|
||
it("forwards retry scheduling and resumes only on a matching native busy event", async () => {
|
||
const next = Date.now() + 120_000;
|
||
const setup = runClient([
|
||
{
|
||
type: "session.status",
|
||
properties: { sessionID: "another-session", status: {
|
||
type: "retry", attempt: 9, message: "Unrelated retry", next,
|
||
} },
|
||
},
|
||
{
|
||
type: "session.status",
|
||
properties: { sessionID: "session-1", status: {
|
||
type: "retry", attempt: 2, message: "HTTP 503", next,
|
||
} },
|
||
},
|
||
{
|
||
type: "session.status",
|
||
properties: { sessionID: "another-session", status: { type: "busy" } },
|
||
},
|
||
{
|
||
type: "session.status",
|
||
properties: { sessionID: "session-1", status: { type: "busy" } },
|
||
},
|
||
{
|
||
type: "session.status",
|
||
properties: { sessionID: "session-1", status: { type: "busy" } },
|
||
},
|
||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
try {
|
||
const events = await collectRun(runtime);
|
||
const statuses = events.filter((event) => event.type === "status");
|
||
expect(statuses.map((event) => event.message)).toEqual([
|
||
"OpenCode 正在处理请求",
|
||
`OpenCode 等待第 2 次重试(计划 ${
|
||
new Date(next).toLocaleTimeString("zh-CN", { hour12: false })
|
||
}):HTTP 503`,
|
||
"OpenCode 已开始重试,等待后续进展",
|
||
]);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("checks embedded availability without starting OpenCode", async () => {
|
||
const child = fakeChild();
|
||
const {
|
||
deps,
|
||
spawnMock,
|
||
detectBinary,
|
||
validateBinary,
|
||
createClient,
|
||
checkServerHealth,
|
||
} = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
|
||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||
available: true,
|
||
detail: "OpenCode 已配置,将在首次使用时启动",
|
||
});
|
||
expect(detectBinary).toHaveBeenCalledWith("opencode", "", undefined);
|
||
expect(validateBinary).toHaveBeenCalledWith("opencode", "opencode");
|
||
expect(spawnMock).not.toHaveBeenCalled();
|
||
expect(createClient).not.toHaveBeenCalled();
|
||
expect(checkServerHealth).not.toHaveBeenCalled();
|
||
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("reports an unavailable embedded binary without starting OpenCode", async () => {
|
||
const child = fakeChild();
|
||
const {
|
||
deps,
|
||
spawnMock,
|
||
detectBinary,
|
||
validateBinary,
|
||
createClient,
|
||
checkServerHealth,
|
||
} = dependencies(child);
|
||
detectBinary.mockResolvedValueOnce({
|
||
path: "/missing/opencode",
|
||
detail: "OpenCode CLI 已就绪",
|
||
});
|
||
validateBinary.mockResolvedValueOnce({
|
||
detail: "OpenCode CLI 未找到",
|
||
});
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
binaryPath: "/missing/opencode",
|
||
bundledBinaryPath: "/bundled/opencode",
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
await expect(runtime.getStatus()).resolves.toEqual({
|
||
id: "opencode",
|
||
label: "OpenCode",
|
||
available: false,
|
||
supportsToolExecution: true,
|
||
detail: "OpenCode CLI 未找到",
|
||
});
|
||
expect(spawnMock).not.toHaveBeenCalled();
|
||
expect(detectBinary).toHaveBeenCalledWith(
|
||
"opencode",
|
||
"/missing/opencode",
|
||
"/bundled/opencode",
|
||
);
|
||
expect(validateBinary).toHaveBeenCalledWith(
|
||
"opencode",
|
||
"/missing/opencode",
|
||
);
|
||
expect(createClient).not.toHaveBeenCalled();
|
||
expect(checkServerHealth).not.toHaveBeenCalled();
|
||
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("reports a missing embedded model credential without starting OpenCode", async () => {
|
||
const child = fakeChild();
|
||
const {
|
||
deps,
|
||
spawnMock,
|
||
detectBinary,
|
||
validateBinary,
|
||
createClient,
|
||
checkServerHealth,
|
||
} = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
modelProfile: {
|
||
id: "00000000-0000-4000-8000-000000000011",
|
||
name: "Independent model",
|
||
baseUrl: "https://model.example",
|
||
modelName: "private-model",
|
||
protocol: "anthropic-messages",
|
||
authentication: "api-key",
|
||
},
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
await expect(runtime.getStatus()).resolves.toEqual({
|
||
id: "opencode",
|
||
label: "OpenCode",
|
||
available: false,
|
||
supportsToolExecution: true,
|
||
detail: "OpenCode 独立模型连接尚未配置 API Key",
|
||
});
|
||
expect(detectBinary).not.toHaveBeenCalled();
|
||
expect(validateBinary).not.toHaveBeenCalled();
|
||
expect(spawnMock).not.toHaveBeenCalled();
|
||
expect(createClient).not.toHaveBeenCalled();
|
||
expect(checkServerHealth).not.toHaveBeenCalled();
|
||
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("uses the detected binary and passes an absolute config path only through env", async () => {
|
||
const serverChild = fakeChild(314);
|
||
const killerChild = fakeChild(315);
|
||
const detectBinary = vi.fn().mockResolvedValue({
|
||
path: "C:\\Tools\\opencode.exe",
|
||
detail: "OpenCode CLI 已就绪",
|
||
});
|
||
const createClient = vi.fn(() => fakeClient());
|
||
const checkServerHealth = vi.fn().mockResolvedValue(true);
|
||
const spawnMock = vi.fn((command: string) => {
|
||
if (command === "taskkill.exe") {
|
||
queueMicrotask(() => {
|
||
closeChild(serverChild, 0);
|
||
});
|
||
return killerChild;
|
||
}
|
||
return serverChild;
|
||
});
|
||
const configPath = "./private/opencode.json";
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
binaryPath: "C:\\Configured\\opencode.exe",
|
||
configPath,
|
||
launchEnvironmentProvider: () =>
|
||
Object.freeze({
|
||
PATH: "C:\\GoodBuddy\\tools;C:\\Windows",
|
||
OPENAI_API_KEY: "must-not-leak",
|
||
ELECTRON_RUN_AS_NODE: "1",
|
||
}),
|
||
}),
|
||
{
|
||
spawn: spawnMock as unknown as typeof spawn,
|
||
detectBinary,
|
||
createClient: createClient as unknown as typeof createOpencodeClient,
|
||
checkServerHealth,
|
||
platform: "win32",
|
||
},
|
||
);
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
detail: "由 GoodBuddy 以当前用户权限管理本机 OpenCode 进程",
|
||
});
|
||
expect(detectBinary).toHaveBeenCalledWith(
|
||
"opencode",
|
||
"C:\\Configured\\opencode.exe",
|
||
undefined,
|
||
);
|
||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||
1,
|
||
"C:\\Tools\\opencode.exe",
|
||
["serve", "--hostname=127.0.0.1", expect.stringMatching(/^--port=\d+$/u)],
|
||
expect.objectContaining({
|
||
cwd: process.cwd(),
|
||
shell: false,
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
windowsHide: true,
|
||
env: expect.objectContaining({
|
||
OPENCODE_CONFIG: resolve(configPath),
|
||
}),
|
||
}),
|
||
);
|
||
const firstSpawn = spawnMock.mock.calls[0] as unknown as
|
||
[string, string[]] | undefined;
|
||
const serverPort = firstSpawn?.[1]
|
||
?.find((argument) => argument.startsWith("--port="))
|
||
?.slice("--port=".length);
|
||
expect(serverPort).toMatch(/^\d+$/u);
|
||
const serverUrl = `http://127.0.0.1:${serverPort}`;
|
||
const clientOptions = (
|
||
createClient.mock.calls as unknown as Array<
|
||
[
|
||
{
|
||
baseUrl?: string;
|
||
directory?: string;
|
||
headers?: Record<string, string>;
|
||
},
|
||
]
|
||
>
|
||
)[0]?.[0] as
|
||
| {
|
||
baseUrl?: string;
|
||
directory?: string;
|
||
headers?: Record<string, string>;
|
||
}
|
||
| undefined;
|
||
expect(clientOptions).toMatchObject({
|
||
baseUrl: serverUrl,
|
||
directory: process.cwd(),
|
||
headers: {
|
||
Authorization: expect.stringMatching(/^Basic /u),
|
||
},
|
||
});
|
||
expect(checkServerHealth).toHaveBeenCalledWith(
|
||
serverUrl,
|
||
clientOptions?.headers?.Authorization,
|
||
expect.any(AbortSignal),
|
||
);
|
||
const spawnOptions = (
|
||
spawnMock.mock.calls as unknown as Array<
|
||
[string, string[], { env?: NodeJS.ProcessEnv }]
|
||
>
|
||
)[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined;
|
||
expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe("goodbuddy");
|
||
expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).toBeTruthy();
|
||
expect(spawnOptions?.env?.PATH).toBe("C:\\GoodBuddy\\tools;C:\\Windows");
|
||
expect(spawnOptions?.env).not.toHaveProperty("ELECTRON_RUN_AS_NODE");
|
||
expect(
|
||
Buffer.from(
|
||
clientOptions?.headers?.Authorization?.slice(6) ?? "",
|
||
"base64",
|
||
).toString(),
|
||
).toBe(`goodbuddy:${spawnOptions?.env?.OPENCODE_SERVER_PASSWORD}`);
|
||
expect(runtime.requiresToolApproval).toBe(false);
|
||
|
||
await runtime.dispose();
|
||
|
||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||
2,
|
||
"taskkill.exe",
|
||
["/PID", "314", "/T", "/F"],
|
||
{
|
||
shell: false,
|
||
stdio: "ignore",
|
||
windowsHide: true,
|
||
},
|
||
);
|
||
expect(killerChild.unref).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it("registers only assigned Skill packages in an isolated config directory", async () => {
|
||
const sourceRoot = await mkdtemp(
|
||
join(tmpdir(), "goodbuddy-opencode-skill-source-"),
|
||
);
|
||
const skillDirectory = join(sourceRoot, "longdoc-docx");
|
||
await mkdir(join(skillDirectory, "templates"), {
|
||
recursive: true,
|
||
});
|
||
await writeFile(
|
||
join(skillDirectory, "SKILL.md"),
|
||
[
|
||
"---",
|
||
"id: longdoc-docx",
|
||
"name: 长文档",
|
||
"description: Build a DOCX",
|
||
"---",
|
||
"",
|
||
"# Long document",
|
||
].join("\n"),
|
||
"utf8",
|
||
);
|
||
await writeFile(
|
||
join(skillDirectory, "templates", "document.txt"),
|
||
"template",
|
||
"utf8",
|
||
);
|
||
const child = fakeChild();
|
||
const { deps, spawnMock } = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
skillPackages: [
|
||
{
|
||
id: "longdoc-docx",
|
||
directory: skillDirectory,
|
||
},
|
||
],
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
|
||
{ env?: NodeJS.ProcessEnv } | undefined;
|
||
const configDirectory = spawnOptions?.env?.OPENCODE_CONFIG_DIR;
|
||
expect(configDirectory).toBeTruthy();
|
||
const registrationRoot = resolve(configDirectory!, "..");
|
||
const registeredSkill = join(configDirectory!, "skills", "longdoc-docx");
|
||
try {
|
||
await expect(
|
||
readFile(join(registeredSkill, "templates", "document.txt"), "utf8"),
|
||
).resolves.toBe("template");
|
||
const registeredManifest = await readFile(
|
||
join(registeredSkill, "SKILL.md"),
|
||
"utf8",
|
||
);
|
||
expect(registeredManifest).toContain("name: longdoc-docx");
|
||
expect(registeredManifest).not.toContain("id: longdoc-docx");
|
||
const config = JSON.parse(
|
||
spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||
) as Record<string, unknown>;
|
||
expect(config).toEqual({
|
||
snapshot: false,
|
||
skills: {
|
||
paths: [join(configDirectory!, "skills")],
|
||
urls: [],
|
||
},
|
||
permission: {
|
||
"*": "allow",
|
||
skill: {
|
||
"*": "deny",
|
||
"longdoc-docx": "allow",
|
||
},
|
||
},
|
||
});
|
||
expect(spawnOptions?.env).toMatchObject({
|
||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
|
||
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
|
||
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
||
XDG_CACHE_HOME: join(registrationRoot, "xdg-cache"),
|
||
XDG_CONFIG_HOME: join(registrationRoot, "xdg-config"),
|
||
XDG_DATA_HOME: join(registrationRoot, "xdg-data"),
|
||
XDG_STATE_HOME: join(registrationRoot, "xdg-state"),
|
||
});
|
||
} finally {
|
||
await runtime.dispose();
|
||
await rm(sourceRoot, { recursive: true, force: true });
|
||
}
|
||
await expect(stat(registrationRoot)).rejects.toThrow();
|
||
});
|
||
|
||
it.each(["1.18.29", "0.0.1"])("handles cached plugin %s without ready markers", async (cachedVersion) => {
|
||
const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-version-"));
|
||
const bundledConfigPath = join(root, "bundle");
|
||
const sharedCacheRoot = join(root, "cache");
|
||
const target = join(sharedCacheRoot, "config", "opencode");
|
||
const packagePath = join("node_modules", "@opencode-ai", "plugin", "package.json");
|
||
for (const [directory, version] of [[bundledConfigPath, "1.18.29"], [target, cachedVersion]]) {
|
||
await mkdir(join(directory!, "node_modules", "@opencode-ai", "plugin"), { recursive: true });
|
||
await writeFile(join(directory!, packagePath), JSON.stringify({ version }));
|
||
}
|
||
await writeFile(join(target, "cached-only"), "old cache");
|
||
const { deps } = dependencies(fakeChild());
|
||
const runtime = new OpenCodeRuntime(options({ bundledConfigPath, sharedCacheRoot }), deps);
|
||
try {
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
|
||
expect(JSON.parse(await readFile(join(target, packagePath), "utf8")).version).toBe("1.18.29");
|
||
if (cachedVersion === "1.18.29") {
|
||
await expect(readFile(join(target, "cached-only"), "utf8")).resolves.toBe("old cache");
|
||
} else {
|
||
await expect(stat(join(target, "cached-only"))).rejects.toThrow();
|
||
}
|
||
} finally {
|
||
await runtime.dispose();
|
||
await rm(root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("reuses shared OpenCode config and content-addressed Skills across projects", async () => {
|
||
const sourceRoot = await mkdtemp(
|
||
join(tmpdir(), "goodbuddy-opencode-shared-skill-source-"),
|
||
);
|
||
const sharedCacheRoot = await mkdtemp(
|
||
join(tmpdir(), "goodbuddy-opencode-shared-cache-"),
|
||
);
|
||
const skillDirectory = join(sourceRoot, "shared-skill");
|
||
const bundledConfigPath = join(sourceRoot, "bundled-config");
|
||
await mkdir(skillDirectory, { recursive: true });
|
||
await mkdir(
|
||
join(bundledConfigPath, "node_modules", "@opencode-ai", "plugin"),
|
||
{ recursive: true },
|
||
);
|
||
await writeFile(
|
||
join(
|
||
bundledConfigPath,
|
||
"node_modules",
|
||
"@opencode-ai",
|
||
"plugin",
|
||
"package.json",
|
||
),
|
||
'{"name":"@opencode-ai/plugin","version":"1.18.29"}\n',
|
||
"utf8",
|
||
);
|
||
await writeFile(
|
||
join(skillDirectory, "SKILL.md"),
|
||
[
|
||
"---",
|
||
"name: Shared Skill",
|
||
"description: Shared test Skill",
|
||
"---",
|
||
"",
|
||
"# Shared Skill",
|
||
].join("\n"),
|
||
"utf8",
|
||
);
|
||
const skillPackages = [
|
||
{
|
||
id: "shared-skill",
|
||
directory: skillDirectory,
|
||
digest: "shared-skill-digest",
|
||
},
|
||
];
|
||
const firstChild = fakeChild();
|
||
const first = dependencies(firstChild);
|
||
const firstRuntime = new OpenCodeRuntime(
|
||
options({
|
||
sharedCacheRoot,
|
||
skillPackages,
|
||
bundledConfigPath,
|
||
}),
|
||
first.deps,
|
||
);
|
||
const secondChild = fakeChild();
|
||
const second = dependencies(secondChild);
|
||
const secondRuntime = new OpenCodeRuntime(
|
||
options({
|
||
sharedCacheRoot,
|
||
skillPackages,
|
||
bundledConfigPath,
|
||
}),
|
||
second.deps,
|
||
);
|
||
|
||
try {
|
||
await expect(firstRuntime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
const firstEnvironment = first.spawnMock.mock.calls[0]?.[2]?.env;
|
||
const firstConfigDirectory = firstEnvironment?.OPENCODE_CONFIG_DIR ?? "";
|
||
const firstConfig = JSON.parse(
|
||
firstEnvironment?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||
) as { skills?: { paths?: string[] } };
|
||
const firstSkillsRoot = firstConfig.skills?.paths?.[0] ?? "";
|
||
expect(firstConfigDirectory).toBe(
|
||
join(sharedCacheRoot, "config", "opencode"),
|
||
);
|
||
expect(firstEnvironment?.XDG_CONFIG_HOME).toBe(
|
||
join(sharedCacheRoot, "config"),
|
||
);
|
||
await expect(
|
||
readFile(
|
||
join(
|
||
firstConfigDirectory,
|
||
"node_modules",
|
||
"@opencode-ai",
|
||
"plugin",
|
||
"package.json",
|
||
),
|
||
"utf8",
|
||
),
|
||
).resolves.toContain("1.18.29");
|
||
expect(firstEnvironment?.XDG_CACHE_HOME).toBe(
|
||
join(sharedCacheRoot, "cache"),
|
||
);
|
||
expect(firstEnvironment).toMatchObject({
|
||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1",
|
||
});
|
||
await firstRuntime.dispose();
|
||
await expect(
|
||
readFile(join(firstSkillsRoot, "shared-skill", "SKILL.md"), "utf8"),
|
||
).resolves.toContain("name: shared-skill");
|
||
|
||
await expect(secondRuntime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
const secondEnvironment = second.spawnMock.mock.calls[0]?.[2]?.env;
|
||
const secondConfig = JSON.parse(
|
||
secondEnvironment?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||
) as { skills?: { paths?: string[] } };
|
||
expect(secondEnvironment?.OPENCODE_CONFIG_DIR).toBe(firstConfigDirectory);
|
||
expect(secondConfig.skills?.paths?.[0]).toBe(firstSkillsRoot);
|
||
} finally {
|
||
await firstRuntime.dispose();
|
||
await secondRuntime.dispose();
|
||
await rm(sourceRoot, { recursive: true, force: true });
|
||
await rm(sharedCacheRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("injects an independent model profile without persisting its key", async () => {
|
||
const child = fakeChild();
|
||
const { deps, spawnMock } = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
modelProfile: {
|
||
id: "00000000-0000-4000-8000-000000000011",
|
||
name: "独立模型",
|
||
baseUrl: "https://model.example",
|
||
modelName: "private-model",
|
||
apiKey: "private-key",
|
||
protocol: "anthropic-messages",
|
||
authentication: "api-key",
|
||
supportsImageInput: true,
|
||
},
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
|
||
{ env?: NodeJS.ProcessEnv } | undefined;
|
||
const config = JSON.parse(
|
||
spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||
) as Record<string, unknown>;
|
||
expect(config).toMatchObject({
|
||
model: "goodbuddy-anthropic/private-model",
|
||
provider: {
|
||
"goodbuddy-anthropic": {
|
||
npm: "@ai-sdk/anthropic",
|
||
options: {
|
||
apiKey: "private-key",
|
||
baseURL: "https://model.example/v1",
|
||
},
|
||
models: {
|
||
"private-model": {
|
||
attachment: true,
|
||
modalities: {
|
||
input: ["text", "image"],
|
||
output: ["text"],
|
||
},
|
||
provider: {
|
||
npm: "@ai-sdk/anthropic",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("isolates an explicit profile from unrelated inherited credentials", async () => {
|
||
const child = fakeChild();
|
||
const { deps, spawnMock } = dependencies(child);
|
||
const inheritedCredentials = {
|
||
ANTHROPIC_API_KEY: "inherited-anthropic",
|
||
OPENAI_API_KEY: "inherited-openai",
|
||
GOOGLE_GENERATIVE_AI_API_KEY: "inherited-google",
|
||
GEMINI_API_KEY: "inherited-gemini",
|
||
AWS_ACCESS_KEY_ID: "inherited-aws-access",
|
||
AWS_SECRET_ACCESS_KEY: "inherited-aws-secret",
|
||
AWS_SESSION_TOKEN: "inherited-aws-session",
|
||
AWS_PROFILE: "inherited-aws-profile",
|
||
OPENROUTER_API_KEY: "inherited-openrouter",
|
||
};
|
||
const previousEnvironment = Object.fromEntries(
|
||
Object.keys(inheritedCredentials).map((name) => [
|
||
name,
|
||
process.env[name],
|
||
]),
|
||
);
|
||
Object.assign(process.env, inheritedCredentials);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
modelProfile: {
|
||
id: "00000000-0000-4000-8000-000000000014",
|
||
name: "Explicit OpenAI profile",
|
||
baseUrl: "https://model.example/v1",
|
||
modelName: "private-model",
|
||
protocol: "openai-responses",
|
||
authentication: "api-key",
|
||
apiKey: "selected-openai-key",
|
||
},
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
try {
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
const environment = (
|
||
spawnMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv } | undefined
|
||
)?.env;
|
||
expect(environment?.OPENAI_API_KEY).toBe("selected-openai-key");
|
||
for (const name of Object.keys(inheritedCredentials)) {
|
||
if (name !== "OPENAI_API_KEY") {
|
||
expect(environment).not.toHaveProperty(name);
|
||
}
|
||
}
|
||
} finally {
|
||
await runtime.dispose();
|
||
for (const [name, value] of Object.entries(previousEnvironment)) {
|
||
if (value === undefined) {
|
||
delete process.env[name];
|
||
} else {
|
||
process.env[name] = value;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
it.each([
|
||
{
|
||
label: "Chat Completions",
|
||
protocol: "openai-chat-completions" as const,
|
||
expectedPath: "/v1/chat/completions",
|
||
unexpectedPath: "/v1/responses",
|
||
},
|
||
{
|
||
label: "Responses",
|
||
protocol: "openai-responses" as const,
|
||
expectedPath: "/v1/responses",
|
||
unexpectedPath: "/v1/chat/completions",
|
||
},
|
||
])(
|
||
"routes a custom-base $label profile through the bundled OpenCode provider",
|
||
async ({ protocol, expectedPath, unexpectedPath }) => {
|
||
const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-routing-"));
|
||
const requestPaths: string[] = [];
|
||
const tenantHeaders: Array<string | string[] | undefined> = [];
|
||
const requestBodies: Array<Record<string, unknown>> = [];
|
||
const server = createServer(async (request, response) => {
|
||
requestPaths.push(request.url ?? "");
|
||
tenantHeaders.push(request.headers["x-tenant-id"]);
|
||
let body = "";
|
||
for await (const chunk of request) {
|
||
body += String(chunk);
|
||
}
|
||
if (body) {
|
||
requestBodies.push(JSON.parse(body) as Record<string, unknown>);
|
||
}
|
||
response.writeHead(400, {
|
||
"content-type": "application/json",
|
||
});
|
||
response.end(
|
||
JSON.stringify({
|
||
error: {
|
||
message: "Intentional local routing probe",
|
||
},
|
||
}),
|
||
);
|
||
});
|
||
await new Promise<void>((resolveListen, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", () => resolveListen());
|
||
});
|
||
const address = server.address();
|
||
if (!address || typeof address === "string") {
|
||
throw new Error("Failed to bind local routing probe");
|
||
}
|
||
const isolatedEnvironment = {
|
||
APPDATA: join(root, "appdata"),
|
||
HOME: root,
|
||
LOCALAPPDATA: join(root, "localappdata"),
|
||
USERPROFILE: root,
|
||
} as const;
|
||
const previousEnvironment = Object.fromEntries(
|
||
Object.keys(isolatedEnvironment).map((name) => [
|
||
name,
|
||
process.env[name],
|
||
]),
|
||
);
|
||
Object.assign(process.env, isolatedEnvironment);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
binaryPath: join(
|
||
process.cwd(),
|
||
"node_modules",
|
||
"opencode-ai",
|
||
"bin",
|
||
"opencode.exe",
|
||
),
|
||
defaultWorkspace: root,
|
||
modelProfile: {
|
||
id: "00000000-0000-4000-8000-000000000013",
|
||
name: "Local endpoint probe",
|
||
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
||
modelName: "probe-model",
|
||
protocol,
|
||
authentication: "api-key",
|
||
apiKey: "local-probe-key",
|
||
requestHeaders: {
|
||
"x-tenant-id": "opencode-native",
|
||
},
|
||
requestBody: {
|
||
goodbuddy_unsupported_probe: "must-not-send",
|
||
},
|
||
},
|
||
}),
|
||
{ startupTimeoutMs: 20_000 },
|
||
);
|
||
const controller = new AbortController();
|
||
const timeout = setTimeout(
|
||
() => controller.abort(new Error("Routing probe timed out")),
|
||
20_000,
|
||
);
|
||
try {
|
||
let failure = "";
|
||
await (async () => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "routing-probe",
|
||
prompt: "Reply with OK",
|
||
workMode: "execute",
|
||
},
|
||
controller.signal,
|
||
)) {
|
||
// The local probe intentionally returns an upstream error.
|
||
void _event;
|
||
}
|
||
})().catch((error) => {
|
||
failure = error instanceof Error ? error.message : String(error);
|
||
});
|
||
if (requestPaths.length === 0) {
|
||
throw new Error(`OpenCode routing probe failed: ${failure}`);
|
||
}
|
||
expect(requestPaths).toContain(expectedPath);
|
||
expect(requestPaths).not.toContain(unexpectedPath);
|
||
expect(tenantHeaders).toContain("opencode-native");
|
||
expect(requestBodies).not.toContainEqual(
|
||
expect.objectContaining({
|
||
goodbuddy_unsupported_probe: "must-not-send",
|
||
}),
|
||
);
|
||
} finally {
|
||
clearTimeout(timeout);
|
||
await runtime.dispose();
|
||
for (const [name, value] of Object.entries(previousEnvironment)) {
|
||
if (value === undefined) {
|
||
delete process.env[name];
|
||
} else {
|
||
process.env[name] = value;
|
||
}
|
||
}
|
||
await new Promise<void>((resolveClose, reject) => {
|
||
server.close((error) => (error ? reject(error) : resolveClose()));
|
||
});
|
||
await rm(root, { recursive: true, force: true });
|
||
}
|
||
},
|
||
30_000,
|
||
);
|
||
|
||
it.each([
|
||
{
|
||
protocol: "openai-chat-completions" as const,
|
||
authentication: "none" as const,
|
||
providerId: "goodbuddy-openai-chat",
|
||
providerPackage: "@ai-sdk/openai-compatible",
|
||
},
|
||
{
|
||
protocol: "openai-responses" as const,
|
||
authentication: "api-key" as const,
|
||
providerId: "goodbuddy-openai-responses",
|
||
providerPackage: "@ai-sdk/openai",
|
||
},
|
||
])(
|
||
"generates an explicit $protocol provider configuration",
|
||
async ({ protocol, authentication, providerId, providerPackage }) => {
|
||
const child = fakeChild();
|
||
const { deps, spawnMock } = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
modelProfile: {
|
||
id: "00000000-0000-4000-8000-000000000012",
|
||
name: "OpenAI 独立模型",
|
||
baseUrl: "https://model.example/v1",
|
||
modelName: "custom-model",
|
||
protocol,
|
||
authentication,
|
||
requestHeaders: {
|
||
"x-tenant-id": "opencode-tenant",
|
||
},
|
||
requestBody: {
|
||
temperature: 0.25,
|
||
},
|
||
...(authentication === "api-key" ? { apiKey: "private-key" } : {}),
|
||
},
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
|
||
{ env?: NodeJS.ProcessEnv } | undefined;
|
||
const config = JSON.parse(
|
||
spawnOptions?.env?.OPENCODE_CONFIG_CONTENT ?? "{}",
|
||
) as {
|
||
model?: string;
|
||
provider?: Record<
|
||
string,
|
||
{
|
||
npm?: string;
|
||
options?: Record<string, unknown>;
|
||
models?: Record<string, { provider?: { npm?: string } }>;
|
||
}
|
||
>;
|
||
};
|
||
expect(config.model).toBe(`${providerId}/custom-model`);
|
||
expect(config.provider?.[providerId]).toMatchObject({
|
||
npm: providerPackage,
|
||
options: {
|
||
baseURL: "https://model.example/v1",
|
||
headers: {
|
||
"x-tenant-id": "opencode-tenant",
|
||
},
|
||
},
|
||
models: {
|
||
"custom-model": {
|
||
provider: {
|
||
npm: providerPackage,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
if (authentication === "api-key") {
|
||
expect(config.provider?.[providerId]?.options?.apiKey).toBe(
|
||
"private-key",
|
||
);
|
||
} else {
|
||
expect(config.provider?.[providerId]?.options).not.toHaveProperty(
|
||
"apiKey",
|
||
);
|
||
}
|
||
expect(JSON.stringify(config)).not.toContain("temperature");
|
||
await runtime.dispose();
|
||
},
|
||
);
|
||
|
||
it("isolates embedded server configuration from inherited env", async () => {
|
||
const child = fakeChild();
|
||
const { deps, spawnMock } = dependencies(child);
|
||
const isolatedNames = [
|
||
"OPENCODE_CONFIG",
|
||
"OPENCODE_CONFIG_CONTENT",
|
||
"OPENCODE_CONFIG_DIR",
|
||
"OPENCODE_SERVER_PASSWORD",
|
||
"OPENCODE_SERVER_USERNAME",
|
||
] as const;
|
||
const inherited = Object.fromEntries(
|
||
isolatedNames.map((name) => [name, process.env[name]]),
|
||
);
|
||
const inheritedOtel = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||
for (const name of isolatedNames) {
|
||
process.env[name] = "must-not-be-inherited";
|
||
}
|
||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://telemetry.invalid";
|
||
try {
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
|
||
const spawnOptions = spawnMock.mock.calls[0]?.[2] as
|
||
{ env?: NodeJS.ProcessEnv } | undefined;
|
||
expect(spawnOptions?.env?.OPENCODE_CONFIG).toBeUndefined();
|
||
expect(spawnOptions?.env?.OPENCODE_CONFIG_CONTENT).not.toBe(
|
||
"must-not-be-inherited",
|
||
);
|
||
expect(spawnOptions?.env?.OPENCODE_CONFIG_DIR).not.toBe(
|
||
"must-not-be-inherited",
|
||
);
|
||
expect(spawnOptions?.env?.OPENCODE_SERVER_USERNAME).toBe("goodbuddy");
|
||
expect(spawnOptions?.env?.OPENCODE_SERVER_PASSWORD).not.toBe(
|
||
"must-not-be-inherited",
|
||
);
|
||
expect(spawnOptions?.env).toMatchObject({
|
||
DO_NOT_TRACK: "1",
|
||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: "1",
|
||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: "1",
|
||
OPENCODE_DISABLE_EXTERNAL_SKILLS: "1",
|
||
OPENCODE_DISABLE_LSP_DOWNLOAD: "1",
|
||
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
||
OPENCODE_DISABLE_SHARE: "1",
|
||
OTEL_EXPORTER_OTLP_ENDPOINT: "",
|
||
OTEL_EXPORTER_OTLP_HEADERS: "",
|
||
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: "",
|
||
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "",
|
||
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "",
|
||
OTEL_SDK_DISABLED: "true",
|
||
});
|
||
await runtime.dispose();
|
||
} finally {
|
||
for (const name of isolatedNames) {
|
||
const value = inherited[name];
|
||
if (value === undefined) {
|
||
delete process.env[name];
|
||
} else {
|
||
process.env[name] = value;
|
||
}
|
||
}
|
||
if (inheritedOtel === undefined) {
|
||
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||
} else {
|
||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = inheritedOtel;
|
||
}
|
||
}
|
||
});
|
||
|
||
it("polls the known loopback URL without waiting for stdout", async () => {
|
||
const child = fakeChild();
|
||
const { deps, checkServerHealth, createClient } = dependencies(child, {
|
||
startupTimeoutMs: 1_000,
|
||
});
|
||
checkServerHealth.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
expect(checkServerHealth).toHaveBeenCalledTimes(2);
|
||
const [url, authorization, signal] = checkServerHealth.mock.calls[0] ?? [];
|
||
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||
expect(authorization).toMatch(/^Basic /);
|
||
expect(signal).toBeInstanceOf(AbortSignal);
|
||
expect(createClient).toHaveBeenCalledWith({
|
||
baseUrl: url,
|
||
directory: process.cwd(),
|
||
headers: { Authorization: authorization },
|
||
});
|
||
});
|
||
|
||
it("keeps a healthy cached server and client after idle", async () => {
|
||
const child = fakeChild();
|
||
const { deps, checkServerHealth, createClient, spawnMock } = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
try {
|
||
await runtime.testConnection();
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
|
||
expect(checkServerHealth).toHaveBeenCalledTimes(2);
|
||
expect(createClient).toHaveBeenCalledOnce();
|
||
expect(spawnMock).toHaveBeenCalledOnce();
|
||
expect(child.kill).not.toHaveBeenCalled();
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("does not replace a shared server while another conversation is running", async () => {
|
||
const setup = runClient([
|
||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
let release!: () => void;
|
||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||
vi.mocked(setup.event.subscribe).mockImplementationOnce(async () => ({
|
||
stream: (async function* () {
|
||
await gate;
|
||
yield { type: "session.idle", properties: { sessionID: "session-1" } };
|
||
})(),
|
||
}) as never);
|
||
const child = fakeChild();
|
||
const replacement = fakeChild(43);
|
||
const { deps, checkServerHealth, spawnMock } = dependencies(child, {
|
||
createClient: () => setup.client,
|
||
});
|
||
spawnMock.mockReturnValueOnce(child).mockReturnValue(replacement);
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
const first = collectRun(runtime);
|
||
try {
|
||
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce());
|
||
checkServerHealth.mockResolvedValueOnce(false);
|
||
const peerEvents: RuntimeEvent[] = [];
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: "peer", conversationId: "peer-conversation",
|
||
prompt: "test", workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
)) peerEvents.push(event);
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
|
||
expect(peerEvents.at(-1)).toMatchObject({ type: "done" });
|
||
expect(checkServerHealth).toHaveBeenCalledOnce();
|
||
expect(child.kill).not.toHaveBeenCalled();
|
||
expect(spawnMock).toHaveBeenCalledOnce();
|
||
release();
|
||
await expect(first).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
|
||
expect(child.kill).toHaveBeenCalledOnce();
|
||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||
} finally {
|
||
release();
|
||
await first.catch(() => undefined);
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it.each(["exit", "dispose"])(
|
||
"settles an active request when its embedded server ends through %s",
|
||
async (ending) => {
|
||
const setup = runClient([]);
|
||
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
|
||
stream: (async function* () {
|
||
const signal = options!.signal!;
|
||
await new Promise<void>((_resolve, reject) => {
|
||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||
});
|
||
yield { type: "session.idle", properties: { sessionID: "session-1" } };
|
||
})(),
|
||
}) as never);
|
||
const child = fakeChild();
|
||
const { deps } = dependencies(child, { createClient: () => setup.client });
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
const controller = new AbortController();
|
||
let settled = false;
|
||
const events: RuntimeEvent[] = [];
|
||
const result = (async () => {
|
||
try {
|
||
for await (const event of runtime.run(
|
||
{ requestId: "active", conversationId: "active", prompt: "test", workMode: "execute" },
|
||
controller.signal,
|
||
)) events.push(event);
|
||
} catch (error) {
|
||
return error;
|
||
} finally {
|
||
settled = true;
|
||
}
|
||
})();
|
||
try {
|
||
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledOnce());
|
||
if (ending === "exit") closeChild(child, 1);
|
||
else await runtime.dispose();
|
||
await vi.waitFor(() => expect(settled).toBe(true), { timeout: 300 });
|
||
await expect(result).resolves.toMatchObject({
|
||
message: expect.stringMatching(/OpenCode 服务.*(?:退出|关闭)/u),
|
||
});
|
||
expect(controller.signal.aborted).toBe(false);
|
||
expect(events.some((event) => event.type === "done")).toBe(false);
|
||
} finally {
|
||
controller.abort();
|
||
await result;
|
||
await runtime.dispose();
|
||
}
|
||
},
|
||
);
|
||
|
||
it("cancels an event-stream retry delay when its server exits", async () => {
|
||
const setup = runClient([]);
|
||
let retrying = false;
|
||
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({
|
||
stream: (async function* () {
|
||
retrying = true;
|
||
await (options as { sseSleepFn: (milliseconds: number) => Promise<void> })
|
||
.sseSleepFn(30_000);
|
||
yield { type: "session.idle", properties: { sessionID: "session-1" } };
|
||
})(),
|
||
}) as never);
|
||
const child = fakeChild();
|
||
const { deps } = dependencies(child, { createClient: () => setup.client });
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
const result = collectRun(runtime).catch((error: unknown) => error);
|
||
try {
|
||
await vi.waitFor(() => expect(retrying).toBe(true));
|
||
closeChild(child, 1);
|
||
await expect(result).resolves.toMatchObject({
|
||
message: expect.stringContaining("OpenCode 服务已退出"),
|
||
});
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it.each(["complete", "exit"])(
|
||
"protects an active Compact until it ends through %s",
|
||
async (ending) => {
|
||
const setup = runClient([
|
||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
let release!: () => void;
|
||
const summarize = vi.fn((_input, options: { signal: AbortSignal }) =>
|
||
new Promise((resolve, reject) => {
|
||
release = () => resolve({ data: true });
|
||
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
|
||
}));
|
||
Object.assign(setup.client, {
|
||
v2: { session: { context: vi.fn().mockResolvedValue({
|
||
data: { data: [{ type: "assistant", model: { providerID: "test", id: "test" } }] },
|
||
}) } },
|
||
});
|
||
Object.assign(setup.client.session, { summarize });
|
||
const child = fakeChild();
|
||
const { deps, checkServerHealth } = dependencies(child, { createClient: () => setup.client });
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
try {
|
||
await collectRun(runtime);
|
||
const compact = runtime.compactConversation({
|
||
requestId: "compact", conversationId: "conversation-1",
|
||
runtimeSelection: { provider: "opencode" }, history: [], historyMessageIds: [],
|
||
}, new AbortController().signal).catch((error: unknown) => error);
|
||
await vi.waitFor(() => expect(summarize).toHaveBeenCalledOnce());
|
||
const probes = checkServerHealth.mock.calls.length;
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({ available: true });
|
||
expect(checkServerHealth).toHaveBeenCalledTimes(probes);
|
||
expect(child.kill).not.toHaveBeenCalled();
|
||
if (ending === "exit") {
|
||
closeChild(child, 1);
|
||
await expect(compact).resolves.toMatchObject({
|
||
message: expect.stringContaining("OpenCode 服务已退出"),
|
||
});
|
||
} else {
|
||
release();
|
||
await expect(compact).resolves.toMatchObject({ result: { compacted: true } });
|
||
await runtime.testConnection();
|
||
expect(checkServerHealth).toHaveBeenCalledTimes(probes + 1);
|
||
}
|
||
} finally {
|
||
release?.();
|
||
await runtime.dispose();
|
||
}
|
||
},
|
||
);
|
||
|
||
it.each(["unhealthy", "rejected", "hung"])(
|
||
"replaces a %s cached server once for concurrent callers",
|
||
async (failure) => {
|
||
const child = fakeChild();
|
||
const replacement = fakeChild(43);
|
||
const { deps, checkServerHealth, createClient, spawnMock } = dependencies(child);
|
||
spawnMock.mockReturnValueOnce(child).mockReturnValue(replacement);
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
try {
|
||
await runtime.testConnection();
|
||
if (failure === "hung") {
|
||
checkServerHealth.mockImplementationOnce(() => new Promise(() => {}));
|
||
} else if (failure === "rejected") {
|
||
checkServerHealth.mockRejectedValueOnce(new Error("fetch failed"));
|
||
} else {
|
||
checkServerHealth.mockResolvedValueOnce(false);
|
||
}
|
||
const statuses = await Promise.all([
|
||
runtime.testConnection(),
|
||
runtime.testConnection(),
|
||
]);
|
||
expect(statuses.every((status) => status.available)).toBe(true);
|
||
expect(child.kill).toHaveBeenCalledOnce();
|
||
expect(replacement.kill).not.toHaveBeenCalled();
|
||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||
expect(createClient).toHaveBeenCalledTimes(2);
|
||
expect(checkServerHealth).toHaveBeenCalledTimes(3);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
},
|
||
);
|
||
|
||
it("creates a new session after replacing a stale server without replaying prompts", async () => {
|
||
const harness = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const child = fakeChild();
|
||
const { deps, checkServerHealth, spawnMock } = dependencies(child, {
|
||
createClient: () => harness.client,
|
||
});
|
||
spawnMock.mockReturnValueOnce(child).mockReturnValue(fakeChild(43));
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
try {
|
||
await collectRun(runtime, "execute");
|
||
checkServerHealth.mockResolvedValueOnce(false);
|
||
await collectRun(runtime, "execute");
|
||
expect(harness.session.create).toHaveBeenCalledTimes(2);
|
||
expect(harness.session.update).not.toHaveBeenCalled();
|
||
expect(harness.session.promptAsync).toHaveBeenCalledTimes(2);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("isolates cancellation while concurrent requests wait for a cached health check", async () => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const child = fakeChild();
|
||
const { deps, checkServerHealth, spawnMock } = dependencies(child, {
|
||
createClient: () => setup.client,
|
||
});
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
let resolveHealth!: (healthy: boolean) => void;
|
||
const health = new Promise<boolean>((resolve) => { resolveHealth = resolve; });
|
||
const runs: Promise<unknown>[] = [];
|
||
try {
|
||
await runtime.testConnection();
|
||
const controller = new AbortController();
|
||
checkServerHealth.mockReturnValueOnce(health);
|
||
const stream = runtime.run(
|
||
{ requestId: "cancelled", conversationId: "conversation-cancelled", prompt: "test" },
|
||
controller.signal,
|
||
);
|
||
const cancelled = stream.next().catch((error: unknown) => error);
|
||
runs.push(cancelled);
|
||
await vi.waitFor(() => expect(checkServerHealth).toHaveBeenCalledTimes(2));
|
||
const waiting = collectRun(runtime);
|
||
runs.push(waiting);
|
||
// Let the second request acquire its conversation lock and join the probe.
|
||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||
controller.abort(new Error("cancelled"));
|
||
await expect(cancelled).resolves.toMatchObject({ message: "cancelled" });
|
||
expect(checkServerHealth.mock.calls[1]![2].aborted).toBe(false);
|
||
// A third caller must still join the pending probe after A exits.
|
||
const third = runtime.testConnection();
|
||
runs.push(third);
|
||
expect(checkServerHealth).toHaveBeenCalledTimes(2);
|
||
resolveHealth(true);
|
||
await expect(waiting).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
|
||
await expect(third).resolves.toMatchObject({ available: true });
|
||
expect(child.kill).not.toHaveBeenCalled();
|
||
expect(spawnMock).toHaveBeenCalledOnce();
|
||
expect(checkServerHealth).toHaveBeenCalledTimes(2);
|
||
} finally {
|
||
resolveHealth(true);
|
||
await Promise.allSettled(runs);
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("allows ARM-class startup to become healthy after ten seconds", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
const child = fakeChild();
|
||
let healthy = false;
|
||
const checkServerHealth = vi.fn(async () => healthy);
|
||
const { deps } = dependencies(child, {
|
||
checkServerHealth,
|
||
});
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
let settled = false;
|
||
const statusPromise = runtime.testConnection().finally(() => {
|
||
settled = true;
|
||
});
|
||
|
||
await vi.advanceTimersByTimeAsync(10_100);
|
||
expect(settled).toBe(false);
|
||
|
||
healthy = true;
|
||
await vi.advanceTimersByTimeAsync(100);
|
||
await expect(statusPromise).resolves.toMatchObject({
|
||
available: true,
|
||
});
|
||
expect(checkServerHealth).toHaveBeenCalled();
|
||
|
||
await runtime.dispose();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("times out, terminates the process tree, and does not expose stderr", async () => {
|
||
const child = fakeChild();
|
||
const secret = "private-config-token";
|
||
const { deps } = dependencies(child, {
|
||
startupTimeoutMs: 5,
|
||
checkServerHealth: vi.fn().mockResolvedValue(false),
|
||
});
|
||
stderrOf(child).write(secret);
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
|
||
const status = await runtime.testConnection();
|
||
|
||
expect(status).toMatchObject({
|
||
available: false,
|
||
detail: "OpenCode Server 启动超时(30 秒)",
|
||
});
|
||
expect(status.detail).not.toContain(secret);
|
||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||
});
|
||
|
||
it("reports early exit without leaking captured stderr", async () => {
|
||
const child = fakeChild();
|
||
const secret = "OPENCODE_CONFIG=/secret/config.json";
|
||
let registrationRoot = "";
|
||
const { deps } = dependencies(child, {
|
||
spawn: vi.fn(
|
||
(
|
||
_command: string,
|
||
_args: string[],
|
||
spawnOptions: { env?: NodeJS.ProcessEnv },
|
||
) => {
|
||
registrationRoot = resolve(
|
||
spawnOptions.env?.OPENCODE_CONFIG_DIR ?? "",
|
||
"..",
|
||
);
|
||
queueMicrotask(() => {
|
||
stderrOf(child).write(secret);
|
||
closeChild(child, 9);
|
||
});
|
||
return child;
|
||
},
|
||
) as unknown as typeof spawn,
|
||
});
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
|
||
const status = await runtime.testConnection();
|
||
|
||
expect(status.detail).toBe("OpenCode Server 启动前退出(code 9)");
|
||
expect(status.detail).not.toContain(secret);
|
||
expect(registrationRoot).toBeTruthy();
|
||
await expect(stat(registrationRoot)).rejects.toThrow();
|
||
});
|
||
|
||
it("terminates startup when the request is aborted", async () => {
|
||
const child = fakeChild();
|
||
const { deps, spawnMock } = dependencies(child, {
|
||
checkServerHealth: vi.fn().mockResolvedValue(false),
|
||
});
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
const controller = new AbortController();
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "test",
|
||
},
|
||
controller.signal,
|
||
);
|
||
|
||
const pending = stream.next();
|
||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||
controller.abort(new Error("sensitive abort reason"));
|
||
|
||
await expect(pending).rejects.toThrow("OpenCode Server 启动已取消");
|
||
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
||
});
|
||
|
||
it("keeps a shared cold start alive when only its first waiter cancels", async () => {
|
||
const child = fakeChild();
|
||
let healthy = false;
|
||
const { deps, spawnMock } = dependencies(child, {
|
||
startupTimeoutMs: 2_000,
|
||
checkServerHealth: vi.fn(async () => healthy),
|
||
});
|
||
const runtime = new OpenCodeRuntime(options(), deps);
|
||
const controller = new AbortController();
|
||
const pending = runtime.run({
|
||
requestId: "cancel-initializer", conversationId: "first",
|
||
prompt: "test", executionWorkspace: resolve("one"),
|
||
}, controller.signal).next();
|
||
const rejected = expect(pending).rejects.toThrow("启动已取消");
|
||
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce());
|
||
const peer = runtime.testConnection();
|
||
controller.abort();
|
||
await rejected;
|
||
expect(child.kill).not.toHaveBeenCalled();
|
||
healthy = true;
|
||
await expect(peer).resolves.toMatchObject({ available: true });
|
||
expect(spawnMock).toHaveBeenCalledOnce();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("bounds session setup without imposing a full-run deadline", async () => {
|
||
const harness = runClient([]);
|
||
(
|
||
harness.session.create as unknown as ReturnType<typeof vi.fn>
|
||
).mockImplementation(() => new Promise(() => undefined));
|
||
const runtime = embeddedRuntime(
|
||
harness.client,
|
||
{},
|
||
{ controlRequestTimeoutMs: 5 },
|
||
);
|
||
|
||
await expect(collectRun(runtime)).rejects.toThrow("OpenCode 创建会话超时");
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("keeps external baseUrl mode free of binary detection and spawning", async () => {
|
||
const child = fakeChild();
|
||
const { deps, spawnMock, detectBinary, createClient } = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
await expect(runtime.getStatus()).resolves.toMatchObject({
|
||
available: true,
|
||
detail: "已连接 http://127.0.0.1:4096",
|
||
});
|
||
expect(detectBinary).not.toHaveBeenCalled();
|
||
expect(spawnMock).not.toHaveBeenCalled();
|
||
expect(createClient).toHaveBeenCalledWith({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
directory: process.cwd(),
|
||
});
|
||
expect(runtime.requiresToolApproval).toBe(false);
|
||
});
|
||
|
||
it.each(["completed", "failed", "cancelled", "consumer-return"])(
|
||
"closes the event subscription when a run is %s",
|
||
async (outcome) => {
|
||
const setup = runClient([]);
|
||
const controller = new AbortController();
|
||
let subscriptionSignal: AbortSignal | undefined;
|
||
let abortedBeforeIteratorClose: boolean | undefined;
|
||
const subscribe = setup.event.subscribe as unknown as ReturnType<
|
||
typeof vi.fn
|
||
>;
|
||
subscribe.mockImplementation(
|
||
async (_input, options: { signal: AbortSignal }) => {
|
||
subscriptionSignal = options.signal;
|
||
return {
|
||
stream: (async function* () {
|
||
try {
|
||
if (outcome === "cancelled") {
|
||
controller.abort(new DOMException("Cancelled", "AbortError"));
|
||
return;
|
||
}
|
||
if (outcome === "failed") return;
|
||
yield {
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
};
|
||
} finally {
|
||
abortedBeforeIteratorClose = subscriptionSignal?.aborted;
|
||
}
|
||
})(),
|
||
};
|
||
},
|
||
);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({ embedded: false, baseUrl: "http://127.0.0.1:4096" }),
|
||
{ createClient: (() => setup.client) as typeof createOpencodeClient },
|
||
);
|
||
const collect = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: "test",
|
||
workMode: "execute",
|
||
},
|
||
controller.signal,
|
||
)) {
|
||
if (outcome === "consumer-return" && event.type === "done") break;
|
||
}
|
||
};
|
||
try {
|
||
if (outcome === "failed") {
|
||
await expect(collect()).rejects.toThrow("事件流意外结束");
|
||
} else if (outcome === "cancelled") {
|
||
await expect(collect()).rejects.toMatchObject({ name: "AbortError" });
|
||
} else {
|
||
await collect();
|
||
}
|
||
expect(subscriptionSignal?.aborted).toBe(true);
|
||
if (outcome !== "failed") {
|
||
expect(abortedBeforeIteratorClose).toBe(true);
|
||
}
|
||
expect(controller.signal.aborted).toBe(outcome === "cancelled");
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
},
|
||
);
|
||
|
||
it("serializes external runs that share one conversation session", async () => {
|
||
const child = fakeChild();
|
||
let releaseFirst!: () => void;
|
||
const firstGate = new Promise<void>((resolve) => {
|
||
releaseFirst = resolve;
|
||
});
|
||
let subscriptionCount = 0;
|
||
const promptAsync = vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
});
|
||
const client = {
|
||
session: {
|
||
create: vi.fn().mockResolvedValue({
|
||
data: { id: "session-1" },
|
||
error: undefined,
|
||
}),
|
||
update: vi.fn().mockResolvedValue({
|
||
data: { id: "session-1" },
|
||
error: undefined,
|
||
}),
|
||
promptAsync,
|
||
abort: vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
}),
|
||
},
|
||
event: {
|
||
subscribe: vi.fn().mockImplementation(async () => {
|
||
subscriptionCount += 1;
|
||
const current = subscriptionCount;
|
||
return {
|
||
stream: (async function* () {
|
||
if (current === 1) {
|
||
await firstGate;
|
||
}
|
||
yield {
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
};
|
||
})(),
|
||
};
|
||
}),
|
||
},
|
||
tool: {
|
||
ids: vi.fn().mockResolvedValue({
|
||
data: [],
|
||
error: undefined,
|
||
}),
|
||
},
|
||
} as unknown as ReturnType<typeof createOpencodeClient>;
|
||
const { deps } = dependencies(child, {
|
||
createClient: vi.fn(
|
||
() => client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
});
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
deps,
|
||
);
|
||
const request = {
|
||
requestId: "00000000-0000-4000-8000-000000000101",
|
||
conversationId: "shared-conversation",
|
||
prompt: "first",
|
||
workMode: "execute" as const,
|
||
};
|
||
const collect = async (
|
||
stream: AsyncGenerator<RuntimeEvent, void, void>,
|
||
): Promise<RuntimeEvent[]> => {
|
||
const events: RuntimeEvent[] = [];
|
||
for await (const event of stream) {
|
||
events.push(event);
|
||
}
|
||
return events;
|
||
};
|
||
const first = collect(runtime.run(request, new AbortController().signal));
|
||
await vi.waitFor(() => expect(promptAsync).toHaveBeenCalledTimes(1));
|
||
const second = collect(
|
||
runtime.run(
|
||
{
|
||
...request,
|
||
requestId: "00000000-0000-4000-8000-000000000102",
|
||
prompt: "second",
|
||
},
|
||
new AbortController().signal,
|
||
),
|
||
);
|
||
await Promise.resolve();
|
||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
||
|
||
releaseFirst();
|
||
await first;
|
||
await second;
|
||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each(["cancel", "stream-error"])("waits for delayed session abort before reusing a conversation after %s", async (outcome) => {
|
||
const setup = runClient([]);
|
||
const controller = new AbortController();
|
||
let releaseAbort!: () => void;
|
||
const abortGate = new Promise<void>((resolve) => { releaseAbort = resolve; });
|
||
let endFirst!: () => void;
|
||
const firstGate = new Promise<void>((resolve) => { endFirst = resolve; });
|
||
let secondActive = false;
|
||
let secondInterrupted = false;
|
||
let subscriptions = 0;
|
||
vi.mocked(setup.session.abort).mockImplementation(async () => {
|
||
await abortGate;
|
||
if (secondActive) secondInterrupted = true;
|
||
return { data: true, error: undefined } as never;
|
||
});
|
||
vi.mocked(setup.event.subscribe).mockImplementation(async () => {
|
||
const first = ++subscriptions === 1;
|
||
return {
|
||
stream: (async function* () {
|
||
if (first) {
|
||
await firstGate;
|
||
if (outcome === "stream-error") throw new Error("stream failed");
|
||
return;
|
||
}
|
||
secondActive = true;
|
||
await abortGate;
|
||
// Let every pending abort reach the server before B completes.
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||
if (secondInterrupted) throw new Error("B interrupted by A's late abort");
|
||
yield { type: "session.idle", properties: { sessionID: "session-1" } };
|
||
secondActive = false;
|
||
})(),
|
||
} as never;
|
||
});
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
embedded: false, baseUrl: "http://127.0.0.1:4096",
|
||
});
|
||
const collect = async (requestId: string, signal: AbortSignal) => {
|
||
const events: RuntimeEvent[] = [];
|
||
for await (const event of runtime.run({
|
||
requestId, conversationId: "shared-conversation", prompt: requestId,
|
||
workMode: "execute",
|
||
}, signal)) events.push(event);
|
||
return events;
|
||
};
|
||
const first = collect("A", controller.signal).catch((error: unknown) => error);
|
||
let second: Promise<unknown> | undefined;
|
||
try {
|
||
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1));
|
||
if (outcome === "cancel") controller.abort(new Error("A cancelled"));
|
||
endFirst();
|
||
second = collect("B", new AbortController().signal).catch((error: unknown) => error);
|
||
await vi.waitFor(() => expect(setup.session.abort).toHaveBeenCalled());
|
||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||
const promptsBeforeAbort = vi.mocked(setup.session.promptAsync).mock.calls.length;
|
||
releaseAbort();
|
||
expect(await first).toMatchObject({ message: outcome === "cancel" ? "A cancelled" : "stream failed" });
|
||
expect(await second).toEqual(expect.arrayContaining([expect.objectContaining({ type: "done" })]));
|
||
expect(promptsBeforeAbort).toBe(1);
|
||
expect(setup.session.abort).toHaveBeenCalledOnce();
|
||
expect(setup.session.create).toHaveBeenCalledOnce();
|
||
expect(setup.session.promptAsync).toHaveBeenCalledTimes(2);
|
||
} finally {
|
||
endFirst();
|
||
releaseAbort();
|
||
await Promise.all([first, second]);
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it.each(["never-returns", "rejects", "error", "false"])("replaces the session after abort %s and lets the next request finish", async (outcome) => {
|
||
const setup = runClient([]);
|
||
const controller = new AbortController();
|
||
let abortSignal: AbortSignal | undefined;
|
||
let abortedAtStart: boolean | undefined;
|
||
vi.mocked(setup.session.abort).mockImplementation(async (_input, options) => {
|
||
abortSignal = options?.signal ?? undefined;
|
||
abortedAtStart = abortSignal?.aborted;
|
||
if (outcome === "never-returns") return new Promise<never>(() => undefined);
|
||
if (outcome === "rejects") throw new Error("abort failed");
|
||
return { data: false, error: outcome === "error" ? { message: "abort failed" } : undefined } as never;
|
||
});
|
||
vi.mocked(setup.session.create)
|
||
.mockResolvedValueOnce({ data: { id: "session-1" } } as never)
|
||
.mockResolvedValueOnce({ data: { id: "session-2" } } as never);
|
||
let subscriptions = 0;
|
||
vi.mocked(setup.event.subscribe).mockImplementation(async () => {
|
||
const first = ++subscriptions === 1;
|
||
return {
|
||
stream: (async function* () {
|
||
if (first) {
|
||
controller.abort(new Error("A cancelled"));
|
||
return;
|
||
}
|
||
yield { type: "session.idle", properties: { sessionID: "session-2" } };
|
||
})(),
|
||
} as never;
|
||
});
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
embedded: false, baseUrl: "http://127.0.0.1:4096",
|
||
}, { controlRequestTimeoutMs: 20 });
|
||
const collect = async (requestId: string, signal: AbortSignal) => {
|
||
const events: RuntimeEvent[] = [];
|
||
for await (const event of runtime.run({
|
||
requestId, conversationId: "shared-conversation", prompt: requestId,
|
||
workMode: "execute",
|
||
}, signal)) events.push(event);
|
||
return events;
|
||
};
|
||
try {
|
||
let settled = false;
|
||
const first = collect("A", controller.signal).catch((error: unknown) => error)
|
||
.finally(() => { settled = true; });
|
||
await vi.waitFor(() => expect(settled).toBe(true));
|
||
expect(await first).toMatchObject({ message: "A cancelled" });
|
||
expect(abortedAtStart).toBe(false);
|
||
expect(abortSignal?.aborted).toBe(outcome === "never-returns");
|
||
expect(setup.session.abort).toHaveBeenCalledOnce();
|
||
await expect(collect("B", new AbortController().signal)).resolves.toEqual(
|
||
expect.arrayContaining([expect.objectContaining({ type: "done", sessionId: "session-2" })]),
|
||
);
|
||
expect(setup.session.create).toHaveBeenCalledTimes(2);
|
||
expect(setup.session.promptAsync).toHaveBeenLastCalledWith(
|
||
expect.objectContaining({ sessionID: "session-2" }),
|
||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||
);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("loads assigned Skills before prompting", async () => {
|
||
const child = fakeChild();
|
||
const promptAsync = vi.fn().mockResolvedValue({ error: undefined });
|
||
const client = {
|
||
session: {
|
||
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
|
||
promptAsync,
|
||
abort: vi.fn().mockResolvedValue(undefined),
|
||
},
|
||
event: {
|
||
subscribe: vi.fn().mockResolvedValue({
|
||
stream: (async function* () {
|
||
yield {
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
};
|
||
})(),
|
||
}),
|
||
},
|
||
tool: {
|
||
ids: vi.fn().mockResolvedValue({
|
||
data: ["read", "write", "goodbuddy-mcp"],
|
||
error: undefined,
|
||
}),
|
||
},
|
||
} as unknown as ReturnType<typeof createOpencodeClient>;
|
||
const { deps } = dependencies(child, {
|
||
createClient: vi.fn(
|
||
() => client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
});
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
skillInstructions: "# 文档写作",
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
const events = [];
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "test",
|
||
workMode: "execute",
|
||
images: [
|
||
{
|
||
name: "screenshot.png",
|
||
mediaType: "image/png",
|
||
data: "aW1hZ2U=",
|
||
},
|
||
],
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
events.push(event);
|
||
}
|
||
|
||
expect(promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
system: "# 文档写作",
|
||
parts: [
|
||
{ type: "text", text: "test" },
|
||
{
|
||
type: "file",
|
||
mime: "image/png",
|
||
filename: "screenshot.png",
|
||
url: "data:image/png;base64,aW1hZ2U=",
|
||
},
|
||
],
|
||
}),
|
||
expect.objectContaining({
|
||
signal: expect.any(AbortSignal),
|
||
}),
|
||
);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("rejects images when the explicit model connection disables image input", async () => {
|
||
const child = fakeChild();
|
||
const { deps, createClient } = dependencies(child);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
modelProfile: {
|
||
id: "00000000-0000-4000-8000-000000000011",
|
||
name: "文本模型",
|
||
baseUrl: "https://model.example",
|
||
modelName: "text-model",
|
||
protocol: "anthropic-messages",
|
||
authentication: "none",
|
||
supportsImageInput: false,
|
||
},
|
||
}),
|
||
deps,
|
||
);
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "describe",
|
||
images: [
|
||
{
|
||
name: "screenshot.png",
|
||
mediaType: "image/png",
|
||
data: "aW1hZ2U=",
|
||
},
|
||
],
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(stream.next()).rejects.toThrow("当前模型连接未启用图像输入");
|
||
expect(createClient).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe("OpenCodeRuntime embedded permission mediation", () => {
|
||
it("shares assigned custom MCP only with embedded Execute through a scoped loopback token", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const gateway = {
|
||
getEndpoint: vi.fn(() => "http://127.0.0.1:4567/mcp"),
|
||
grantCustomMcp: vi.fn(() => "custom-capability"),
|
||
prepareCustomMcpTools: vi.fn(async () => [
|
||
{
|
||
name: "mcp_12345678_abcdef01_private_tool",
|
||
inputSchema: { type: "object" },
|
||
},
|
||
]),
|
||
revoke: vi.fn(),
|
||
} as unknown as KnowledgeMcpGateway;
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: gateway,
|
||
mcpServers: [
|
||
{
|
||
id: "00000000-0000-4000-8000-000000000092",
|
||
name: "Private MCP",
|
||
description: "",
|
||
enabled: true,
|
||
allowDynamicTools: false,
|
||
assignments: ["opencode"],
|
||
secretConfigured: true,
|
||
secret: "must-stay-in-main",
|
||
transport: "http",
|
||
url: "https://private.example/mcp",
|
||
},
|
||
],
|
||
});
|
||
|
||
await collectRun(runtime, "execute");
|
||
|
||
expect(gateway.grantCustomMcp).toHaveBeenCalledWith(
|
||
"3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
expect.any(Array),
|
||
expect.any(AbortSignal),
|
||
);
|
||
expect(setup.client.mcp.add).toHaveBeenCalledWith(
|
||
{
|
||
directory: process.cwd(),
|
||
name: expect.stringMatching(/^gbc-[a-f0-9]{6}$/u),
|
||
config: {
|
||
type: "remote",
|
||
url: "http://127.0.0.1:4567/mcp",
|
||
enabled: true,
|
||
headers: {
|
||
Authorization: "Bearer custom-capability",
|
||
},
|
||
oauth: false,
|
||
},
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(
|
||
JSON.stringify(
|
||
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>).mock
|
||
.calls,
|
||
),
|
||
).not.toContain("must-stay-in-main");
|
||
expect(
|
||
JSON.stringify(
|
||
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>).mock
|
||
.calls,
|
||
),
|
||
).not.toContain("private.example");
|
||
expect(gateway.revoke).toHaveBeenCalledWith("custom-capability");
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each([["ask", true] as const, ["execute", false] as const])(
|
||
"does not share custom MCP with OpenCode in %s mode when embedded is %s",
|
||
async (workMode, embedded) => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const gateway = {
|
||
getEndpoint: vi.fn(() => "http://127.0.0.1:4567/mcp"),
|
||
grantCustomMcp: vi.fn(() => "custom-capability"),
|
||
prepareCustomMcpTools: vi.fn(async () => []),
|
||
revoke: vi.fn(),
|
||
} as unknown as KnowledgeMcpGateway;
|
||
const runtime = embedded
|
||
? embeddedRuntime(setup.client, {
|
||
knowledgeGateway: gateway,
|
||
mcpServers: [
|
||
{
|
||
id: "00000000-0000-4000-8000-000000000093",
|
||
name: "Private MCP",
|
||
description: "",
|
||
enabled: true,
|
||
allowDynamicTools: false,
|
||
assignments: ["opencode"],
|
||
secretConfigured: false,
|
||
transport: "stdio",
|
||
command: "private-command",
|
||
args: [],
|
||
},
|
||
],
|
||
})
|
||
: new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
knowledgeGateway: gateway,
|
||
mcpServers: [
|
||
{
|
||
id: "00000000-0000-4000-8000-000000000093",
|
||
name: "Private MCP",
|
||
description: "",
|
||
enabled: true,
|
||
allowDynamicTools: false,
|
||
assignments: ["opencode"],
|
||
secretConfigured: false,
|
||
transport: "stdio",
|
||
command: "private-command",
|
||
args: [],
|
||
},
|
||
],
|
||
}),
|
||
dependencies(fakeChild(), {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
}).deps,
|
||
);
|
||
|
||
await collectRun(runtime, workMode);
|
||
|
||
expect(gateway.grantCustomMcp).not.toHaveBeenCalled();
|
||
expect(setup.client.mcp.add).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
},
|
||
);
|
||
|
||
it.each(["parent", "child"] as const)("parses %s OpenCode questions and sends selected answers back", async (owner) => {
|
||
const longQuestion = `请选择实现方式\n${"发布说明内容。".repeat(6_000)}`;
|
||
const setup = runClient([
|
||
{
|
||
id: "question-tool-running",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-question-1",
|
||
callID: "call-question-1",
|
||
type: "tool",
|
||
tool: "question",
|
||
state: {
|
||
status: "running",
|
||
input: { questions: [] },
|
||
time: { start: 1 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "question-event",
|
||
type: "question.asked",
|
||
properties: {
|
||
id: "question-1",
|
||
sessionID: owner === "parent" ? "session-1" : "child-session",
|
||
questions: [
|
||
{
|
||
header: "实现方式",
|
||
question: longQuestion,
|
||
options: [
|
||
{
|
||
label: "直接修改",
|
||
description: "立即更新现有实现",
|
||
},
|
||
{
|
||
label: "先写测试",
|
||
description: "先增加回归测试",
|
||
},
|
||
],
|
||
multiple: false,
|
||
custom: true,
|
||
},
|
||
],
|
||
tool: {
|
||
messageID: "message-1",
|
||
callID: "call-question-1",
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "question-tool-completed",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-question-1",
|
||
callID: "call-question-1",
|
||
type: "tool",
|
||
tool: "question",
|
||
state: {
|
||
status: "completed",
|
||
input: { questions: [] },
|
||
output: "Answered",
|
||
time: { start: 1, end: 2 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
vi.mocked(setup.client.session.get).mockResolvedValue({
|
||
data: { id: "child-session", parentID: "session-1" },
|
||
} as never);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "test",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: "status" },
|
||
});
|
||
const questionEvent = await stream.next();
|
||
expect(questionEvent.value).toMatchObject({
|
||
type: "question",
|
||
questionId: expect.stringMatching(/^opencode-[a-f0-9]{48}$/u),
|
||
questions: [
|
||
{
|
||
header: "实现方式",
|
||
question: longQuestion,
|
||
multiple: false,
|
||
custom: true,
|
||
},
|
||
],
|
||
});
|
||
const questionId =
|
||
questionEvent.value?.type === "question"
|
||
? questionEvent.value.questionId
|
||
: "";
|
||
await runtime.respondToQuestion(questionId, [["先写测试"]]);
|
||
expect(setup.questionReply).toHaveBeenCalledWith(
|
||
{
|
||
requestID: "question-1",
|
||
directory: process.cwd(),
|
||
answers: [["先写测试"]],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: "done" },
|
||
});
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("keeps concurrent child questions independently replyable and attributes only explicit task sessions", async () => {
|
||
const question = (id: string, sessionID: string) => ({
|
||
type: "question.asked", properties: { id, sessionID,
|
||
questions: [{ header: "Scope", question: `Choose ${id}`, options: [] }] },
|
||
});
|
||
const setup = runClient([
|
||
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
|
||
id: "task-part", callID: "task-call", type: "tool", tool: "task",
|
||
state: { status: "running", input: { subagent_type: "general", prompt: "Inspect", description: "Inspect task" },
|
||
metadata: { sessionId: "child-session" }, time: { start: 1 } },
|
||
} } },
|
||
question("first", "child-session"),
|
||
question("second", "other-child"),
|
||
question("first", "child-session"),
|
||
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
|
||
id: "task-part", callID: "task-call", type: "tool", tool: "task",
|
||
state: { status: "completed", input: { subagent_type: "general", prompt: "Inspect" },
|
||
output: "Finished", time: { start: 1, end: 2 } },
|
||
} } },
|
||
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
vi.mocked(setup.client.session.get).mockResolvedValue({ data: { parentID: "session-1" } } as never);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
const stream = runtime.run({ requestId: crypto.randomUUID(), conversationId: "parallel",
|
||
prompt: "test", workMode: "execute" }, new AbortController().signal);
|
||
await stream.next();
|
||
const task = (await stream.next()).value;
|
||
expect(task?.type).toBe("subagent");
|
||
const first = (await stream.next()).value;
|
||
const second = (await stream.next()).value;
|
||
if (task?.type !== "subagent" || first?.type !== "question" || second?.type !== "question") throw new Error("Missing questions");
|
||
expect(first.childTaskId).toBe(task.childTaskId);
|
||
expect(second.childTaskId).toBeUndefined();
|
||
expect(first.questionId).not.toBe(second.questionId);
|
||
setup.questionReply.mockRejectedValueOnce(new Error("Reply failed"));
|
||
await expect(runtime.respondToQuestion(first.questionId, [["Answer"]])).rejects.toThrow("Reply failed");
|
||
await runtime.respondToQuestion(first.questionId, [["Answer"]]);
|
||
await runtime.respondToQuestion(second.questionId);
|
||
expect(setup.questionReply).toHaveBeenLastCalledWith(expect.objectContaining({ requestID: "first", answers: [["Answer"]] }), expect.anything());
|
||
expect(setup.questionReject).toHaveBeenCalledWith(expect.objectContaining({ requestID: "second" }), expect.anything());
|
||
const remaining = [];
|
||
for await (const event of stream) remaining.push(event);
|
||
expect(remaining.some((event) => event.type === "question")).toBe(false);
|
||
await expect(runtime.respondToQuestion(first.questionId, [["Late"]])).rejects.toThrow();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("namespaces identical upstream question IDs across concurrent external conversations", async () => {
|
||
const setup = runClient([]);
|
||
(
|
||
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
|
||
).mockImplementation(async () => ({
|
||
stream: (async function* () {
|
||
yield {
|
||
id: "question-event",
|
||
type: "question.asked",
|
||
properties: {
|
||
id: "shared-question",
|
||
sessionID: "session-1",
|
||
questions: [
|
||
{
|
||
header: "Choice",
|
||
question: "Choose",
|
||
options: [],
|
||
multiple: false,
|
||
custom: true,
|
||
},
|
||
],
|
||
},
|
||
};
|
||
yield {
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
};
|
||
})(),
|
||
}));
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
dependencies(fakeChild(), {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
}).deps,
|
||
);
|
||
const first = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "first",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
const second = runtime.run(
|
||
{
|
||
requestId: "4f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-2",
|
||
prompt: "second",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await Promise.all([first.next(), second.next()]);
|
||
const [firstQuestion, secondQuestion] = await Promise.all([
|
||
first.next(),
|
||
second.next(),
|
||
]);
|
||
const firstId =
|
||
firstQuestion.value?.type === "question"
|
||
? firstQuestion.value.questionId
|
||
: "";
|
||
const secondId =
|
||
secondQuestion.value?.type === "question"
|
||
? secondQuestion.value.questionId
|
||
: "";
|
||
expect(firstId).toMatch(/^opencode-[a-f0-9]{48}$/u);
|
||
expect(secondId).toMatch(/^opencode-[a-f0-9]{48}$/u);
|
||
expect(firstId).not.toBe(secondId);
|
||
|
||
await Promise.all([
|
||
runtime.respondToQuestion(firstId, [["first answer"]]),
|
||
runtime.respondToQuestion(secondId, [["second answer"]]),
|
||
]);
|
||
expect(setup.questionReply).toHaveBeenCalledTimes(2);
|
||
expect(setup.questionReply).toHaveBeenNthCalledWith(
|
||
1,
|
||
{
|
||
requestID: "shared-question",
|
||
directory: process.cwd(),
|
||
answers: [["first answer"]],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(setup.questionReply).toHaveBeenNthCalledWith(
|
||
2,
|
||
{
|
||
requestID: "shared-question",
|
||
directory: process.cwd(),
|
||
answers: [["second answer"]],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await Promise.all([first.next(), second.next()]);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("emits native Task calls as subagents instead of generic tools", async () => {
|
||
const taskInput = {
|
||
subagent_type: "explorer",
|
||
description: "Review application architecture",
|
||
prompt: "Inspect the complete source tree.",
|
||
};
|
||
const setup = runClient([
|
||
{
|
||
id: "task-running",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-task-1",
|
||
callID: "call-task-1",
|
||
type: "tool",
|
||
tool: "task",
|
||
state: {
|
||
status: "running",
|
||
input: taskInput,
|
||
time: { start: 1 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "task-completed",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-task-1",
|
||
callID: "call-task-1",
|
||
type: "tool",
|
||
tool: "task",
|
||
state: {
|
||
status: "completed",
|
||
input: taskInput,
|
||
output: "Architecture review complete.",
|
||
title: "Review application architecture",
|
||
metadata: {},
|
||
time: { start: 1, end: 2 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
|
||
const events = await collectRun(runtime);
|
||
const subagents = events.filter((event) => event.type === "subagent");
|
||
|
||
expect(subagents).toHaveLength(2);
|
||
expect(subagents[0]).toMatchObject({
|
||
expertName: "explorer",
|
||
routingMode: "native",
|
||
runtimeCallId: "call-task-1",
|
||
state: "running",
|
||
reason: "Review application architecture",
|
||
});
|
||
expect(subagents[1]).toMatchObject({
|
||
childTaskId:
|
||
subagents[0]?.type === "subagent"
|
||
? subagents[0].childTaskId
|
||
: undefined,
|
||
state: "completed",
|
||
output: "Architecture review complete.",
|
||
});
|
||
expect(events.some((event) => event.type === "tool")).toBe(false);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each([
|
||
[" Custom delegate ", "Custom delegate"],
|
||
["", "OpenCode 工具:custom_delegate"],
|
||
["x".repeat(300), "x".repeat(240)],
|
||
])("keeps generic tool titles bounded: %s", async (title, summary) => {
|
||
const setup = runClient([
|
||
{
|
||
id: "custom-tool-completed",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-custom-1",
|
||
callID: "call-custom-1",
|
||
type: "tool",
|
||
tool: "custom_delegate",
|
||
state: {
|
||
status: "completed",
|
||
input: {
|
||
subagent_type: "explorer",
|
||
prompt: "Inspect the complete source tree.",
|
||
},
|
||
output: "Custom tool result.",
|
||
title,
|
||
metadata: {},
|
||
time: { start: 1, end: 2 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
|
||
const events = await collectRun(runtime);
|
||
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: "tool",
|
||
callId: "call-custom-1",
|
||
name: "custom_delegate",
|
||
state: "completed",
|
||
summary,
|
||
}),
|
||
);
|
||
expect(events.some((event) => event.type === "subagent")).toBe(false);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each(["error", "interrupted"])("retains the running tool title after %s", async (ending) => {
|
||
const part = {
|
||
id: "part-read-1",
|
||
callID: "call-read-1",
|
||
type: "tool" as const,
|
||
tool: "read",
|
||
};
|
||
const setup = runClient([
|
||
{
|
||
id: "read-running",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
...part,
|
||
state: {
|
||
status: "running",
|
||
input: { filePath: "README.md" },
|
||
title: "README.md",
|
||
time: { start: 1 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
...(ending === "error" ? [{
|
||
id: "read-error",
|
||
type: "message.part.updated" as const,
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
...part,
|
||
state: {
|
||
status: "error" as const,
|
||
input: { filePath: "README.md" },
|
||
error: "File not found",
|
||
time: { start: 1, end: 2 },
|
||
},
|
||
},
|
||
},
|
||
}] : []),
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
const events: RuntimeEvent[] = [];
|
||
await expect((async () => {
|
||
for await (const event of runtime.run({
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "Read README.md",
|
||
workMode: "execute",
|
||
}, new AbortController().signal)) {
|
||
events.push(event);
|
||
}
|
||
})()).rejects.toThrow();
|
||
expect(events.filter((event) => event.type === "tool")).toEqual([
|
||
expect.objectContaining({ state: "running", summary: "README.md" }),
|
||
expect.objectContaining({ state: "failed", summary: "README.md" }),
|
||
]);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("does not impose a default wall-clock deadline on OpenCode runs", async () => {
|
||
vi.useFakeTimers();
|
||
try {
|
||
let release!: () => void;
|
||
const gate = new Promise<void>((resolve) => {
|
||
release = resolve;
|
||
});
|
||
const setup = runClient([]);
|
||
(
|
||
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
|
||
).mockResolvedValue({
|
||
stream: (async function* () {
|
||
await gate;
|
||
yield {
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
};
|
||
})(),
|
||
});
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
dependencies(fakeChild(), {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
}).deps,
|
||
);
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "work for more than a day",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: "status" },
|
||
});
|
||
const pending = stream.next();
|
||
await vi.advanceTimersByTimeAsync(25 * 60 * 60_000);
|
||
expect(setup.session.abort).not.toHaveBeenCalled();
|
||
release();
|
||
await expect(pending).resolves.toMatchObject({
|
||
value: { type: "done" },
|
||
});
|
||
await runtime.dispose();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
}
|
||
});
|
||
|
||
it("aborts an OpenCode run at its total execution deadline", async () => {
|
||
const setup = runClient([]);
|
||
(
|
||
setup.event.subscribe as unknown as ReturnType<typeof vi.fn>
|
||
).mockImplementation(
|
||
async (_input: unknown, options: { signal: AbortSignal }) => ({
|
||
stream: (async function* () {
|
||
await new Promise<void>((_resolve, reject) => {
|
||
options.signal.addEventListener(
|
||
"abort",
|
||
() => reject(options.signal.reason),
|
||
{ once: true },
|
||
);
|
||
});
|
||
yield {
|
||
id: "unreachable",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
};
|
||
})(),
|
||
}),
|
||
);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
dependencies(fakeChild(), {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
executionTimeoutMs: 20,
|
||
}).deps,
|
||
);
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "never finish",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: "status" },
|
||
});
|
||
await expect(stream.next()).rejects.toThrow(
|
||
"OpenCode 执行超过 20 毫秒总时限",
|
||
);
|
||
expect(setup.session.abort).toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("applies the total deadline while agent discovery is stalled", async () => {
|
||
const setup = runClient([]);
|
||
const agents = vi.fn(() => new Promise<never>(() => undefined));
|
||
Object.assign(setup.client, {
|
||
app: { agents },
|
||
});
|
||
const child = fakeChild();
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
customization: { defaultAgent: "build" },
|
||
}),
|
||
dependencies(child, {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
executionTimeoutMs: 20,
|
||
}).deps,
|
||
);
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "never reach the stream",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(stream.next()).rejects.toThrow(
|
||
"OpenCode 执行超过 20 毫秒总时限",
|
||
);
|
||
expect(agents).toHaveBeenCalledWith(
|
||
{ directory: process.cwd() },
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(setup.event.subscribe).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("deletes a session created after timeout and does not reuse it", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
let resolveStalledCreation!: (value: {
|
||
data: { id: string };
|
||
error: undefined;
|
||
}) => void;
|
||
(
|
||
setup.session.create as unknown as ReturnType<typeof vi.fn>
|
||
).mockImplementationOnce(
|
||
() =>
|
||
new Promise((resolve) => {
|
||
resolveStalledCreation = resolve;
|
||
}),
|
||
);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
dependencies(fakeChild(), {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
executionTimeoutMs: 20,
|
||
}).deps,
|
||
);
|
||
const first = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "stalled creation",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(first.next()).rejects.toThrow(
|
||
"OpenCode 执行超过 20 毫秒总时限",
|
||
);
|
||
resolveStalledCreation({
|
||
data: { id: "stale-session" },
|
||
error: undefined,
|
||
});
|
||
await vi.waitFor(() =>
|
||
expect(setup.session.delete).toHaveBeenCalledWith(
|
||
{
|
||
sessionID: "stale-session",
|
||
directory: process.cwd(),
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
),
|
||
);
|
||
|
||
const secondEvents = await collectRun(runtime);
|
||
expect(secondEvents.at(-1)).toMatchObject({ type: "done" });
|
||
expect(setup.session.create).toHaveBeenCalledTimes(2);
|
||
expect(setup.session.update).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("truncates oversized text display without stopping the run", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "first-text",
|
||
type: "message.part.delta",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
partID: "part-1",
|
||
field: "text",
|
||
delta: "a".repeat(600_000),
|
||
},
|
||
},
|
||
{
|
||
id: "second-text",
|
||
type: "message.part.delta",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
partID: "part-1",
|
||
field: "text",
|
||
delta: "b".repeat(600_000),
|
||
},
|
||
},
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
const events = await collectRun(runtime, "execute");
|
||
const text = events
|
||
.flatMap((event) => (event.type === "text" ? [event.delta] : []))
|
||
.join("");
|
||
|
||
expect(Buffer.byteLength(text)).toBe(1024 * 1024);
|
||
expect(events).toContainEqual({
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
type: "status",
|
||
message: "OpenCode 输出较长,已停止继续展示文本;任务仍在继续",
|
||
});
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
expect(setup.session.abort).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("completes more than 100 distinct tool calls in one run", async () => {
|
||
const setup = runClient([
|
||
...Array.from({ length: 101 }, (_, index) =>
|
||
completedToolEvent(`call-${index + 1}`),
|
||
),
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
|
||
const events = await collectRun(runtime, "execute");
|
||
|
||
expect(events.filter((event) => event.type === "tool")).toHaveLength(101);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("adds only request-scoped built-in read tools for Ask and disconnects them", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const toolIds = setup.tool.ids as unknown as ReturnType<typeof vi.fn>;
|
||
toolIds
|
||
.mockResolvedValueOnce({
|
||
data: ["read", "write", "bash"],
|
||
error: undefined,
|
||
})
|
||
.mockResolvedValueOnce({
|
||
data: ["read", "write", "bash", "goodbuddy_knowledge_search"],
|
||
error: undefined,
|
||
})
|
||
.mockResolvedValue({
|
||
data: ["read", "write", "bash", "goodbuddy_knowledge_search"],
|
||
error: undefined,
|
||
});
|
||
const gateway = {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway;
|
||
const child = fakeChild();
|
||
const { deps } = dependencies(child, {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
});
|
||
const runtime = new OpenCodeRuntime(
|
||
options({ knowledgeGateway: gateway }),
|
||
deps,
|
||
);
|
||
|
||
const events = [];
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "search",
|
||
workMode: "ask",
|
||
knowledgeCapabilityToken: "secret-capability",
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
events.push(event);
|
||
}
|
||
|
||
expect(setup.client.mcp.add).toHaveBeenCalledWith(
|
||
{
|
||
directory: process.cwd(),
|
||
name: expect.stringMatching(/^gbd-[a-f0-9]{6}$/u),
|
||
config: {
|
||
type: "remote",
|
||
url: "http://127.0.0.1:4567/mcp",
|
||
enabled: true,
|
||
headers: {
|
||
Authorization: "Bearer secret-capability",
|
||
},
|
||
oauth: false,
|
||
},
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
const knowledgeMcpName = (
|
||
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>).mock
|
||
.calls[0]?.[0] as { name: string }
|
||
).name;
|
||
const knowledgeToolId = `${knowledgeMcpName}_knowledge_search`;
|
||
expect(setup.session.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
permission: [
|
||
{ permission: "*", pattern: "*", action: "deny" },
|
||
{
|
||
permission: knowledgeToolId,
|
||
pattern: "*",
|
||
action: "allow",
|
||
},
|
||
],
|
||
}),
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(setup.session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
tools: {
|
||
read: false,
|
||
write: false,
|
||
bash: false,
|
||
"gbd-*": false,
|
||
"gbc-*": false,
|
||
[knowledgeToolId]: true,
|
||
},
|
||
}),
|
||
expect.anything(),
|
||
);
|
||
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith(
|
||
{
|
||
name: expect.stringMatching(/^gbd-/u),
|
||
directory: process.cwd(),
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each(["knowledge", "custom"].flatMap<{
|
||
kind: string; scenario: string; detail: string; attempts: number;
|
||
succeeds?: boolean; thrown?: boolean; status?: string; apiError?: boolean;
|
||
}>((kind) => [
|
||
{ kind, scenario: "retry success", detail: "MCP error -32001: Request timed out", attempts: 2, succeeds: true },
|
||
{ kind, scenario: "retry exhausted", detail: "connect ECONNREFUSED", attempts: 2 },
|
||
{ kind, scenario: "thrown connect error", detail: "connect ECONNRESET", attempts: 2, succeeds: true, thrown: true },
|
||
{ kind, scenario: "auth", detail: "HTTP 401 Unauthorized; ECONNRESET", attempts: 1 },
|
||
{ kind, scenario: "OAuth", detail: "OAuth error: request timed out", attempts: 1 },
|
||
{ kind, scenario: "protocol", detail: "Invalid protocol response; connection closed", attempts: 1 },
|
||
{ kind, scenario: "unknown", detail: "Unexpected initialization failure", attempts: 1 },
|
||
{ kind, scenario: "plain failed", detail: "failed", attempts: 1 },
|
||
{ kind, scenario: "needs auth", detail: "needs_auth", attempts: 1, status: "needs_auth" },
|
||
{ kind, scenario: "missing status", detail: "unknown", attempts: 1, status: "missing" },
|
||
{ kind, scenario: "API error", detail: "HTTP 403 Forbidden", attempts: 1, apiError: true },
|
||
]))("handles $kind MCP initialization: $scenario", async ({ kind, detail, attempts, succeeds, thrown, status, apiError }) => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const add = vi.mocked(setup.client.mcp.add);
|
||
const disconnect = vi.mocked(setup.client.mcp.disconnect);
|
||
const secret = "request-private-capability";
|
||
const failure = `${detail}\nAuthorization: Bearer ${secret}\npassword=private-password\nhttps://user:private-password@example.com/ECONNRESET?token=private-query\n${"x".repeat(3_000)}`;
|
||
const fail = async (input?: { name?: string }) => {
|
||
if (thrown) throw new Error(failure);
|
||
if (apiError) return { error: { name: "UnknownError", data: { message: failure } }, response: { status: 403 } };
|
||
return { response: { status: 200 }, data: status === "missing" ? {} : {
|
||
[input!.name!]: { status: status ?? "failed", error: failure },
|
||
} };
|
||
};
|
||
// The SDK returns a map keyed by the registered name, not a top-level status.
|
||
if (succeeds) add.mockImplementationOnce(fail as never);
|
||
else add.mockImplementation(fail as never);
|
||
const gateway = {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
grantCustomMcp: vi.fn(() => secret),
|
||
prepareCustomMcpTools: vi.fn(async () => [{ name: "custom_tool" }]),
|
||
revoke: vi.fn(),
|
||
};
|
||
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
observeFailure,
|
||
knowledgeGateway: gateway as unknown as KnowledgeMcpGateway,
|
||
mcpServers: kind === "custom" ? [{
|
||
id: "custom", name: "Custom", description: "", transport: "http",
|
||
url: "https://example.com/mcp", enabled: true, allowDynamicTools: false,
|
||
assignments: ["opencode"], secretConfigured: false,
|
||
}] : [],
|
||
});
|
||
const collect = async () => {
|
||
const events: RuntimeEvent[] = [];
|
||
for await (const event of runtime.run({
|
||
requestId: "request-1", conversationId: "conversation-1", prompt: "test",
|
||
workMode: kind === "custom" ? "execute" : "ask",
|
||
...(kind === "knowledge" ? { knowledgeCapabilityToken: secret } : {}),
|
||
}, new AbortController().signal)) events.push(event);
|
||
return events;
|
||
};
|
||
try {
|
||
if (succeeds) {
|
||
await expect(collect()).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
|
||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||
} else {
|
||
const error = await collect().catch((error: unknown) => error) as Error;
|
||
expect(error).toBeInstanceOf(Error);
|
||
expect(error.message).toContain(detail);
|
||
expect(error.message).not.toMatch(/request-private-capability|private-password|private-query/);
|
||
expect(error.message.length).toBeLessThan(2_100);
|
||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||
}
|
||
expect(add).toHaveBeenCalledTimes(attempts);
|
||
expect(disconnect).toHaveBeenCalledTimes(attempts);
|
||
expect(observeFailure).toHaveBeenCalledTimes(succeeds ? 1 : attempts);
|
||
for (const [index, [record]] of observeFailure.mock.calls.entries()) {
|
||
expect(record).toMatchObject({
|
||
component: "runtime", stage: "connect", code: "runtime.mcp.failed",
|
||
mcp: {
|
||
phase: "connect", attempt: index + 1, kind: kind === "knowledge" ? "builtin" : "custom",
|
||
correlationId: `sha256:${createHash("sha256").update(secret).digest("hex")}`,
|
||
elapsedMs: expect.any(Number),
|
||
category: apiError ? "http-rejection" : thrown ? "transport-failure" : "initialization-failure",
|
||
...(apiError ? { status: 403 } : {}),
|
||
},
|
||
});
|
||
expect(record.mcp!.elapsedMs).toBeGreaterThanOrEqual(0);
|
||
expect(JSON.stringify(record)).not.toMatch(/request-private-capability|private-password|private-query|example.com|ECONNRESET/);
|
||
expect((record.error as Error).message).toBe("Local MCP operation failed");
|
||
}
|
||
if (attempts === 2) {
|
||
expect(add.mock.calls[1]![0]?.config).toEqual(add.mock.calls[0]![0]?.config);
|
||
if (thrown) expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
|
||
else expect(add.mock.calls[1]![0]?.name).toBe(add.mock.calls[0]![0]?.name);
|
||
expect(disconnect.mock.invocationCallOrder[0]).toBeGreaterThan(add.mock.invocationCallOrder[0]!);
|
||
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(add.mock.invocationCallOrder[1]!);
|
||
}
|
||
if (succeeds) {
|
||
const name = add.mock.calls.at(-1)![0]!.name;
|
||
const toolId = `${name}_${kind === "custom" ? "custom_tool" : "knowledge_search"}`;
|
||
expect(setup.session.create).toHaveBeenCalledWith(expect.objectContaining({
|
||
permission: expect.arrayContaining([{ permission: toolId, pattern: "*", action: "allow" }]),
|
||
}), expect.anything());
|
||
expect(setup.session.promptAsync).toHaveBeenCalledWith(expect.objectContaining({
|
||
tools: expect.objectContaining({ [toolId]: true }),
|
||
}), expect.anything());
|
||
}
|
||
expect(add.mock.calls[0]![0]?.config).toMatchObject({
|
||
headers: { Authorization: `Bearer ${secret}` }, oauth: false,
|
||
});
|
||
if (kind === "custom") {
|
||
expect(gateway.grantCustomMcp).toHaveBeenCalledOnce();
|
||
expect(gateway.prepareCustomMcpTools).toHaveBeenCalledOnce();
|
||
expect(gateway.revoke).toHaveBeenCalledExactlyOnceWith(secret);
|
||
}
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])(
|
||
"handles installed SDK fetch results: %s", async (scenario) => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const token = "private-sdk-capability";
|
||
const cause = Object.assign(new Error(`socket reset\nAuthorization: Bearer ${token}\npassword=private-password\nhttps://user:private-password@example.com/private-path`), { code: "ECONNRESET" });
|
||
// A circular cause must not cause unbounded traversal or expose a stack.
|
||
Object.assign(cause, { cause });
|
||
const failure = new TypeError("fetch failed", { cause });
|
||
const order: string[] = [];
|
||
const signals: AbortSignal[] = [];
|
||
let adds = 0;
|
||
const sdk = createOpencodeClient({
|
||
baseUrl: "http://127.0.0.1:4096", throwOnError: false,
|
||
fetch: async (input, init) => {
|
||
const request = input instanceof Request ? input : new Request(input, init);
|
||
if (new URL(request.url).pathname.endsWith("/disconnect")) {
|
||
order.push("disconnect");
|
||
signals.push(request.signal);
|
||
if (scenario === "cleanup-transport") throw failure;
|
||
if (scenario === "cleanup-timeout") return new Promise<Response>(() => {});
|
||
return Response.json(scenario !== "cleanup-false");
|
||
}
|
||
order.push("add");
|
||
adds++;
|
||
if (scenario.startsWith("http-")) {
|
||
return Response.json({ name: "UnknownError", data: { message: `ECONNRESET password=private-password` } }, { status: Number(scenario.slice(5)) });
|
||
}
|
||
if (scenario === "unknown") throw new TypeError("fetch failed");
|
||
if (adds === 1 || scenario === "exhausted") throw failure;
|
||
const { name } = await request.json() as { name: string };
|
||
return Response.json({ [name]: { status: "connected" } });
|
||
},
|
||
});
|
||
Object.assign(setup.client, { mcp: sdk.mcp });
|
||
const add = vi.spyOn(sdk.mcp, "add");
|
||
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>();
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
observeFailure,
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
const collect = async () => {
|
||
for await (const event of runtime.run({
|
||
requestId: "sdk-request", conversationId: "sdk-conversation", prompt: "test", workMode: "ask",
|
||
knowledgeCapabilityToken: token,
|
||
}, new AbortController().signal)) void event;
|
||
};
|
||
try {
|
||
if (scenario === "recovered") {
|
||
await collect();
|
||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||
} else {
|
||
const error = await collect().catch((error: unknown) => error) as Error;
|
||
expect(error).toBeInstanceOf(Error);
|
||
expect(error.message).toContain(scenario === "unknown" ? "fetch failed" : "ECONNRESET");
|
||
expect(error.message).not.toMatch(/private-|example\.com|socket reset.*at /);
|
||
expect(error.message.length).toBeLessThan(2_100);
|
||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||
}
|
||
const first = await add.mock.results[0]!.value;
|
||
if (scenario.startsWith("http-")) {
|
||
expect(first.response.status).toBe(Number(scenario.slice(5)));
|
||
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "http-rejection", status: Number(scenario.slice(5)) });
|
||
} else {
|
||
expect(first.response).toBeUndefined();
|
||
expect(first.error).toBeInstanceOf(TypeError);
|
||
if (scenario !== "unknown") expect(first.error).toBe(failure);
|
||
expect(observeFailure.mock.calls[0]![0].mcp).toMatchObject({ category: "transport-failure" });
|
||
}
|
||
expect(add).toHaveBeenCalledTimes(scenario === "recovered" || scenario === "exhausted" ? 2 : 1);
|
||
if (add.mock.calls.length === 2) {
|
||
expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
|
||
}
|
||
expect(order).toEqual(scenario === "recovered" || scenario === "exhausted"
|
||
? ["add", "disconnect", "add", "disconnect"]
|
||
: scenario.startsWith("cleanup-") ? ["add", "disconnect", "disconnect"] : ["add", "disconnect"]);
|
||
if (scenario === "cleanup-timeout") expect(signals.every(signal => signal.aborted)).toBe(true);
|
||
const records = observeFailure.mock.calls.map(([record]) => record);
|
||
expect(JSON.stringify(records)).not.toMatch(/private-|ECONNRESET|socket reset|example\.com/);
|
||
expect(records.every(record => record.mcp?.correlationId === `sha256:${createHash("sha256").update(token).digest("hex")}`)).toBe(true);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
},
|
||
);
|
||
|
||
it("persists a recovered MCP attempt even when the observer throws", async () => {
|
||
const directory = await mkdtemp(join(tmpdir(), "goodbuddy-mcp-diagnostics-"));
|
||
const diagnostics = new DesktopDiagnostics(directory);
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const token = "private-correlation-token";
|
||
vi.mocked(setup.client.mcp.add).mockImplementationOnce((async (input: { name: string }) => ({
|
||
data: { [input.name]: { status: "failed", error: `connect ECONNRESET Authorization: Bearer ${token}` } },
|
||
})) as never);
|
||
const writes: Promise<void>[] = [];
|
||
const observer = vi.fn<DesktopDiagnosticFailureObserver>((failure) => {
|
||
writes.push(diagnostics.recordFailure(failure));
|
||
throw new Error("observer failed");
|
||
});
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
observeFailure: observer,
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
try {
|
||
for await (const event of runtime.run({
|
||
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||
knowledgeCapabilityToken: token,
|
||
}, new AbortController().signal)) void event;
|
||
expect(setup.client.mcp.add).toHaveBeenCalledTimes(2);
|
||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||
expect(observer).toHaveBeenCalledOnce();
|
||
await Promise.all(writes);
|
||
await diagnostics.dispose();
|
||
const restarted = new DesktopDiagnostics(directory);
|
||
try {
|
||
const records = await restarted.readRecent();
|
||
expect(records).toHaveLength(1);
|
||
expect(records[0]?.mcp).toMatchObject({
|
||
phase: "connect", attempt: 1, kind: "builtin",
|
||
correlationId: `sha256:${createHash("sha256").update(token).digest("hex")}`,
|
||
});
|
||
expect((await restarted.exportRecent()).toString()).not.toMatch(/private-|ECONNRESET|Authorization|127\.0\.0\.1/);
|
||
} finally { await restarted.dispose(); }
|
||
} finally {
|
||
await runtime.dispose();
|
||
await diagnostics.dispose();
|
||
await rm(directory, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it.each(["reject", "error", "false", "timeout"])("does not retry or reuse the name after MCP cleanup %s", async (failure) => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const add = vi.mocked(setup.client.mcp.add);
|
||
add.mockImplementationOnce((async (input: { name: string }) => ({
|
||
response: { status: 200 },
|
||
data: { [input.name]: { status: "failed", error: "connect ECONNREFUSED" } },
|
||
})) as never);
|
||
const disconnect = vi.mocked(setup.client.mcp.disconnect);
|
||
if (failure === "reject") disconnect.mockRejectedValue(new Error("cleanup failed"));
|
||
if (failure === "error") disconnect.mockResolvedValue({ error: { message: "cleanup failed" } } as never);
|
||
if (failure === "false") disconnect.mockResolvedValue({ data: false } as never);
|
||
if (failure === "timeout") disconnect.mockImplementation((() => new Promise(() => {})) as never);
|
||
const observeFailure = vi.fn<DesktopDiagnosticFailureObserver>(() => { throw new Error("observer failed"); });
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
observeFailure,
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
const collect = async (requestId: string) => {
|
||
for await (const event of runtime.run({
|
||
requestId, conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||
knowledgeCapabilityToken: requestId,
|
||
}, new AbortController().signal)) void event;
|
||
};
|
||
try {
|
||
await expect(collect("first")).rejects.toThrow("ECONNREFUSED");
|
||
expect(add).toHaveBeenCalledOnce();
|
||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||
expect(observeFailure).toHaveBeenCalledTimes(3);
|
||
expect(observeFailure.mock.calls.slice(1).map(([record]) => record.mcp)).toEqual([
|
||
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin", category: failure === "false" ? "cleanup-failure" : "transport-failure" }),
|
||
expect.objectContaining({ phase: "disconnect", attempt: 1, kind: "builtin" }),
|
||
]);
|
||
expect(new Set(observeFailure.mock.calls.map(([record]) => record.mcp?.correlationId)).size).toBe(1);
|
||
disconnect.mockResolvedValue({ data: true } as never);
|
||
await collect("second");
|
||
expect(add.mock.calls[1]![0]?.name).not.toBe(add.mock.calls[0]![0]?.name);
|
||
await collect("third");
|
||
expect(add.mock.calls[2]![0]?.name).toBe(add.mock.calls[1]![0]?.name);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it.each(["add", "disconnect", "retry"])("respects cancellation during MCP %s", async (phase) => {
|
||
const setup = runClient([]);
|
||
const controller = new AbortController();
|
||
const add = vi.mocked(setup.client.mcp.add);
|
||
add.mockImplementation((async (input: { name: string }) => {
|
||
if (phase === "add" || (phase === "retry" && add.mock.calls.length === 2)) {
|
||
controller.abort(new Error("cancel initialization"));
|
||
}
|
||
return { data: { [input.name]: { status: "failed", error: "connect ECONNRESET" } } };
|
||
}) as never);
|
||
const disconnect = vi.mocked(setup.client.mcp.disconnect);
|
||
disconnect.mockImplementation((async () => {
|
||
if (phase === "disconnect") controller.abort(new Error("cancel initialization"));
|
||
return { data: true };
|
||
}) as never);
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
try {
|
||
await expect((async () => {
|
||
for await (const event of runtime.run({
|
||
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||
knowledgeCapabilityToken: "private-token",
|
||
}, controller.signal)) void event;
|
||
})()).rejects.toThrow("cancel initialization");
|
||
expect(add).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
|
||
expect(disconnect).toHaveBeenCalledTimes(phase === "retry" ? 2 : 1);
|
||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("does not retry MCP initialization or the prompt after a transient tool execution error", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "tool-error", type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-1", callID: "call-1", type: "tool", tool: "knowledge_search",
|
||
state: {
|
||
status: "error", input: {}, error: "connect ECONNRESET",
|
||
time: { start: 1, end: 2 },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
try {
|
||
const events: RuntimeEvent[] = [];
|
||
await expect((async () => {
|
||
for await (const event of runtime.run({
|
||
requestId: "request-1", conversationId: "conversation-1", prompt: "test", workMode: "ask",
|
||
knowledgeCapabilityToken: "private-token",
|
||
}, new AbortController().signal)) events.push(event);
|
||
})()).rejects.toThrow("connect ECONNRESET");
|
||
expect(events).toContainEqual(expect.objectContaining({ type: "tool", state: "failed" }));
|
||
expect(setup.client.mcp.add).toHaveBeenCalledOnce();
|
||
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
|
||
expect(setup.session.promptAsync).toHaveBeenCalledOnce();
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("keeps temporary MCP names stable within a conversation and short enough for providers", async () => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["goodbuddy_config_capabilities"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
const runOnce = async (requestId: string, conversationId: string) => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId,
|
||
conversationId,
|
||
prompt: "search",
|
||
workMode: "ask",
|
||
knowledgeCapabilityToken: requestId,
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
void _event;
|
||
}
|
||
};
|
||
try {
|
||
await runOnce("request-1", "conversation-1");
|
||
await runOnce("request-2", "conversation-1");
|
||
await runOnce("request-3", "conversation-2");
|
||
const add = setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>;
|
||
const names = add.mock.calls.map(
|
||
(call) => (call[0] as { name: string }).name,
|
||
);
|
||
expect(names[0]).toMatch(/^gbd-[a-f0-9]{6}$/u);
|
||
expect(names[1]).toBe(names[0]);
|
||
expect(names[2]).toMatch(/^gbd-[a-f0-9]{6}$/u);
|
||
expect(names[2]).not.toBe(names[0]);
|
||
expect(`${names[0]}_goodbuddy_config_capabilities`.length).toBeLessThanOrEqual(64);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("does not reuse a temporary MCP name whose disconnect failed", async () => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const disconnect = setup.client.mcp.disconnect as unknown as ReturnType<
|
||
typeof vi.fn
|
||
>;
|
||
disconnect
|
||
.mockRejectedValueOnce(new Error("disconnect timed out"))
|
||
.mockResolvedValueOnce({ data: undefined, error: { name: "BadRequest" } });
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
const runOnce = async (requestId: string) => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId,
|
||
conversationId: "conversation-1",
|
||
prompt: "search",
|
||
workMode: "ask",
|
||
knowledgeCapabilityToken: requestId,
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
void _event;
|
||
}
|
||
};
|
||
try {
|
||
await runOnce("request-1");
|
||
await runOnce("request-2");
|
||
await runOnce("request-3");
|
||
await runOnce("request-4");
|
||
const add = setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>;
|
||
const names = add.mock.calls.map(
|
||
(call) => (call[0] as { name: string }).name,
|
||
);
|
||
const base = names[0]!;
|
||
expect(base).toMatch(/^gbd-[a-f0-9]{6}$/u);
|
||
expect(new Set(names.slice(0, 3)).size).toBe(3);
|
||
expect(names.every(name => name.length <= 12)).toBe(true);
|
||
// Successful cleanup preserves the replacement name for the same slot.
|
||
expect(names[3]).toBe(names[2]);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("leases distinct temporary MCP slots for concurrent holders and caps them", () => {
|
||
const setup = runClient([]);
|
||
const runtime = embeddedRuntime(setup.client) as unknown as {
|
||
acquireTemporaryMcpSlot: (client: unknown, key: string) => number;
|
||
releaseTemporaryMcpSlot: (client: unknown, key: string, slot: number) => void;
|
||
};
|
||
const otherClient = {};
|
||
const slots = Array.from({ length: 9 }, () =>
|
||
runtime.acquireTemporaryMcpSlot(setup.client, "abc123"),
|
||
);
|
||
expect(slots).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||
expect(() => runtime.acquireTemporaryMcpSlot(setup.client, "abc123")).toThrow(
|
||
/并行的 OpenCode 请求过多/u,
|
||
);
|
||
// Other conversation keys and other OpenCode clients are independent.
|
||
expect(runtime.acquireTemporaryMcpSlot(setup.client, "def456")).toBe(1);
|
||
expect(runtime.acquireTemporaryMcpSlot(otherClient, "abc123")).toBe(1);
|
||
runtime.releaseTemporaryMcpSlot(setup.client, "abc123", 4);
|
||
expect(runtime.acquireTemporaryMcpSlot(setup.client, "abc123")).toBe(4);
|
||
expect(createTemporaryMcpName("gbd-", "abc123", 1)).toBe("gbd-abc123");
|
||
expect(createTemporaryMcpName("gbc-", "abc123", 4)).toBe("gbc-abc123-4");
|
||
for (const generation of [1, 35, 36, 36 ** 8 - 1]) {
|
||
const name = createTemporaryMcpName("gbc-", "abc123", 9, generation);
|
||
expect(`${name}_${"x".repeat(51)}`).toHaveLength(64);
|
||
expect(name).not.toBe(createTemporaryMcpName("gbc-", "abc123", 9));
|
||
}
|
||
expect(() => createTemporaryMcpName("gbc-", "abc123", 9, 36 ** 8)).toThrow("generations exhausted");
|
||
});
|
||
|
||
it.each(["throw", "no-response", "timeout", "abort"])("retires an uncertain MCP add after %s despite successful disconnect", async (failure) => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const controller = new AbortController();
|
||
const add = vi.mocked(setup.client.mcp.add);
|
||
add.mockImplementationOnce((async () => {
|
||
if (failure === "throw") throw new Error("unknown initialization failure");
|
||
if (failure === "no-response") return { error: new Error("unknown initialization failure") };
|
||
if (failure === "abort") controller.abort(new Error("cancel initialization"));
|
||
return new Promise(() => {});
|
||
}) as never);
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
}, { controlRequestTimeoutMs: 50 });
|
||
const run = async (signal: AbortSignal) => {
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(), conversationId: "uncertain", prompt: "test", workMode: "ask",
|
||
knowledgeCapabilityToken: "token",
|
||
}, signal)) void event;
|
||
};
|
||
try {
|
||
await expect(run(controller.signal)).rejects.toThrow();
|
||
expect(setup.client.mcp.disconnect).toHaveBeenCalledOnce();
|
||
await run(new AbortController().signal);
|
||
await run(new AbortController().signal);
|
||
const names = add.mock.calls.map(([input]) => input!.name);
|
||
expect(names[1]).not.toBe(names[0]);
|
||
expect(names[2]).toBe(names[1]);
|
||
expect(vi.mocked(setup.client.mcp.disconnect).mock.calls.map(([input]) => input!.name)).toEqual(names);
|
||
} finally { await runtime.dispose(); }
|
||
});
|
||
|
||
it("releases active slots after more than nine failed cleanups and isolates replacement generations", async () => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
const disconnect = vi.mocked(setup.client.mcp.disconnect);
|
||
disconnect.mockResolvedValue({ data: false } as never);
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
const run = async (conversationId: string) => {
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(), conversationId, prompt: "test", workMode: "ask",
|
||
knowledgeCapabilityToken: "token",
|
||
}, new AbortController().signal)) void event;
|
||
};
|
||
try {
|
||
for (let i = 0; i < 12; i++) await run("cleanup-failures");
|
||
await run("other-conversation");
|
||
disconnect.mockResolvedValue({ data: true } as never);
|
||
await run("other-conversation");
|
||
await run("cleanup-failures");
|
||
await run("cleanup-failures");
|
||
const names = vi.mocked(setup.client.mcp.add).mock.calls.map(([input]) => input!.name);
|
||
expect(new Set(names.slice(0, 15)).size).toBe(15);
|
||
expect(names[15]).toBe(names[14]);
|
||
expect(names.every(name => name !== undefined && name.length <= 12)).toBe(true);
|
||
expect(disconnect.mock.calls.map(([input]) => input!.name)).toEqual(names);
|
||
} finally { await runtime.dispose(); }
|
||
});
|
||
|
||
it("enables the deterministic MCP tool name when tool ids omit dynamic tools", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const baseline = {
|
||
data: ["read", "write", "bash"],
|
||
error: undefined,
|
||
};
|
||
const toolIds = setup.tool.ids as unknown as ReturnType<typeof vi.fn>;
|
||
toolIds.mockResolvedValue(baseline);
|
||
const child = fakeChild();
|
||
const { deps } = dependencies(child, {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
});
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
}),
|
||
deps,
|
||
);
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "search",
|
||
workMode: "ask",
|
||
knowledgeCapabilityToken: "secret-capability",
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
void _event;
|
||
}
|
||
|
||
const knowledgeMcpName = (
|
||
(setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>).mock
|
||
.calls[0]?.[0] as { name: string }
|
||
).name;
|
||
const knowledgeToolId = `${knowledgeMcpName}_knowledge_search`;
|
||
expect(toolIds).toHaveBeenCalledTimes(1);
|
||
expect(setup.session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
tools: expect.objectContaining({
|
||
read: false,
|
||
write: false,
|
||
bash: false,
|
||
[knowledgeToolId]: true,
|
||
}),
|
||
}),
|
||
expect.anything(),
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each([false, true])("runs embedded conversations concurrently while serializing MCP registrations (retry=%s)", async (retry) => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const toolIds = setup.tool.ids as unknown as ReturnType<typeof vi.fn>;
|
||
const baseline = {
|
||
data: ["read", "write"],
|
||
error: undefined,
|
||
};
|
||
const withKnowledge = {
|
||
data: ["read", "write", "goodbuddy_knowledge_search"],
|
||
error: undefined,
|
||
};
|
||
for (const response of [
|
||
baseline,
|
||
withKnowledge,
|
||
withKnowledge,
|
||
baseline,
|
||
withKnowledge,
|
||
withKnowledge,
|
||
]) {
|
||
toolIds.mockResolvedValueOnce(response);
|
||
}
|
||
let resolveFirstAdd!: () => void;
|
||
const firstAdd = new Promise<void>((resolve) => {
|
||
resolveFirstAdd = resolve;
|
||
});
|
||
const mcpAdd = setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>;
|
||
mcpAdd
|
||
.mockImplementationOnce(async (input: { name: string }) => {
|
||
await firstAdd;
|
||
return {
|
||
data: {
|
||
[input.name]: retry
|
||
? { status: "failed", error: "connect ECONNRESET" }
|
||
: { status: "connected" },
|
||
},
|
||
error: undefined,
|
||
};
|
||
})
|
||
.mockImplementation(async (input: { name: string }) => ({
|
||
data: {
|
||
[input.name]: { status: "connected" },
|
||
},
|
||
error: undefined,
|
||
}));
|
||
const child = fakeChild();
|
||
const { deps } = dependencies(child, {
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
});
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
}),
|
||
deps,
|
||
);
|
||
const collect = async (
|
||
requestId: string,
|
||
conversationId: string,
|
||
token: string,
|
||
): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId,
|
||
conversationId,
|
||
prompt: "search",
|
||
workMode: "ask",
|
||
knowledgeCapabilityToken: token,
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
void _event;
|
||
}
|
||
};
|
||
|
||
const first = collect(
|
||
"3f496642-f47d-4e0a-8944-a32c77b0d6e1",
|
||
"conversation-one",
|
||
"first-token",
|
||
);
|
||
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(1));
|
||
const second = collect(
|
||
"3f496642-f47d-4e0a-8944-a32c77b0d6e2",
|
||
"conversation-two",
|
||
"second-token",
|
||
);
|
||
try {
|
||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||
expect(mcpAdd).toHaveBeenCalledTimes(1);
|
||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||
} finally {
|
||
resolveFirstAdd();
|
||
}
|
||
await vi.waitFor(() => expect(mcpAdd).toHaveBeenCalledTimes(retry ? 3 : 2));
|
||
await Promise.all([first, second]);
|
||
expect(
|
||
mcpAdd.mock.calls.map(
|
||
([input]) =>
|
||
(
|
||
input as {
|
||
config: { headers: { Authorization: string } };
|
||
}
|
||
).config.headers.Authorization,
|
||
),
|
||
).toEqual(retry
|
||
? ["Bearer first-token", "Bearer first-token", "Bearer second-token"]
|
||
: ["Bearer first-token", "Bearer second-token"]);
|
||
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(retry ? 3 : 2);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each(["knowledge", "custom"])("releases the conversation when a queued %s MCP add is cancelled", async (kind) => {
|
||
const setup = runClient([
|
||
{ id: "idle", type: "session.idle", properties: { sessionID: "session-1" } },
|
||
]);
|
||
let releaseRegistration!: () => void;
|
||
const registration = new Promise<void>((resolve) => { releaseRegistration = resolve; });
|
||
const add = setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>;
|
||
add.mockImplementationOnce(async (input: { name: string }) => {
|
||
await registration;
|
||
return { data: { [input.name]: { status: "connected" } } };
|
||
});
|
||
const revoke = vi.fn();
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
grantCustomMcp: () => "custom-token",
|
||
prepareCustomMcpTools: async () => [{ name: "custom_tool" }],
|
||
revoke,
|
||
} as unknown as KnowledgeMcpGateway,
|
||
mcpServers: [{
|
||
id: "custom",
|
||
name: "Custom",
|
||
description: "",
|
||
transport: "http",
|
||
url: "https://example.com/mcp",
|
||
enabled: true,
|
||
allowDynamicTools: false,
|
||
assignments: ["opencode"],
|
||
secretConfigured: false,
|
||
}],
|
||
});
|
||
const mutations = vi.spyOn(runtime as unknown as { mutateMcp: (...args: unknown[]) => Promise<unknown> }, "mutateMcp");
|
||
const controller = new AbortController();
|
||
const collect = async (requestId: string, conversationId: string, signal: AbortSignal, mcp: boolean) => {
|
||
const events: RuntimeEvent[] = [];
|
||
for await (const event of runtime.run({
|
||
requestId, conversationId, prompt: "test",
|
||
workMode: mcp && kind === "custom" ? "execute" : "ask",
|
||
...(mcp && kind === "knowledge" ? { knowledgeCapabilityToken: requestId } : {}),
|
||
}, signal)) events.push(event);
|
||
return events;
|
||
};
|
||
const runs: Promise<unknown>[] = [];
|
||
try {
|
||
runs.push(collect("first", "conversation-first", new AbortController().signal, true));
|
||
await vi.waitFor(() => expect(add).toHaveBeenCalledOnce());
|
||
const cancelled = collect("cancelled", "conversation-second", controller.signal, true).catch((error: unknown) => error);
|
||
runs.push(cancelled);
|
||
await vi.waitFor(() => expect(mutations).toHaveBeenCalledTimes(2));
|
||
controller.abort(new Error("cancelled queued add"));
|
||
let cancellationFinished = false;
|
||
void cancelled.then(() => { cancellationFinished = true; });
|
||
await vi.waitFor(() => expect(cancellationFinished).toBe(true));
|
||
await expect(cancelled).resolves.toMatchObject({ message: "cancelled queued add" });
|
||
if (kind === "custom") expect(revoke).toHaveBeenCalledWith("custom-token");
|
||
const next = collect("next", "conversation-second", new AbortController().signal, false);
|
||
runs.push(next);
|
||
await expect(next).resolves.toContainEqual(expect.objectContaining({ type: "done" }));
|
||
expect(add).toHaveBeenCalledOnce();
|
||
expect(setup.client.mcp.disconnect).not.toHaveBeenCalled();
|
||
releaseRegistration();
|
||
await Promise.all(runs);
|
||
expect(setup.client.mcp.disconnect).toHaveBeenCalledExactlyOnceWith(
|
||
{ name: add.mock.calls[0]![0].name, directory: process.cwd() },
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
} finally {
|
||
releaseRegistration();
|
||
await Promise.allSettled(runs);
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("keeps cleanup queued behind blocked registration while sessions run concurrently", async () => {
|
||
const setup = runClient([]);
|
||
const releases: (() => void)[] = [];
|
||
const finished = Array.from({ length: 3 }, () =>
|
||
new Promise<void>((resolve) => releases.push(resolve)),
|
||
);
|
||
let releaseRegistration!: () => void;
|
||
const registration = new Promise<void>((resolve) => {
|
||
releaseRegistration = resolve;
|
||
});
|
||
const create = setup.session.create as unknown as ReturnType<typeof vi.fn>;
|
||
let sessionCount = 0;
|
||
create.mockImplementation(async () => ({
|
||
data: { id: `session-${++sessionCount}` },
|
||
error: undefined,
|
||
}));
|
||
const subscribe = setup.event.subscribe as unknown as ReturnType<
|
||
typeof vi.fn
|
||
>;
|
||
let subscriptionCount = 0;
|
||
subscribe.mockImplementation(async () => {
|
||
const index = subscriptionCount++;
|
||
return {
|
||
stream: (async function* () {
|
||
await finished[index];
|
||
yield {
|
||
id: `idle-${index}`,
|
||
type: "session.idle",
|
||
properties: { sessionID: `session-${index + 1}` },
|
||
};
|
||
})(),
|
||
};
|
||
});
|
||
const add = setup.client.mcp.add as unknown as ReturnType<typeof vi.fn>;
|
||
add.mockImplementation(async (input: { name: string }) => {
|
||
if (add.mock.calls.length === 3) await registration;
|
||
return { data: { [input.name]: { status: "connected" } } };
|
||
});
|
||
const disconnect = setup.client.mcp.disconnect as unknown as ReturnType<
|
||
typeof vi.fn
|
||
>;
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
});
|
||
const events: RuntimeEvent[] = [];
|
||
const collect = async (index: number): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: `3f496642-f47d-4e0a-8944-a32c77b0d6e${index}`,
|
||
conversationId: `conversation-${index}`,
|
||
prompt: "search",
|
||
workMode: "ask",
|
||
knowledgeCapabilityToken: `token-${index}`,
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
events.push(event);
|
||
}
|
||
};
|
||
const runs: Promise<void>[] = [];
|
||
try {
|
||
runs.push(collect(1));
|
||
await vi.waitFor(() =>
|
||
expect(setup.session.promptAsync).toHaveBeenCalledTimes(1),
|
||
);
|
||
runs.push(collect(2));
|
||
await vi.waitFor(() =>
|
||
expect(setup.session.promptAsync).toHaveBeenCalledTimes(2),
|
||
);
|
||
expect(events.filter((event) => event.type === "done")).toHaveLength(0);
|
||
expect(create).toHaveBeenCalledTimes(2);
|
||
expect(setup.session.promptAsync).toHaveBeenNthCalledWith(
|
||
2,
|
||
expect.objectContaining({ sessionID: "session-2" }),
|
||
expect.anything(),
|
||
);
|
||
|
||
runs.push(collect(3));
|
||
await vi.waitFor(() => expect(add).toHaveBeenCalledTimes(3));
|
||
releases[0]!();
|
||
await vi.waitFor(() =>
|
||
expect(events.filter((event) => event.type === "done")).toHaveLength(1),
|
||
);
|
||
// Exceed the disconnect budget while another registration owns the queue.
|
||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||
expect(disconnect).not.toHaveBeenCalled();
|
||
expect(setup.session.promptAsync).toHaveBeenCalledTimes(2);
|
||
|
||
releaseRegistration();
|
||
await runs[0];
|
||
expect(disconnect).toHaveBeenCalledWith(
|
||
{ name: add.mock.calls[0]![0].name, directory: process.cwd() },
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(disconnect.mock.calls[0]![1].signal.aborted).toBe(false);
|
||
await vi.waitFor(() =>
|
||
expect(setup.session.promptAsync).toHaveBeenCalledTimes(3),
|
||
);
|
||
releases[1]!();
|
||
releases[2]!();
|
||
await Promise.all(runs);
|
||
expect(disconnect.mock.calls.map(([input]) => input.name)).toEqual(
|
||
add.mock.calls.map(([input]) => input.name),
|
||
);
|
||
expect(events.filter((event) => event.type === "done")).toHaveLength(3);
|
||
} finally {
|
||
releaseRegistration();
|
||
releases.forEach((release) => release());
|
||
await Promise.allSettled(runs);
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("does not send a knowledge capability to external OpenCode", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
embedded: false,
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
knowledgeGateway: {
|
||
getEndpoint: () => "http://127.0.0.1:4567/mcp",
|
||
getAvailableToolNames: () => ["knowledge_search"],
|
||
} as unknown as KnowledgeMcpGateway,
|
||
}),
|
||
{
|
||
createClient: vi.fn(
|
||
() => setup.client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
},
|
||
);
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "search",
|
||
workMode: "ask",
|
||
knowledgeCapabilityToken: "must-not-leave-main",
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
void _event;
|
||
}
|
||
expect(setup.client.mcp.add).not.toHaveBeenCalled();
|
||
expect(
|
||
JSON.stringify(
|
||
(setup.session.promptAsync as unknown as ReturnType<typeof vi.fn>).mock
|
||
.calls,
|
||
),
|
||
).not.toContain("must-not-leave-main");
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("allows only registered native Skills in read-only modes", async () => {
|
||
const sourceRoot = await mkdtemp(
|
||
join(tmpdir(), "goodbuddy-opencode-permission-skill-"),
|
||
);
|
||
const skillDirectory = join(sourceRoot, "longdoc-docx");
|
||
await mkdir(skillDirectory);
|
||
await writeFile(
|
||
join(skillDirectory, "SKILL.md"),
|
||
[
|
||
"---",
|
||
"name: longdoc-docx",
|
||
"description: Build a DOCX",
|
||
"---",
|
||
"",
|
||
"# Long document",
|
||
].join("\n"),
|
||
"utf8",
|
||
);
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
skillInstructions: "# Original path: C:\\private\\skills",
|
||
skillPackages: [
|
||
{
|
||
id: "longdoc-docx",
|
||
directory: skillDirectory,
|
||
},
|
||
],
|
||
});
|
||
try {
|
||
await collectRun(runtime, "ask");
|
||
|
||
expect(setup.session.create).toHaveBeenCalledWith(
|
||
{
|
||
title: "GoodBuddy 对话",
|
||
directory: process.cwd(),
|
||
permission: [
|
||
{ permission: "*", pattern: "*", action: "deny" },
|
||
{ permission: "skill", pattern: "*", action: "deny" },
|
||
{
|
||
permission: "skill",
|
||
pattern: "longdoc-docx",
|
||
action: "allow",
|
||
},
|
||
],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(setup.session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
system: undefined,
|
||
tools: {
|
||
read: false,
|
||
write: false,
|
||
bash: false,
|
||
task: false,
|
||
"gbd-*": false,
|
||
"gbc-*": false,
|
||
skill: true,
|
||
},
|
||
}),
|
||
expect.anything(),
|
||
);
|
||
} finally {
|
||
await runtime.dispose();
|
||
await rm(sourceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
it("configures Execute tools as allowed before prompting", async () => {
|
||
const { client, callOrder, permissionReply, session } = runClient([
|
||
permissionEvent({ sessionID: "unrelated-session" }),
|
||
permissionEvent(),
|
||
permissionEvent(),
|
||
completedToolEvent(),
|
||
{
|
||
id: "event-reasoning-part",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-reasoning",
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
type: "reasoning",
|
||
text: "",
|
||
time: { start: 1 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "event-reasoning",
|
||
type: "message.part.delta",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
partID: "part-reasoning",
|
||
field: "text",
|
||
delta: "reasoning output",
|
||
},
|
||
},
|
||
{
|
||
id: "event-text",
|
||
type: "message.part.delta",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
partID: "part-1",
|
||
field: "text",
|
||
delta: "approved output",
|
||
},
|
||
},
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
const events = await collectRun(runtime, "execute");
|
||
|
||
expect(callOrder).toEqual(["subscribe", "prompt"]);
|
||
expect(session.create).toHaveBeenCalledWith(
|
||
{
|
||
title: "GoodBuddy 对话",
|
||
directory: process.cwd(),
|
||
permission: [
|
||
{ permission: "*", pattern: "*", action: "allow" },
|
||
{
|
||
permission: "gbd-*",
|
||
pattern: "*",
|
||
action: "deny",
|
||
},
|
||
{
|
||
permission: "gbc-*",
|
||
pattern: "*",
|
||
action: "deny",
|
||
},
|
||
],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
tools: {
|
||
"gbd-*": false,
|
||
"gbc-*": false,
|
||
},
|
||
}),
|
||
expect.anything(),
|
||
);
|
||
expect(permissionReply).toHaveBeenCalledOnce();
|
||
expect(permissionReply).toHaveBeenCalledWith(
|
||
{
|
||
requestID: "permission-1",
|
||
directory: process.cwd(),
|
||
reply: "once",
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: "reasoning",
|
||
delta: "reasoning output",
|
||
}),
|
||
);
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: "tool",
|
||
callId: "call-1",
|
||
state: "pending",
|
||
}),
|
||
);
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: "tool",
|
||
callId: "call-1",
|
||
state: "completed",
|
||
input: '{\n "command": "npm test",\n "token": "visible-token"\n}',
|
||
output: "Tests passed\nAuthorization: Bearer secret-token",
|
||
}),
|
||
);
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: "text",
|
||
delta: "approved output",
|
||
}),
|
||
);
|
||
expect(
|
||
events.filter(
|
||
(event) =>
|
||
event.type === "reasoning" ||
|
||
event.type === "text" ||
|
||
event.type === "tool",
|
||
),
|
||
).toEqual([
|
||
expect.objectContaining({
|
||
type: "tool",
|
||
callId: "call-1",
|
||
state: "pending",
|
||
}),
|
||
expect.objectContaining({
|
||
type: "tool",
|
||
callId: "call-1",
|
||
state: "completed",
|
||
}),
|
||
expect.objectContaining({
|
||
type: "reasoning",
|
||
delta: "reasoning output",
|
||
}),
|
||
expect.objectContaining({
|
||
type: "text",
|
||
delta: "approved output",
|
||
}),
|
||
]);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("preserves markdown boundaries between native reasoning parts", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "reasoning-part-1",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "reasoning-1",
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
type: "reasoning",
|
||
text: "",
|
||
time: { start: 1 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "reasoning-delta-1",
|
||
type: "message.part.delta",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
partID: "reasoning-1",
|
||
field: "text",
|
||
delta: "**Evaluating request limits**",
|
||
},
|
||
},
|
||
{
|
||
id: "reasoning-part-2",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "reasoning-2",
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
type: "reasoning",
|
||
text: "",
|
||
time: { start: 2 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "reasoning-delta-2",
|
||
type: "message.part.delta",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
partID: "reasoning-2",
|
||
field: "text",
|
||
delta: "**Planning limit removals**",
|
||
},
|
||
},
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(setup.client);
|
||
|
||
const events = await collectRun(runtime);
|
||
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === "reasoning")
|
||
.map((event) => event.type === "reasoning" ? event.delta : ""),
|
||
).toEqual([
|
||
"**Evaluating request limits**",
|
||
"\n\n**Planning limit removals**",
|
||
]);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("auto-allows bounded fallback permission requests without GoodBuddy approval", async () => {
|
||
const { client, permissionReply } = runClient([
|
||
permissionEvent(),
|
||
permissionEvent({
|
||
id: "permission-2",
|
||
patterns: ["npm run lint"],
|
||
metadata: { command: "npm run lint" },
|
||
always: ["npm run lint"],
|
||
tool: {
|
||
messageID: "message-2",
|
||
callID: "call-2",
|
||
},
|
||
}),
|
||
completedToolEvent("call-1"),
|
||
completedToolEvent("call-2"),
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
await collectRun(runtime, "execute");
|
||
|
||
expect(permissionReply.mock.calls).toEqual([
|
||
[
|
||
{
|
||
requestID: "permission-1",
|
||
directory: process.cwd(),
|
||
reply: "once",
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
],
|
||
[
|
||
{
|
||
requestID: "permission-2",
|
||
directory: process.cwd(),
|
||
reply: "once",
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
],
|
||
]);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it.each(["ask", "execute"] as const)("settles owned descendant directory permissions in %s without touching peer sessions", async (mode) => {
|
||
const { client, permissionReply } = runClient([
|
||
permissionEvent({ id: "peer-permission", sessionID: "peer-session" }),
|
||
permissionEvent({
|
||
id: "child-permission", sessionID: "grandchild-session",
|
||
permission: "external_directory", patterns: ["/tmp/logs/*"],
|
||
}),
|
||
permissionEvent({
|
||
id: "child-permission", sessionID: "grandchild-session",
|
||
permission: "external_directory", patterns: ["/tmp/logs/*"],
|
||
}),
|
||
{
|
||
id: "event-idle", type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const sessionGet = vi.fn(async ({ sessionID }: { sessionID: string }) => ({
|
||
data: {
|
||
id: sessionID,
|
||
parentID: sessionID === "grandchild-session" ? "child-session"
|
||
: sessionID === "child-session" ? "session-1" : undefined,
|
||
},
|
||
}));
|
||
client.session.get = sessionGet as unknown as typeof client.session.get;
|
||
const runtime = embeddedRuntime(client);
|
||
try {
|
||
const events = await collectRun(runtime, mode);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
expect(events.filter((event) => event.type === "tool")).toEqual([]);
|
||
expect(permissionReply).toHaveBeenCalledExactlyOnceWith({
|
||
requestID: "child-permission", directory: process.cwd(),
|
||
reply: mode === "execute" ? "once" : "reject",
|
||
}, { signal: expect.any(AbortSignal) });
|
||
expect(sessionGet).toHaveBeenCalledTimes(3);
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("fails the run when a tool reports an error before session idle", async () => {
|
||
const { client, session } = runClient([
|
||
{
|
||
id: "event-tool-error",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-1",
|
||
callID: "call-1",
|
||
type: "tool",
|
||
tool: "write",
|
||
state: {
|
||
status: "error",
|
||
error: "write failed Authorization: Bearer secret-token",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "test",
|
||
workMode: "execute",
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: "status" },
|
||
});
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: {
|
||
type: "tool",
|
||
callId: "call-1",
|
||
state: "failed",
|
||
error: "write failed Authorization: Bearer secret-token",
|
||
},
|
||
});
|
||
await expect(stream.next()).rejects.toThrow(
|
||
"write failed Authorization: Bearer secret-token",
|
||
);
|
||
expect(session.abort).toHaveBeenCalledOnce();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("keeps a completed response when an earlier tool attempt failed", async () => {
|
||
const { client, session } = runClient([
|
||
{
|
||
id: "event-tool-error",
|
||
type: "message.part.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
part: {
|
||
id: "part-1",
|
||
callID: "call-1",
|
||
type: "tool",
|
||
tool: "read",
|
||
state: {
|
||
status: "error",
|
||
error: "Cannot read binary file",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
completedToolEvent("call-2", "write"),
|
||
{
|
||
id: "event-text",
|
||
type: "message.part.delta",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
messageID: "message-1",
|
||
partID: "part-text",
|
||
field: "text",
|
||
delta: "PPT 已生成并保存。",
|
||
},
|
||
},
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
const events = await collectRun(runtime, "execute");
|
||
|
||
expect(
|
||
events.filter(
|
||
(event) => event.type === "tool" && event.callId === "call-1",
|
||
),
|
||
).toEqual([
|
||
expect.objectContaining({
|
||
state: "failed",
|
||
error: "Cannot read binary file",
|
||
}),
|
||
expect.objectContaining({
|
||
state: "recoverable",
|
||
error: "Cannot read binary file",
|
||
}),
|
||
]);
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: "text",
|
||
delta: "PPT 已生成并保存。",
|
||
}),
|
||
);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
expect(session.abort).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("surfaces a rejected async prompt instead of reporting success", async () => {
|
||
const { client, session } = runClient([
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
vi.mocked(session.promptAsync).mockResolvedValueOnce({
|
||
data: undefined,
|
||
error: {
|
||
data: {
|
||
message: "prompt rejected Authorization: Bearer secret-token",
|
||
},
|
||
},
|
||
} as never);
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
await expect(collectRun(runtime)).rejects.toThrow(
|
||
"prompt rejected Authorization: Bearer secret-token",
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("removes an unanswered prompt from a reused session after failure", async () => {
|
||
const { client, session } = runClient([
|
||
{
|
||
id: "event-error",
|
||
type: "session.error",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
error: { name: "UnknownError", data: { message: "model failed" } },
|
||
},
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
await expect(collectRun(runtime)).rejects.toThrow("model failed");
|
||
|
||
const promptMessageId = vi.mocked(session.promptAsync).mock.calls[0]?.[0]
|
||
?.messageID;
|
||
expect(promptMessageId).toMatch(/^msg_/);
|
||
expect(session.deleteMessage).toHaveBeenCalledWith(
|
||
{
|
||
sessionID: "session-1",
|
||
messageID: promptMessageId,
|
||
directory: process.cwd(),
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("keeps a cancelled prompt and marks the next reused-session turn as following an interruption", async () => {
|
||
const setup = runClient([]);
|
||
const controller = new AbortController();
|
||
let subscriptions = 0;
|
||
vi.mocked(setup.event.subscribe).mockImplementation(async (_input, init) => {
|
||
const first = ++subscriptions === 1;
|
||
const signal = (init as { signal: AbortSignal }).signal;
|
||
return {
|
||
stream: (async function* () {
|
||
if (first) {
|
||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
||
return;
|
||
}
|
||
yield { type: "session.idle", properties: { sessionID: "session-1" } };
|
||
})(),
|
||
} as never;
|
||
});
|
||
const runtime = embeddedRuntime(setup.client);
|
||
const collect = async (prompt: string, signal: AbortSignal) => {
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(), conversationId: "conversation-1", prompt, workMode: "ask",
|
||
}, signal)) void event;
|
||
};
|
||
try {
|
||
const first = collect("first question", controller.signal).catch((error: unknown) => error);
|
||
await vi.waitFor(() => expect(setup.session.promptAsync).toHaveBeenCalledTimes(1));
|
||
controller.abort(new Error("cancelled"));
|
||
await expect(first).resolves.toMatchObject({ message: "cancelled" });
|
||
expect(setup.session.deleteMessage).not.toHaveBeenCalled();
|
||
|
||
await collect("new question", new AbortController().signal);
|
||
await collect("third question", new AbortController().signal);
|
||
|
||
const parts = vi.mocked(setup.session.promptAsync).mock.calls.map(
|
||
([input]) => (input as { parts: { text?: string; synthetic?: boolean }[] }).parts,
|
||
);
|
||
expect(parts[1]).toEqual([
|
||
expect.objectContaining({ synthetic: true, text: expect.stringContaining("interrupted the previous turn") }),
|
||
{ type: "text", text: "new question" },
|
||
]);
|
||
expect(parts[2]).toEqual([{ type: "text", text: "third question" }]);
|
||
expect(setup.session.create).toHaveBeenCalledOnce();
|
||
} finally {
|
||
await runtime.dispose();
|
||
}
|
||
});
|
||
|
||
it("deletes an ephemeral OpenCode session when released", async () => {
|
||
const { client, session } = runClient([
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
await collectRun(runtime);
|
||
await runtime.releaseConversation("conversation-1");
|
||
|
||
expect(session.delete).toHaveBeenCalledWith(
|
||
{
|
||
sessionID: "session-1",
|
||
directory: process.cwd(),
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("ignores unrelated requests and rejects bounded malformed requests without prompting", async () => {
|
||
const { client, permissionReply } = runClient([
|
||
permissionEvent({ sessionID: "unrelated-session" }),
|
||
permissionEvent({
|
||
patterns: Array.from({ length: 33 }, () => "*"),
|
||
}),
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
await collectRun(runtime, "execute");
|
||
|
||
expect(permissionReply).toHaveBeenCalledOnce();
|
||
expect(permissionReply).toHaveBeenCalledWith(
|
||
{
|
||
requestID: "permission-1",
|
||
directory: process.cwd(),
|
||
reply: "reject",
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("fails closed when the OpenCode permission reply fails", async () => {
|
||
const { client, permissionReply, session } = runClient([permissionEvent()]);
|
||
permissionReply.mockResolvedValue({
|
||
data: false,
|
||
error: { message: "secret server error" },
|
||
});
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
await expect(collectRun(runtime, "execute")).rejects.toThrow(
|
||
"OpenCode 权限回复失败",
|
||
);
|
||
expect(session.abort).toHaveBeenCalledWith(
|
||
{ sessionID: "session-1", directory: process.cwd() },
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("uses deny-all session rules and hard tool disable in Ask mode", async () => {
|
||
const { client, session, tool } = runClient([
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
await collectRun(runtime, "ask");
|
||
|
||
expect(session.create).toHaveBeenCalledWith(
|
||
{
|
||
title: "GoodBuddy 对话",
|
||
directory: process.cwd(),
|
||
permission: [{ permission: "*", pattern: "*", action: "deny" }],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(tool.ids).toHaveBeenCalledWith(
|
||
{ directory: process.cwd() },
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
tools: {
|
||
read: false,
|
||
write: false,
|
||
bash: false,
|
||
task: false,
|
||
"gbd-*": false,
|
||
"gbc-*": false,
|
||
},
|
||
}),
|
||
expect.anything(),
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("updates reused sessions when the work mode changes", async () => {
|
||
const { client, session } = runClient([
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
await collectRun(runtime, "execute");
|
||
await collectRun(runtime, "ask");
|
||
|
||
expect(session.update).toHaveBeenCalledWith(
|
||
{
|
||
sessionID: "session-1",
|
||
directory: process.cwd(),
|
||
permission: [{ permission: "*", pattern: "*", action: "deny" }],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("configures external Execute sessions without whole-run approval", async () => {
|
||
const { client, session, permissionReply } = runClient([
|
||
permissionEvent(),
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
{
|
||
createClient: vi.fn(
|
||
() => client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
},
|
||
);
|
||
await collectRun(runtime, "execute");
|
||
|
||
expect(runtime.requiresToolApproval).toBe(false);
|
||
expect(session.create).toHaveBeenCalledWith(
|
||
{
|
||
title: "GoodBuddy 对话",
|
||
directory: process.cwd(),
|
||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(permissionReply).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
});
|
||
|
||
describe("OpenCodeRuntime native customization", () => {
|
||
it("maps bounded native inventory and filters GoodBuddy-owned capabilities", async () => {
|
||
const sourceRoot = await mkdtemp(
|
||
join(tmpdir(), "goodbuddy-opencode-native-snapshot-"),
|
||
);
|
||
const assignedSkillDirectory = join(sourceRoot, "assigned-skill");
|
||
await mkdir(assignedSkillDirectory);
|
||
await writeFile(
|
||
join(assignedSkillDirectory, "SKILL.md"),
|
||
[
|
||
"---",
|
||
"name: assigned-skill",
|
||
"description: Assigned test skill",
|
||
"---",
|
||
"",
|
||
"# Assigned skill",
|
||
].join("\n"),
|
||
"utf8",
|
||
);
|
||
const client = {
|
||
session: {
|
||
list: vi.fn().mockResolvedValue({
|
||
data: [],
|
||
error: undefined,
|
||
}),
|
||
},
|
||
app: {
|
||
agents: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "build",
|
||
description: "Primary builder",
|
||
mode: "primary",
|
||
native: true,
|
||
hidden: false,
|
||
permission: [],
|
||
options: {},
|
||
},
|
||
{
|
||
name: "hidden",
|
||
mode: "all",
|
||
hidden: true,
|
||
permission: [],
|
||
options: {},
|
||
},
|
||
],
|
||
}),
|
||
skills: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "native-skill",
|
||
description: "Native skill",
|
||
location: "C:\\private\\native",
|
||
content: "must not be exposed",
|
||
},
|
||
{
|
||
name: "assigned-skill",
|
||
location: "C:\\private\\assigned",
|
||
content: "assigned content",
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
tool: {
|
||
ids: vi.fn().mockResolvedValue({
|
||
data: [
|
||
"apply_patch",
|
||
"bash",
|
||
"edit",
|
||
"glob",
|
||
"grep",
|
||
"invalid",
|
||
"question",
|
||
"read",
|
||
"skill",
|
||
"task",
|
||
"todowrite",
|
||
"webfetch",
|
||
"websearch",
|
||
"write",
|
||
"gbd-123_search",
|
||
"extension_tool",
|
||
],
|
||
error: undefined,
|
||
}),
|
||
},
|
||
command: {
|
||
list: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "review",
|
||
description: "Review changes",
|
||
source: "command",
|
||
template: "private command template",
|
||
hints: [],
|
||
},
|
||
{
|
||
name: "mcp-prompt",
|
||
description: "Prompt from MCP",
|
||
source: "mcp",
|
||
template: "Inspect $ARGUMENTS",
|
||
hints: [],
|
||
},
|
||
{
|
||
name: "assigned-skill",
|
||
source: "skill",
|
||
template: "assigned skill template",
|
||
hints: [],
|
||
},
|
||
{
|
||
name: "gbd-123",
|
||
source: "mcp",
|
||
template: "temporary prompt",
|
||
hints: [],
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
lsp: {
|
||
status: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
id: "typescript",
|
||
name: "TypeScript",
|
||
root: "C:\\private\\workspace",
|
||
status: "connected",
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
formatter: {
|
||
status: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "prettier",
|
||
enabled: true,
|
||
extensions: [".ts", ".tsx"],
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
mcp: {
|
||
status: vi.fn().mockResolvedValue({
|
||
data: {
|
||
public: { status: "failed", error: "private failure" },
|
||
"gbc-123": { status: "connected" },
|
||
},
|
||
}),
|
||
},
|
||
experimental: {
|
||
resource: {
|
||
list: vi.fn().mockResolvedValue({
|
||
data: {
|
||
"public-resource": {
|
||
name: "Public resource",
|
||
uri: "docs://public",
|
||
description: "Reference",
|
||
mimeType: "text/plain",
|
||
client: "public",
|
||
},
|
||
temporary: {
|
||
name: "Temporary resource",
|
||
uri: "docs://temporary",
|
||
client: "gbd-123",
|
||
},
|
||
},
|
||
}),
|
||
},
|
||
},
|
||
} as unknown as ReturnType<typeof createOpencodeClient>;
|
||
const runtime = embeddedRuntime(client, {
|
||
skillPackages: [
|
||
{
|
||
id: "assigned-skill",
|
||
directory: assignedSkillDirectory,
|
||
},
|
||
],
|
||
});
|
||
|
||
const snapshot = await runtime.getNativeSnapshot();
|
||
|
||
expect(snapshot).toMatchObject({
|
||
available: true,
|
||
inventoryStatus: "available",
|
||
detail: "OpenCode 原生能力已就绪",
|
||
agents: [
|
||
{
|
||
id: "build",
|
||
mode: "primary",
|
||
native: true,
|
||
hidden: false,
|
||
},
|
||
{
|
||
id: "hidden",
|
||
hidden: true,
|
||
},
|
||
],
|
||
toolsSupported: true,
|
||
tools: expect.arrayContaining([
|
||
{
|
||
id: "edit",
|
||
name: "edit",
|
||
kind: "write",
|
||
source: "runtime",
|
||
ask: "blocked",
|
||
execute: "allowed",
|
||
},
|
||
{
|
||
id: "read",
|
||
name: "read",
|
||
kind: "read",
|
||
source: "runtime",
|
||
ask: "blocked",
|
||
execute: "allowed",
|
||
},
|
||
{
|
||
id: "skill",
|
||
name: "skill",
|
||
kind: "agent",
|
||
source: "runtime",
|
||
ask: "conditional",
|
||
execute: "allowed",
|
||
},
|
||
{
|
||
id: "extension_tool",
|
||
name: "extension_tool",
|
||
kind: "other",
|
||
source: "unknown",
|
||
ask: "blocked",
|
||
execute: "allowed",
|
||
},
|
||
]),
|
||
commands: [
|
||
{
|
||
id: "review",
|
||
source: "command",
|
||
},
|
||
{
|
||
id: "mcp-prompt",
|
||
source: "mcp",
|
||
},
|
||
],
|
||
prompts: [
|
||
{
|
||
id: "mcp-prompt",
|
||
prompt: "Inspect $ARGUMENTS",
|
||
source: "mcp",
|
||
},
|
||
],
|
||
lsp: [
|
||
{
|
||
id: "typescript",
|
||
name: "TypeScript",
|
||
status: "connected",
|
||
},
|
||
],
|
||
formatters: [
|
||
{
|
||
id: "prettier",
|
||
enabled: true,
|
||
extensions: [".ts", ".tsx"],
|
||
},
|
||
],
|
||
mcpServers: [
|
||
{
|
||
id: "public",
|
||
status: "failed",
|
||
},
|
||
],
|
||
skills: [
|
||
{
|
||
id: "native-skill",
|
||
description: "Native skill",
|
||
},
|
||
],
|
||
resources: [
|
||
{
|
||
id: "public-resource",
|
||
uri: "docs://public",
|
||
server: "public",
|
||
},
|
||
],
|
||
resourcesSupported: true,
|
||
context: {
|
||
strategy: "native",
|
||
manualCompact: true,
|
||
},
|
||
});
|
||
const serialized = JSON.stringify(snapshot);
|
||
expect(serialized).not.toContain("private failure");
|
||
expect(serialized).not.toContain("private command template");
|
||
expect(serialized).not.toContain("must not be exposed");
|
||
expect(serialized).not.toContain("C:\\private");
|
||
expect(serialized).not.toContain("assigned-skill");
|
||
expect(serialized).not.toContain("gbd-");
|
||
expect(serialized).not.toContain("gbc-");
|
||
expect(serialized).not.toContain('"invalid"');
|
||
|
||
vi.mocked(client.tool.ids).mockRejectedValueOnce(
|
||
new Error("tool inventory unavailable"),
|
||
);
|
||
const partialSnapshot = await runtime.getNativeSnapshot();
|
||
expect(partialSnapshot).toMatchObject({
|
||
available: true,
|
||
inventoryStatus: "partial",
|
||
tools: [],
|
||
toolsSupported: false,
|
||
});
|
||
expect(partialSnapshot.detail).toContain("工具");
|
||
await runtime.dispose();
|
||
await rm(sourceRoot, { recursive: true, force: true });
|
||
});
|
||
|
||
it("reports external OpenCode connectivity without claiming readable native inventory", async () => {
|
||
const client = runClient([]).client;
|
||
const runtime = new OpenCodeRuntime(
|
||
options({
|
||
baseUrl: "http://127.0.0.1:4096",
|
||
embedded: false,
|
||
}),
|
||
{
|
||
createClient: vi.fn(
|
||
() => client,
|
||
) as unknown as typeof createOpencodeClient,
|
||
},
|
||
);
|
||
|
||
await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({
|
||
available: true,
|
||
inventoryStatus: "connection-only",
|
||
tools: [],
|
||
toolsSupported: false,
|
||
});
|
||
expect(client.tool.ids).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("uses an explicit valid agent over the configured default", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
Object.assign(setup.client, {
|
||
app: {
|
||
agents: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "build",
|
||
mode: "primary",
|
||
hidden: false,
|
||
permission: [],
|
||
options: {},
|
||
},
|
||
{
|
||
name: "plan",
|
||
mode: "all",
|
||
hidden: false,
|
||
permission: [],
|
||
options: {},
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
});
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
customization: { defaultAgent: "build" },
|
||
});
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "test",
|
||
workMode: "execute",
|
||
runtimeControl: {
|
||
provider: "opencode",
|
||
agent: "plan",
|
||
},
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
void _event;
|
||
}
|
||
|
||
expect(setup.session.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ agent: "plan" }),
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(setup.session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({ agent: "plan" }),
|
||
expect.anything(),
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("uses the configured default agent when no request override is present", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
Object.assign(setup.client, {
|
||
app: {
|
||
agents: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "build",
|
||
mode: "primary",
|
||
hidden: false,
|
||
permission: [],
|
||
options: {},
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
});
|
||
const runtime = embeddedRuntime(setup.client, {
|
||
customization: { defaultAgent: "build" },
|
||
});
|
||
|
||
await collectRun(runtime);
|
||
|
||
expect(setup.session.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({ agent: "build" }),
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(setup.session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({ agent: "build" }),
|
||
expect.anything(),
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("rejects a stale or hidden agent instead of falling back", async () => {
|
||
const setup = runClient([]);
|
||
Object.assign(setup.client, {
|
||
app: {
|
||
agents: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "hidden",
|
||
mode: "primary",
|
||
hidden: true,
|
||
permission: [],
|
||
options: {},
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
});
|
||
const runtime = embeddedRuntime(setup.client);
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "test",
|
||
workMode: "execute",
|
||
runtimeControl: {
|
||
provider: "opencode",
|
||
agent: "hidden",
|
||
},
|
||
},
|
||
new AbortController().signal,
|
||
);
|
||
|
||
await expect(stream.next()).rejects.toThrow(
|
||
"OpenCode Agent 不存在、已隐藏或不可作为主 Agent:hidden",
|
||
);
|
||
expect(setup.session.create).not.toHaveBeenCalled();
|
||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("executes validated native commands through the command API", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const command = vi.fn().mockResolvedValue({
|
||
data: { info: {}, parts: [] },
|
||
});
|
||
Object.assign(setup.client, {
|
||
app: {
|
||
agents: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "build",
|
||
mode: "primary",
|
||
hidden: false,
|
||
permission: [],
|
||
options: {},
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
command: {
|
||
list: vi.fn().mockResolvedValue({
|
||
data: [
|
||
{
|
||
name: "review",
|
||
source: "command",
|
||
template: "Review $ARGUMENTS",
|
||
hints: [],
|
||
},
|
||
],
|
||
}),
|
||
},
|
||
});
|
||
Object.assign(setup.client.session, { command });
|
||
const runtime = embeddedRuntime(setup.client);
|
||
|
||
const events = [];
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
prompt: "must not become slash text",
|
||
workMode: "execute",
|
||
runtimeControl: {
|
||
provider: "opencode",
|
||
agent: "build",
|
||
command: {
|
||
name: "review",
|
||
arguments: "--staged",
|
||
},
|
||
},
|
||
},
|
||
new AbortController().signal,
|
||
)) {
|
||
events.push(event);
|
||
}
|
||
|
||
expect(command).toHaveBeenCalledWith(
|
||
{
|
||
sessionID: "session-1",
|
||
directory: process.cwd(),
|
||
command: "review",
|
||
arguments: "--staged",
|
||
agent: "build",
|
||
messageID: expect.stringMatching(/^msg_/u),
|
||
},
|
||
expect.objectContaining({
|
||
signal: expect.any(AbortSignal),
|
||
}),
|
||
);
|
||
expect(setup.session.promptAsync).not.toHaveBeenCalled();
|
||
expect(events.at(-1)).toMatchObject({
|
||
type: "done",
|
||
sessionId: "session-1",
|
||
});
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("compacts a managed session through the supported native API", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const context = vi.fn().mockResolvedValue({
|
||
data: {
|
||
data: [
|
||
{
|
||
type: "assistant",
|
||
model: {
|
||
providerID: "anthropic",
|
||
id: "claude-sonnet",
|
||
},
|
||
},
|
||
],
|
||
},
|
||
});
|
||
const summarize = vi.fn().mockResolvedValue({
|
||
data: true,
|
||
error: undefined,
|
||
});
|
||
Object.assign(setup.client, {
|
||
v2: {
|
||
session: { context },
|
||
},
|
||
session: { ...setup.client.session, summarize },
|
||
});
|
||
const runtime = embeddedRuntime(setup.client);
|
||
await collectRun(runtime);
|
||
vi.mocked(setup.event.subscribe).mockResolvedValueOnce({
|
||
stream: (async function* () {
|
||
yield {
|
||
type: "message.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
info: {
|
||
id: "compaction-message",
|
||
sessionID: "session-1",
|
||
role: "assistant",
|
||
time: {
|
||
created: 1,
|
||
completed: 2,
|
||
},
|
||
parentID: "compaction-parent",
|
||
modelID: "claude-sonnet",
|
||
providerID: "anthropic",
|
||
mode: "compaction",
|
||
agent: "build",
|
||
path: {
|
||
cwd: process.cwd(),
|
||
root: process.cwd(),
|
||
},
|
||
cost: 0,
|
||
tokens: {
|
||
input: 100,
|
||
output: 20,
|
||
reasoning: 0,
|
||
cache: {
|
||
read: 30,
|
||
write: 4,
|
||
},
|
||
total: 124,
|
||
},
|
||
finish: "stop",
|
||
},
|
||
},
|
||
};
|
||
yield {
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
};
|
||
})(),
|
||
} as never);
|
||
const signal = new AbortController().signal;
|
||
|
||
await expect(
|
||
runtime.compactConversation(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
runtimeSelection: { provider: "opencode" },
|
||
history: [],
|
||
historyMessageIds: [],
|
||
},
|
||
signal,
|
||
),
|
||
).resolves.toEqual({
|
||
result: {
|
||
provider: "opencode",
|
||
strategy: "native",
|
||
compacted: true,
|
||
detail: "OpenCode 已完成原生上下文压缩",
|
||
},
|
||
usageEvents: [
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
type: "model-usage",
|
||
callId: "compaction-message",
|
||
runtime: "opencode",
|
||
provider: "anthropic",
|
||
model: "claude-sonnet",
|
||
inputTokens: 100,
|
||
outputTokens: 20,
|
||
cacheReadTokens: 30,
|
||
cacheWriteTokens: 4,
|
||
reportedTotalTokens: 124,
|
||
},
|
||
],
|
||
});
|
||
expect(context).toHaveBeenCalledWith(
|
||
{ sessionID: "session-1" },
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(summarize).toHaveBeenCalledWith(
|
||
{
|
||
sessionID: "session-1",
|
||
directory: process.cwd(),
|
||
providerID: "anthropic",
|
||
modelID: "claude-sonnet",
|
||
auto: false,
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("reports when a managed session has no model to compact with", async () => {
|
||
const setup = runClient([
|
||
{
|
||
id: "idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const context = vi.fn().mockResolvedValue({
|
||
data: { data: [] },
|
||
});
|
||
const summarize = vi.fn();
|
||
Object.assign(setup.client, {
|
||
v2: {
|
||
session: { context },
|
||
},
|
||
session: { ...setup.client.session, summarize },
|
||
});
|
||
const runtime = embeddedRuntime(setup.client);
|
||
await collectRun(runtime);
|
||
|
||
await expect(
|
||
runtime.compactConversation(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "conversation-1",
|
||
runtimeSelection: { provider: "opencode" },
|
||
history: [],
|
||
historyMessageIds: [],
|
||
},
|
||
new AbortController().signal,
|
||
),
|
||
).resolves.toEqual({
|
||
result: {
|
||
provider: "opencode",
|
||
strategy: "native",
|
||
compacted: false,
|
||
detail: "当前 OpenCode 会话尚无可用于压缩的模型记录",
|
||
},
|
||
});
|
||
expect(summarize).not.toHaveBeenCalled();
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("reports when no managed OpenCode session can be compacted", async () => {
|
||
const runtime = new OpenCodeRuntime(options());
|
||
|
||
await expect(
|
||
runtime.compactConversation(
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
conversationId: "missing-conversation",
|
||
runtimeSelection: { provider: "opencode" },
|
||
history: [],
|
||
historyMessageIds: [],
|
||
},
|
||
new AbortController().signal,
|
||
),
|
||
).resolves.toEqual({
|
||
result: {
|
||
provider: "opencode",
|
||
strategy: "native",
|
||
compacted: false,
|
||
detail: "当前 GoodBuddy 对话尚无可压缩的 OpenCode 会话",
|
||
},
|
||
});
|
||
await runtime.dispose();
|
||
});
|
||
});
|
||
|
||
describe("OpenCodeRuntime model usage", () => {
|
||
it("emits one provider-reported usage event for each terminal assistant message", async () => {
|
||
const assistantMessage = {
|
||
id: "message-assistant-1",
|
||
sessionID: "session-1",
|
||
role: "assistant",
|
||
time: {
|
||
created: 1,
|
||
completed: 2,
|
||
},
|
||
parentID: "message-user-1",
|
||
modelID: "claude-sonnet-provider",
|
||
providerID: "anthropic",
|
||
mode: "build",
|
||
agent: "build",
|
||
path: {
|
||
cwd: process.cwd(),
|
||
root: process.cwd(),
|
||
},
|
||
cost: 0.01,
|
||
tokens: {
|
||
total: 42,
|
||
input: 23,
|
||
output: 11,
|
||
reasoning: 3,
|
||
cache: {
|
||
read: 7,
|
||
write: 5,
|
||
},
|
||
},
|
||
};
|
||
const { client } = runClient([
|
||
{
|
||
id: "event-incomplete",
|
||
type: "message.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
info: {
|
||
...assistantMessage,
|
||
time: { created: 1 },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "event-terminal",
|
||
type: "message.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
info: assistantMessage,
|
||
},
|
||
},
|
||
{
|
||
id: "event-terminal-duplicate",
|
||
type: "message.updated",
|
||
properties: {
|
||
sessionID: "session-1",
|
||
info: assistantMessage,
|
||
},
|
||
},
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
const events = await collectRun(runtime);
|
||
|
||
expect(events.filter((event) => event.type === "model-usage")).toEqual([
|
||
{
|
||
requestId: "3f496642-f47d-4e0a-8944-a32c77b0d6ef",
|
||
type: "model-usage",
|
||
callId: "message-assistant-1",
|
||
runtime: "opencode",
|
||
provider: "anthropic",
|
||
model: "claude-sonnet-provider",
|
||
inputTokens: 23,
|
||
outputTokens: 11,
|
||
cacheReadTokens: 7,
|
||
cacheWriteTokens: 5,
|
||
reportedTotalTokens: 42,
|
||
},
|
||
]);
|
||
expect(events.at(-2)).toMatchObject({ type: "model-usage" });
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
await runtime.dispose();
|
||
});
|
||
|
||
it("ignores assistant usage from another session", async () => {
|
||
const { client } = runClient([
|
||
{
|
||
id: "event-unrelated-usage",
|
||
type: "message.updated",
|
||
properties: {
|
||
sessionID: "session-2",
|
||
info: {
|
||
id: "message-assistant-2",
|
||
sessionID: "session-2",
|
||
role: "assistant",
|
||
time: {
|
||
created: 1,
|
||
completed: 2,
|
||
},
|
||
parentID: "message-user-2",
|
||
modelID: "unrelated-model",
|
||
providerID: "unrelated-provider",
|
||
mode: "build",
|
||
agent: "build",
|
||
path: {
|
||
cwd: process.cwd(),
|
||
root: process.cwd(),
|
||
},
|
||
cost: 0,
|
||
tokens: {
|
||
input: 100,
|
||
output: 50,
|
||
reasoning: 0,
|
||
cache: {
|
||
read: 0,
|
||
write: 0,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
id: "event-idle",
|
||
type: "session.idle",
|
||
properties: { sessionID: "session-1" },
|
||
},
|
||
]);
|
||
const runtime = embeddedRuntime(client);
|
||
|
||
const events = await collectRun(runtime);
|
||
|
||
expect(events.filter((event) => event.type === "model-usage")).toEqual([]);
|
||
await runtime.dispose();
|
||
});
|
||
});
|