456 lines
24 KiB
TypeScript
456 lines
24 KiB
TypeScript
import {
|
|
LOCAL_QUERY_TOOL_IDS,
|
|
PRODUCT_MCP_TOOL_IDS,
|
|
} from "../../src/runtime/product-mcp-tools.js";
|
|
import { describe, expect, it } from "bun:test";
|
|
import { type OpenCodeClient as OpencodeClient } from "@opencode/client";
|
|
import { createServer } from "node:net";
|
|
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { basename, join } from "node:path";
|
|
import { bindScopedConversationWorkspace, createConversationWorkspace, migrateConversationWorkspace, sessionWorkspaceDirectory } from "../../src/runtime/conversationWorkspace.js";
|
|
|
|
import { config } from "../../src/config.js";
|
|
import { modelCheckTargets } from "../../src/runtime/model-check.js";
|
|
import {
|
|
OpencodeRuntimeAdapter,
|
|
resolveEmbeddedOpencodePort,
|
|
} from "../../src/runtime/opencode.js";
|
|
|
|
const createRuntimeAdapter = (
|
|
messages: unknown[],
|
|
calls: {
|
|
reverted: string[];
|
|
removed: string[];
|
|
} = { reverted: [], removed: [] },
|
|
) =>
|
|
Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
messages: async () => messages,
|
|
revertMessage: async (_sessionId: string, messageId: string) => {
|
|
calls.reverted.push(messageId);
|
|
},
|
|
removeMessage: async (_sessionId: string, messageId: string) => {
|
|
calls.removed.push(messageId);
|
|
},
|
|
}) as OpencodeRuntimeAdapter;
|
|
|
|
describe("OpencodeRuntimeAdapter.removeSession", () => {
|
|
it("removes its native session and workspace, rejects a foreign binding, and preserves files on failure", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "tjwater-session-cleanup-"));
|
|
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
|
config.CONVERSATION_WORKSPACE_ROOT = root;
|
|
const scope = { actorKey: "actor-a", projectKey: "project-a" };
|
|
const id = "cleanup-session";
|
|
const directory = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: id });
|
|
const foreign = await bindScopedConversationWorkspace(await createConversationWorkspace(root), root, { ...scope, sessionId: "foreign-session" });
|
|
const calls: string[] = [];
|
|
let binding = directory;
|
|
let removed = false;
|
|
let failRemoval = true;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
ensureClient: async () => ({ session: {
|
|
get: async () => { if (removed) throw { _tag: "SessionNotFoundError" }; return { id, location: { directory: binding } }; },
|
|
interrupt: async () => { calls.push("interrupt"); },
|
|
active: async () => ({}),
|
|
remove: async () => { if (failRemoval) throw new Error("native removal failed"); removed = true; calls.push("remove"); },
|
|
} }),
|
|
}) as OpencodeRuntimeAdapter;
|
|
try {
|
|
binding = foreign;
|
|
await expect(runtime.removeSession(id)).rejects.toThrow("another session");
|
|
expect(calls).toEqual([]);
|
|
binding = directory;
|
|
await expect(runtime.removeSession(id, scope)).rejects.toThrow("native removal failed");
|
|
expect((await lstat(directory)).isDirectory()).toBe(true);
|
|
failRemoval = false;
|
|
await runtime.removeSession(id, scope);
|
|
await expect(lstat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect((await lstat(foreign)).isDirectory()).toBe(true);
|
|
expect(calls).toEqual(["interrupt", "interrupt", "remove"]);
|
|
await runtime.removeSession(id, scope);
|
|
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); }
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
|
|
it("skips reverting the first user message when the runtime session is empty", async () => {
|
|
const calls = { reverted: [] as string[], removed: [] as string[] };
|
|
const runtime = createRuntimeAdapter([], calls);
|
|
|
|
await runtime.revertToUserMessage("session-1", { userOrdinal: 1 });
|
|
|
|
expect(calls).toEqual({ reverted: [], removed: [] });
|
|
});
|
|
|
|
it("keeps ordinal mismatches visible when runtime messages exist", async () => {
|
|
const runtime = createRuntimeAdapter([
|
|
{ info: { id: "user-1", role: "user" } },
|
|
{ info: { id: "assistant-1", role: "assistant" } },
|
|
]);
|
|
|
|
await expect(
|
|
runtime.revertToUserMessage("session-1", { userOrdinal: 2 }),
|
|
).rejects.toThrow("target user message not found to revert");
|
|
});
|
|
|
|
it("reverts and removes messages from the target user message onward", async () => {
|
|
const calls = { reverted: [] as string[], removed: [] as string[] };
|
|
const runtime = createRuntimeAdapter(
|
|
[
|
|
{ info: { id: "user-1", role: "user" } },
|
|
{ info: { id: "assistant-1", role: "assistant" } },
|
|
{ info: { id: "user-2", role: "user" } },
|
|
{ info: { id: "assistant-2", role: "assistant" } },
|
|
],
|
|
calls,
|
|
);
|
|
|
|
await runtime.revertToUserMessage("session-1", { userOrdinal: 2 });
|
|
|
|
expect(calls).toEqual({
|
|
reverted: ["user-2"],
|
|
removed: ["user-2"],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
|
it("retries bootstrap after a failed startup attempt", async () => {
|
|
let attempts = 0;
|
|
const client = {
|
|
global: { health: async () => ({ data: { healthy: true } }) },
|
|
} as unknown as OpencodeClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
clientPromise: null,
|
|
closeServer: null,
|
|
bootstrapClient: async () => {
|
|
attempts += 1;
|
|
if (attempts === 1) {
|
|
throw new Error("startup failed");
|
|
}
|
|
return client;
|
|
},
|
|
}) as OpencodeRuntimeAdapter;
|
|
|
|
await expect(runtime.ensureClient()).rejects.toThrow("startup failed");
|
|
await expect(runtime.ensureClient()).resolves.toBe(client);
|
|
expect(attempts).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.generateText", () => {
|
|
it("uses scoped one-shot generation and cleans its temporary context on success and failure", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "prompt-suggestion-runtime-"));
|
|
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
|
config.CONVERSATION_WORKSPACE_ROOT = root;
|
|
const controller = new AbortController();
|
|
const calls: unknown[] = [];
|
|
let fail = false;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
ensureClient: async () => ({
|
|
rpc: { call: async () => { calls.push("catalog ready"); } },
|
|
session: {
|
|
create: async (input: unknown) => { calls.push(input); return { id: "temporary-generator" }; },
|
|
generate: async (input: unknown, options: { signal?: AbortSignal }) => {
|
|
calls.push({ input, signal: options.signal });
|
|
if (fail) throw new Error("inference failed");
|
|
return { text: "generated suggestions" };
|
|
},
|
|
remove: async (input: unknown) => { calls.push(input); },
|
|
prompt: () => { throw new Error("must not dispatch a chat turn"); },
|
|
},
|
|
}),
|
|
}) as OpencodeRuntimeAdapter;
|
|
try {
|
|
expect(await runtime.generateText("recommend questions", { providerID: "test", modelID: "fast" }, controller.signal)).toBe("generated suggestions");
|
|
expect(calls[0]).toMatchObject({ model: { providerID: "test", id: "fast" } });
|
|
expect(calls[1]).toBe("catalog ready");
|
|
expect(calls[2]).toEqual({ input: { sessionID: "temporary-generator", prompt: "recommend questions" }, signal: controller.signal });
|
|
expect(calls[3]).toEqual({ sessionID: "temporary-generator" });
|
|
expect(await readdir(root)).toEqual([]);
|
|
fail = true;
|
|
await expect(runtime.generateText("recommend questions")).rejects.toThrow("inference failed");
|
|
expect(calls.at(-1)).toEqual({ sessionID: "temporary-generator" });
|
|
expect(await readdir(root)).toEqual([]);
|
|
} finally {
|
|
config.CONVERSATION_WORKSPACE_ROOT = previousRoot;
|
|
await rm(root, { recursive: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.resolveAvailableModel", () => {
|
|
it("selects the first preferred model actually exposed by OpenCode", async () => {
|
|
const client = {
|
|
rpc: { call: async () => ({ output: [] }) },
|
|
model: {
|
|
list: async () => ({ data: [{ providerID: "deepseek", id: "deepseek-v4-pro", enabled: true, capabilities: { tools: true } }] }),
|
|
},
|
|
} as unknown as OpencodeClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
ensureClient: async () => client,
|
|
}) as OpencodeRuntimeAdapter;
|
|
|
|
await expect(runtime.resolveAvailableModel([
|
|
"deepseek/deepseek-flash",
|
|
"deepseek/deepseek-v4-pro",
|
|
])).resolves.toBe("deepseek/deepseek-v4-pro");
|
|
});
|
|
});
|
|
|
|
describe("resolveEmbeddedOpencodePort", () => {
|
|
it("falls back to a dynamic port when the configured port is occupied", async () => {
|
|
const server = createServer();
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close();
|
|
throw new Error("expected a TCP server address");
|
|
}
|
|
|
|
try {
|
|
await expect(
|
|
resolveEmbeddedOpencodePort("127.0.0.1", address.port),
|
|
).resolves.toBe(0);
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("OpencodeRuntimeAdapter.warmup", () => {
|
|
const toolIds = [...PRODUCT_MCP_TOOL_IDS, ...LOCAL_QUERY_TOOL_IDS, "websearch", "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
|
|
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string, delayedMcp = false,
|
|
search: { providers?: readonly string[]; selection?: false | { provider: string }; unavailable?: boolean; extraTools?: readonly string[] } = {}) {
|
|
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
|
|
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
|
config.CONVERSATION_WORKSPACE_ROOT = root;
|
|
const calls: string[] = [];
|
|
let catalogReads = 0;
|
|
const [providerID, id] = config.OPENCODE_MODEL.split("/");
|
|
const client = {
|
|
server: { info: async () => { calls.push("health"); return { version: "2.0.24" }; } },
|
|
session: {
|
|
create: async (input: { location: { directory: string } }) => { calls.push("session.create"); return { id: "warmup-session", location: input.location }; },
|
|
remove: async () => { calls.push("session.remove"); },
|
|
},
|
|
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
|
|
websearch: { providers: async () => {
|
|
calls.push("websearch.providers");
|
|
if (search.unavailable) throw new Error("PRIVATE_URL_AND_SECRET");
|
|
return { data: (search.providers ?? ["fixture-search"]).map((id) => ({ id, name: id })) };
|
|
} },
|
|
config: { get: async () => {
|
|
calls.push("config.get");
|
|
return [{ type: "directory", path: root }, { type: "document", info: { websearch: { provider: "unavailable-lower-precedence" } } },
|
|
{ type: "document", info: { websearch: search.selection ?? { provider: "fixture-search" } } }];
|
|
} },
|
|
rpc: { call: async (input: { method: string }) => {
|
|
if (input.method === "check_models") {
|
|
calls.push("model.check");
|
|
const targets = modelCheckTargets(config.MODEL_CONFIG).models;
|
|
return { output: {
|
|
models: targets.map((model) => ({ id: model.id, model_id: model.wire_id, status: model.id === invalidModel ? "not_advertised" : "valid" })),
|
|
providers: [...new Set(targets.map((model) => model.provider_id))].map((id) => ({ id, status: "checked" })),
|
|
} };
|
|
}
|
|
if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; }
|
|
calls.push("tool.list");
|
|
catalogReads++;
|
|
return { output: [...toolIds, ...(search.extraTools ?? [])].filter((id) => !missing.includes(id) && !(delayedMcp && catalogReads === 1 && id === LOCAL_QUERY_TOOL_IDS[0])).map((id) => ({ id, direct: id !== "websearch", sandboxed: id === "shell", workspaceRoot: root })) };
|
|
} },
|
|
} as unknown as OpencodeClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
|
return { runtime, calls, cleanup: async () => { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true, force: true }); } };
|
|
}
|
|
it("verifies business tools and model before reporting ready and removes the warmup session", async () => {
|
|
const { runtime, calls, cleanup } = await createWarmupRuntime();
|
|
try { await runtime.warmup();
|
|
expect(calls).toEqual(["health", "session.create", "tool.list", "websearch.providers", "config.get", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); }
|
|
});
|
|
it("rejects a missing native search tool or unusable provider configuration before readiness and cleans up", async () => {
|
|
for (const [missing, search, message] of [
|
|
[["websearch"], {}, "websearch tool is missing"],
|
|
[[], { providers: [] }, "no registered search provider"],
|
|
[[], { selection: false }, "web search is disabled"],
|
|
[[], { selection: { provider: "absent" } }, "configured search provider is unavailable"],
|
|
[[], { unavailable: true }, "web search configuration unavailable"],
|
|
[[], { extraTools: ["browser_click"] }, "retired tools are still registered"],
|
|
[[], { extraTools: ["mcp_tjwater_web_search"] }, "retired tools are still registered"],
|
|
] as const) {
|
|
const { runtime, calls, cleanup } = await createWarmupRuntime([...missing], undefined, false, search);
|
|
try {
|
|
await expect(runtime.warmup()).rejects.toThrow(message);
|
|
expect(calls).not.toContain("model.check");
|
|
expect(calls.at(-1)).toBe("session.remove");
|
|
} finally { await cleanup(); }
|
|
}
|
|
});
|
|
it("accepts random provider selection without sending a search query during startup", async () => {
|
|
const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, false, { selection: { provider: "random" } });
|
|
try {
|
|
await runtime.warmup();
|
|
expect(calls).toContain("websearch.providers");
|
|
} finally { await cleanup(); }
|
|
});
|
|
it("fails readiness if a business tool was lost and still cleans up", async () => {
|
|
const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]);
|
|
try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis");
|
|
expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); }
|
|
});
|
|
it("waits for native MCP catalog publication before reporting ready", async () => {
|
|
const { runtime, calls, cleanup } = await createWarmupRuntime([], undefined, true);
|
|
try {
|
|
await runtime.warmup();
|
|
expect(calls.filter((call) => call === "tool.list")).toHaveLength(2);
|
|
expect(calls.indexOf("model.check")).toBeGreaterThan(calls.lastIndexOf("tool.list"));
|
|
} finally { await cleanup(); }
|
|
});
|
|
it("rejects an invalid non-default model before readiness and cleans up the session", async () => {
|
|
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol");
|
|
try {
|
|
await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-6.1-sol: not_advertised");
|
|
expect(calls).not.toContain("model.list");
|
|
expect(calls.at(-1)).toBe("session.remove");
|
|
} finally { await cleanup(); }
|
|
});
|
|
});
|
|
|
|
describe("conversation workspace binding", () => {
|
|
it("recovers scoped bindings without moving files during MCP status reads", async () => {
|
|
const base = await mkdtemp(join(tmpdir(), "scoped-binding-test-"));
|
|
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
|
config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces");
|
|
const original = await createConversationWorkspace(config.CONVERSATION_WORKSPACE_ROOT);
|
|
await writeFile(join(original, "files", "result.txt"), "preserved");
|
|
const owner = { actorKey: "actor-scope", projectKey: "project-scope", sessionId: "native-scope" };
|
|
const expected = join(sessionWorkspaceDirectory(config.CONVERSATION_WORKSPACE_ROOT, owner), "workspace");
|
|
let directory = original;
|
|
let moves = 0;
|
|
let reportedDirectory: string | undefined;
|
|
const client = { session: {
|
|
get: async () => ({ location: { directory } }),
|
|
move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; },
|
|
} };
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
ensureClient: async () => client,
|
|
readMcpReport: async (_client: unknown, location: string) => { reportedDirectory = location; return { servers: [], configuration_errors: [] }; },
|
|
}) as OpencodeRuntimeAdapter;
|
|
try {
|
|
await expect(runtime.ensureSessionWorkspace(owner.sessionId, owner)).rejects.toThrow("binding unavailable");
|
|
await runtime.mcpStatus(owner.sessionId, owner);
|
|
expect(reportedDirectory).toBe(expected);
|
|
expect(moves).toBe(1);
|
|
expect(await readFile(join(expected, "files", "result.txt"), "utf8")).toBe("preserved");
|
|
expect(await runtime.ensureSessionWorkspace(owner.sessionId, owner)).toBe(expected);
|
|
expect(moves).toBe(2);
|
|
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); }
|
|
});
|
|
it("moves legacy files into data and retries a failed native directory binding without losing results", async () => {
|
|
const base = await mkdtemp(join(tmpdir(), "workspace-migration-test-"));
|
|
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
|
config.CONVERSATION_WORKSPACE_ROOT = join(base, "data", "session-workspaces");
|
|
const original = await createConversationWorkspace(join(base, ".agent-workspaces"));
|
|
await writeFile(join(original, "files", "result.json"), '{"value":7}');
|
|
let directory = original;
|
|
let moves = 0;
|
|
const client = { session: {
|
|
get: async () => ({ location: { directory } }),
|
|
move: async (input: { directory: string }) => { if (++moves === 1) throw new Error("binding unavailable"); directory = input.directory; },
|
|
} };
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
|
try {
|
|
await expect(runtime.ensureSessionWorkspace("legacy-session")).rejects.toThrow("binding unavailable");
|
|
const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("legacy-session"), runtime.ensureSessionWorkspace("legacy-session")]);
|
|
expect(first).toBe(join(config.CONVERSATION_WORKSPACE_ROOT, basename(original)));
|
|
expect(second).toBe(first);
|
|
expect(moves).toBe(2);
|
|
expect(await readFile(join(first, "files", "result.json"), "utf8")).toBe('{"value":7}');
|
|
expect(await Bun.file(join(original, "files", "result.json")).exists()).toBe(false);
|
|
const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
|
expect(await restored.ensureSessionWorkspace("legacy-session")).toBe(first);
|
|
expect(moves).toBe(2);
|
|
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(base, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it("refuses a migration collision without overwriting either workspace", async () => {
|
|
const base = await mkdtemp(join(tmpdir(), "workspace-collision-test-"));
|
|
try {
|
|
const root = join(base, "data", "session-workspaces");
|
|
const original = await createConversationWorkspace(join(base, ".agent-workspaces"));
|
|
const destination = join(root, basename(original));
|
|
await mkdir(join(destination, "files"), { recursive: true });
|
|
await writeFile(join(original, "files", "result.txt"), "original");
|
|
await writeFile(join(destination, "files", "result.txt"), "destination");
|
|
await expect(migrateConversationWorkspace(original, root)).rejects.toThrow("already exists");
|
|
expect(await readFile(join(original, "files", "result.txt"), "utf8")).toBe("original");
|
|
expect(await readFile(join(destination, "files", "result.txt"), "utf8")).toBe("destination");
|
|
} finally { await rm(base, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it("reads MCP status without migrating a legacy session or creating a workspace", async () => {
|
|
let location: unknown;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
ensureClient: async () => ({
|
|
session: { get: async () => ({ location: { directory: process.cwd() } }), move: async () => { throw new Error("GET must not move sessions"); } },
|
|
rpc: { call: async (input: { location: unknown }) => { location = input.location; return { output: { servers: [], configuration_errors: [] } }; } },
|
|
}),
|
|
ensureSessionWorkspace: async () => { throw new Error("GET must not create workspaces"); },
|
|
}) as OpencodeRuntimeAdapter;
|
|
expect(await runtime.mcpStatus("legacy-session")).toEqual({ servers: [], configuration_errors: [] });
|
|
expect(location).toEqual({ directory: process.cwd() });
|
|
});
|
|
|
|
it("coalesces old-session migration and restores its persisted directory", async () => {
|
|
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
|
const root = await mkdtemp(join(tmpdir(), "runtime-workspace-test-"));
|
|
config.CONVERSATION_WORKSPACE_ROOT = root;
|
|
let directory = process.cwd();
|
|
let moves = 0;
|
|
const client = {
|
|
session: {
|
|
get: async () => ({ id: "old-session", location: { directory } }),
|
|
move: async (input: { directory: string }) => { moves++; directory = input.directory; },
|
|
},
|
|
} as unknown as OpencodeClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
|
try {
|
|
const [first, second] = await Promise.all([runtime.ensureSessionWorkspace("old-session"), runtime.ensureSessionWorkspace("old-session")]);
|
|
expect(first).toBe(second);
|
|
expect(first).not.toBe(process.cwd());
|
|
expect(moves).toBe(1);
|
|
const restored = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
|
expect(await restored.ensureSessionWorkspace("old-session")).toBe(first);
|
|
expect(moves).toBe(1);
|
|
directory = "/tmp";
|
|
await expect(restored.ensureSessionWorkspace("foreign-session")).rejects.toThrow("inside");
|
|
} finally { config.CONVERSATION_WORKSPACE_ROOT = previousRoot; await rm(root, { recursive: true }); }
|
|
});
|
|
});
|
|
|
|
describe("explicit native Shell approval", () => {
|
|
it("ties the pending decision to the full command and originating native call", async () => {
|
|
const events = [
|
|
{ type: "connected" },
|
|
{ type: "permission.replied", data: { sessionID: "other", requestID: "perm-test", reply: "once" } },
|
|
{ type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "once" } },
|
|
];
|
|
let created: unknown;
|
|
const client = {
|
|
event: { subscribe: () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }) },
|
|
permission: { create: async (input: unknown) => { created = input; return { id: "perm-test", effect: "ask" }; } },
|
|
} as unknown as OpencodeClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => client }) as OpencodeRuntimeAdapter;
|
|
await runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal);
|
|
expect(created).toMatchObject({ action: "shell", resources: ["printf hello"], save: [], source: { type: "tool", id: "call-test", messageID: "message-test" } });
|
|
events[2] = { type: "permission.replied", data: { sessionID: "session-test", requestID: "perm-test", reply: "reject" } };
|
|
await expect(runtime.approveSandboxShell({ sessionId: "session-test", command: "printf hello", callId: "call-test", messageId: "message-test" }, new AbortController().signal)).rejects.toThrow("rejected");
|
|
});
|
|
});
|