Display native OpenCode and Continue checklists alongside scheduled tasks, preserving request ownership, explicit clears and saved progress. Bundle both remote runtimes and support managed Continue installation, model routing, cancellation and restart continuation. Validation: real local and Linux remote model checks, full Electron remote Continue flows, typecheck and lint passed. Full suite: 4707 passed, 67 skipped, one OpenCode compaction failure passed targeted rerun. Release note: Display native execution checklists above conversations and support managed remote Continue projects with saved progress and cancellation recovery.
5227 lines
161 KiB
TypeScript
5227 lines
161 KiB
TypeScript
import { EventEmitter } from "node:events";
|
||
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 type { 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 {
|
||
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,
|
||
}),
|
||
},
|
||
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,
|
||
})),
|
||
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(/^goodbuddy-custom-[a-f0-9]{20}$/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(/^goodbuddy-data-[a-f0-9]{20}$/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,
|
||
"goodbuddy-data-*": false,
|
||
"goodbuddy-custom-*": false,
|
||
[knowledgeToolId]: true,
|
||
},
|
||
}),
|
||
expect.anything(),
|
||
);
|
||
expect(setup.client.mcp.disconnect).toHaveBeenCalledWith(
|
||
{
|
||
name: expect.stringMatching(/^goodbuddy-data-/u),
|
||
directory: process.cwd(),
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(events.at(-1)).toMatchObject({ type: "done" });
|
||
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("runs embedded conversations concurrently while serializing MCP registrations", 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>;
|
||
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]: { 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(2));
|
||
await Promise.all([first, second]);
|
||
expect(
|
||
mcpAdd.mock.calls.map(
|
||
([input]) =>
|
||
(
|
||
input as {
|
||
config: { headers: { Authorization: string } };
|
||
}
|
||
).config.headers.Authorization,
|
||
),
|
||
).toEqual(["Bearer first-token", "Bearer second-token"]);
|
||
expect(setup.client.mcp.disconnect).toHaveBeenCalledTimes(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,
|
||
"goodbuddy-data-*": false,
|
||
"goodbuddy-custom-*": 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: "goodbuddy-data-*",
|
||
pattern: "*",
|
||
action: "deny",
|
||
},
|
||
{
|
||
permission: "goodbuddy-custom-*",
|
||
pattern: "*",
|
||
action: "deny",
|
||
},
|
||
],
|
||
},
|
||
{ signal: expect.any(AbortSignal) },
|
||
);
|
||
expect(session.promptAsync).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
tools: {
|
||
"goodbuddy-data-*": false,
|
||
"goodbuddy-custom-*": 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("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,
|
||
"goodbuddy-data-*": false,
|
||
"goodbuddy-custom-*": 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",
|
||
"goodbuddy-data-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: "goodbuddy-data-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" },
|
||
"goodbuddy-custom-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: "goodbuddy-data-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("goodbuddy-data-");
|
||
expect(serialized).not.toContain("goodbuddy-custom-");
|
||
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();
|
||
});
|
||
});
|