mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(opencode): agent goes idle after a v2 question is dismissed (#6177)
* fix(opencode): end the v2 turn when the user dismisses a question OpenCode ends the execution when a question is dismissed but reports the interruption with the reason "shutdown", which the turn tracker keeps open waiting for a resume that never comes. The agent stayed running, and Stop, reload and send were refused. A question dismissed from Paseo now lets that interruption end the turn as canceled. * test(opencode): cover the next message after a dismissed v2 question --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { ModelInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import type { FormInfo, ModelInfo, SessionMessageInfo } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
||||
@@ -210,6 +210,62 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("ends the turn when the user dismisses a question", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
let form: FormInfo | null = null;
|
||||
harness.api.session.form.list = async () => (form ? [form] : []);
|
||||
harness.api.session.form.cancel = async () => {
|
||||
form = null;
|
||||
harness.active = false;
|
||||
// OpenCode 2.0.20 reports a dismissed question as an interruption without a user reason.
|
||||
harness.push({
|
||||
id: "dismissed",
|
||||
created: 3,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: "session", seq: 3, version: 1 },
|
||||
data: { sessionID: "session", reason: "shutdown" },
|
||||
});
|
||||
};
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.startTurn("ask me something");
|
||||
await expect.poll(() => harness.prompts).toHaveLength(1);
|
||||
form = {
|
||||
id: "question",
|
||||
sessionID: "session",
|
||||
title: "Color",
|
||||
fields: [{ key: "color", type: "string", options: [{ label: "Red", value: "red" }] }],
|
||||
};
|
||||
harness.push({
|
||||
id: "form",
|
||||
created: 2,
|
||||
type: "form.created",
|
||||
data: { form },
|
||||
});
|
||||
await expect.poll(() => session.getPendingPermissions()).toHaveLength(1);
|
||||
|
||||
await session.respondToPermission("question", { behavior: "deny" });
|
||||
|
||||
await expect.poll(() => terminalEvents(events)).toHaveLength(1);
|
||||
expect(terminalEvents(events)[0]).toMatchObject({ type: "turn_canceled" });
|
||||
|
||||
await session.startTurn("next message");
|
||||
await expect.poll(() => harness.prompts).toHaveLength(2);
|
||||
harness.finishExecution();
|
||||
await expect.poll(() => terminalEvents(events)).toHaveLength(2);
|
||||
expect(terminalEvents(events)[1]).toMatchObject({ type: "turn_completed" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("recovers the latest execution from the durable log when shutdown and resume events were missed", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.autoComplete = false;
|
||||
|
||||
@@ -33,6 +33,9 @@ export class SessionPermissions {
|
||||
list() {
|
||||
return [...this.pending.values()];
|
||||
}
|
||||
isQuestion(requestId: string, sessionID: string) {
|
||||
return this.forms.get(requestId)?.sessionID === sessionID;
|
||||
}
|
||||
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
|
||||
const form = this.forms.get(requestId);
|
||||
if (form) {
|
||||
|
||||
@@ -405,7 +405,10 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
}
|
||||
async respondToPermission(requestId: string, response: AgentPermissionResponse) {
|
||||
await this.reconnectIfExited();
|
||||
const dismissesQuestion =
|
||||
response.behavior === "deny" && this.permissions.isQuestion(requestId, this.id);
|
||||
await this.permissions.respondToPermission(requestId, response);
|
||||
if (dismissesQuestion) this.turns.questionDismissed();
|
||||
}
|
||||
private async reconcileSnapshot() {
|
||||
const [info, history] = await Promise.all([
|
||||
|
||||
@@ -60,6 +60,7 @@ interface Turn {
|
||||
completion: Promise<void>;
|
||||
settle(): void;
|
||||
accepted: boolean;
|
||||
questionDismissed: boolean;
|
||||
}
|
||||
|
||||
export class SessionTurns {
|
||||
@@ -112,6 +113,7 @@ export class SessionTurns {
|
||||
settle,
|
||||
accepted,
|
||||
output,
|
||||
questionDismissed: false,
|
||||
};
|
||||
this.turn = turn;
|
||||
this.execution = null;
|
||||
@@ -162,6 +164,13 @@ export class SessionTurns {
|
||||
this.sequence = event.durable.seq;
|
||||
this.requestReconciliation();
|
||||
}
|
||||
// OpenCode ends the execution when the user dismisses its question, but reports the
|
||||
// interruption as "shutdown", the same reason as an execution it will resume.
|
||||
questionDismissed() {
|
||||
if (!this.turn) return;
|
||||
this.turn.questionDismissed = true;
|
||||
this.requestReconciliation();
|
||||
}
|
||||
private requestReconciliation() {
|
||||
void this.reconcile().catch((error: unknown) => this.options.reportReconciliationError(error));
|
||||
}
|
||||
@@ -216,7 +225,9 @@ export class SessionTurns {
|
||||
this.sequence = execution.durable.seq;
|
||||
if (
|
||||
execution.type === "session.execution.started" ||
|
||||
(execution.type === "session.execution.interrupted" && execution.data.reason === "shutdown")
|
||||
(execution.type === "session.execution.interrupted" &&
|
||||
execution.data.reason === "shutdown" &&
|
||||
!turn.questionDismissed)
|
||||
)
|
||||
return;
|
||||
// Read the final history only after observing idle. Never turn an observation transport error into an execution failure.
|
||||
|
||||
Reference in New Issue
Block a user