Show OpenCode follow-ups after background work (#2258)
* fix(opencode): surface autonomous parent turns OpenCode extensions can prompt an idle session without Paseo owning an active turn. Adopt exact-session activity while keeping child permissions unbound. * refactor(opencode): centralize session ownership guard Keep autonomous turn ownership in the shared exact-session check so all qualifying events use one source of truth. * fix(opencode): serialize autonomous handoff Abort and await provider-side autonomous work before starting a direct Paseo prompt so late output cannot leak into the new turn. * fix(opencode): reject stale interrupted activity Keep late output from an interrupted Paseo turn out of autonomous admission until a real user message establishes the next run. * fix(opencode): fence interrupted turn events Use provider abort settlement and terminal events as the interrupted-turn boundary so delayed canceled user messages cannot resurrect old work. * fix(opencode): recover from failed aborts * fix(opencode): preserve timed-out abort fence * refactor(opencode): model provider turn lifecycle * fix(opencode): recover stop fence after stream loss
This commit is contained in:
@@ -91,6 +91,40 @@ function providerAssistantMessages(events: AgentStreamEvent[], text: string): Ag
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 assistantTurnEvents({
|
function assistantTurnEvents({
|
||||||
sessionId = "session-1",
|
sessionId = "session-1",
|
||||||
text = "Hello from OpenCode",
|
text = "Hello from OpenCode",
|
||||||
@@ -123,6 +157,29 @@ function assistantTurnEvents({
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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({
|
function manualCompactEvents({
|
||||||
sessionId = "session-1",
|
sessionId = "session-1",
|
||||||
summaryText = "## Goal\n- Preserve context while continuing the task.",
|
summaryText = "## Goal\n- Preserve context while continuing the task.",
|
||||||
@@ -1844,56 +1901,91 @@ describe("OpenCode adapter startTurn error handling", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delays the next prompt until a slow interrupt abort settles", async () => {
|
test("retries abort and waits for provider idle before starting the next prompt", async () => {
|
||||||
vi.useFakeTimers();
|
const { parent: session, openCode } = await createParentSession("ses_unit_test");
|
||||||
const abortDeferred = createTestDeferred<{ data: boolean; error: undefined }>();
|
openCode.sessionPromptAsyncEvents = [];
|
||||||
const promptAsync = vi.fn().mockResolvedValue({ data: {}, error: undefined });
|
openCode.sessionAbortImplementation = async () => {
|
||||||
const abort = vi
|
if (openCode.calls.sessionAbort.length === 1) {
|
||||||
.fn()
|
throw new Error("abort transport failed");
|
||||||
.mockReturnValueOnce(abortDeferred.promise)
|
}
|
||||||
.mockResolvedValue({ data: true, error: undefined });
|
openCode.emitEvent({
|
||||||
const fakeClient = {
|
type: "session.idle",
|
||||||
global: {
|
properties: { sessionID: "ses_unit_test" },
|
||||||
event: vi.fn().mockImplementation(
|
});
|
||||||
async (options: {
|
return { data: true };
|
||||||
signal: AbortSignal;
|
};
|
||||||
}): Promise<{ stream: AsyncIterable<OpenCodeEvent> }> => ({
|
try {
|
||||||
stream: abortableOpenCodeStream(options.signal),
|
await session.startTurn("first");
|
||||||
}),
|
await session.interrupt();
|
||||||
),
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(1);
|
||||||
},
|
|
||||||
session: {
|
|
||||||
promptAsync,
|
|
||||||
abort,
|
|
||||||
},
|
|
||||||
} as never;
|
|
||||||
|
|
||||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
await session.startTurn("second");
|
||||||
{ provider: "opencode", cwd: "/tmp/test" },
|
expect(openCode.calls.sessionAbort).toHaveLength(2);
|
||||||
fakeClient,
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||||
"ses_unit_test",
|
} finally {
|
||||||
createTestLogger(),
|
await session.close();
|
||||||
);
|
}
|
||||||
|
|
||||||
await session.startTurn("first");
|
|
||||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const interruptPromise = session.interrupt();
|
|
||||||
await vi.advanceTimersByTimeAsync(2_000);
|
|
||||||
await interruptPromise;
|
|
||||||
expect(abort).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const secondTurnPromise = session.startTurn("second");
|
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
|
||||||
expect(promptAsync).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
abortDeferred.resolve({ data: true, error: undefined });
|
|
||||||
await secondTurnPromise;
|
|
||||||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
await session.interrupt();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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("reconnects and confirms provider idle when the event stream ends while stopping", async () => {
|
||||||
|
const firstStreamEnd = createTestDeferred<void>();
|
||||||
|
let subscriptionCount = 0;
|
||||||
|
const { parent: session, openCode } = await createParentSession(
|
||||||
|
"ses_stream_reconnect",
|
||||||
|
(client) => {
|
||||||
|
client.globalEventImplementation = async (options) => {
|
||||||
|
subscriptionCount += 1;
|
||||||
|
const signal = (options as { signal: AbortSignal }).signal;
|
||||||
|
const end = subscriptionCount === 1 ? firstStreamEnd.promise : waitForAbort(signal);
|
||||||
|
return {
|
||||||
|
stream: {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
yield { type: "server.connected", properties: {} };
|
||||||
|
await end;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
openCode.sessionPromptAsyncEvents = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await session.startTurn("first");
|
||||||
|
await session.interrupt();
|
||||||
|
|
||||||
|
const secondTurn = session.startTurn("second");
|
||||||
|
firstStreamEnd.resolve();
|
||||||
|
|
||||||
|
await expect(secondTurn).resolves.toEqual({ turnId: "opencode-turn-1" });
|
||||||
|
expect(openCode.calls.globalEvent).toHaveLength(2);
|
||||||
|
expect(openCode.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]);
|
||||||
|
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}, 15_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("OpenCodeAgentClient env", () => {
|
describe("OpenCodeAgentClient env", () => {
|
||||||
@@ -2464,7 +2556,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("synthesizes a turn for externally driven adopted child timeline events", async () => {
|
test("synthesizes an autonomous turn for adopted child timeline events", async () => {
|
||||||
const { child, childClient, parent } = await createAdoptedChildSession();
|
const { child, childClient, parent } = await createAdoptedChildSession();
|
||||||
const completed = createTestDeferred<void>();
|
const completed = createTestDeferred<void>();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
@@ -2515,7 +2607,184 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("synthesizes a turn for externally driven adopted child permissions", async () => {
|
test("surfaces an autonomous OpenCode wake as a new parent turn", 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.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"],
|
||||||
|
["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.",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} 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],
|
||||||
|
);
|
||||||
|
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 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 { child, childClient, parent } = await createAdoptedChildSession();
|
||||||
const completed = createTestDeferred<void>();
|
const completed = createTestDeferred<void>();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
@@ -2617,6 +2886,13 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
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(
|
expect(
|
||||||
events.filter(
|
events.filter(
|
||||||
(event) =>
|
(event) =>
|
||||||
@@ -3446,31 +3722,3 @@ function waitForAbort(signal: AbortSignal): Promise<void> {
|
|||||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortableOpenCodeStream(signal: AbortSignal): AsyncIterable<OpenCodeEvent> {
|
|
||||||
return {
|
|
||||||
[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<IteratorResult<OpenCodeEvent>>((resolve) => {
|
|
||||||
if (signal.aborted) {
|
|
||||||
resolve({ done: true, value: undefined });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener("abort", () => resolve({ done: true, value: undefined }), {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2756,6 +2756,11 @@ function createDeferred<T>(): Deferred<T> {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenCodeTurnState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "running"; turnId: string }
|
||||||
|
| { status: "stopping"; idle: Deferred<void> };
|
||||||
|
|
||||||
function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null {
|
function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null {
|
||||||
const record = readOpenCodeRecord(event);
|
const record = readOpenCodeRecord(event);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
@@ -2791,14 +2796,6 @@ function getOpenCodeEventSessionId(event: OpenCodeEvent): string | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpenCodeUserMessageEvent(event: OpenCodeEvent, sessionId: string): boolean {
|
|
||||||
return (
|
|
||||||
event.type === "message.updated" &&
|
|
||||||
event.properties.info.sessionID === sessionId &&
|
|
||||||
event.properties.info.role === "user"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boolean {
|
function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boolean {
|
||||||
if (event.type === "session.idle" || event.type === "session.error") {
|
if (event.type === "session.idle" || event.type === "session.error") {
|
||||||
return event.properties.sessionID === sessionId;
|
return event.properties.sessionID === sessionId;
|
||||||
@@ -2893,7 +2890,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private autoAcceptEnabled = false;
|
private autoAcceptEnabled = false;
|
||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
private pendingAbortPromise: Promise<void> | null = null;
|
|
||||||
private accumulatedUsage: AgentUsage = {};
|
private accumulatedUsage: AgentUsage = {};
|
||||||
private sessionTotalCostUsd: number | undefined;
|
private sessionTotalCostUsd: number | undefined;
|
||||||
private mcpConfigured = false;
|
private mcpConfigured = false;
|
||||||
@@ -2915,8 +2911,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private availableModesCache: AgentMode[] | null = null;
|
private availableModesCache: AgentMode[] | null = null;
|
||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
private nextTurnOrdinal = 0;
|
private nextTurnOrdinal = 0;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private turnState: OpenCodeTurnState = { status: "idle" };
|
||||||
private activeForegroundTurnSource: "paseo" | "external" | null = null;
|
|
||||||
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||||
private subAgentsByCallId = new Map<string, OpenCodeSubAgentActivityState>();
|
private subAgentsByCallId = new Map<string, OpenCodeSubAgentActivityState>();
|
||||||
private subAgentCallIdByChildSessionId = new Map<string, string>();
|
private subAgentCallIdByChildSessionId = new Map<string, string>();
|
||||||
@@ -2932,7 +2927,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private eventStreamAbortController: AbortController | null = null;
|
private eventStreamAbortController: AbortController | null = null;
|
||||||
private eventStreamReady: Deferred<void> | null = null;
|
private eventStreamReady: Deferred<void> | null = null;
|
||||||
private eventStreamTask: Promise<void> | null = null;
|
private eventStreamTask: Promise<void> | null = null;
|
||||||
private suppressTerminalUntilNextUserMessage = false;
|
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private readonly persistSession: boolean;
|
private readonly persistSession: boolean;
|
||||||
private deletedFromProvider = false;
|
private deletedFromProvider = false;
|
||||||
@@ -2967,6 +2961,10 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return this.sessionId;
|
return this.sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get activeForegroundTurnId(): string | null {
|
||||||
|
return this.turnState.status === "running" ? this.turnState.turnId : null;
|
||||||
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get features(): AgentFeature[] {
|
||||||
return [buildOpenCodeAutoAcceptFeature(this.config)];
|
return [buildOpenCodeAutoAcceptFeature(this.config)];
|
||||||
}
|
}
|
||||||
@@ -3008,29 +3006,24 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async interrupt(): Promise<void> {
|
async interrupt(): Promise<void> {
|
||||||
let turnId = this.activeForegroundTurnId;
|
const turnId = this.activeForegroundTurnId;
|
||||||
const turnAbortController = this.abortController;
|
this.abortController?.abort();
|
||||||
turnAbortController?.abort();
|
if (turnId) {
|
||||||
|
this.beginStoppingTurn(turnId);
|
||||||
|
}
|
||||||
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
|
// COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
|
||||||
// the running tool actually stops, which can be tens of seconds for
|
// the running tool actually stops, which can be tens of seconds for
|
||||||
// long-running tools. Cap the wait so the user-visible cancel lands
|
// long-running tools. Cap the wait so the user-visible cancel lands
|
||||||
// quickly while still giving OpenCode a chance to confirm the abort
|
// quickly while still giving OpenCode a chance to confirm the abort
|
||||||
// cleanly. Drop the timeout once upstream returns abort acknowledgement
|
// cleanly. Drop the timeout once upstream returns abort acknowledgement
|
||||||
// before tool teardown.
|
// before tool teardown.
|
||||||
const abortPromise = this.beginSessionAbort(turnId, "interrupt");
|
const abortPromise = this.abortSession(turnId, "interrupt");
|
||||||
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
|
await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ err: error, sessionId: this.sessionId, turnId },
|
{ err: error, sessionId: this.sessionId, turnId },
|
||||||
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
|
"OpenCode session.abort exceeded the cancel cap; proceeding with local cancel",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (turnId) {
|
|
||||||
this.suppressTerminalUntilNextUserMessage = true;
|
|
||||||
this.finishForegroundTurn(
|
|
||||||
{ type: "turn_canceled", provider: "opencode", reason: "interrupted" },
|
|
||||||
turnId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||||
@@ -3042,8 +3035,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
|
private abortSession(turnId: string | null, reason: string): Promise<void> {
|
||||||
const abortPromise = this.client.session
|
return this.client.session
|
||||||
.abort({
|
.abort({
|
||||||
sessionID: this.sessionId,
|
sessionID: this.sessionId,
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
@@ -3055,49 +3048,77 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
"OpenCode session.abort rejected",
|
"OpenCode session.abort rejected",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const trackedAbortPromise = abortPromise.finally(() => {
|
|
||||||
if (this.pendingAbortPromise === trackedAbortPromise) {
|
|
||||||
this.pendingAbortPromise = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.pendingAbortPromise = trackedAbortPromise;
|
|
||||||
return trackedAbortPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async awaitPendingAbortBeforeStartingTurn(): Promise<void> {
|
private async waitUntilProviderIdle(): Promise<void> {
|
||||||
const pendingAbortPromise = this.pendingAbortPromise;
|
if (this.turnState.status !== "stopping") {
|
||||||
if (!pendingAbortPromise) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopping = this.turnState;
|
||||||
|
// OpenCode creates prompts before joining its single session runner. Sending
|
||||||
|
// while that runner is stopping can strand the new prompt behind the old
|
||||||
|
// run, so only provider-confirmed idle makes the session reusable.
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
pendingAbortPromise,
|
this.observeProviderStopBoundary(stopping),
|
||||||
OPENCODE_PENDING_ABORT_START_TIMEOUT_MS,
|
OPENCODE_PENDING_ABORT_START_TIMEOUT_MS,
|
||||||
"OpenCode pending session.abort",
|
"OpenCode previous turn to stop",
|
||||||
).catch((error) => {
|
);
|
||||||
this.logger.warn(
|
}
|
||||||
{ err: error, sessionId: this.sessionId },
|
|
||||||
"OpenCode session.abort was still pending before starting the next turn",
|
private async observeProviderStopBoundary(
|
||||||
);
|
stopping: Extract<OpenCodeTurnState, { status: "stopping" }>,
|
||||||
});
|
): Promise<void> {
|
||||||
|
while (this.turnState === stopping) {
|
||||||
|
void this.abortSession(null, "turn_start_boundary");
|
||||||
|
const eventStreamTask = this.eventStreamTask;
|
||||||
|
const boundary = await (eventStreamTask
|
||||||
|
? Promise.race([
|
||||||
|
stopping.idle.promise.then(() => "idle" as const),
|
||||||
|
eventStreamTask.then(
|
||||||
|
() => "stream_ended" as const,
|
||||||
|
() => "stream_ended" as const,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
: Promise.resolve("stream_ended" as const));
|
||||||
|
if (boundary === "idle") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureEventStreamReady();
|
||||||
|
const response = await this.client.session.status({ directory: this.config.cwd });
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to confirm OpenCode session status: ${toDiagnosticErrorMessage(response.error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const statuses = readOpenCodeRecord(response.data);
|
||||||
|
if (!statuses) {
|
||||||
|
throw new Error("OpenCode returned an invalid session status response");
|
||||||
|
}
|
||||||
|
const status = readOpenCodeRecord(statuses[this.sessionId]);
|
||||||
|
const statusType = readNonEmptyString(status?.type);
|
||||||
|
if (!status || statusType === "idle") {
|
||||||
|
this.finishStoppingTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statusType !== "busy" && statusType !== "retry") {
|
||||||
|
throw new Error(`OpenCode returned an unknown session status '${statusType ?? "missing"}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async startTurn(
|
async startTurn(
|
||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
options?: AgentRunOptions,
|
options?: AgentRunOptions,
|
||||||
): Promise<{ turnId: string }> {
|
): Promise<{ turnId: string }> {
|
||||||
if (this.activeForegroundTurnId) {
|
if (this.turnState.status === "running") {
|
||||||
if (this.activeForegroundTurnSource === "external") {
|
throw new Error("A foreground turn is already active");
|
||||||
// A direct Paseo prompt owns the foreground; close the adopted child run first.
|
}
|
||||||
this.finishForegroundTurn(
|
await this.waitUntilProviderIdle();
|
||||||
{ type: "turn_completed", provider: "opencode", usage: undefined },
|
if (this.turnState.status !== "idle") {
|
||||||
this.activeForegroundTurnId,
|
throw new Error("OpenCode is still stopping the previous turn");
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error("A foreground turn is already active");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await this.awaitPendingAbortBeforeStartingTurn();
|
|
||||||
|
|
||||||
this.runningToolCalls.clear();
|
this.runningToolCalls.clear();
|
||||||
this.subAgentsByCallId.clear();
|
this.subAgentsByCallId.clear();
|
||||||
@@ -3127,8 +3148,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const turnId = this.createTurnId();
|
const turnId = this.createTurnId();
|
||||||
this.activeForegroundTurnId = turnId;
|
this.turnState = { status: "running", turnId };
|
||||||
this.activeForegroundTurnSource = "paseo";
|
|
||||||
this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId);
|
this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId);
|
||||||
|
|
||||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||||
@@ -3555,6 +3575,20 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
if (!event) {
|
if (!event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.turnState.status === "stopping" &&
|
||||||
|
getOpenCodeEventSessionId(event) === this.sessionId
|
||||||
|
) {
|
||||||
|
if (isOpenCodeTerminalEvent(event, this.sessionId)) {
|
||||||
|
this.finishStoppingTurn();
|
||||||
|
}
|
||||||
|
this.traceOpenCode("provider.opencode.event.skip", {
|
||||||
|
n: eventCount,
|
||||||
|
reason: "turn_stopping",
|
||||||
|
type: event.type,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const translated = await this.translateEvent(event);
|
const translated = await this.translateEvent(event);
|
||||||
const foregroundEvents: AgentStreamEvent[] = [];
|
const foregroundEvents: AgentStreamEvent[] = [];
|
||||||
for (const translatedEvent of translated) {
|
for (const translatedEvent of translated) {
|
||||||
@@ -3564,8 +3598,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
foregroundEvents.push(translatedEvent);
|
foregroundEvents.push(translatedEvent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!turnId && this.shouldStartExternalDrivenTurn(event, foregroundEvents)) {
|
if (!turnId && this.shouldStartAutonomousTurn(event, foregroundEvents)) {
|
||||||
turnId = this.startExternalDrivenTurn();
|
turnId = this.startAutonomousTurn();
|
||||||
}
|
}
|
||||||
if (!turnId) {
|
if (!turnId) {
|
||||||
this.emitBackgroundPermissionRequests(foregroundEvents);
|
this.emitBackgroundPermissionRequests(foregroundEvents);
|
||||||
@@ -3576,18 +3610,6 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.suppressTerminalUntilNextUserMessage) {
|
|
||||||
if (isOpenCodeUserMessageEvent(event, this.sessionId)) {
|
|
||||||
this.suppressTerminalUntilNextUserMessage = false;
|
|
||||||
} else if (isOpenCodeTerminalEvent(event, this.sessionId)) {
|
|
||||||
this.traceOpenCode("provider.opencode.event.skip", {
|
|
||||||
n: eventCount,
|
|
||||||
reason: "stale_interrupt_terminal",
|
|
||||||
type: event.type,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.traceOpenCode("provider.opencode.parsed_event", {
|
this.traceOpenCode("provider.opencode.parsed_event", {
|
||||||
turnId,
|
turnId,
|
||||||
n: eventCount,
|
n: eventCount,
|
||||||
@@ -3625,30 +3647,39 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldStartExternalDrivenTurn(
|
private shouldStartAutonomousTurn(
|
||||||
event: OpenCodeEvent,
|
event: OpenCodeEvent,
|
||||||
foregroundEvents: readonly AgentStreamEvent[],
|
foregroundEvents: readonly AgentStreamEvent[],
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (this.turnState.status !== "idle") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (getOpenCodeEventSessionId(event) !== this.sessionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// OpenCode publishes the persisted user message before it marks the runner
|
||||||
|
// busy. That message is the earliest unambiguous boundary for a plugin-
|
||||||
|
// initiated parent turn; session metadata and assistant echoes are not.
|
||||||
|
if (event.type === "message.updated" && event.properties.info.role === "user") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (!this.externallyDriven) {
|
if (!this.externallyDriven) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.activeForegroundTurnId) {
|
if (
|
||||||
return false;
|
foregroundEvents.some(
|
||||||
}
|
(foregroundEvent) =>
|
||||||
if (foregroundEvents.some((foregroundEvent) => !toTerminalTurnEvent(foregroundEvent))) {
|
foregroundEvent.type !== "thread_started" && !toTerminalTurnEvent(foregroundEvent),
|
||||||
|
)
|
||||||
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return event.type === "session.status" && event.properties.status.type === "busy";
|
||||||
event.type === "session.status" &&
|
|
||||||
event.properties.sessionID === this.sessionId &&
|
|
||||||
event.properties.status.type === "busy"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private startExternalDrivenTurn(): string {
|
private startAutonomousTurn(): string {
|
||||||
const turnId = this.createTurnId();
|
const turnId = this.createTurnId();
|
||||||
this.activeForegroundTurnId = turnId;
|
this.turnState = { status: "running", turnId };
|
||||||
this.activeForegroundTurnSource = "external";
|
|
||||||
this.runningToolCalls.clear();
|
this.runningToolCalls.clear();
|
||||||
this.subAgentsByCallId.clear();
|
this.subAgentsByCallId.clear();
|
||||||
this.subAgentCallIdByChildSessionId.clear();
|
this.subAgentCallIdByChildSessionId.clear();
|
||||||
@@ -3680,12 +3711,36 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.pendingUserMessageText = null;
|
this.pendingUserMessageText = null;
|
||||||
this.pendingClientMessageId = null;
|
this.pendingClientMessageId = null;
|
||||||
this.activeForegroundTurnId = null;
|
this.turnState = { status: "idle" };
|
||||||
this.activeForegroundTurnSource = null;
|
|
||||||
this.abortController = null;
|
this.abortController = null;
|
||||||
this.notifySubscribers(event, turnId);
|
this.notifySubscribers(event, turnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private beginStoppingTurn(turnId: string): void {
|
||||||
|
if (this.turnState.status !== "running" || this.turnState.turnId !== turnId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.synthesizeInterruptedToolCalls(turnId);
|
||||||
|
this.pendingUserMessageText = null;
|
||||||
|
this.pendingClientMessageId = null;
|
||||||
|
this.abortController = null;
|
||||||
|
this.turnState = { status: "stopping", idle: createDeferred<void>() };
|
||||||
|
this.notifySubscribers(
|
||||||
|
{ type: "turn_canceled", provider: "opencode", reason: "interrupted" },
|
||||||
|
turnId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishStoppingTurn(): void {
|
||||||
|
if (this.turnState.status !== "stopping") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stopping = this.turnState;
|
||||||
|
resetOpenCodeTurnTrackingState(this.createTranslationState());
|
||||||
|
this.turnState = { status: "idle" };
|
||||||
|
stopping.idle.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
private trackToolCall(item: ToolCallTimelineItem): void {
|
private trackToolCall(item: ToolCallTimelineItem): void {
|
||||||
if (item.status === "running") {
|
if (item.status === "running") {
|
||||||
this.runningToolCalls.set(item.callId, item);
|
this.runningToolCalls.set(item.callId, item);
|
||||||
@@ -3932,7 +3987,10 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
});
|
});
|
||||||
await this.deleteProviderSessionIfEphemeral();
|
await this.deleteProviderSessionIfEphemeral();
|
||||||
this.activeForegroundTurnId = null;
|
if (this.turnState.status === "stopping") {
|
||||||
|
this.turnState.idle.resolve();
|
||||||
|
}
|
||||||
|
this.turnState = { status: "idle" };
|
||||||
} finally {
|
} finally {
|
||||||
await this.releaseServer?.();
|
await this.releaseServer?.();
|
||||||
this.releaseServer = null;
|
this.releaseServer = null;
|
||||||
|
|||||||
+16
-1
@@ -89,6 +89,7 @@ export class TestOpenCodeClient {
|
|||||||
sessionGet: [] as unknown[],
|
sessionGet: [] as unknown[],
|
||||||
sessionMessages: [] as unknown[],
|
sessionMessages: [] as unknown[],
|
||||||
sessionPromptAsync: [] as unknown[],
|
sessionPromptAsync: [] as unknown[],
|
||||||
|
sessionStatus: [] as unknown[],
|
||||||
sessionSummarize: [] as unknown[],
|
sessionSummarize: [] as unknown[],
|
||||||
sessionUpdate: [] as unknown[],
|
sessionUpdate: [] as unknown[],
|
||||||
};
|
};
|
||||||
@@ -102,9 +103,13 @@ export class TestOpenCodeClient {
|
|||||||
permissionReplyResponse: OpenCodeResponse = {};
|
permissionReplyResponse: OpenCodeResponse = {};
|
||||||
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
|
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
|
||||||
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
|
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
|
||||||
|
globalEventImplementation:
|
||||||
|
| ((options: unknown) => Promise<{ stream: AsyncIterable<unknown> }>)
|
||||||
|
| null = null;
|
||||||
questionRejectResponse: OpenCodeResponse = {};
|
questionRejectResponse: OpenCodeResponse = {};
|
||||||
questionReplyResponse: OpenCodeResponse = {};
|
questionReplyResponse: OpenCodeResponse = {};
|
||||||
sessionAbortResponse: OpenCodeResponse = {};
|
sessionAbortResponse: OpenCodeResponse = {};
|
||||||
|
sessionAbortImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
|
||||||
sessionCommandError: unknown = null;
|
sessionCommandError: unknown = null;
|
||||||
sessionCommandEvents: unknown[] = [idleEvent()];
|
sessionCommandEvents: unknown[] = [idleEvent()];
|
||||||
sessionCommandResponse: OpenCodeResponse = {};
|
sessionCommandResponse: OpenCodeResponse = {};
|
||||||
@@ -118,6 +123,7 @@ export class TestOpenCodeClient {
|
|||||||
sessionMessagesResponse: OpenCodeResponse = { data: [] };
|
sessionMessagesResponse: OpenCodeResponse = { data: [] };
|
||||||
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
||||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||||
|
sessionStatusResponse: OpenCodeResponse = { data: {} };
|
||||||
sessionSummarizeEvents: unknown[] = [idleEvent()];
|
sessionSummarizeEvents: unknown[] = [idleEvent()];
|
||||||
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
|
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
|
||||||
sessionUpdateResponse: OpenCodeResponse = {};
|
sessionUpdateResponse: OpenCodeResponse = {};
|
||||||
@@ -162,6 +168,9 @@ export class TestOpenCodeClient {
|
|||||||
global: {
|
global: {
|
||||||
event: async (options: unknown) => {
|
event: async (options: unknown) => {
|
||||||
this.calls.globalEvent.push(options);
|
this.calls.globalEvent.push(options);
|
||||||
|
if (this.globalEventImplementation) {
|
||||||
|
return await this.globalEventImplementation(options);
|
||||||
|
}
|
||||||
const signal = (options as { signal?: AbortSignal }).signal;
|
const signal = (options as { signal?: AbortSignal }).signal;
|
||||||
return {
|
return {
|
||||||
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
|
stream: signal ? stopEventStreamOnAbort(this.eventStream, signal) : this.eventStream,
|
||||||
@@ -205,7 +214,9 @@ export class TestOpenCodeClient {
|
|||||||
session: {
|
session: {
|
||||||
abort: async (parameters: unknown) => {
|
abort: async (parameters: unknown) => {
|
||||||
this.calls.sessionAbort.push(parameters);
|
this.calls.sessionAbort.push(parameters);
|
||||||
return this.sessionAbortResponse;
|
return this.sessionAbortImplementation
|
||||||
|
? await this.sessionAbortImplementation(parameters)
|
||||||
|
: this.sessionAbortResponse;
|
||||||
},
|
},
|
||||||
command: async (parameters: unknown) => {
|
command: async (parameters: unknown) => {
|
||||||
this.calls.sessionCommand.push(parameters);
|
this.calls.sessionCommand.push(parameters);
|
||||||
@@ -247,6 +258,10 @@ export class TestOpenCodeClient {
|
|||||||
}
|
}
|
||||||
return this.sessionPromptAsyncResponse;
|
return this.sessionPromptAsyncResponse;
|
||||||
},
|
},
|
||||||
|
status: async (parameters: unknown) => {
|
||||||
|
this.calls.sessionStatus.push(parameters);
|
||||||
|
return this.sessionStatusResponse;
|
||||||
|
},
|
||||||
summarize: async (parameters: unknown) => {
|
summarize: async (parameters: unknown) => {
|
||||||
this.calls.sessionSummarize.push(parameters);
|
this.calls.sessionSummarize.push(parameters);
|
||||||
for (const event of this.sessionSummarizeEvents) {
|
for (const event of this.sessionSummarizeEvents) {
|
||||||
|
|||||||
Reference in New Issue
Block a user