mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
* feat(providers): add native options and exact MCP grants Keep provider policy in provider-owned schemas so unattended Hub runs can grant only requested tools from injected MCP servers. Preserve the native resolved sandbox policy when a workflow override is present, including configured writable roots. * test(hub): verify real provider tool policy * test(app): avoid pinning transient restart status
5354 lines
164 KiB
TypeScript
5354 lines
164 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
|
import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client";
|
|
import {
|
|
__openCodeInternals,
|
|
OpenCodeAgentClient,
|
|
type OpenCodeEventTranslationState,
|
|
translateOpenCodeEvent,
|
|
} from "./opencode-agent.js";
|
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
|
import {
|
|
TestOpenCodeClient,
|
|
TestOpenCodeHarness,
|
|
} from "./opencode/test-utils/test-opencode-harness.js";
|
|
import type {
|
|
AgentSessionConfig,
|
|
AgentStreamEvent,
|
|
ToolCallTimelineItem,
|
|
AssistantMessageTimelineItem,
|
|
AgentTimelineItem,
|
|
} from "../agent-sdk-types.js";
|
|
|
|
function tmpCwd(): string {
|
|
const dir = mkdtempSync(path.join(os.tmpdir(), "opencode-agent-test-"));
|
|
try {
|
|
return realpathSync(dir);
|
|
} catch {
|
|
return dir;
|
|
}
|
|
}
|
|
|
|
const TEST_MODEL = "opencode/big-pickle";
|
|
|
|
interface TurnResult {
|
|
events: AgentStreamEvent[];
|
|
assistantMessages: AssistantMessageTimelineItem[];
|
|
toolCalls: ToolCallTimelineItem[];
|
|
allTimelineItems: AgentTimelineItem[];
|
|
turnCompleted: boolean;
|
|
turnFailed: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Promise<TurnResult> {
|
|
const result: TurnResult = {
|
|
events: [],
|
|
assistantMessages: [],
|
|
toolCalls: [],
|
|
allTimelineItems: [],
|
|
turnCompleted: false,
|
|
turnFailed: false,
|
|
};
|
|
|
|
for await (const event of iterator) {
|
|
result.events.push(event);
|
|
|
|
if (event.type === "timeline") {
|
|
result.allTimelineItems.push(event.item);
|
|
if (event.item.type === "assistant_message") {
|
|
result.assistantMessages.push(event.item);
|
|
} else if (event.item.type === "tool_call") {
|
|
result.toolCalls.push(event.item);
|
|
}
|
|
}
|
|
|
|
if (event.type === "turn_completed") {
|
|
result.turnCompleted = true;
|
|
break;
|
|
}
|
|
if (event.type === "turn_failed") {
|
|
result.turnFailed = true;
|
|
result.error = event.error;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function providerAssistantMessages(events: AgentStreamEvent[], text: string): AgentStreamEvent[] {
|
|
return events.filter(
|
|
(event) =>
|
|
event.type === "provider_subagent" &&
|
|
event.event.type === "timeline" &&
|
|
event.event.item.type === "assistant_message" &&
|
|
event.event.item.text === text,
|
|
);
|
|
}
|
|
|
|
type TurnEventSignature = [type: AgentStreamEvent["type"], turnId: string | undefined];
|
|
|
|
function turnEventSignatures(events: AgentStreamEvent[]): TurnEventSignature[] {
|
|
return events.map((event) => [event.type, "turnId" in event ? event.turnId : undefined]);
|
|
}
|
|
|
|
function userMessageEvents(params: {
|
|
sessionId: string;
|
|
messageId: string;
|
|
text: string;
|
|
}): unknown[] {
|
|
return [
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: { id: params.messageId, sessionID: params.sessionId, role: "user" },
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: `part_${params.messageId}`,
|
|
sessionID: params.sessionId,
|
|
messageID: params.messageId,
|
|
type: "text",
|
|
text: params.text,
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function assistantMessageEvents({
|
|
sessionId = "session-1",
|
|
messageId = "msg_assistant",
|
|
text = "Hello from OpenCode",
|
|
}: {
|
|
sessionId?: string;
|
|
messageId?: string;
|
|
text?: string;
|
|
} = {}): unknown[] {
|
|
return [
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: messageId,
|
|
sessionID: sessionId,
|
|
role: "assistant",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: sessionId,
|
|
messageID: messageId,
|
|
partID: messageId === "msg_assistant" ? "prt_text" : `part_${messageId}`,
|
|
field: "text",
|
|
delta: text,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function assistantTurnEvents(
|
|
params: {
|
|
sessionId?: string;
|
|
messageId?: string;
|
|
text?: string;
|
|
} = {},
|
|
): unknown[] {
|
|
return [
|
|
...assistantMessageEvents(params),
|
|
{ type: "session.idle", properties: { sessionID: params.sessionId ?? "session-1" } },
|
|
];
|
|
}
|
|
|
|
async function createParentSession(
|
|
sessionId: string,
|
|
configure?: (openCode: TestOpenCodeClient) => void,
|
|
): Promise<{
|
|
readonly parent: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>>;
|
|
readonly openCode: TestOpenCodeClient;
|
|
}> {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCode = new TestOpenCodeClient();
|
|
openCode.sessionCreateResponse = { data: { id: sessionId } };
|
|
configure?.(openCode);
|
|
runtime.enqueueClient(openCode);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const parent = await client.createSession({
|
|
provider: "opencode",
|
|
cwd: "/workspace/repo",
|
|
});
|
|
return { parent, openCode };
|
|
}
|
|
|
|
function manualCompactEvents({
|
|
sessionId = "session-1",
|
|
summaryText = "## Goal\n- Preserve context while continuing the task.",
|
|
}: {
|
|
sessionId?: string;
|
|
summaryText?: string;
|
|
} = {}): OpenCodeEvent[] {
|
|
return [
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_compact_user",
|
|
sessionID: sessionId,
|
|
role: "user",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_compact",
|
|
sessionID: sessionId,
|
|
messageID: "msg_compact_user",
|
|
type: "compaction",
|
|
auto: false,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_compact_summary",
|
|
sessionID: sessionId,
|
|
role: "assistant",
|
|
providerID: "test-provider",
|
|
modelID: "gpt-5.5",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_compact_summary",
|
|
sessionID: sessionId,
|
|
messageID: "msg_compact_summary",
|
|
type: "text",
|
|
text: summaryText,
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
},
|
|
{ type: "session.compacted", properties: { sessionID: sessionId } },
|
|
{ type: "session.idle", properties: { sessionID: sessionId } },
|
|
];
|
|
}
|
|
|
|
describe("OpenCodeAgentClient adapter smoke tests", () => {
|
|
const logger = createTestLogger();
|
|
const buildConfig = (cwd: string): AgentSessionConfig => ({
|
|
provider: "opencode",
|
|
cwd,
|
|
model: TEST_MODEL,
|
|
});
|
|
|
|
test("creates a session with valid id and provider", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCode = new TestOpenCodeClient();
|
|
runtime.enqueueClient(openCode);
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession(buildConfig(cwd));
|
|
|
|
expect(typeof session.id).toBe("string");
|
|
expect(session.id.length).toBeGreaterThan(0);
|
|
expect(session.provider).toBe("opencode");
|
|
|
|
await session.close();
|
|
expect(openCode.calls.sessionAbort).toEqual([{ sessionID: "session-1", directory: cwd }]);
|
|
expect(openCode.calls.sessionUpdate).toEqual([]);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}, 60_000);
|
|
|
|
test("archives and unarchives the durable native session through client hooks", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const archiveClient = new TestOpenCodeClient();
|
|
const unarchiveClient = new TestOpenCodeClient();
|
|
runtime.enqueueClient(archiveClient);
|
|
runtime.enqueueClient(unarchiveClient);
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const handle = {
|
|
provider: "opencode" as const,
|
|
sessionId: "session-1",
|
|
metadata: { cwd },
|
|
};
|
|
|
|
await client.archiveNativeSession(handle);
|
|
await client.unarchiveNativeSession(handle);
|
|
|
|
expect(archiveClient.calls.sessionUpdate).toEqual([
|
|
{
|
|
sessionID: "session-1",
|
|
directory: cwd,
|
|
time: { archived: expect.any(Number) },
|
|
},
|
|
]);
|
|
expect(unarchiveClient.calls.sessionUpdate).toEqual([
|
|
{
|
|
sessionID: "session-1",
|
|
directory: cwd,
|
|
time: { archived: null },
|
|
},
|
|
]);
|
|
expect(runtime.acquisitions.every((acquisition) => acquisition.releaseCount === 1)).toBe(true);
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
test("single turn completes with streaming deltas", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession(buildConfig(cwd));
|
|
|
|
const iterator = streamSession(session, "Say hello");
|
|
const turn = await collectTurnEvents(iterator);
|
|
|
|
expect(turn.turnCompleted).toBe(true);
|
|
expect(turn.turnFailed).toBe(false);
|
|
expect(turn.assistantMessages).toEqual([
|
|
{
|
|
type: "assistant_message",
|
|
text: "Hello from OpenCode",
|
|
messageId: "msg_assistant",
|
|
},
|
|
]);
|
|
expect(openCodeClient.calls.sessionPromptAsync).toEqual([
|
|
expect.objectContaining({
|
|
sessionID: "session-1",
|
|
directory: cwd,
|
|
model: { providerID: "opencode", modelID: "big-pickle" },
|
|
}),
|
|
]);
|
|
// No modeId configured → no agent field: OpenCode must fall back to its
|
|
// own default agent instead of Paseo assuming any particular agent exists.
|
|
expect(openCodeClient.calls.sessionPromptAsync[0]).not.toHaveProperty("agent");
|
|
|
|
await session.close();
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}, 120_000);
|
|
|
|
test("completed and structured assistant messages preserve OpenCode message IDs", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionPromptAsyncEvents = [
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_structured",
|
|
sessionID: "session-1",
|
|
role: "assistant",
|
|
structured: "structured reply",
|
|
time: { completed: 1 },
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_completed",
|
|
sessionID: "session-1",
|
|
role: "assistant",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_completed",
|
|
sessionID: "session-1",
|
|
messageID: "msg_completed",
|
|
type: "text",
|
|
text: "completed reply",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
},
|
|
{ type: "session.idle", properties: { sessionID: "session-1" } },
|
|
];
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession(buildConfig(cwd));
|
|
|
|
const turn = await collectTurnEvents(streamSession(session, "Reply twice"));
|
|
|
|
expect(turn.assistantMessages).toEqual([
|
|
{
|
|
type: "assistant_message",
|
|
text: "structured reply",
|
|
messageId: "msg_structured",
|
|
},
|
|
{
|
|
type: "assistant_message",
|
|
text: "completed reply",
|
|
messageId: "msg_completed",
|
|
},
|
|
]);
|
|
|
|
await session.close();
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
});
|
|
|
|
test("manual compact hides the generated summary text", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionSummarizeEvents = manualCompactEvents();
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({
|
|
provider: "opencode",
|
|
cwd,
|
|
model: "test-provider/gpt-5.5",
|
|
});
|
|
|
|
const turn = await collectTurnEvents(streamSession(session, "/compact"));
|
|
|
|
expect(turn.turnCompleted).toBe(true);
|
|
expect(turn.assistantMessages).toEqual([]);
|
|
expect(turn.allTimelineItems).toEqual([
|
|
{ type: "user_message", text: "/compact", messageId: "msg_compact_user" },
|
|
{ type: "compaction", status: "loading", trigger: "manual" },
|
|
{ type: "compaction", status: "completed" },
|
|
]);
|
|
expect(openCodeClient.calls.sessionSummarize).toEqual([
|
|
expect.objectContaining({
|
|
sessionID: "session-1",
|
|
directory: cwd,
|
|
providerID: "test-provider",
|
|
modelID: "gpt-5.5",
|
|
}),
|
|
]);
|
|
|
|
await session.close();
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}, 120_000);
|
|
|
|
test("fetchCatalog returns models with required fields", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.providerListResponse = {
|
|
data: {
|
|
connected: ["opencode"],
|
|
all: [
|
|
{
|
|
id: "opencode",
|
|
name: "OpenCode",
|
|
source: "api",
|
|
models: {
|
|
"big-pickle": {
|
|
name: "Big Pickle",
|
|
limit: {
|
|
context: 200_000,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
openCodeClient.appAgentsResponse = {
|
|
data: [
|
|
{
|
|
name: "build",
|
|
mode: "primary",
|
|
hidden: false,
|
|
},
|
|
],
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
const paseoHome = tmpCwd();
|
|
const opencodeHome = path.join(paseoHome, "opencode-home");
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
resolveHomeDir: () => opencodeHome,
|
|
});
|
|
const catalog = await client.fetchCatalog({ scope: "global", force: false });
|
|
|
|
expect(Array.isArray(catalog.models)).toBe(true);
|
|
expect(catalog.models).toHaveLength(1);
|
|
|
|
for (const model of catalog.models) {
|
|
expect(model.provider).toBe("opencode");
|
|
expect(typeof model.id).toBe("string");
|
|
expect(model.id.length).toBeGreaterThan(0);
|
|
expect(typeof model.label).toBe("string");
|
|
expect(model.label.length).toBeGreaterThan(0);
|
|
|
|
// HARD ASSERT: Model ID contains provider prefix (format: providerId/modelId)
|
|
expect(model.id).toContain("/");
|
|
expect(model.metadata).toMatchObject({
|
|
providerId: expect.any(String),
|
|
modelId: expect.any(String),
|
|
});
|
|
expect(typeof model.metadata?.contextWindowMaxTokens).toBe("number");
|
|
}
|
|
expect(catalog.models[0]).toMatchObject({
|
|
id: TEST_MODEL,
|
|
label: "Big Pickle",
|
|
metadata: {
|
|
providerId: "opencode",
|
|
modelId: "big-pickle",
|
|
contextWindowMaxTokens: 200_000,
|
|
},
|
|
});
|
|
expect(openCodeClient.calls.providerList).toEqual([{ directory: opencodeHome }]);
|
|
rmSync(paseoHome, { recursive: true, force: true });
|
|
}, 60_000);
|
|
|
|
test("fetchCatalog releases the acquired server when opencode-home cannot be created", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const paseoHome = tmpCwd();
|
|
const opencodeHome = path.join(paseoHome, "opencode-home");
|
|
writeFileSync(opencodeHome, "not a directory");
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
resolveHomeDir: () => opencodeHome,
|
|
});
|
|
|
|
await expect(client.fetchCatalog({ scope: "global", force: false })).rejects.toThrow();
|
|
|
|
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
|
|
expect(runtime.clientCreations).toEqual([]);
|
|
rmSync(paseoHome, { recursive: true, force: true });
|
|
});
|
|
|
|
test("fetchCatalog releases the acquired server when opencode-home cannot be resolved", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
resolveHomeDir: () => {
|
|
throw new Error("cannot resolve opencode-home");
|
|
},
|
|
});
|
|
|
|
await expect(client.fetchCatalog({ scope: "global", force: false })).rejects.toThrow(
|
|
"cannot resolve opencode-home",
|
|
);
|
|
|
|
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
|
|
expect(runtime.clientCreations).toEqual([]);
|
|
});
|
|
|
|
test("limits concurrent OpenCode metadata requests across clients", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
let activeProviderListCalls = 0;
|
|
let maxActiveProviderListCalls = 0;
|
|
const response = {
|
|
data: {
|
|
connected: ["opencode"],
|
|
all: [
|
|
{
|
|
id: "opencode",
|
|
name: "OpenCode",
|
|
source: "api",
|
|
models: {
|
|
"big-pickle": {
|
|
name: "Big Pickle",
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
for (let index = 0; index < 12; index += 1) {
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.providerListImplementation = async () => {
|
|
activeProviderListCalls += 1;
|
|
maxActiveProviderListCalls = Math.max(maxActiveProviderListCalls, activeProviderListCalls);
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
activeProviderListCalls -= 1;
|
|
return response;
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
}
|
|
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
await Promise.all(
|
|
Array.from({ length: 12 }, (_, index) =>
|
|
client.fetchCatalog({
|
|
scope: "workspace",
|
|
cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`),
|
|
force: false,
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(maxActiveProviderListCalls).toBeLessThanOrEqual(4);
|
|
});
|
|
|
|
test("available modes reflect the agents OpenCode discovers", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.appAgentsResponse = {
|
|
data: [
|
|
{ name: "build", mode: "primary" },
|
|
{ name: "plan", mode: "primary" },
|
|
],
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession(buildConfig(cwd));
|
|
|
|
const modes = await session.getAvailableModes();
|
|
|
|
expect(modes.some((mode) => mode.id === "build")).toBe(true);
|
|
expect(modes.some((mode) => mode.id === "plan")).toBe(true);
|
|
|
|
await session.close();
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}, 60_000);
|
|
|
|
test("available modes are empty when OpenCode discovers no agents", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
// Default TestOpenCodeClient returns no agents. Discovery failure/empty
|
|
// must not fabricate modes — OpenCode users can rename/delete any agent,
|
|
// so a hardcoded fallback could validate a mode that doesn't exist.
|
|
runtime.enqueueClient(new TestOpenCodeClient());
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession(buildConfig(cwd));
|
|
|
|
const modes = await session.getAvailableModes();
|
|
|
|
expect(modes).toEqual([]);
|
|
|
|
await session.close();
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}, 60_000);
|
|
|
|
test("custom agents defined in opencode.json appear in available modes", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.appAgentsResponse = {
|
|
data: [
|
|
{
|
|
name: "paseo-test-custom",
|
|
description: "Custom agent defined for Paseo integration test",
|
|
mode: "primary",
|
|
},
|
|
{ name: "compaction", mode: "subagent" },
|
|
{ name: "summary", mode: "subagent" },
|
|
{ name: "title", mode: "subagent" },
|
|
],
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession(buildConfig(cwd));
|
|
|
|
const modes = await session.getAvailableModes();
|
|
|
|
expect(modes.map((mode) => mode.id)).toEqual(["paseo-test-custom"]);
|
|
|
|
const custom = modes.find((mode) => mode.id === "paseo-test-custom");
|
|
expect(custom).toBeDefined();
|
|
expect(custom!.label).toBe("Paseo-test-custom");
|
|
expect(custom!.description).toBe("Custom agent defined for Paseo integration test");
|
|
|
|
// System agents should not appear as selectable modes
|
|
expect(modes.some((mode) => mode.id === "compaction")).toBe(false);
|
|
expect(modes.some((mode) => mode.id === "summary")).toBe(false);
|
|
expect(modes.some((mode) => mode.id === "title")).toBe(false);
|
|
|
|
await session.close();
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}, 60_000);
|
|
|
|
test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
|
|
const cwd = tmpCwd();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const planOpenCodeClient = new TestOpenCodeClient();
|
|
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
|
|
const buildOpenCodeClient = new TestOpenCodeClient();
|
|
buildOpenCodeClient.sessionPromptAsyncEvents = [
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_assistant",
|
|
sessionID: "session-1",
|
|
role: "assistant",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_tool",
|
|
sessionID: "session-1",
|
|
messageID: "msg_assistant",
|
|
type: "tool",
|
|
tool: "write",
|
|
callID: "call_write",
|
|
state: {
|
|
status: "completed",
|
|
input: { filePath: "build-mode-output.txt", content: "hello" },
|
|
output: "created build-mode-output.txt",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
...assistantTurnEvents({ text: "Build response" }),
|
|
];
|
|
runtime.enqueueClient(planOpenCodeClient);
|
|
runtime.enqueueClient(buildOpenCodeClient);
|
|
const client = new OpenCodeAgentClient(logger, undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
|
|
const planSession = await client.createSession({
|
|
...buildConfig(cwd),
|
|
modeId: "plan",
|
|
});
|
|
|
|
const planTurn = await collectTurnEvents(
|
|
streamSession(
|
|
planSession,
|
|
"Create a file named plan-mode-output.txt in the current directory containing exactly hello.",
|
|
),
|
|
);
|
|
|
|
expect(planTurn.turnCompleted).toBe(true);
|
|
expect(planTurn.turnFailed).toBe(false);
|
|
expect(planTurn.toolCalls).toHaveLength(0);
|
|
expect(planOpenCodeClient.calls.sessionPromptAsync).toEqual([
|
|
expect.objectContaining({
|
|
sessionID: "session-1",
|
|
directory: cwd,
|
|
agent: "plan",
|
|
}),
|
|
]);
|
|
|
|
const planResponse = planTurn.assistantMessages
|
|
.map((message) => message.text)
|
|
.join("")
|
|
.trim();
|
|
expect(planResponse.length).toBeGreaterThan(0);
|
|
|
|
await planSession.close();
|
|
|
|
const buildSession = await client.createSession({
|
|
...buildConfig(cwd),
|
|
modeId: "build",
|
|
});
|
|
|
|
const buildTurn = await collectTurnEvents(
|
|
streamSession(
|
|
buildSession,
|
|
"Use a file editing tool to create a file named build-mode-output.txt in the current directory containing exactly hello.",
|
|
),
|
|
);
|
|
|
|
expect(buildTurn.turnCompleted).toBe(true);
|
|
expect(buildTurn.turnFailed).toBe(false);
|
|
expect(buildTurn.toolCalls.some((toolCall) => toolCall.status === "completed")).toBe(true);
|
|
expect(buildOpenCodeClient.calls.sessionPromptAsync).toEqual([
|
|
expect.objectContaining({
|
|
sessionID: "session-1",
|
|
directory: cwd,
|
|
agent: "build",
|
|
}),
|
|
]);
|
|
|
|
const buildResponse = buildTurn.assistantMessages
|
|
.map((message) => message.text)
|
|
.join("")
|
|
.trim();
|
|
expect(buildResponse.length).toBeGreaterThan(0);
|
|
|
|
await buildSession.close();
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}, 180_000);
|
|
});
|
|
|
|
describe("OpenCode adapter context-window normalization", () => {
|
|
test("builds OpenCode file parts for image prompt blocks", () => {
|
|
expect(
|
|
__openCodeInternals.buildOpenCodePromptParts([
|
|
{ type: "text", text: "Describe this image." },
|
|
{ type: "image", mimeType: "image/png", data: "YWJjMTIz" },
|
|
]),
|
|
).toEqual([
|
|
{ type: "text", text: "Describe this image." },
|
|
{
|
|
type: "file",
|
|
mime: "image/png",
|
|
filename: "attachment-1.png",
|
|
url: "data:image/png;base64,YWJjMTIz",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("preserves provider catalog context limit in model metadata", () => {
|
|
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
|
{ id: "openai", name: "OpenAI" },
|
|
"gpt-5",
|
|
{
|
|
name: "GPT-5",
|
|
family: "gpt",
|
|
limit: {
|
|
context: 400_000,
|
|
input: 200_000,
|
|
output: 16_384,
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(definition.metadata).toMatchObject({
|
|
providerId: "openai",
|
|
modelId: "gpt-5",
|
|
contextWindowMaxTokens: 400_000,
|
|
limit: {
|
|
context: 400_000,
|
|
input: 200_000,
|
|
output: 16_384,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("resolves selected model context window from connected provider catalog data", () => {
|
|
expect(
|
|
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
|
|
{
|
|
connected: ["openai"],
|
|
all: [
|
|
{
|
|
id: "openai",
|
|
models: {
|
|
"gpt-5": {
|
|
limit: {
|
|
context: 400_000,
|
|
output: 16_384,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: "anthropic",
|
|
models: {
|
|
"claude-opus": {
|
|
limit: {
|
|
context: 1_000_000,
|
|
output: 8_192,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"openai/gpt-5",
|
|
),
|
|
).toBe(400_000);
|
|
|
|
expect(
|
|
__openCodeInternals.resolveOpenCodeSelectedModelContextWindow(
|
|
{
|
|
connected: ["openai"],
|
|
all: [
|
|
{
|
|
id: "anthropic",
|
|
models: {
|
|
"claude-opus": {
|
|
limit: {
|
|
context: 1_000_000,
|
|
output: 8_192,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
"anthropic/claude-opus",
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
test("includes api-source providers in context window lookup even when absent from connected", () => {
|
|
// Providers with source "api" are managed by the OpenCode console/subscription and are
|
|
// usable even when they don't appear in `connected`.
|
|
const lookup = __openCodeInternals.buildOpenCodeModelContextWindowLookup({
|
|
connected: [],
|
|
all: [
|
|
{
|
|
id: "pi",
|
|
source: "api",
|
|
models: {
|
|
"pi-model-1": { limit: { context: 200_000 } },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(lookup.get("pi/pi-model-1")).toBe(200_000);
|
|
});
|
|
|
|
test("excludes non-api-source providers absent from connected in context window lookup", () => {
|
|
const lookup = __openCodeInternals.buildOpenCodeModelContextWindowLookup({
|
|
connected: ["openai"],
|
|
all: [
|
|
{
|
|
id: "openai",
|
|
source: "env",
|
|
models: {
|
|
"gpt-5": { limit: { context: 400_000 } },
|
|
},
|
|
},
|
|
{
|
|
id: "anthropic",
|
|
source: "env",
|
|
models: {
|
|
"claude-opus": { limit: { context: 1_000_000 } },
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(lookup.get("openai/gpt-5")).toBe(400_000);
|
|
expect(lookup.get("anthropic/claude-opus")).toBeUndefined();
|
|
});
|
|
|
|
test("normalizes step-finish usage into AgentUsage context window fields", () => {
|
|
const usage = { contextWindowMaxTokens: 400_000 };
|
|
|
|
__openCodeInternals.mergeOpenCodeStepFinishUsage(usage, {
|
|
cost: 0.25,
|
|
tokens: {
|
|
total: 999_999,
|
|
input: 30_000,
|
|
output: 12_000,
|
|
reasoning: 10_000,
|
|
cache: {
|
|
read: 2_000,
|
|
write: 1_000,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(usage).toEqual({
|
|
contextWindowMaxTokens: 400_000,
|
|
contextWindowUsedTokens: 55_000,
|
|
cachedInputTokens: 2_000,
|
|
inputTokens: 30_000,
|
|
outputTokens: 12_000,
|
|
totalCostUsd: 0.25,
|
|
});
|
|
expect(__openCodeInternals.hasNormalizedOpenCodeUsage(usage)).toBe(true);
|
|
});
|
|
|
|
test("resolves context window max tokens from assistant message metadata", () => {
|
|
const usage = {};
|
|
const onAssistantModelContextWindowResolved = vi.fn();
|
|
|
|
translateOpenCodeEvent(
|
|
{
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "message-1",
|
|
sessionID: "session-1",
|
|
role: "assistant",
|
|
providerID: "openai",
|
|
modelID: "gpt-5",
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
{
|
|
sessionId: "session-1",
|
|
messageRoles: new Map(),
|
|
accumulatedUsage: usage,
|
|
streamedPartKeys: new Set(),
|
|
emittedStructuredMessageIds: new Set(),
|
|
partTypes: new Map(),
|
|
modelContextWindowsByModelKey: new Map([["openai/gpt-5", 400_000]]),
|
|
onAssistantModelContextWindowResolved,
|
|
},
|
|
);
|
|
|
|
expect(onAssistantModelContextWindowResolved).toHaveBeenCalledWith(400_000);
|
|
});
|
|
|
|
test("renders github issue attachments as text prompt parts", () => {
|
|
const parts = __openCodeInternals.buildOpenCodePromptParts([
|
|
{
|
|
type: "github_issue",
|
|
mimeType: "application/github-issue",
|
|
number: 55,
|
|
title: "Improve startup error details",
|
|
url: "https://github.com/getpaseo/paseo/issues/55",
|
|
body: "Issue body",
|
|
},
|
|
]);
|
|
|
|
expect(parts).toEqual([
|
|
{
|
|
type: "text",
|
|
text: expect.stringContaining("GitHub Issue #55: Improve startup error details"),
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("treats primary and all OpenCode agents as selectable modes", () => {
|
|
expect(__openCodeInternals.isSelectableOpenCodeAgent({ mode: "primary" })).toBe(true);
|
|
expect(__openCodeInternals.isSelectableOpenCodeAgent({ mode: "all" })).toBe(true);
|
|
expect(__openCodeInternals.isSelectableOpenCodeAgent({ mode: "subagent" })).toBe(false);
|
|
expect(__openCodeInternals.isSelectableOpenCodeAgent({ mode: "all", hidden: true })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("carries only hex OpenCode agent colors as mode color tiers", () => {
|
|
expect(
|
|
__openCodeInternals.mapOpenCodeAgentToMode({
|
|
name: "review",
|
|
description: "Review code",
|
|
color: "#ff6b6b",
|
|
}),
|
|
).toMatchObject({
|
|
id: "review",
|
|
label: "Review",
|
|
description: "Review code",
|
|
colorTier: "#ff6b6b",
|
|
});
|
|
|
|
expect(
|
|
__openCodeInternals.mapOpenCodeAgentToMode({
|
|
name: "creative",
|
|
color: "accent",
|
|
}),
|
|
).not.toHaveProperty("colorTier");
|
|
|
|
expect(
|
|
__openCodeInternals.mapOpenCodeAgentToMode({
|
|
name: "debug",
|
|
color: "#fff",
|
|
}),
|
|
).not.toHaveProperty("colorTier");
|
|
});
|
|
});
|
|
|
|
describe("OpenCode adapter startTurn error handling", () => {
|
|
test("dynamically adds injected MCP servers without config-backed connect", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
runtime.enqueueClient(openCodeClient);
|
|
const cwd = tmpCwd();
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
|
|
try {
|
|
const session = await client.createSession({
|
|
provider: "opencode",
|
|
cwd,
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "http",
|
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test-agent",
|
|
},
|
|
},
|
|
});
|
|
|
|
await collectTurnEvents(streamSession(session, "hello"));
|
|
|
|
expect(openCodeClient.calls.mcpAdd).toEqual([
|
|
{
|
|
directory: cwd,
|
|
name: "paseo",
|
|
config: {
|
|
type: "remote",
|
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test-agent",
|
|
enabled: true,
|
|
},
|
|
},
|
|
]);
|
|
expect(openCodeClient.calls.mcpConnect).toEqual([]);
|
|
|
|
await session.close();
|
|
} finally {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("fails the turn when OpenCode reports MCP add failure in data payload", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.mcpAddResponse = {
|
|
data: {
|
|
paseo: {
|
|
status: "failed",
|
|
error: "SSE error: Non-200 status code (400)",
|
|
},
|
|
},
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
const cwd = tmpCwd();
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
|
|
try {
|
|
const session = await client.createSession({
|
|
provider: "opencode",
|
|
cwd,
|
|
mcpServers: {
|
|
paseo: {
|
|
type: "http",
|
|
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test-agent",
|
|
},
|
|
},
|
|
});
|
|
|
|
await expect(collectTurnEvents(streamSession(session, "hello"))).rejects.toThrow(
|
|
/Failed to add OpenCode MCP server 'paseo': SSE error/,
|
|
);
|
|
|
|
await session.close();
|
|
} finally {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("emits turn_started before live OpenCode timeline items", async () => {
|
|
const eventsGate = createTestDeferred<void>();
|
|
const globalEvents = [
|
|
{
|
|
payload: {
|
|
type: "server.connected",
|
|
properties: {},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/test",
|
|
payload: {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_user",
|
|
sessionID: "ses_unit_test",
|
|
role: "user",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/test",
|
|
payload: {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_assistant",
|
|
sessionID: "ses_unit_test",
|
|
role: "assistant",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/test",
|
|
payload: {
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
partID: "prt_text",
|
|
field: "text",
|
|
delta: "Hello from global",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/test",
|
|
payload: {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "ses_unit_test",
|
|
status: { type: "idle" },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
const fakeClient = {
|
|
global: {
|
|
event: vi.fn().mockResolvedValue({
|
|
stream: (async function* () {
|
|
await eventsGate.promise;
|
|
yield* globalEvents;
|
|
})(),
|
|
}),
|
|
},
|
|
session: {
|
|
promptAsync: vi.fn().mockImplementation(async () => {
|
|
eventsGate.resolve();
|
|
return { data: {}, error: undefined };
|
|
}),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
const turn = await collectTurnEvents(
|
|
streamSession(session, "hello", { clientMessageId: "client-message-1" }),
|
|
);
|
|
|
|
expect(turn.events.map((event) => event.type)).toEqual([
|
|
"turn_started",
|
|
"timeline",
|
|
"timeline",
|
|
"turn_completed",
|
|
]);
|
|
expect(turn.events[1]).toMatchObject({
|
|
type: "timeline",
|
|
item: { type: "user_message", clientMessageId: "client-message-1" },
|
|
});
|
|
expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
|
"opencode-turn-0",
|
|
"opencode-turn-0",
|
|
"opencode-turn-0",
|
|
"opencode-turn-0",
|
|
]);
|
|
});
|
|
|
|
test("unwraps OpenCode global event payloads during a turn", async () => {
|
|
const eventsGate = createTestDeferred<void>();
|
|
const globalEvents = [
|
|
{
|
|
payload: {
|
|
type: "server.connected",
|
|
properties: {},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/other",
|
|
payload: {
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: "other-session",
|
|
messageID: "msg_other",
|
|
partID: "prt_other",
|
|
field: "text",
|
|
delta: "ignore me",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/test",
|
|
payload: {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_assistant",
|
|
sessionID: "ses_unit_test",
|
|
role: "assistant",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/test",
|
|
payload: {
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
partID: "prt_text",
|
|
field: "text",
|
|
delta: "Hello from global",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
directory: "/tmp/test",
|
|
payload: {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "ses_unit_test",
|
|
status: { type: "idle" },
|
|
},
|
|
},
|
|
},
|
|
];
|
|
const fakeClient = {
|
|
event: {
|
|
subscribe: vi.fn(),
|
|
},
|
|
global: {
|
|
event: vi.fn().mockResolvedValue({
|
|
stream: (async function* () {
|
|
await eventsGate.promise;
|
|
yield* globalEvents;
|
|
})(),
|
|
}),
|
|
},
|
|
session: {
|
|
promptAsync: vi.fn().mockImplementation(async () => {
|
|
eventsGate.resolve();
|
|
return { data: {}, error: undefined };
|
|
}),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
const turn = await collectTurnEvents(streamSession(session, "hello"));
|
|
|
|
expect(fakeClient.global.event).toHaveBeenCalledWith({
|
|
signal: expect.any(AbortSignal),
|
|
sseMaxRetryAttempts: 0,
|
|
});
|
|
expect(fakeClient.event.subscribe).not.toHaveBeenCalled();
|
|
expect(turn.turnCompleted).toBe(true);
|
|
expect(turn.turnFailed).toBe(false);
|
|
expect(turn.assistantMessages.map((message) => message.text).join("")).toBe(
|
|
"Hello from global",
|
|
);
|
|
});
|
|
|
|
test("keeps a turn active while OpenCode is retrying", async () => {
|
|
vi.useFakeTimers();
|
|
const eventsGate = createTestDeferred<void>();
|
|
let eventStreamSignal: AbortSignal | undefined;
|
|
const retryStream: AsyncIterable<unknown> = {
|
|
[Symbol.asyncIterator]: () => {
|
|
let emitted = false;
|
|
return {
|
|
next: async () => {
|
|
await eventsGate.promise;
|
|
if (!emitted) {
|
|
emitted = true;
|
|
return {
|
|
done: false,
|
|
value: {
|
|
payload: {
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "ses_unit_test",
|
|
status: {
|
|
type: "retry",
|
|
attempt: 1,
|
|
message: "model does not exist",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
return new Promise<IteratorResult<unknown>>((resolve) => {
|
|
if (eventStreamSignal?.aborted) {
|
|
resolve({ done: true, value: undefined });
|
|
return;
|
|
}
|
|
eventStreamSignal?.addEventListener(
|
|
"abort",
|
|
() => resolve({ done: true, value: undefined }),
|
|
{ once: true },
|
|
);
|
|
});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const fakeClient = {
|
|
global: {
|
|
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => {
|
|
eventStreamSignal = signal;
|
|
return { stream: retryStream };
|
|
}),
|
|
},
|
|
session: {
|
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
|
update: vi.fn().mockResolvedValue({ error: null }),
|
|
promptAsync: vi.fn().mockImplementation(async () => {
|
|
eventsGate.resolve();
|
|
return { data: {}, error: undefined };
|
|
}),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
try {
|
|
await session.startTurn("hello");
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
|
|
expect(events).toContainEqual({
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: {
|
|
type: "error",
|
|
message: "Provider retry (attempt 1): model does not exist",
|
|
},
|
|
turnId: "opencode-turn-0",
|
|
});
|
|
expect(events.some((event) => event.type === "turn_failed")).toBe(false);
|
|
await session.close();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("deletes provider session on close when persistence is disabled", async () => {
|
|
const fakeClient = {
|
|
session: {
|
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
|
update: vi.fn().mockResolvedValue({ error: null }),
|
|
delete: vi.fn().mockResolvedValue({ error: null }),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
new Map(),
|
|
undefined,
|
|
false,
|
|
);
|
|
|
|
await session.close();
|
|
|
|
expect(fakeClient.session.delete).toHaveBeenCalledWith({
|
|
sessionID: "ses_unit_test",
|
|
directory: "/tmp/test",
|
|
});
|
|
});
|
|
|
|
test("does not delete provider session on close by default", async () => {
|
|
const fakeClient = {
|
|
session: {
|
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
|
update: vi.fn().mockResolvedValue({ error: null }),
|
|
delete: vi.fn().mockResolvedValue({ error: null }),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
await session.close();
|
|
|
|
expect(fakeClient.session.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("waits for the OpenCode event stream to finish after close aborts it", async () => {
|
|
const streamAborted = createTestDeferred<void>();
|
|
const finishStreamCleanup = createTestDeferred<void>();
|
|
const fakeClient = {
|
|
global: {
|
|
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
|
stream: {
|
|
[Symbol.asyncIterator]: () => ({
|
|
next: async () => {
|
|
if (!signal.aborted) {
|
|
await waitForAbort(signal);
|
|
}
|
|
streamAborted.resolve();
|
|
await finishStreamCleanup.promise;
|
|
return { done: true, value: undefined };
|
|
},
|
|
}),
|
|
},
|
|
})),
|
|
},
|
|
session: {
|
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
|
update: vi.fn().mockResolvedValue({ error: null }),
|
|
},
|
|
} as never;
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
let closeSettled = false;
|
|
|
|
const closePromise = session.close().then(() => {
|
|
closeSettled = true;
|
|
return undefined;
|
|
});
|
|
await streamAborted.promise;
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
expect(closeSettled).toBe(false);
|
|
|
|
finishStreamCleanup.resolve();
|
|
await closePromise;
|
|
});
|
|
|
|
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
|
const fakeClient = {
|
|
session: {
|
|
get: vi.fn().mockResolvedValue({
|
|
data: { revert: undefined },
|
|
error: undefined,
|
|
}),
|
|
messages: vi.fn().mockResolvedValue({
|
|
data: [
|
|
{
|
|
info: {
|
|
id: "msg_user",
|
|
sessionID: "ses_unit_test",
|
|
role: "user",
|
|
time: { created: 1778762475873 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_user",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_user",
|
|
type: "text",
|
|
text: "Reply with exactly: probe ok",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: "msg_assistant",
|
|
sessionID: "ses_unit_test",
|
|
role: "assistant",
|
|
time: { created: 1778762475884, completed: 1778762489358 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_reasoning",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
type: "reasoning",
|
|
text: "thinking",
|
|
time: { start: 1778762482953, end: 1778762483610 },
|
|
},
|
|
{
|
|
id: "prt_text",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
type: "text",
|
|
text: "probe ok",
|
|
time: { start: 1778762483612, end: 1778762489351 },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
error: undefined,
|
|
}),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
const history: AgentStreamEvent[] = [];
|
|
for await (const event of session.streamHistory()) {
|
|
history.push(event);
|
|
}
|
|
|
|
expect(history).toEqual([
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
timestamp: "2026-05-14T12:41:15.873Z",
|
|
item: {
|
|
type: "user_message",
|
|
text: "Reply with exactly: probe ok",
|
|
messageId: "msg_user",
|
|
},
|
|
},
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
timestamp: "2026-05-14T12:41:22.953Z",
|
|
item: { type: "reasoning", text: "thinking" },
|
|
},
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
timestamp: "2026-05-14T12:41:23.612Z",
|
|
item: {
|
|
type: "assistant_message",
|
|
text: "probe ok",
|
|
messageId: "msg_assistant",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
|
|
const fakeClient = {
|
|
session: {
|
|
get: vi.fn().mockResolvedValue({
|
|
data: { revert: undefined },
|
|
error: undefined,
|
|
}),
|
|
messages: vi.fn().mockResolvedValue({
|
|
data: [
|
|
{
|
|
info: {
|
|
id: "msg_assistant",
|
|
sessionID: "ses_unit_test",
|
|
role: "assistant",
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_text",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
type: "text",
|
|
text: "no clocks here",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
error: undefined,
|
|
}),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
const history: AgentStreamEvent[] = [];
|
|
for await (const event of session.streamHistory()) {
|
|
history.push(event);
|
|
}
|
|
|
|
expect(history).toEqual([
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: {
|
|
type: "assistant_message",
|
|
text: "no clocks here",
|
|
messageId: "msg_assistant",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("streamHistory maps persisted OpenCode tool parts through canonical detail branches", async () => {
|
|
const patchText = [
|
|
"*** Begin Patch",
|
|
"*** Delete File: /tmp/repo/src/App.tsx",
|
|
"*** End Patch",
|
|
].join("\n");
|
|
|
|
const fakeClient = {
|
|
session: {
|
|
get: vi.fn().mockResolvedValue({
|
|
data: { revert: undefined },
|
|
error: undefined,
|
|
}),
|
|
messages: vi.fn().mockResolvedValue({
|
|
data: [
|
|
{
|
|
info: {
|
|
id: "msg_assistant",
|
|
sessionID: "ses_unit_test",
|
|
role: "assistant",
|
|
},
|
|
parts: [
|
|
{
|
|
id: "part-grep",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
type: "tool",
|
|
tool: "grep",
|
|
callID: "call-grep",
|
|
state: {
|
|
status: "completed",
|
|
input: { pattern: "sendCorrelatedSessionRequest" },
|
|
},
|
|
},
|
|
{
|
|
id: "part-skill",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
type: "tool",
|
|
tool: "skill",
|
|
callID: "call-skill",
|
|
state: {
|
|
status: "completed",
|
|
input: { name: "diagnose" },
|
|
output: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
|
|
},
|
|
},
|
|
{
|
|
id: "part-apply-patch",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
type: "tool",
|
|
tool: "apply_patch",
|
|
callID: "call-apply-patch",
|
|
state: {
|
|
status: "completed",
|
|
input: { patchText },
|
|
output: "Success. Updated the following files:\nD /tmp/repo/src/App.tsx",
|
|
},
|
|
},
|
|
{
|
|
id: "part-todowrite",
|
|
sessionID: "ses_unit_test",
|
|
messageID: "msg_assistant",
|
|
type: "tool",
|
|
tool: "todowrite",
|
|
callID: "call-todowrite",
|
|
state: {
|
|
status: "completed",
|
|
input: {
|
|
todos: [
|
|
{
|
|
content: "Inspect current directory and existing files",
|
|
status: "completed",
|
|
priority: "high",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
error: undefined,
|
|
}),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/repo" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
const history: AgentStreamEvent[] = [];
|
|
for await (const event of session.streamHistory()) {
|
|
history.push(event);
|
|
}
|
|
|
|
expect(history).toEqual([
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call-grep",
|
|
name: "grep",
|
|
status: "completed",
|
|
detail: {
|
|
type: "search",
|
|
query: "sendCorrelatedSessionRequest",
|
|
toolName: "grep",
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call-skill",
|
|
name: "skill",
|
|
status: "completed",
|
|
detail: {
|
|
type: "plain_text",
|
|
label: "diagnose",
|
|
icon: "sparkles",
|
|
text: '<skill_content name="diagnose"># Skill: diagnose</skill_content>',
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call-apply-patch",
|
|
name: "apply_patch",
|
|
status: "completed",
|
|
detail: {
|
|
type: "edit",
|
|
filePath: "/tmp/repo/src/App.tsx",
|
|
unifiedDiff: [
|
|
"diff --git a//tmp/repo/src/App.tsx b//tmp/repo/src/App.tsx",
|
|
"--- a//tmp/repo/src/App.tsx",
|
|
"+++ /dev/null",
|
|
].join("\n"),
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
{
|
|
type: "timeline",
|
|
provider: "opencode",
|
|
item: {
|
|
type: "todo",
|
|
items: [{ text: "Inspect current directory and existing files", completed: true }],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("emits turn_failed when client.session.promptAsync throws synchronously", async () => {
|
|
// Yield the server-connected event, then park forever. The adapter waits
|
|
// for that first event before sending the prompt.
|
|
const neverYieldingStream: AsyncIterable<OpenCodeEvent> = {
|
|
[Symbol.asyncIterator]: () => {
|
|
let emittedConnected = false;
|
|
return {
|
|
next: () => {
|
|
if (!emittedConnected) {
|
|
emittedConnected = true;
|
|
return Promise.resolve({
|
|
done: false,
|
|
value: { type: "server.connected", properties: {} } as OpenCodeEvent,
|
|
});
|
|
}
|
|
return new Promise(() => {});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
const fakeClient = {
|
|
global: {
|
|
event: vi.fn().mockResolvedValue({ stream: neverYieldingStream }),
|
|
},
|
|
session: {
|
|
promptAsync: vi.fn(() => {
|
|
throw new Error("boom: synchronous throw");
|
|
}),
|
|
},
|
|
} as never;
|
|
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
await session.startTurn("hello");
|
|
|
|
const failed = events.find((event) => event.type === "turn_failed");
|
|
expect(failed).toBeDefined();
|
|
expect(failed?.type).toBe("turn_failed");
|
|
if (failed?.type === "turn_failed") {
|
|
expect(failed.error).toContain("boom: synchronous throw");
|
|
}
|
|
});
|
|
|
|
test("sends exact Hub MCP permission grants without approving unrelated tools", async () => {
|
|
const promptAsync = vi.fn(async () => ({ data: {}, error: undefined }));
|
|
const fakeClient = {
|
|
global: {
|
|
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
|
stream: {
|
|
async *[Symbol.asyncIterator](): AsyncGenerator<OpenCodeEvent> {
|
|
yield { type: "server.connected", properties: {} } as OpenCodeEvent;
|
|
await waitForAbort(signal);
|
|
},
|
|
},
|
|
})),
|
|
},
|
|
session: { promptAsync },
|
|
} as never;
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{
|
|
provider: "opencode",
|
|
cwd: "/tmp/test",
|
|
providerOptions: { permission: { bash: "ask", hub_reply: "deny" } },
|
|
toolPolicy: {
|
|
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
|
|
},
|
|
},
|
|
fakeClient,
|
|
"ses_unit_test",
|
|
createTestLogger(),
|
|
);
|
|
|
|
await session.startTurn("finish");
|
|
|
|
expect(promptAsync).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
permission: [
|
|
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
|
|
{ permission: "bash", pattern: "*", action: "ask" },
|
|
{ permission: "hub_reply", pattern: "*", action: "deny" },
|
|
],
|
|
}),
|
|
);
|
|
expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual(
|
|
expect.objectContaining({ permission: "bash", action: "allow" }),
|
|
);
|
|
await session.close();
|
|
});
|
|
|
|
test("waits for the stop abort and provider idle before starting the next prompt", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_unit_test");
|
|
const retryStarted = createTestDeferred<void>();
|
|
const settleRetry = createTestDeferred<void>();
|
|
const oldIdleConsumed = createTestDeferred<void>();
|
|
const replacementCompleted = createTestDeferred<void>();
|
|
const events: AgentStreamEvent[] = [];
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
if (openCode.calls.sessionAbort.length === 1) {
|
|
throw new Error("abort transport failed");
|
|
}
|
|
retryStarted.resolve();
|
|
await settleRetry.promise;
|
|
return { data: true };
|
|
};
|
|
session.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "provider_subagent") {
|
|
oldIdleConsumed.resolve();
|
|
}
|
|
if (event.type === "turn_completed") {
|
|
replacementCompleted.resolve();
|
|
}
|
|
});
|
|
try {
|
|
await session.startTurn("first");
|
|
const interrupt = session.interrupt();
|
|
await retryStarted.promise;
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_unit_test" },
|
|
});
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_after_old_idle",
|
|
parentID: "ses_unit_test",
|
|
title: "Old idle drain marker",
|
|
directory: "/workspace/repo",
|
|
},
|
|
},
|
|
});
|
|
await oldIdleConsumed.promise;
|
|
const replacement = session.startTurn("second");
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
|
|
settleRetry.resolve();
|
|
await interrupt;
|
|
await replacement;
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
|
|
|
for (const event of assistantTurnEvents({
|
|
sessionId: "ses_unit_test",
|
|
text: "Replacement completed safely.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
await replacementCompleted.promise;
|
|
|
|
expect(
|
|
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
|
|
).toEqual([
|
|
["turn_started", "opencode-turn-0"],
|
|
["turn_canceled", "opencode-turn-0"],
|
|
["turn_started", "opencode-turn-1"],
|
|
["timeline", "opencode-turn-1"],
|
|
["turn_completed", "opencode-turn-1"],
|
|
]);
|
|
} finally {
|
|
settleRetry.resolve();
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("streams an autonomous wake after the canceled run reaches its terminal", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_wake_after_terminal");
|
|
const settleAbort = createTestDeferred<void>();
|
|
const events: AgentStreamEvent[] = [];
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
await settleAbort.promise;
|
|
return { data: true };
|
|
};
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
try {
|
|
await session.startTurn("first");
|
|
const interrupt = session.interrupt();
|
|
await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1));
|
|
|
|
for (const event of assistantMessageEvents({
|
|
sessionId: "ses_wake_after_terminal",
|
|
messageId: "msg_canceled_assistant",
|
|
text: "Canceled residue.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_wake_after_terminal" },
|
|
});
|
|
openCode.emitEvent({
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "ses_wake_after_terminal",
|
|
status: { type: "busy" },
|
|
},
|
|
});
|
|
for (const event of userMessageEvents({
|
|
sessionId: "ses_wake_after_terminal",
|
|
messageId: "msg_wake_after_terminal",
|
|
text: "Wake after the canceled terminal",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
for (const event of assistantMessageEvents({
|
|
sessionId: "ses_wake_after_terminal",
|
|
messageId: "msg_wake_assistant",
|
|
text: "Wake output streams live.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "timeline",
|
|
turnId: "opencode-turn-1",
|
|
item: expect.objectContaining({ text: "Wake output streams live." }),
|
|
}),
|
|
),
|
|
);
|
|
expect(events).not.toContainEqual(
|
|
expect.objectContaining({
|
|
type: "timeline",
|
|
item: expect.objectContaining({ text: "Canceled residue." }),
|
|
}),
|
|
);
|
|
|
|
settleAbort.resolve();
|
|
await interrupt;
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_wake_after_terminal" },
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }),
|
|
),
|
|
);
|
|
await expect(session.startTurn("after the wake")).resolves.toEqual({
|
|
turnId: "opencode-turn-2",
|
|
});
|
|
} finally {
|
|
settleAbort.resolve();
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("preserves an autonomous wake that arrives after provider idle but before abort settlement", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_deferred_autonomous");
|
|
const settleAbort = createTestDeferred<void>();
|
|
const wakeEventsConsumed = createTestDeferred<void>();
|
|
const events: AgentStreamEvent[] = [];
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
await settleAbort.promise;
|
|
return { data: true };
|
|
};
|
|
session.subscribe((event) => {
|
|
events.push(event);
|
|
if (
|
|
event.type === "provider_subagent" &&
|
|
event.event.type === "upsert" &&
|
|
event.event.id === "ses_deferred_wake_drain"
|
|
) {
|
|
wakeEventsConsumed.resolve();
|
|
}
|
|
});
|
|
|
|
try {
|
|
await session.startTurn("first");
|
|
openCode.emitEvent({
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_canceled_usage",
|
|
sessionID: "ses_deferred_autonomous",
|
|
messageID: "msg_canceled_assistant",
|
|
type: "step-finish",
|
|
tokens: { input: 100, output: 20, cache: { read: 50 } },
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "usage_updated",
|
|
usage: expect.objectContaining({
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
cachedInputTokens: 50,
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
const interrupt = session.interrupt();
|
|
await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1));
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_deferred_autonomous" },
|
|
});
|
|
openCode.emitEvent({
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_plugin_wake",
|
|
sessionID: "ses_deferred_autonomous",
|
|
role: "user",
|
|
},
|
|
},
|
|
});
|
|
openCode.emitEvent({
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_deferred_autonomous", status: { type: "busy" } },
|
|
});
|
|
openCode.emitEvent({
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_autonomous_usage",
|
|
sessionID: "ses_deferred_autonomous",
|
|
messageID: "msg_autonomous_assistant",
|
|
type: "step-finish",
|
|
tokens: { input: 7, output: 3 },
|
|
},
|
|
},
|
|
});
|
|
for (const event of assistantTurnEvents({
|
|
sessionId: "ses_deferred_autonomous",
|
|
text: "Autonomous wake completed.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_deferred_wake_drain",
|
|
parentID: "ses_deferred_autonomous",
|
|
title: "Deferred wake drain marker",
|
|
directory: "/workspace/repo",
|
|
},
|
|
},
|
|
});
|
|
await wakeEventsConsumed.promise;
|
|
|
|
settleAbort.resolve();
|
|
await interrupt;
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
expect(events).toContainEqual({
|
|
type: "turn_started",
|
|
provider: "opencode",
|
|
turnId: "opencode-turn-1",
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "turn_completed",
|
|
provider: "opencode",
|
|
turnId: "opencode-turn-1",
|
|
usage: expect.objectContaining({
|
|
inputTokens: 7,
|
|
outputTokens: 3,
|
|
contextWindowUsedTokens: 10,
|
|
}),
|
|
}),
|
|
);
|
|
const autonomousCompletion = events.find(
|
|
(event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1",
|
|
);
|
|
expect(autonomousCompletion?.type).toBe("turn_completed");
|
|
if (autonomousCompletion?.type === "turn_completed") {
|
|
expect(autonomousCompletion.usage?.cachedInputTokens).toBeUndefined();
|
|
}
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "timeline",
|
|
turnId: "opencode-turn-1",
|
|
item: expect.objectContaining({
|
|
type: "assistant_message",
|
|
text: "Autonomous wake completed.",
|
|
}),
|
|
}),
|
|
);
|
|
await expect(session.startTurn("after autonomous wake")).resolves.toEqual({
|
|
turnId: "opencode-turn-2",
|
|
});
|
|
} finally {
|
|
settleAbort.resolve();
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("waits for an idle-session abort before starting a prompt", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_idle_abort");
|
|
const settleAbort = createTestDeferred<void>();
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
await settleAbort.promise;
|
|
return { data: true };
|
|
};
|
|
|
|
try {
|
|
const interrupt = session.interrupt();
|
|
const prompt = session.startTurn("next");
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(0);
|
|
|
|
settleAbort.resolve();
|
|
await interrupt;
|
|
await prompt;
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
} finally {
|
|
settleAbort.resolve();
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("does not acknowledge cancellation when both abort attempts fail", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_failed_idle_abort");
|
|
const events: AgentStreamEvent[] = [];
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
throw new Error("abort failed");
|
|
};
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
try {
|
|
await session.startTurn("still running upstream");
|
|
await expect(session.interrupt()).rejects.toThrow("abort failed");
|
|
|
|
await expect(session.startTurn("unsafe replacement")).rejects.toThrow("abort failed");
|
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
expect(events.map((event) => event.type)).toEqual(["turn_started"]);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("acknowledges the canceled turn when a repeated Stop finally aborts", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_retried_stop");
|
|
const events: AgentStreamEvent[] = [];
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
if (openCode.calls.sessionAbort.length <= 2) {
|
|
throw new Error("abort failed");
|
|
}
|
|
return { data: true };
|
|
};
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
try {
|
|
await session.startTurn("still running upstream");
|
|
await expect(session.interrupt()).rejects.toThrow("abort failed");
|
|
expect(events.map((event) => event.type)).toEqual(["turn_started"]);
|
|
|
|
await session.interrupt();
|
|
|
|
expect(openCode.calls.sessionAbort).toHaveLength(3);
|
|
expect(events).toContainEqual({
|
|
type: "turn_canceled",
|
|
provider: "opencode",
|
|
reason: "interrupted",
|
|
turnId: "opencode-turn-0",
|
|
});
|
|
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_retried_stop" },
|
|
});
|
|
await expect(session.startTurn("replacement")).resolves.toEqual({
|
|
turnId: "opencode-turn-1",
|
|
});
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("keeps a waiting replacement behind an abort issued while it waits", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_stop_during_wait");
|
|
const settleFirstAbort = createTestDeferred<void>();
|
|
const settleSecondAbort = createTestDeferred<void>();
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
await (openCode.calls.sessionAbort.length === 1
|
|
? settleFirstAbort.promise
|
|
: settleSecondAbort.promise);
|
|
return { data: true };
|
|
};
|
|
|
|
let replacement: Promise<{ turnId: string }> | undefined;
|
|
try {
|
|
await session.startTurn("first");
|
|
const firstStop = session.interrupt();
|
|
void firstStop.catch(() => undefined);
|
|
await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1));
|
|
|
|
replacement = session.startTurn("replacement");
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_stop_during_wait" },
|
|
});
|
|
|
|
const secondStop = session.interrupt();
|
|
void secondStop.catch(() => undefined);
|
|
await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(2));
|
|
|
|
settleFirstAbort.resolve();
|
|
await firstStop;
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
|
|
settleSecondAbort.resolve();
|
|
await secondStop;
|
|
await replacement;
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
|
} finally {
|
|
settleFirstAbort.resolve();
|
|
settleSecondAbort.resolve();
|
|
await replacement?.catch(() => undefined);
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("keeps a replacement behind an abort left in flight by an earlier stop", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_carried_abort");
|
|
const settleFirstAbort = createTestDeferred<void>();
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
if (openCode.calls.sessionAbort.length === 1) {
|
|
await settleFirstAbort.promise;
|
|
}
|
|
return { data: true };
|
|
};
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
let replacement: Promise<{ turnId: string }> | undefined;
|
|
try {
|
|
await session.startTurn("first");
|
|
const firstStop = session.interrupt();
|
|
void firstStop.catch(() => undefined);
|
|
await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1));
|
|
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_carried_abort" },
|
|
});
|
|
openCode.emitEvent({
|
|
type: "session.status",
|
|
properties: {
|
|
sessionID: "ses_carried_abort",
|
|
status: { type: "busy" },
|
|
},
|
|
});
|
|
for (const event of userMessageEvents({
|
|
sessionId: "ses_carried_abort",
|
|
messageId: "msg_wake_between_stops",
|
|
text: "Autonomous wake between stops",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "turn_started",
|
|
provider: "opencode",
|
|
turnId: "opencode-turn-1",
|
|
}),
|
|
);
|
|
|
|
await session.interrupt();
|
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_carried_abort" },
|
|
});
|
|
|
|
replacement = session.startTurn("replacement");
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
|
|
settleFirstAbort.resolve();
|
|
await firstStop;
|
|
await replacement;
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
|
} finally {
|
|
settleFirstAbort.resolve();
|
|
await replacement?.catch(() => undefined);
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("keeps a replacement behind an earlier Stop's abort that is still in flight", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_overlapping_stops");
|
|
const settleFirstAbort = createTestDeferred<void>();
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
if (openCode.calls.sessionAbort.length === 1) {
|
|
await settleFirstAbort.promise;
|
|
}
|
|
return { data: true };
|
|
};
|
|
|
|
let replacement: Promise<{ turnId: string }> | undefined;
|
|
try {
|
|
await session.startTurn("first");
|
|
const firstStop = session.interrupt();
|
|
void firstStop.catch(() => undefined);
|
|
await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1));
|
|
|
|
await session.interrupt();
|
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_overlapping_stops" },
|
|
});
|
|
|
|
replacement = session.startTurn("replacement");
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
|
|
settleFirstAbort.resolve();
|
|
await firstStop;
|
|
await replacement;
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
|
} finally {
|
|
settleFirstAbort.resolve();
|
|
await replacement?.catch(() => undefined);
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("does not retry an owned abort after the session closes", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_close_pending_abort");
|
|
const settleOwnedAbort = createTestDeferred<void>();
|
|
openCode.sessionAbortImplementation = async () => {
|
|
if (openCode.calls.sessionAbort.length === 1) {
|
|
await settleOwnedAbort.promise;
|
|
throw new Error("late abort failure");
|
|
}
|
|
return { data: true };
|
|
};
|
|
|
|
const interrupt = session.interrupt();
|
|
await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1));
|
|
await session.close();
|
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
|
|
|
settleOwnedAbort.resolve();
|
|
await expect(interrupt).rejects.toThrow("late abort failure");
|
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
|
});
|
|
|
|
test("fails closed when the SDK abort throws before returning a promise", async () => {
|
|
const openCode = new TestOpenCodeClient();
|
|
const sdkClient = openCode.asSdkClient();
|
|
const workingAbort = sdkClient.session.abort;
|
|
sdkClient.session.abort = (() => {
|
|
throw new Error("synchronous abort failure");
|
|
}) as typeof sdkClient.session.abort;
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/workspace/repo" },
|
|
sdkClient,
|
|
"ses_sync_abort_failure",
|
|
createTestLogger(),
|
|
);
|
|
|
|
try {
|
|
await expect(session.interrupt()).rejects.toThrow("synchronous abort failure");
|
|
|
|
await expect(session.startTurn("unsafe replacement")).rejects.toThrow(
|
|
"synchronous abort failure",
|
|
);
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(0);
|
|
} finally {
|
|
sdkClient.session.abort = workingAbort;
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("fails closed when idle-session abort attempts return SDK errors", async () => {
|
|
const { parent: session, openCode } = await createParentSession("ses_error_idle_abort");
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => ({
|
|
error: new Error("abort response failed"),
|
|
});
|
|
|
|
try {
|
|
await expect(session.interrupt()).rejects.toThrow("abort response failed");
|
|
|
|
await expect(session.startTurn("unsafe replacement")).rejects.toThrow(
|
|
"abort response failed",
|
|
);
|
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(0);
|
|
} finally {
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("does not send a prompt while the previous provider turn is still stopping", async () => {
|
|
vi.useFakeTimers();
|
|
const { parent: session, openCode } = await createParentSession("ses_unit_test");
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
|
|
try {
|
|
await session.startTurn("first");
|
|
await session.interrupt();
|
|
|
|
const secondTurn = expect(session.startTurn("second")).rejects.toThrow(
|
|
"OpenCode previous turn to stop",
|
|
);
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
await secondTurn;
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
await session.close();
|
|
}
|
|
});
|
|
|
|
test("keeps waiting for the stop terminal when the reconnect status probe fails", async () => {
|
|
const firstStreamEnd = createTestDeferred<void>();
|
|
const settleAbort = createTestDeferred<void>();
|
|
const releaseTerminal = createTestDeferred<void>();
|
|
let subscriptionCount = 0;
|
|
const { parent: session, openCode } = await createParentSession(
|
|
"ses_stop_status_probe_failure",
|
|
(client) => {
|
|
client.globalEventImplementation = async (options) => {
|
|
subscriptionCount += 1;
|
|
const signal = (options as { signal: AbortSignal }).signal;
|
|
return {
|
|
stream: {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "server.connected", properties: {} };
|
|
if (subscriptionCount === 1) {
|
|
await firstStreamEnd.promise;
|
|
return;
|
|
}
|
|
await releaseTerminal.promise;
|
|
yield {
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_stop_status_probe_failure" },
|
|
};
|
|
await waitForAbort(signal);
|
|
},
|
|
},
|
|
};
|
|
};
|
|
client.sessionStatusImplementation = async () => {
|
|
throw new Error("provider status unavailable");
|
|
};
|
|
},
|
|
);
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
openCode.sessionAbortImplementation = async () => {
|
|
await settleAbort.promise;
|
|
return { data: true };
|
|
};
|
|
|
|
try {
|
|
await session.startTurn("first");
|
|
const interrupt = session.interrupt();
|
|
const replacement = session.startTurn("second");
|
|
firstStreamEnd.resolve();
|
|
await vi.waitFor(() => expect(openCode.calls.sessionStatus).toHaveLength(1));
|
|
|
|
settleAbort.resolve();
|
|
await interrupt;
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
expect(openCode.calls.globalEvent).toHaveLength(2);
|
|
expect(openCode.calls.sessionStatus).toHaveLength(1);
|
|
|
|
releaseTerminal.resolve();
|
|
await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" });
|
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
|
expect(openCode.calls.globalEvent).toHaveLength(2);
|
|
} finally {
|
|
settleAbort.resolve();
|
|
releaseTerminal.resolve();
|
|
await session.close();
|
|
}
|
|
}, 15_000);
|
|
|
|
test("does not reconnect the stop observer while closing", async () => {
|
|
const firstStreamEnd = createTestDeferred<void>();
|
|
const streamSignals: AbortSignal[] = [];
|
|
let subscriptionCount = 0;
|
|
const { parent: session, openCode } = await createParentSession(
|
|
"ses_close_while_stopping",
|
|
(client) => {
|
|
client.globalEventImplementation = async (options) => {
|
|
subscriptionCount += 1;
|
|
const signal = (options as { signal: AbortSignal }).signal;
|
|
streamSignals.push(signal);
|
|
return {
|
|
stream: {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield { type: "server.connected", properties: {} };
|
|
if (subscriptionCount === 1) {
|
|
await firstStreamEnd.promise;
|
|
return;
|
|
}
|
|
await waitForAbort(signal);
|
|
},
|
|
},
|
|
};
|
|
};
|
|
},
|
|
);
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
|
|
try {
|
|
await session.startTurn("first");
|
|
await session.interrupt();
|
|
const replacement = session.startTurn("second");
|
|
void replacement.catch(() => undefined);
|
|
firstStreamEnd.resolve();
|
|
await session.close();
|
|
|
|
await expect(replacement).rejects.toThrow("OpenCode session is closed");
|
|
expect(openCode.calls.globalEvent).toHaveLength(1);
|
|
} finally {
|
|
for (const signal of streamSignals) {
|
|
if (!signal.aborted) {
|
|
(signal as AbortSignal & { dispatchEvent: (event: Event) => boolean }).dispatchEvent(
|
|
new Event("abort"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("OpenCodeAgentClient env", () => {
|
|
test("passes launch-context env to env-specific server acquisition", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
runtime.enqueueClient(openCodeClient);
|
|
const cwd = tmpCwd();
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
|
|
try {
|
|
const session = await client.createSession(
|
|
{
|
|
provider: "opencode",
|
|
cwd,
|
|
},
|
|
{
|
|
env: {
|
|
CHUNK14_PROBE: "expected",
|
|
},
|
|
},
|
|
);
|
|
await session.close();
|
|
|
|
expect(runtime.acquisitions[0]).toMatchObject({
|
|
kind: "dedicated",
|
|
env: {
|
|
CHUNK14_PROBE: "expected",
|
|
},
|
|
});
|
|
} finally {
|
|
rmSync(cwd, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("OpenCode persisted sessions", () => {
|
|
test("replay hides summaries produced by manual compact", () => {
|
|
const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([
|
|
{
|
|
info: {
|
|
id: "msg_compact_user",
|
|
sessionID: "ses_1",
|
|
role: "user",
|
|
time: { created: 1000 },
|
|
agent: "build",
|
|
model: { providerID: "test-provider", modelID: "gpt-5.5" },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_compact_text",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_compact_user",
|
|
type: "text",
|
|
text: "/compact",
|
|
},
|
|
{
|
|
id: "prt_compact",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_compact_user",
|
|
type: "compaction",
|
|
auto: false,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: "msg_compact_summary",
|
|
sessionID: "ses_1",
|
|
role: "assistant",
|
|
time: { created: 1001, completed: 1002 },
|
|
providerID: "test-provider",
|
|
modelID: "gpt-5.5",
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_summary",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_compact_summary",
|
|
type: "text",
|
|
text: "## Goal\n- Preserve context while continuing the task.",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: "msg_next_user",
|
|
sessionID: "ses_1",
|
|
role: "user",
|
|
time: { created: 1003 },
|
|
agent: "build",
|
|
model: { providerID: "test-provider", modelID: "gpt-5.5" },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_next_user",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_next_user",
|
|
type: "text",
|
|
text: "continue",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(timeline).toEqual([
|
|
{ type: "user_message", text: "/compact", messageId: "msg_compact_user" },
|
|
{ type: "compaction", status: "completed", trigger: "manual" },
|
|
{ type: "user_message", text: "continue", messageId: "msg_next_user" },
|
|
]);
|
|
});
|
|
|
|
test("replay suppresses OpenCode compaction summary messages", () => {
|
|
const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([
|
|
{
|
|
info: {
|
|
id: "msg_compaction_user",
|
|
sessionID: "ses_1",
|
|
role: "user",
|
|
time: { created: 1000 },
|
|
agent: "build",
|
|
model: { providerID: "opencode", modelID: "big-pickle" },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_compaction",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_compaction_user",
|
|
type: "compaction",
|
|
auto: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: "msg_compaction_summary",
|
|
sessionID: "ses_1",
|
|
role: "assistant",
|
|
time: { created: 1001, completed: 1002 },
|
|
parentID: "msg_compaction_user",
|
|
providerID: "opencode",
|
|
modelID: "big-pickle",
|
|
mode: "compaction",
|
|
agent: "compaction",
|
|
path: { cwd: "/workspace/repo", root: "/workspace/repo" },
|
|
summary: true,
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_summary",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_compaction_summary",
|
|
type: "text",
|
|
text: "## Goal\n- Preserve context while continuing the task.",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: "msg_user_after_compaction",
|
|
sessionID: "ses_1",
|
|
role: "user",
|
|
time: { created: 1003 },
|
|
agent: "build",
|
|
model: { providerID: "opencode", modelID: "big-pickle" },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_user_after_compaction",
|
|
sessionID: "ses_1",
|
|
messageID: "msg_user_after_compaction",
|
|
type: "text",
|
|
text: "/create-pr",
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
|
|
expect(timeline).toEqual([
|
|
{ type: "compaction", status: "completed", trigger: "auto" },
|
|
{ type: "user_message", text: "/create-pr", messageId: "msg_user_after_compaction" },
|
|
]);
|
|
});
|
|
|
|
test("listImportableSessions scopes upstream rows without hydrating session messages", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
const cwd = "/workspace/repo";
|
|
const otherCwd = "/workspace/other";
|
|
|
|
openCodeClient.experimentalSessionListResponse = {
|
|
data: [
|
|
{
|
|
id: "ses_old",
|
|
directory: cwd,
|
|
title: "Old session",
|
|
time: { created: 1000, updated: 1000 },
|
|
},
|
|
{
|
|
id: "ses_new",
|
|
directory: cwd,
|
|
title: "New session",
|
|
time: { created: 2000, updated: 3000 },
|
|
},
|
|
{
|
|
id: "ses_other",
|
|
directory: otherCwd,
|
|
title: "Other cwd",
|
|
time: { created: 4000, updated: 4000 },
|
|
},
|
|
],
|
|
};
|
|
openCodeClient.sessionMessagesResponse = {
|
|
data: [
|
|
{
|
|
info: {
|
|
id: "msg_user",
|
|
sessionID: "ses_new",
|
|
role: "user",
|
|
time: { created: 2100 },
|
|
agent: "build",
|
|
model: { providerID: "opencode", modelID: "big-pickle" },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_user",
|
|
sessionID: "ses_new",
|
|
messageID: "msg_user",
|
|
type: "text",
|
|
text: "hello world",
|
|
time: { start: 2100 },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
info: {
|
|
id: "msg_assistant",
|
|
sessionID: "ses_new",
|
|
role: "assistant",
|
|
time: { created: 2200, completed: 2400 },
|
|
structured: { fallback: false },
|
|
agent: "build",
|
|
providerID: "opencode",
|
|
modelID: "big-pickle",
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_reasoning",
|
|
sessionID: "ses_new",
|
|
messageID: "msg_assistant",
|
|
type: "reasoning",
|
|
text: "thinking clearly",
|
|
time: { start: 2200 },
|
|
},
|
|
{
|
|
id: "prt_tool",
|
|
sessionID: "ses_new",
|
|
messageID: "msg_assistant",
|
|
type: "tool",
|
|
tool: "bash",
|
|
callID: "call_shell",
|
|
state: {
|
|
status: "completed",
|
|
input: { command: "echo hello" },
|
|
output: "hello\n",
|
|
},
|
|
time: { start: 2250, end: 2300 },
|
|
},
|
|
{
|
|
id: "prt_assistant",
|
|
sessionID: "ses_new",
|
|
messageID: "msg_assistant",
|
|
type: "text",
|
|
text: "hello back",
|
|
time: { start: 2350 },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const sessions = await client.listImportableSessions({ cwd, limit: 1 });
|
|
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0]).toMatchObject({
|
|
providerHandleId: "ses_new",
|
|
cwd,
|
|
title: "New session",
|
|
firstPromptPreview: null,
|
|
lastPromptPreview: null,
|
|
});
|
|
expect(sessions[0]?.lastActivityAt.toISOString()).toBe("1970-01-01T00:00:03.000Z");
|
|
expect(runtime.clientCreations).toEqual([{ baseUrl: runtime.server.url, directory: cwd }]);
|
|
expect(openCodeClient.calls.experimentalSessionList).toEqual([
|
|
{ archived: true, roots: true, limit: 200, directory: cwd },
|
|
]);
|
|
expect(openCodeClient.calls.sessionMessages).toEqual([]);
|
|
});
|
|
|
|
test("importSession reads only the selected OpenCode session without listing", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const metadataClient = new TestOpenCodeClient();
|
|
const resumedClient = new TestOpenCodeClient();
|
|
const cwd = "/workspace/repo";
|
|
const selectedSession = {
|
|
id: "ses_selected",
|
|
directory: cwd,
|
|
title: "Selected session",
|
|
time: { created: 2000, updated: 3000 },
|
|
};
|
|
const messages = [
|
|
{
|
|
info: {
|
|
id: "msg_user",
|
|
sessionID: "ses_selected",
|
|
role: "user",
|
|
time: { created: 2100 },
|
|
agent: "build",
|
|
model: { providerID: "opencode", modelID: "big-pickle" },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_user",
|
|
sessionID: "ses_selected",
|
|
messageID: "msg_user",
|
|
type: "text",
|
|
text: "import only this session",
|
|
time: { start: 2100 },
|
|
},
|
|
],
|
|
},
|
|
];
|
|
metadataClient.sessionGetResponse = { data: selectedSession };
|
|
metadataClient.sessionMessagesResponse = { data: messages };
|
|
resumedClient.sessionGetResponse = { data: selectedSession };
|
|
resumedClient.sessionMessagesResponse = { data: messages };
|
|
runtime.enqueueClient(metadataClient);
|
|
runtime.enqueueClient(resumedClient);
|
|
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const imported = await client.importSession(
|
|
{ providerHandleId: "ses_selected", cwd },
|
|
{
|
|
config: { provider: "opencode", cwd },
|
|
storedConfig: { provider: "opencode", cwd },
|
|
},
|
|
);
|
|
|
|
expect(metadataClient.calls.experimentalSessionList).toEqual([]);
|
|
expect(metadataClient.calls.sessionGet).toEqual([
|
|
{ sessionID: "ses_selected", directory: cwd },
|
|
]);
|
|
expect(metadataClient.calls.sessionMessages).toEqual([
|
|
{ sessionID: "ses_selected", directory: cwd },
|
|
]);
|
|
expect(imported.config).toMatchObject({
|
|
provider: "opencode",
|
|
cwd,
|
|
title: "Selected session",
|
|
modeId: "build",
|
|
model: "opencode/big-pickle",
|
|
});
|
|
expect(imported.persistence).toMatchObject({
|
|
provider: "opencode",
|
|
sessionId: "ses_selected",
|
|
nativeHandle: "ses_selected",
|
|
});
|
|
expect(imported.timeline.map((entry) => entry.item)).toEqual([
|
|
{ type: "user_message", text: "import only this session", messageId: "msg_user" },
|
|
]);
|
|
expect(resumedClient.calls.sessionMessages).toEqual([
|
|
{ sessionID: "ses_selected", directory: cwd },
|
|
]);
|
|
});
|
|
|
|
test("listImportableSessions matches Windows cwd paths with forward slashes", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
const requestedCwd = "C:/Users/Administrator/GhostFactory";
|
|
const storedCwd = "C:\\Users\\Administrator\\GhostFactory";
|
|
|
|
openCodeClient.experimentalSessionListResponse = {
|
|
data: [
|
|
{
|
|
id: "ses_windows",
|
|
directory: storedCwd,
|
|
title: "Windows session",
|
|
time: { created: 2000, updated: 3000 },
|
|
},
|
|
{
|
|
id: "ses_other",
|
|
directory: "C:\\Users\\Administrator\\OtherProject",
|
|
title: "Other cwd",
|
|
time: { created: 4000, updated: 4000 },
|
|
},
|
|
],
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const sessions = await client.listImportableSessions({ cwd: requestedCwd, limit: 1 });
|
|
|
|
expect(sessions).toHaveLength(1);
|
|
expect(sessions[0]).toMatchObject({
|
|
providerHandleId: "ses_windows",
|
|
cwd: storedCwd,
|
|
title: "Windows session",
|
|
});
|
|
expect(openCodeClient.calls.experimentalSessionList).toEqual([
|
|
{ archived: true, roots: true, limit: 200, directory: requestedCwd },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("OpenCode provider subagent contract", () => {
|
|
async function createAdoptedChildSession(
|
|
configureChild?: (client: TestOpenCodeClient) => void,
|
|
): Promise<{
|
|
readonly runtime: TestOpenCodeHarness;
|
|
readonly provider: OpenCodeAgentClient;
|
|
readonly parent: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>>;
|
|
readonly child: Awaited<ReturnType<OpenCodeAgentClient["resumeSession"]>>;
|
|
readonly childClient: TestOpenCodeClient;
|
|
}> {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const parentClient = new TestOpenCodeClient();
|
|
const childClient = new TestOpenCodeClient();
|
|
configureChild?.(childClient);
|
|
parentClient.sessionCreateResponse = { data: { id: "ses_parent_external" } };
|
|
runtime.enqueueClient(parentClient);
|
|
runtime.enqueueClient(childClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const parent = await client.createSession(
|
|
{ provider: "opencode", cwd: "/workspace/repo" },
|
|
{ env: { PASEO_AGENT_ID: "parent-agent" } },
|
|
);
|
|
|
|
parentClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_external",
|
|
parentID: "ses_parent_external",
|
|
title: "Externally driven child",
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
const child = await client.resumeSession(
|
|
{
|
|
provider: "opencode",
|
|
sessionId: "ses_child_external",
|
|
nativeHandle: "ses_child_external",
|
|
metadata: { cwd: "/workspace/repo" },
|
|
},
|
|
undefined,
|
|
{ env: { PASEO_AGENT_ID: "child-agent" } },
|
|
);
|
|
return { runtime, provider: client, parent, child, childClient };
|
|
}
|
|
|
|
test("archives an adopted child on the parent's registered OpenCode server", async () => {
|
|
const { runtime, provider, parent, child } = await createAdoptedChildSession();
|
|
const archiveClient = new TestOpenCodeClient();
|
|
runtime.enqueueClient(archiveClient);
|
|
|
|
await provider.archiveNativeSession({
|
|
provider: "opencode",
|
|
sessionId: "ses_child_external",
|
|
metadata: { cwd: "/workspace/repo" },
|
|
});
|
|
|
|
expect(archiveClient.calls.sessionUpdate).toEqual([
|
|
{
|
|
sessionID: "ses_child_external",
|
|
directory: "/workspace/repo",
|
|
time: { archived: expect.any(Number) },
|
|
},
|
|
]);
|
|
expect(runtime.acquisitions.at(-1)).toEqual({
|
|
kind: "existing",
|
|
url: runtime.server.url,
|
|
releaseCount: 1,
|
|
});
|
|
await child.close();
|
|
await parent.close();
|
|
});
|
|
|
|
test("resumes an adopted child on the parent's registered OpenCode server", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const parentClient = new TestOpenCodeClient();
|
|
const childClient = new TestOpenCodeClient();
|
|
parentClient.sessionCreateResponse = { data: { id: "ses_parent_registry" } };
|
|
runtime.enqueueClient(parentClient);
|
|
runtime.enqueueClient(childClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const parent = await client.createSession(
|
|
{ provider: "opencode", cwd: "/workspace/repo" },
|
|
{ env: { PASEO_AGENT_ID: "parent-agent" } },
|
|
);
|
|
const events: AgentStreamEvent[] = [];
|
|
parent.subscribe((event) => events.push(event));
|
|
|
|
parentClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_registry",
|
|
parentID: "ses_parent_registry",
|
|
title: "Live child",
|
|
},
|
|
},
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
const child = await client.resumeSession(
|
|
{
|
|
provider: "opencode",
|
|
sessionId: "ses_child_registry",
|
|
nativeHandle: "ses_child_registry",
|
|
metadata: { cwd: "/workspace/repo" },
|
|
},
|
|
undefined,
|
|
{ env: { PASEO_AGENT_ID: "child-agent" } },
|
|
);
|
|
await child.close();
|
|
await parent.close();
|
|
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_registry",
|
|
description: "Live child",
|
|
status: "running",
|
|
},
|
|
});
|
|
expect(runtime.acquisitions).toEqual([
|
|
{ kind: "dedicated", env: { PASEO_AGENT_ID: "parent-agent" }, releaseCount: 1 },
|
|
{ kind: "existing", url: runtime.server.url, releaseCount: 1 },
|
|
]);
|
|
expect(runtime.clientCreations).toEqual([
|
|
{ baseUrl: runtime.server.url, directory: "/workspace/repo" },
|
|
{ baseUrl: runtime.server.url, directory: "/workspace/repo" },
|
|
]);
|
|
});
|
|
|
|
test("synthesizes an autonomous turn for adopted child timeline events", async () => {
|
|
const { child, childClient, parent } = await createAdoptedChildSession();
|
|
const completed = createTestDeferred<void>();
|
|
const events: AgentStreamEvent[] = [];
|
|
child.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "turn_completed") {
|
|
completed.resolve();
|
|
}
|
|
});
|
|
|
|
for (const event of [
|
|
{
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_child_external", status: { type: "busy" } },
|
|
},
|
|
...assistantTurnEvents({ sessionId: "ses_child_external", text: "child says hi" }).slice(
|
|
0,
|
|
2,
|
|
),
|
|
{ type: "session.idle", properties: { sessionID: "ses_child_external" } },
|
|
]) {
|
|
childClient.emitEvent(event);
|
|
}
|
|
|
|
await completed.promise;
|
|
await child.close();
|
|
await parent.close();
|
|
|
|
expect(events.map((event) => event.type)).toEqual([
|
|
"turn_started",
|
|
"timeline",
|
|
"turn_completed",
|
|
]);
|
|
expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([
|
|
"opencode-turn-0",
|
|
"opencode-turn-0",
|
|
"opencode-turn-0",
|
|
]);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "timeline",
|
|
item: {
|
|
type: "assistant_message",
|
|
text: "child says hi",
|
|
messageId: "msg_assistant",
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
test.each(["busy", "retry"] as const)(
|
|
"reconciles an adopted child that was already %s before attachment",
|
|
async (runnerStatus) => {
|
|
const { child, childClient, parent } = await createAdoptedChildSession((client) => {
|
|
client.sessionStatusResponse = {
|
|
data: { ses_child_external: { type: runnerStatus } },
|
|
};
|
|
});
|
|
const completed = createTestDeferred<void>();
|
|
const events: AgentStreamEvent[] = [];
|
|
try {
|
|
child.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "turn_completed") {
|
|
completed.resolve();
|
|
}
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual({
|
|
type: "turn_started",
|
|
provider: "opencode",
|
|
turnId: "opencode-turn-0",
|
|
});
|
|
});
|
|
for (const event of assistantTurnEvents({
|
|
sessionId: "ses_child_external",
|
|
text: "already-running child says hi",
|
|
})) {
|
|
childClient.emitEvent(event);
|
|
}
|
|
|
|
await completed.promise;
|
|
|
|
expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
|
|
expect(turnEventSignatures(events)).toEqual([
|
|
["turn_started", "opencode-turn-0"],
|
|
["timeline", "opencode-turn-0"],
|
|
["turn_completed", "opencode-turn-0"],
|
|
]);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "timeline",
|
|
item: expect.objectContaining({ text: "already-running child says hi" }),
|
|
}),
|
|
);
|
|
} finally {
|
|
await child.close();
|
|
await parent.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
test("ignores a stale busy snapshot after the live stream reaches idle", async () => {
|
|
const statusStarted = createTestDeferred<void>();
|
|
const releaseStatus = createTestDeferred<void>();
|
|
const { child, childClient, parent } = await createAdoptedChildSession((client) => {
|
|
client.sessionStatusImplementation = async () => {
|
|
statusStarted.resolve();
|
|
await releaseStatus.promise;
|
|
return { data: { ses_child_external: { type: "busy" } } };
|
|
};
|
|
});
|
|
const idleConsumed = createTestDeferred<void>();
|
|
const events: AgentStreamEvent[] = [];
|
|
try {
|
|
child.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "provider_subagent") {
|
|
idleConsumed.resolve();
|
|
}
|
|
});
|
|
|
|
await statusStarted.promise;
|
|
childClient.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_child_external" },
|
|
});
|
|
childClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_idle_drain_marker",
|
|
parentID: "ses_child_external",
|
|
title: "Idle drain marker",
|
|
},
|
|
},
|
|
});
|
|
await idleConsumed.promise;
|
|
|
|
releaseStatus.resolve();
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
expect(events.some((event) => event.type === "turn_started")).toBe(false);
|
|
} finally {
|
|
releaseStatus.resolve();
|
|
await child.close();
|
|
await parent.close();
|
|
}
|
|
});
|
|
|
|
test("uses busy as the autonomous boundary without replaying earlier idle content", async () => {
|
|
const { parent, openCode } = await createParentSession("ses_parent_plugin_wake");
|
|
const events: AgentStreamEvent[] = [];
|
|
parent.subscribe((event) => events.push(event));
|
|
|
|
try {
|
|
for (const event of userMessageEvents({
|
|
sessionId: "ses_parent_plugin_wake",
|
|
messageId: "msg_plugin_wake",
|
|
text: "<system-reminder>All background tasks are complete.</system-reminder>",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_plugin_wake_drain_marker",
|
|
parentID: "ses_parent_plugin_wake",
|
|
title: "Pre-busy drain marker",
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(expect.objectContaining({ type: "provider_subagent" }));
|
|
});
|
|
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([]);
|
|
events.length = 0;
|
|
|
|
openCode.emitEvent({
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_parent_plugin_wake", status: { type: "busy" } },
|
|
});
|
|
for (const event of assistantTurnEvents({
|
|
sessionId: "ses_parent_plugin_wake",
|
|
text: "Parent consumed the background result.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
|
|
await vi.waitFor(() => {
|
|
expect(turnEventSignatures(events)).toEqual([
|
|
["turn_started", "opencode-turn-0"],
|
|
["timeline", "opencode-turn-0"],
|
|
["turn_completed", "opencode-turn-0"],
|
|
]);
|
|
});
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "timeline",
|
|
item: expect.objectContaining({
|
|
type: "assistant_message",
|
|
text: "Parent consumed the background result.",
|
|
}),
|
|
}),
|
|
);
|
|
expect(events).not.toContainEqual(
|
|
expect.objectContaining({
|
|
type: "timeline",
|
|
item: expect.objectContaining({
|
|
text: "<system-reminder>All background tasks are complete.</system-reminder>",
|
|
}),
|
|
}),
|
|
);
|
|
} finally {
|
|
await parent.close();
|
|
}
|
|
});
|
|
|
|
test("starts autonomous activity from busy without requiring an initiating message", async () => {
|
|
const { parent, openCode } = await createParentSession("ses_parent_busy_only");
|
|
const events: AgentStreamEvent[] = [];
|
|
parent.subscribe((event) => events.push(event));
|
|
|
|
try {
|
|
openCode.emitEvent({
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_parent_busy_only", status: { type: "busy" } },
|
|
});
|
|
for (const event of assistantTurnEvents({
|
|
sessionId: "ses_parent_busy_only",
|
|
text: "Autonomous response after busy.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
|
|
await vi.waitFor(() => {
|
|
expect(turnEventSignatures(events)).toEqual([
|
|
["turn_started", "opencode-turn-0"],
|
|
["timeline", "opencode-turn-0"],
|
|
["turn_completed", "opencode-turn-0"],
|
|
]);
|
|
});
|
|
} finally {
|
|
await parent.close();
|
|
}
|
|
});
|
|
|
|
test("does not mistake OpenCode session metadata for an autonomous turn", async () => {
|
|
const { parent, openCode } = await createParentSession("ses_parent_metadata");
|
|
const events: AgentStreamEvent[] = [];
|
|
const streamDrained = createTestDeferred<void>();
|
|
parent.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "provider_subagent") {
|
|
streamDrained.resolve();
|
|
}
|
|
});
|
|
|
|
try {
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_parent_metadata" } },
|
|
});
|
|
openCode.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_parent_metadata" },
|
|
});
|
|
for (const event of assistantTurnEvents({
|
|
sessionId: "ses_parent_metadata",
|
|
text: "Autonomous response.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_after_parent_metadata",
|
|
parentID: "ses_parent_metadata",
|
|
title: "Stream drain marker",
|
|
directory: "/workspace/repo",
|
|
},
|
|
},
|
|
});
|
|
|
|
await streamDrained.promise;
|
|
expect(
|
|
turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")),
|
|
).toEqual([]);
|
|
} finally {
|
|
await parent.close();
|
|
}
|
|
});
|
|
|
|
test("keeps an autonomous turn active until OpenCode reaches its terminal event", async () => {
|
|
const { parent, openCode } = await createParentSession("ses_parent_active_wake");
|
|
const events: AgentStreamEvent[] = [];
|
|
parent.subscribe((event) => events.push(event));
|
|
|
|
try {
|
|
openCode.emitEvent(
|
|
userMessageEvents({
|
|
sessionId: "ses_parent_active_wake",
|
|
messageId: "msg_autonomous_wake",
|
|
text: "Autonomous wake",
|
|
})[0],
|
|
);
|
|
openCode.emitEvent({
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_parent_active_wake", status: { type: "busy" } },
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(events).toEqual([
|
|
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
|
]);
|
|
});
|
|
|
|
await expect(parent.startTurn("Continue from Paseo")).rejects.toThrow(
|
|
"A foreground turn is already active",
|
|
);
|
|
expect(openCode.calls.sessionAbort).toEqual([]);
|
|
expect(openCode.calls.sessionPromptAsync).toEqual([]);
|
|
} finally {
|
|
await parent.close();
|
|
}
|
|
});
|
|
|
|
test("does not start a new autonomous turn when post-turn user message updates arrive for an already emitted message", async () => {
|
|
const { parent, openCode } = await createParentSession("ses_parent_post_turn");
|
|
openCode.sessionPromptAsyncEvents = [
|
|
...userMessageEvents({
|
|
sessionId: "ses_parent_post_turn",
|
|
messageId: "msg_user_1",
|
|
text: "Hello OpenCode",
|
|
}),
|
|
...assistantTurnEvents({
|
|
sessionId: "ses_parent_post_turn",
|
|
text: "Response from OpenCode",
|
|
}),
|
|
];
|
|
const events: AgentStreamEvent[] = [];
|
|
const streamDrained = createTestDeferred<void>();
|
|
parent.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "provider_subagent") {
|
|
streamDrained.resolve();
|
|
}
|
|
});
|
|
|
|
try {
|
|
await parent.startTurn("Hello OpenCode");
|
|
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(expect.objectContaining({ type: "turn_completed" }));
|
|
});
|
|
|
|
// Post-turn message update for the same user message ID that already completed
|
|
openCode.emitEvent({
|
|
type: "message.updated",
|
|
properties: {
|
|
info: { id: "msg_user_1", sessionID: "ses_parent_post_turn", role: "user" },
|
|
},
|
|
});
|
|
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_drain_marker",
|
|
parentID: "ses_parent_post_turn",
|
|
title: "Stream drain marker",
|
|
directory: "/workspace/repo",
|
|
},
|
|
},
|
|
});
|
|
|
|
await streamDrained.promise;
|
|
|
|
// Verify that no new turn_started was emitted after turn_completed
|
|
const turnStartedCount = events.filter((e) => e.type === "turn_started").length;
|
|
expect(turnStartedCount).toBe(1);
|
|
} finally {
|
|
await parent.close();
|
|
}
|
|
});
|
|
test("does not adopt late output from an interrupted Paseo turn", async () => {
|
|
const { parent, openCode } = await createParentSession("ses_parent_interrupted");
|
|
openCode.sessionPromptAsyncEvents = [];
|
|
const events: AgentStreamEvent[] = [];
|
|
const streamDrained = createTestDeferred<void>();
|
|
parent.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "provider_subagent") {
|
|
streamDrained.resolve();
|
|
}
|
|
});
|
|
|
|
try {
|
|
await parent.startTurn("Start from Paseo");
|
|
await parent.interrupt();
|
|
|
|
for (const event of userMessageEvents({
|
|
sessionId: "ses_parent_interrupted",
|
|
messageId: "msg_delayed_interrupted_user",
|
|
text: "Delayed interrupted prompt",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
for (const event of assistantTurnEvents({
|
|
sessionId: "ses_parent_interrupted",
|
|
text: "Late interrupted response.",
|
|
})) {
|
|
openCode.emitEvent(event);
|
|
}
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_after_interrupted_output",
|
|
parentID: "ses_parent_interrupted",
|
|
title: "Stream drain marker",
|
|
directory: "/workspace/repo",
|
|
},
|
|
},
|
|
});
|
|
|
|
await streamDrained.promise;
|
|
expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([
|
|
{ type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" },
|
|
{
|
|
type: "turn_canceled",
|
|
provider: "opencode",
|
|
reason: "interrupted",
|
|
turnId: "opencode-turn-0",
|
|
},
|
|
]);
|
|
} finally {
|
|
await parent.close();
|
|
}
|
|
});
|
|
|
|
test("synthesizes an autonomous turn for adopted child permissions", async () => {
|
|
const { child, childClient, parent } = await createAdoptedChildSession();
|
|
const completed = createTestDeferred<void>();
|
|
const events: AgentStreamEvent[] = [];
|
|
child.subscribe((event) => {
|
|
events.push(event);
|
|
if (event.type === "turn_completed") {
|
|
completed.resolve();
|
|
}
|
|
});
|
|
|
|
childClient.emitEvent({
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_child_external", status: { type: "busy" } },
|
|
});
|
|
childClient.emitEvent({
|
|
type: "permission.asked",
|
|
properties: {
|
|
id: "perm_child_external",
|
|
sessionID: "ses_child_external",
|
|
permission: "bash",
|
|
patterns: ["npm test"],
|
|
metadata: { command: "npm test", cwd: "/workspace/repo" },
|
|
},
|
|
});
|
|
childClient.emitEvent({
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_child_external" },
|
|
});
|
|
|
|
await completed.promise;
|
|
await child.close();
|
|
await parent.close();
|
|
|
|
expect(events.map((event) => event.type)).toEqual([
|
|
"turn_started",
|
|
"permission_requested",
|
|
"turn_completed",
|
|
]);
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "permission_requested",
|
|
request: expect.objectContaining({ id: "perm_child_external" }),
|
|
turnId: "opencode-turn-0",
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("forwards provider child permissions through the parent session", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const parentClient = new TestOpenCodeClient();
|
|
parentClient.sessionCreateResponse = { data: { id: "ses_parent_permission" } };
|
|
runtime.enqueueClient(parentClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const parent = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
parent.subscribe((event) => events.push(event));
|
|
|
|
parentClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_provider_child_permission",
|
|
parentID: "ses_parent_permission",
|
|
title: "Permission child",
|
|
directory: "/workspace/child",
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: expect.objectContaining({
|
|
type: "upsert",
|
|
id: "ses_provider_child_permission",
|
|
}),
|
|
});
|
|
});
|
|
|
|
parentClient.emitEvent({
|
|
type: "permission.asked",
|
|
properties: {
|
|
id: "perm_provider_child",
|
|
sessionID: "ses_provider_child_permission",
|
|
permission: "bash",
|
|
patterns: ["npm test"],
|
|
metadata: { command: "npm test", cwd: "/workspace/repo" },
|
|
},
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(parent.getPendingPermissions()).toEqual([
|
|
expect.objectContaining({ id: "perm_provider_child" }),
|
|
]);
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "permission_requested",
|
|
request: expect.objectContaining({ id: "perm_provider_child" }),
|
|
}),
|
|
);
|
|
});
|
|
expect(events.some((event) => event.type === "turn_started")).toBe(false);
|
|
expect(
|
|
events.find(
|
|
(event) =>
|
|
event.type === "permission_requested" && event.request.id === "perm_provider_child",
|
|
),
|
|
).not.toHaveProperty("turnId");
|
|
expect(
|
|
events.filter(
|
|
(event) =>
|
|
event.type === "permission_requested" && event.request.id === "perm_provider_child",
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
await parent.respondToPermission("perm_provider_child", { behavior: "allow" });
|
|
expect(parentClient.calls.permissionReply).toContainEqual(
|
|
expect.objectContaining({
|
|
requestID: "perm_provider_child",
|
|
directory: "/workspace/child",
|
|
reply: "once",
|
|
}),
|
|
);
|
|
await parent.close();
|
|
});
|
|
|
|
test("auto-approves provider child permissions in the child directory", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const parentClient = new TestOpenCodeClient();
|
|
parentClient.sessionCreateResponse = { data: { id: "ses_parent_auto_permission" } };
|
|
runtime.enqueueClient(parentClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const parent = await client.createSession({
|
|
provider: "opencode",
|
|
cwd: "/workspace/repo",
|
|
featureValues: { auto_accept: true },
|
|
});
|
|
parent.subscribe(() => undefined);
|
|
|
|
parentClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_provider_child_auto_permission",
|
|
parentID: "ses_parent_auto_permission",
|
|
title: "Auto permission child",
|
|
directory: "/workspace/auto-child",
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(parentClient.calls.sessionChildren.length).toBeGreaterThanOrEqual(1),
|
|
);
|
|
parentClient.emitEvent({
|
|
type: "permission.asked",
|
|
properties: {
|
|
id: "perm_provider_child_auto",
|
|
sessionID: "ses_provider_child_auto_permission",
|
|
permission: "bash",
|
|
patterns: ["npm test"],
|
|
metadata: { command: "npm test" },
|
|
},
|
|
});
|
|
|
|
await vi.waitFor(() =>
|
|
expect(parentClient.calls.permissionReply).toContainEqual(
|
|
expect.objectContaining({
|
|
requestID: "perm_provider_child_auto",
|
|
directory: "/workspace/auto-child",
|
|
reply: "once",
|
|
}),
|
|
),
|
|
);
|
|
expect(parent.getPendingPermissions()).toEqual([]);
|
|
await parent.close();
|
|
});
|
|
|
|
test("forwards provider child questions through the parent session", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const parentClient = new TestOpenCodeClient();
|
|
parentClient.sessionCreateResponse = { data: { id: "ses_parent_question" } };
|
|
runtime.enqueueClient(parentClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const parent = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
parent.subscribe((event) => events.push(event));
|
|
|
|
parentClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_provider_child_question",
|
|
parentID: "ses_parent_question",
|
|
title: "Question child",
|
|
directory: "/workspace/question-child",
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "provider_subagent",
|
|
event: expect.objectContaining({ id: "ses_provider_child_question" }),
|
|
}),
|
|
),
|
|
);
|
|
|
|
parentClient.emitEvent({
|
|
type: "question.asked",
|
|
properties: {
|
|
id: "question_provider_child",
|
|
sessionID: "ses_provider_child_question",
|
|
questions: [
|
|
{
|
|
question: "Which path?",
|
|
header: "Path",
|
|
options: [{ label: "A", description: "Choose A" }],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
await vi.waitFor(() => {
|
|
expect(parent.getPendingPermissions()).toEqual([
|
|
expect.objectContaining({
|
|
id: "question_provider_child",
|
|
kind: "question",
|
|
input: expect.objectContaining({
|
|
questions: [expect.objectContaining({ question: "Which path?" })],
|
|
}),
|
|
}),
|
|
]);
|
|
});
|
|
expect(
|
|
events.filter(
|
|
(event) =>
|
|
event.type === "permission_requested" && event.request.id === "question_provider_child",
|
|
),
|
|
).toHaveLength(1);
|
|
|
|
await parent.respondToPermission("question_provider_child", {
|
|
behavior: "allow",
|
|
updatedInput: { answers: { Path: "A" } },
|
|
});
|
|
expect(parentClient.calls.questionReply).toContainEqual(
|
|
expect.objectContaining({
|
|
requestID: "question_provider_child",
|
|
directory: "/workspace/question-child",
|
|
answers: [["A"]],
|
|
}),
|
|
);
|
|
await parent.close();
|
|
});
|
|
|
|
test("emits a provider subagent for a child created while the parent has no active turn", async () => {
|
|
const releaseChildEvent = createTestDeferred<void>();
|
|
const childConsumed = createTestDeferred<void>();
|
|
const fakeClient = {
|
|
global: {
|
|
event: vi.fn().mockResolvedValue({
|
|
stream: (async function* () {
|
|
yield { type: "server.connected", properties: {} };
|
|
await releaseChildEvent.promise;
|
|
yield {
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_background",
|
|
parentID: "ses_parent",
|
|
title: "Plugin child",
|
|
},
|
|
},
|
|
};
|
|
yield {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_child_background",
|
|
sessionID: "ses_child_background",
|
|
role: "assistant",
|
|
},
|
|
},
|
|
};
|
|
yield {
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "part_child_background",
|
|
sessionID: "ses_child_background",
|
|
messageID: "msg_child_background",
|
|
type: "text",
|
|
text: "Background findings.",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
};
|
|
yield {
|
|
type: "session.idle",
|
|
properties: { sessionID: "ses_child_background" },
|
|
};
|
|
childConsumed.resolve();
|
|
})(),
|
|
}),
|
|
},
|
|
session: {
|
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
|
update: vi.fn().mockResolvedValue({ error: null }),
|
|
},
|
|
} as never;
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_parent",
|
|
createTestLogger(),
|
|
);
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
releaseChildEvent.resolve();
|
|
await childConsumed.promise;
|
|
await session.close();
|
|
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_background",
|
|
description: "Plugin child",
|
|
status: "running",
|
|
},
|
|
});
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "timeline",
|
|
id: "ses_child_background",
|
|
item: {
|
|
type: "assistant_message",
|
|
text: "Background findings.",
|
|
messageId: "msg_child_background",
|
|
},
|
|
},
|
|
});
|
|
expect(events.at(-1)).toEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: { type: "upsert", id: "ses_child_background", status: "completed" },
|
|
});
|
|
});
|
|
|
|
test("translates plugin child sessions without a waiting sub_agent tool call", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
|
|
const events = translateOpenCodeEvent(
|
|
{
|
|
type: "session.updated",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_plugin",
|
|
parentID: "ses_parent",
|
|
title: "Background plugin child",
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
);
|
|
|
|
expect(events).toEqual([
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_plugin",
|
|
description: "Background plugin child",
|
|
status: "running",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("folds child assistant facts into deduped presentation upserts without status", async () => {
|
|
const releaseEvents = createTestDeferred<void>();
|
|
const eventsConsumed = createTestDeferred<void>();
|
|
const fakeClient = {
|
|
global: {
|
|
event: vi.fn().mockResolvedValue({
|
|
stream: (async function* () {
|
|
yield { type: "server.connected", properties: {} };
|
|
await releaseEvents.promise;
|
|
yield {
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "ses_child_facts", parentID: "ses_parent", title: "Fact child" },
|
|
},
|
|
};
|
|
// Streaming frame: model facts, no completion yet.
|
|
yield {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_child_facts",
|
|
sessionID: "ses_child_facts",
|
|
role: "assistant",
|
|
agent: "general",
|
|
providerID: "anthropic",
|
|
modelID: "claude-sonnet-5",
|
|
variant: "high",
|
|
time: { created: 1 },
|
|
},
|
|
},
|
|
};
|
|
// Same facts again: must not re-emit an identical subtitle upsert.
|
|
yield {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_child_facts",
|
|
sessionID: "ses_child_facts",
|
|
role: "assistant",
|
|
agent: "general",
|
|
providerID: "anthropic",
|
|
modelID: "claude-sonnet-5",
|
|
variant: "high",
|
|
time: { created: 1 },
|
|
},
|
|
},
|
|
};
|
|
// Completion carries the token sums.
|
|
yield {
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_child_facts",
|
|
sessionID: "ses_child_facts",
|
|
role: "assistant",
|
|
agent: "general",
|
|
providerID: "anthropic",
|
|
modelID: "claude-sonnet-5",
|
|
variant: "high",
|
|
time: { created: 1, completed: 2 },
|
|
tokens: {
|
|
input: 10_000,
|
|
output: 5_000,
|
|
reasoning: 1_000,
|
|
cache: { read: 400, write: 100 },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
yield { type: "session.idle", properties: { sessionID: "ses_child_facts" } };
|
|
eventsConsumed.resolve();
|
|
})(),
|
|
}),
|
|
},
|
|
session: {
|
|
abort: vi.fn().mockResolvedValue({ error: null }),
|
|
update: vi.fn().mockResolvedValue({ error: null }),
|
|
},
|
|
} as never;
|
|
const session = new __openCodeInternals.OpenCodeAgentSession(
|
|
{ provider: "opencode", cwd: "/tmp/test" },
|
|
fakeClient,
|
|
"ses_parent",
|
|
createTestLogger(),
|
|
);
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
releaseEvents.resolve();
|
|
await eventsConsumed.promise;
|
|
await session.close();
|
|
|
|
const subtitleUpserts = events.flatMap((event) =>
|
|
event.type === "provider_subagent" &&
|
|
event.event.type === "upsert" &&
|
|
event.event.subtitle !== undefined
|
|
? [event.event]
|
|
: [],
|
|
);
|
|
expect(subtitleUpserts).toEqual([
|
|
{
|
|
type: "upsert",
|
|
id: "ses_child_facts",
|
|
title: "general",
|
|
subtitle: "general · claude-sonnet-5 · High",
|
|
},
|
|
{
|
|
type: "upsert",
|
|
id: "ses_child_facts",
|
|
subtitle: "general · claude-sonnet-5 · High · 16.5k tokens",
|
|
},
|
|
]);
|
|
// Presentation upserts must not carry status: they can never revert a finished child.
|
|
for (const upsert of subtitleUpserts) {
|
|
expect(upsert).not.toHaveProperty("status");
|
|
}
|
|
expect(subtitleUpserts.filter((upsert) => upsert.title !== undefined)).toHaveLength(1);
|
|
expect(events.at(-1)).toEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: { type: "upsert", id: "ses_child_facts", status: "completed" },
|
|
});
|
|
});
|
|
|
|
test("does not overwrite a link-set title with the assistant-frame agent", async () => {
|
|
const { parent, openCode } = await createParentSession("ses_parent_link_title");
|
|
const events: AgentStreamEvent[] = [];
|
|
parent.subscribe((event) => events.push(event));
|
|
|
|
openCode.emitEvent({
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_link_title_task",
|
|
sessionID: "ses_parent_link_title",
|
|
messageID: "msg_parent_link_title",
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: "call_link_title",
|
|
state: {
|
|
status: "running",
|
|
input: { subagent_type: "explore", description: "Inspect title precedence" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
openCode.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_link_title",
|
|
parentID: "ses_parent_link_title",
|
|
title: "Inspect title precedence",
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: expect.objectContaining({
|
|
type: "upsert",
|
|
id: "ses_child_link_title",
|
|
title: "explore",
|
|
toolCallId: "call_link_title",
|
|
}),
|
|
}),
|
|
);
|
|
|
|
openCode.emitEvent({
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_child_link_title",
|
|
sessionID: "ses_child_link_title",
|
|
role: "assistant",
|
|
agent: "general",
|
|
providerID: "anthropic",
|
|
modelID: "claude-sonnet-5",
|
|
time: { created: 1 },
|
|
},
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_link_title",
|
|
subtitle: "general · claude-sonnet-5",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const childTitles = events.flatMap((event) =>
|
|
event.type === "provider_subagent" &&
|
|
event.event.type === "upsert" &&
|
|
event.event.id === "ses_child_link_title" &&
|
|
event.event.title !== undefined
|
|
? [event.event.title]
|
|
: [],
|
|
);
|
|
expect(childTitles).toEqual(["explore"]);
|
|
await parent.close();
|
|
});
|
|
|
|
test("maps child detection facts onto title, description, and subtitle", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
|
|
const events = translateOpenCodeEvent(
|
|
{
|
|
type: "session.updated",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_rich",
|
|
parentID: "ses_parent",
|
|
title: "Investigate flaky test",
|
|
agent: "explore",
|
|
model: { providerID: "anthropic", id: "claude-sonnet-5", variant: "high" },
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
);
|
|
|
|
expect(events).toEqual([
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_rich",
|
|
title: "explore",
|
|
description: "Investigate flaky test",
|
|
status: "running",
|
|
subtitle: "explore · claude-sonnet-5 · High",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("leaves title and description unset when the child session carries no facts", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
|
|
const events = translateOpenCodeEvent(
|
|
{
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "ses_child_bare", parentID: "ses_parent" },
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
);
|
|
|
|
expect(events).toEqual([
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: { type: "upsert", id: "ses_child_bare", status: "running" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("links a waiting task tool call to a child detected afterwards", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
const events: AgentStreamEvent[] = [];
|
|
|
|
events.push(
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_parent_task",
|
|
sessionID: "ses_parent",
|
|
messageID: "msg_parent",
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: "call_task",
|
|
state: {
|
|
status: "running",
|
|
input: { subagent_type: "explore", description: "Inspect repo" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "ses_child_linked", parentID: "ses_parent", title: "Inspect repo" },
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
);
|
|
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_linked",
|
|
toolCallId: "call_task",
|
|
title: "explore",
|
|
description: "Inspect repo",
|
|
subtitle: "explore",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("links a task tool call whose metadata resolves the child session id after detection", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
const events: AgentStreamEvent[] = [];
|
|
|
|
events.push(
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "ses_childlatelink", parentID: "ses_parent", title: "Late link" },
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
// No task call was waiting at detection time; the link arrives via the tool output.
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_parent_task_late",
|
|
sessionID: "ses_parent",
|
|
messageID: "msg_parent",
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: "call_task_late",
|
|
state: {
|
|
status: "completed",
|
|
input: { subagent_type: "plan", description: "Draft the plan" },
|
|
output: '<task id="ses_childlatelink">Done.</task>',
|
|
metadata: { sessionId: "ses_childlatelink" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
);
|
|
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_childlatelink",
|
|
toolCallId: "call_task_late",
|
|
title: "plan",
|
|
description: "Draft the plan",
|
|
subtitle: "plan",
|
|
},
|
|
});
|
|
});
|
|
|
|
test("links parallel task calls from their canonical child session metadata", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
const events: AgentStreamEvent[] = [];
|
|
|
|
for (const task of [
|
|
{ callId: "call_alpha", description: "Inspect alpha" },
|
|
{ callId: "call_beta", description: "Inspect beta" },
|
|
]) {
|
|
events.push(
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: `prt_${task.callId}`,
|
|
sessionID: "ses_parent",
|
|
messageID: "msg_parent",
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: task.callId,
|
|
state: {
|
|
status: "running",
|
|
input: { subagent_type: "explore", description: task.description },
|
|
},
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
);
|
|
}
|
|
|
|
for (const childSessionId of ["ses_child_alpha", "ses_child_beta"]) {
|
|
events.push(
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: childSessionId, parentID: "ses_parent", title: "Child session" },
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
);
|
|
}
|
|
|
|
for (const task of [
|
|
{ callId: "call_alpha", childSessionId: "ses_child_alpha", description: "Inspect alpha" },
|
|
{ callId: "call_beta", childSessionId: "ses_child_beta", description: "Inspect beta" },
|
|
]) {
|
|
events.push(
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: `prt_${task.callId}`,
|
|
sessionID: "ses_parent",
|
|
messageID: "msg_parent",
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: task.callId,
|
|
state: {
|
|
status: "completed",
|
|
input: { subagent_type: "explore", description: task.description },
|
|
output: `<task id="${task.childSessionId}">Done.</task>`,
|
|
metadata: { sessionId: task.childSessionId },
|
|
},
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
);
|
|
}
|
|
|
|
expect(events).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_alpha",
|
|
toolCallId: "call_alpha",
|
|
title: "explore",
|
|
description: "Inspect alpha",
|
|
subtitle: "explore",
|
|
},
|
|
},
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_beta",
|
|
toolCallId: "call_beta",
|
|
title: "explore",
|
|
description: "Inspect beta",
|
|
subtitle: "explore",
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
});
|
|
|
|
test("linking after detection keeps the task description over the session title", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
|
|
const detection = translateOpenCodeEvent(
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_parent_task",
|
|
sessionID: "ses_parent",
|
|
messageID: "msg_parent",
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: "call_task",
|
|
state: {
|
|
status: "running",
|
|
input: { subagent_type: "explore", description: "Audit configs" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
);
|
|
const linked = translateOpenCodeEvent(
|
|
{
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_dup",
|
|
parentID: "ses_parent",
|
|
title: "OpenCode session title",
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
);
|
|
|
|
const upserts = [...detection, ...linked].flatMap((event) =>
|
|
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
|
);
|
|
// Detection precedes the link inside the same translation batch; the link's description
|
|
// (task input) lands last, so the sticky store keeps the task as the row label.
|
|
expect(upserts.at(-1)).toMatchObject({
|
|
id: "ses_child_dup",
|
|
toolCallId: "call_task",
|
|
title: "explore",
|
|
description: "Audit configs",
|
|
});
|
|
for (const upsert of upserts) {
|
|
expect(upsert.title).not.toBe("OpenCode session title");
|
|
}
|
|
});
|
|
|
|
test("translates provider deletion of a known child session", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
state.subAgentCallIdByChildSessionId?.set("ses_child_deleted", "call_task");
|
|
|
|
const events = translateOpenCodeEvent(
|
|
{
|
|
type: "session.deleted",
|
|
properties: { sessionID: "ses_child_deleted" },
|
|
} as OpenCodeEvent,
|
|
state,
|
|
);
|
|
|
|
expect(events).toEqual([
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: { type: "remove", id: "ses_child_deleted" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("hydrates existing children breadth-first and emits each detection once after subscribe", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent" } };
|
|
openCodeClient.sessionChildrenResponses = [
|
|
{
|
|
data: [
|
|
{ id: "ses_child_a", parentID: "ses_parent", title: "Child A" },
|
|
{ id: "ses_child_b", parentID: "ses_parent", title: "Child B" },
|
|
],
|
|
},
|
|
{ data: [{ id: "ses_grandchild_a", parentID: "ses_child_a", title: "Grandchild A" }] },
|
|
{ data: [] },
|
|
{ data: [] },
|
|
];
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(4));
|
|
await session.close();
|
|
|
|
expect(openCodeClient.calls.sessionChildren).toEqual([
|
|
{ path: { id: "ses_parent" } },
|
|
{ path: { id: "ses_child_a" } },
|
|
{ path: { id: "ses_child_b" } },
|
|
{ path: { id: "ses_grandchild_a" } },
|
|
]);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: { type: "upsert", id: "ses_child_a", description: "Child A", status: "completed" },
|
|
},
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: { type: "upsert", id: "ses_child_b", description: "Child B", status: "completed" },
|
|
},
|
|
{
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_grandchild_a",
|
|
description: "Grandchild A",
|
|
status: "completed",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("hydrates persisted child messages into the provider subagent timeline", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_with_history" } };
|
|
openCodeClient.sessionChildrenResponses = [
|
|
{
|
|
data: [
|
|
{
|
|
id: "ses_child_with_history",
|
|
parentID: "ses_parent_with_history",
|
|
title: "Historical child",
|
|
directory: "/workspace/child",
|
|
},
|
|
],
|
|
},
|
|
{ data: [] },
|
|
];
|
|
openCodeClient.sessionMessagesResponse = {
|
|
data: [
|
|
{
|
|
info: {
|
|
id: "msg_child_history",
|
|
sessionID: "ses_child_with_history",
|
|
role: "assistant",
|
|
time: { created: 2, completed: 2.1 },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_child_history",
|
|
sessionID: "ses_child_with_history",
|
|
messageID: "msg_child_history",
|
|
type: "text",
|
|
text: "Persisted child result.",
|
|
time: { start: 2, end: 2.1 },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2));
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_with_history",
|
|
description: "Historical child",
|
|
status: "completed",
|
|
cwd: "/workspace/child",
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "timeline",
|
|
id: "ses_child_with_history",
|
|
item: expect.objectContaining({
|
|
type: "assistant_message",
|
|
text: "Persisted child result.",
|
|
messageId: "msg_child_history",
|
|
}),
|
|
timestamp: "1970-01-01T00:00:02.000Z",
|
|
},
|
|
}),
|
|
);
|
|
expect(openCodeClient.calls.sessionMessages).toEqual([
|
|
{ sessionID: "ses_child_with_history", directory: "/workspace/child" },
|
|
]);
|
|
await session.close();
|
|
});
|
|
|
|
test("derives hydrated child presentation facts from the session record and last assistant message", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_facts" } };
|
|
openCodeClient.sessionChildrenResponses = [
|
|
{
|
|
data: [
|
|
{
|
|
id: "ses_child_hydrated_facts",
|
|
parentID: "ses_parent_facts",
|
|
title: "Chase the regression",
|
|
model: { providerID: "anthropic", id: "claude-sonnet-5", variant: "max" },
|
|
},
|
|
],
|
|
},
|
|
{ data: [] },
|
|
];
|
|
openCodeClient.sessionMessagesResponse = {
|
|
data: [
|
|
{
|
|
info: {
|
|
id: "msg_child_hydrated",
|
|
sessionID: "ses_child_hydrated_facts",
|
|
role: "assistant",
|
|
agent: "explore",
|
|
providerID: "anthropic",
|
|
modelID: "claude-sonnet-5",
|
|
variant: "max",
|
|
time: { created: 2, completed: 3 },
|
|
tokens: { input: 800, output: 150, reasoning: 30, cache: { read: 15, write: 5 } },
|
|
},
|
|
parts: [
|
|
{
|
|
id: "prt_child_hydrated",
|
|
sessionID: "ses_child_hydrated_facts",
|
|
messageID: "msg_child_hydrated",
|
|
type: "text",
|
|
text: "Found it.",
|
|
time: { start: 2, end: 3 },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2));
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_hydrated_facts",
|
|
description: "Chase the regression",
|
|
status: "completed",
|
|
subtitle: "claude-sonnet-5 · Max",
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "upsert",
|
|
id: "ses_child_hydrated_facts",
|
|
title: "explore",
|
|
subtitle: "explore · claude-sonnet-5 · Max · 1k tokens",
|
|
},
|
|
}),
|
|
);
|
|
await session.close();
|
|
});
|
|
|
|
test("preserves child events that arrive while existing children hydrate", async () => {
|
|
const hydration = createTestDeferred<{
|
|
data: Array<{ id: string; parentID: string; title: string }>;
|
|
}>();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_hydrating" } };
|
|
let childListRequest = 0;
|
|
openCodeClient.sessionChildrenImplementation = async () => {
|
|
childListRequest += 1;
|
|
return childListRequest === 1 ? await hydration.promise : { data: [] };
|
|
};
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1));
|
|
|
|
openCodeClient.emitEvent({
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_child_hydrating",
|
|
sessionID: "ses_child_hydrating",
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
openCodeClient.emitEvent({
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_child_hydrating",
|
|
sessionID: "ses_child_hydrating",
|
|
messageID: "msg_child_hydrating",
|
|
type: "text",
|
|
text: "Hydration did not lose this.",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
hydration.resolve({
|
|
data: [
|
|
{
|
|
id: "ses_child_hydrating",
|
|
parentID: "ses_parent_hydrating",
|
|
title: "Hydrating child",
|
|
},
|
|
],
|
|
});
|
|
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual(
|
|
expect.objectContaining({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: expect.objectContaining({
|
|
type: "timeline",
|
|
id: "ses_child_hydrating",
|
|
item: expect.objectContaining({
|
|
type: "assistant_message",
|
|
text: "Hydration did not lose this.",
|
|
messageId: "msg_child_hydrating",
|
|
}),
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
await session.close();
|
|
});
|
|
|
|
test("does not duplicate persisted child output when the matching live event was buffered", async () => {
|
|
const hydration = createTestDeferred<{
|
|
data: Array<{ id: string; parentID: string; title: string }>;
|
|
}>();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_dedupe" } };
|
|
let childListRequest = 0;
|
|
openCodeClient.sessionChildrenImplementation = async () => {
|
|
childListRequest += 1;
|
|
return childListRequest === 1 ? await hydration.promise : { data: [] };
|
|
};
|
|
const message = {
|
|
id: "msg_child_dedupe",
|
|
sessionID: "ses_child_dedupe",
|
|
role: "assistant" as const,
|
|
time: { created: 1, completed: 2 },
|
|
};
|
|
const part = {
|
|
id: "prt_child_dedupe",
|
|
sessionID: "ses_child_dedupe",
|
|
messageID: "msg_child_dedupe",
|
|
type: "text" as const,
|
|
text: "Only once.",
|
|
time: { start: 1, end: 2 },
|
|
};
|
|
openCodeClient.sessionMessagesResponse = { data: [{ info: message, parts: [part] }] };
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1));
|
|
|
|
openCodeClient.emitEvent({ type: "message.updated", properties: { info: message } });
|
|
openCodeClient.emitEvent({ type: "message.part.updated", properties: { part } });
|
|
hydration.resolve({
|
|
data: [
|
|
{
|
|
id: "ses_child_dedupe",
|
|
parentID: "ses_parent_dedupe",
|
|
title: "Dedupe child",
|
|
},
|
|
],
|
|
});
|
|
|
|
await vi.waitFor(() => expect(providerAssistantMessages(events, "Only once.")).toHaveLength(1));
|
|
await session.close();
|
|
});
|
|
|
|
test("marks a hydrated child running when OpenCode reports it busy", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_busy" } };
|
|
openCodeClient.sessionChildrenResponses = [
|
|
{ data: [{ id: "ses_child_busy", parentID: "ses_parent_busy", title: "Busy child" }] },
|
|
{ data: [] },
|
|
];
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2));
|
|
|
|
openCodeClient.emitEvent({
|
|
type: "session.status",
|
|
properties: { sessionID: "ses_child_busy", status: { type: "busy" } },
|
|
});
|
|
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: { type: "upsert", id: "ses_child_busy", status: "running" },
|
|
}),
|
|
);
|
|
await session.close();
|
|
});
|
|
|
|
test("preserves a live provider child user prompt", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_child_prompt" } };
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
openCodeClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: {
|
|
id: "ses_child_prompt",
|
|
parentID: "ses_parent_child_prompt",
|
|
title: "Prompt child",
|
|
},
|
|
},
|
|
});
|
|
openCodeClient.emitEvent({
|
|
type: "message.updated",
|
|
properties: {
|
|
info: { id: "msg_child_prompt", sessionID: "ses_child_prompt", role: "user" },
|
|
},
|
|
});
|
|
openCodeClient.emitEvent({
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_child_prompt",
|
|
sessionID: "ses_child_prompt",
|
|
messageID: "msg_child_prompt",
|
|
type: "text",
|
|
text: "Inspect the auth flow.",
|
|
time: { start: 1, end: 2 },
|
|
},
|
|
},
|
|
});
|
|
|
|
await vi.waitFor(() =>
|
|
expect(events).toContainEqual({
|
|
type: "provider_subagent",
|
|
provider: "opencode",
|
|
event: {
|
|
type: "timeline",
|
|
id: "ses_child_prompt",
|
|
item: {
|
|
type: "user_message",
|
|
text: "Inspect the auth flow.",
|
|
messageId: "msg_child_prompt",
|
|
},
|
|
timestamp: undefined,
|
|
},
|
|
}),
|
|
);
|
|
await session.close();
|
|
});
|
|
|
|
test("does not rehydrate children for repeated events from an unrelated session", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_isolated" } };
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
session.subscribe(() => undefined);
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1));
|
|
await Promise.resolve();
|
|
|
|
for (const messageId of ["sibling-message-1", "sibling-message-2"]) {
|
|
openCodeClient.emitEvent({
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: messageId,
|
|
sessionID: "ses_unrelated_sibling",
|
|
role: "assistant",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(openCodeClient.calls.sessionChildren).toHaveLength(1);
|
|
await session.close();
|
|
});
|
|
|
|
test("closes without waiting for child hydration", async () => {
|
|
const hydration = createTestDeferred<{ data: [] }>();
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_close" } };
|
|
openCodeClient.sessionChildrenImplementation = async () => await hydration.promise;
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" });
|
|
session.subscribe(() => undefined);
|
|
await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1));
|
|
|
|
await session.close();
|
|
|
|
expect(runtime.acquisitions[0]?.releaseCount).toBe(1);
|
|
hydration.resolve({ data: [] });
|
|
});
|
|
|
|
test("does not fold child tool parts into the parent sub_agent action log", () => {
|
|
const state = createOpenCodeTranslationState("ses_parent");
|
|
const events: AgentStreamEvent[] = [];
|
|
|
|
events.push(
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_parent_task",
|
|
sessionID: "ses_parent",
|
|
messageID: "msg_parent",
|
|
type: "tool",
|
|
tool: "task",
|
|
callID: "call_task",
|
|
state: {
|
|
status: "running",
|
|
input: { subagent_type: "explore", description: "Inspect repo" },
|
|
},
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_child", parentID: "ses_parent" } },
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
...translateOpenCodeEvent(
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
part: {
|
|
id: "prt_child_tool",
|
|
sessionID: "ses_child",
|
|
messageID: "msg_child",
|
|
type: "tool",
|
|
tool: "bash",
|
|
callID: "call_bash",
|
|
state: { status: "completed", input: { command: "echo child" }, output: "child\n" },
|
|
},
|
|
},
|
|
} as OpenCodeEvent,
|
|
state,
|
|
),
|
|
);
|
|
|
|
const subAgentItems = events.flatMap((event) => {
|
|
if (event.type !== "timeline" || event.item.type !== "tool_call") {
|
|
return [];
|
|
}
|
|
return event.item.detail.type === "sub_agent" ? [event.item] : [];
|
|
});
|
|
const latest = subAgentItems.at(-1);
|
|
if (!latest || latest.detail.type !== "sub_agent") {
|
|
throw new Error("expected parent sub_agent timeline item");
|
|
}
|
|
|
|
expect(latest.detail).toEqual({
|
|
type: "sub_agent",
|
|
subAgentType: "explore",
|
|
description: "Inspect repo",
|
|
childSessionId: "ses_child",
|
|
log: "",
|
|
actions: [],
|
|
});
|
|
});
|
|
});
|
|
|
|
function createOpenCodeTranslationState(sessionId: string): OpenCodeEventTranslationState {
|
|
return {
|
|
sessionId,
|
|
cwd: "/workspace/repo",
|
|
messageRoles: new Map(),
|
|
accumulatedUsage: {},
|
|
streamedPartKeys: new Set(),
|
|
emittedStructuredMessageIds: new Set(),
|
|
compactionSummaryMessageIds: new Set(),
|
|
emittedCompactionPartIds: new Set(),
|
|
partTypes: new Map(),
|
|
subAgentsByCallId: new Map(),
|
|
subAgentCallIdByChildSessionId: new Map(),
|
|
};
|
|
}
|
|
|
|
function createTestDeferred<T>(): {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (error: unknown) => void;
|
|
} {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (error: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function waitForAbort(signal: AbortSignal): Promise<void> {
|
|
if (signal.aborted) {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve) => {
|
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
});
|
|
}
|
|
|
|
describe("OpenCode snapshot summary false-idle regression", () => {
|
|
test("user message.updated carrying snapshot diffs does not start a turn", async () => {
|
|
const runtime = new TestOpenCodeHarness();
|
|
const openCodeClient = new TestOpenCodeClient();
|
|
openCodeClient.sessionCreateResponse = { data: { id: "ses_snapshot" } };
|
|
runtime.enqueueClient(openCodeClient);
|
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
|
serverManager: runtime,
|
|
createClient: runtime.createClient,
|
|
});
|
|
const session = await client.createSession(
|
|
{ provider: "opencode", cwd: "/workspace/repo" },
|
|
{ env: { PASEO_AGENT_ID: "snapshot-agent" } },
|
|
);
|
|
const events: AgentStreamEvent[] = [];
|
|
session.subscribe((event) => events.push(event));
|
|
|
|
// Captured live shape: after the turn goes idle OpenCode writes the filesystem
|
|
// snapshot diff onto the user message, with no time.end on the message.
|
|
openCodeClient.emitEvent({
|
|
type: "message.updated",
|
|
properties: {
|
|
info: {
|
|
id: "msg_snapshot_user",
|
|
sessionID: "ses_snapshot",
|
|
role: "user",
|
|
time: { created: 1000 },
|
|
summary: {
|
|
diffs: [{ file: "packages/server/src/thing.ts", additions: 3, deletions: 1 }],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
// Drain marker: events are processed in order, so observing this one proves
|
|
// the snapshot update above was already handled.
|
|
openCodeClient.emitEvent({
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "ses_snapshot_child", parentID: "ses_snapshot", title: "Drain marker" },
|
|
},
|
|
});
|
|
await vi.waitFor(() => {
|
|
expect(events).toContainEqual(expect.objectContaining({ type: "provider_subagent" }));
|
|
});
|
|
|
|
expect(events.some((event) => event.type === "turn_started")).toBe(false);
|
|
await session.close();
|
|
});
|
|
});
|