feat(opencode): steer active turns without interrupting (#3580)
* feat(opencode): add active-turn steering via native V2 prompt Add OpenCode support for active-turn steering (PR #3394) using the native V2 session prompt with delivery: steer. Implements steerActiveTurn, pending steer echo correlation for multiple steers, prompted event handling, and definitive vs ambiguous error handling. Updates docs and SDK to 1.18.18 for typed V2 access. * fix(opencode): use V1 prompt for steer so model sees it V2 prompt was admitted to a separate inbox not visible to the V1 processor, so "How old is bigbird?" was shown inline but never in the LLM context. Switch steer to session/prompt_async (V1) which queues while busy and is included in the next LLM call's history. Update tests to expect V1 and keep the timeline grouping. * fix(opencode): honor steering permission contract Admit the steer before denying permissions that block its delivery, matching the provider steering contract. Remove the abandoned V2 path and SDK upgrade because active-turn delivery uses the existing prompt endpoint. * chore(opencode): preserve SDK patch metadata --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
32d6b18dbf
commit
bc02cfd8cd
+1
-1
@@ -81,7 +81,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. The released OpenCode v1 adapter does not implement this operation.
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
||||
|
||||
|
||||
@@ -2028,6 +2028,167 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("uses OpenCode native steer admission and reconciles the user echo", async () => {
|
||||
const { parent: session, openCode } = await createParentSession("ses_native_steer");
|
||||
openCode.sessionPromptAsyncEvents = [];
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const { turnId } = await session.startTurn("first");
|
||||
const result = await session.steerActiveTurn?.("steer this turn", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "accepted" });
|
||||
expect(openCode.calls.sessionPromptAsync).toHaveLength(2);
|
||||
const request = openCode.calls.sessionPromptAsync[1] as {
|
||||
sessionID: string;
|
||||
messageID: string;
|
||||
parts: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
expect(request).toMatchObject({
|
||||
sessionID: "ses_native_steer",
|
||||
});
|
||||
expect(request.messageID).toMatch(/^msg_/);
|
||||
expect(request.parts).toEqual([{ type: "text", text: "steer this turn" }]);
|
||||
|
||||
openCode.emitEvent({
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: request.messageID, sessionID: "ses_native_steer", role: "user" },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
let found;
|
||||
for (const e of events) {
|
||||
if (
|
||||
e.type === "timeline" &&
|
||||
e.item.type === "user_message" &&
|
||||
e.item.text === "steer this turn" &&
|
||||
e.item.messageId === request.messageID &&
|
||||
e.item.clientMessageId === "client-steer-1"
|
||||
) {
|
||||
found = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(found).toBeDefined();
|
||||
});
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("clears permissions blocking an accepted human steer", async () => {
|
||||
const { parent: session, openCode } = await createParentSession("ses_steer_permission");
|
||||
openCode.sessionPromptAsyncEvents = [];
|
||||
const { turnId } = await session.startTurn("first");
|
||||
openCode.emitEvent({
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-steer",
|
||||
sessionID: "ses_steer_permission",
|
||||
permission: "bash",
|
||||
patterns: ["echo blocked"],
|
||||
metadata: { command: "echo blocked", cwd: "/workspace/repo" },
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn?.("change course", {
|
||||
expectedTurnId: turnId,
|
||||
clearPendingPermissions: true,
|
||||
}),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
|
||||
expect(openCode.calls.permissionReply).toContainEqual(
|
||||
expect.objectContaining({ requestID: "permission-steer", reply: "reject" }),
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(0);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("keeps multiple native steers correlated in admission order", async () => {
|
||||
const { parent: session, openCode } = await createParentSession("ses_native_steer_fifo");
|
||||
openCode.sessionPromptAsyncEvents = [];
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const { turnId } = await session.startTurn("first");
|
||||
await session.steerActiveTurn?.("steer one", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer-1",
|
||||
});
|
||||
await session.steerActiveTurn?.("steer two", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer-2",
|
||||
});
|
||||
|
||||
const requests = openCode.calls.sessionPromptAsync.slice(1) as Array<{ messageID: string }>;
|
||||
expect(requests).toHaveLength(2);
|
||||
for (const request of requests) {
|
||||
openCode.emitEvent({
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: request.messageID, sessionID: "ses_native_steer_fifo", role: "user" },
|
||||
},
|
||||
});
|
||||
}
|
||||
const userMessageTexts = () => {
|
||||
const texts: string[] = [];
|
||||
for (const e of events) {
|
||||
if (e.type === "timeline" && e.item.type === "user_message") texts.push(e.item.text);
|
||||
}
|
||||
return texts;
|
||||
};
|
||||
await vi.waitFor(() => expect(userMessageTexts()).toEqual(["steer one", "steer two"]));
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("falls back only for a definitive native steer rejection", async () => {
|
||||
const { parent: session, openCode } = await createParentSession("ses_native_steer_404");
|
||||
openCode.sessionPromptAsyncEvents = [];
|
||||
let promptCount = 0;
|
||||
openCode.sessionPromptAsyncImplementation = async () => {
|
||||
promptCount += 1;
|
||||
if (promptCount === 1) return { data: {}, error: undefined };
|
||||
return { error: new Error("missing"), response: { status: 404 } };
|
||||
};
|
||||
const { turnId } = await session.startTurn("first");
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn?.("steer", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer",
|
||||
}),
|
||||
).resolves.toEqual({ status: "unavailable" });
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("surfaces an ambiguous native steer failure", async () => {
|
||||
const { parent: session, openCode } = await createParentSession("ses_native_steer_error");
|
||||
openCode.sessionPromptAsyncEvents = [];
|
||||
let promptCount = 0;
|
||||
openCode.sessionPromptAsyncImplementation = async () => {
|
||||
promptCount += 1;
|
||||
if (promptCount === 1) return { data: {}, error: undefined };
|
||||
throw new Error("connection lost");
|
||||
};
|
||||
const { turnId } = await session.startTurn("first");
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn?.("steer", {
|
||||
expectedTurnId: turnId,
|
||||
clientMessageId: "client-steer",
|
||||
}),
|
||||
).rejects.toThrow("connection lost");
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("sends exact Hub MCP permission grants without approving unrelated tools", async () => {
|
||||
const promptAsync = vi.fn(async () => ({ data: {}, error: undefined }));
|
||||
const fakeClient = {
|
||||
|
||||
@@ -52,6 +52,8 @@ import {
|
||||
type ResolveAgentCreateConfigResult,
|
||||
type McpServerConfig,
|
||||
type ProviderCatalog,
|
||||
type SteerActiveTurnOptions,
|
||||
type SteerResult,
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -1016,6 +1018,12 @@ function buildOpenCodeUserTimelineText(prompt: AgentPromptInput): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isOpenCodeDefinitiveSteerRejection(error: unknown, status?: number): boolean {
|
||||
if (status === 404) return true;
|
||||
const message = toDiagnosticErrorMessage(error).toLowerCase();
|
||||
return /session\s+(?:is\s+)?(?:not found|inactive|not active|not running)/.test(message);
|
||||
}
|
||||
|
||||
async function collectOpenCodeImportableSessionsFromSdk(
|
||||
client: Pick<OpencodeClient, "experimental">,
|
||||
options?: ListImportableSessionsOptions,
|
||||
@@ -1822,6 +1830,7 @@ export interface OpenCodeEventTranslationState {
|
||||
messageRoles: Map<string, OpenCodeMessageRole>;
|
||||
pendingUserMessageText?: string | null;
|
||||
pendingClientMessageId?: string | null;
|
||||
pendingSteerSubmissions?: OpenCodePendingSteerSubmission[];
|
||||
emittedUserMessageIds?: Set<string>;
|
||||
accumulatedUsage: AgentUsage;
|
||||
sessionTotalCostUsd?: number;
|
||||
@@ -1846,6 +1855,12 @@ export interface OpenCodeEventTranslationState {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
interface OpenCodePendingSteerSubmission {
|
||||
providerMessageId: string;
|
||||
text: string;
|
||||
clientMessageId: string | null;
|
||||
}
|
||||
|
||||
interface OpenCodeTraceData {
|
||||
turnId?: string;
|
||||
[key: string]: unknown;
|
||||
@@ -2605,11 +2620,19 @@ function appendOpenCodeUserMessageUpdated(
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const text = state.pendingUserMessageText;
|
||||
const pendingSteerIndex = state.pendingSteerSubmissions?.findIndex(
|
||||
(submission) => submission.providerMessageId === info.id,
|
||||
);
|
||||
const pendingSteer =
|
||||
pendingSteerIndex !== undefined && pendingSteerIndex >= 0
|
||||
? state.pendingSteerSubmissions?.splice(pendingSteerIndex, 1)[0]
|
||||
: undefined;
|
||||
const text = pendingSteer?.text ?? state.pendingUserMessageText;
|
||||
if (!text || text.trim().length === 0 || state.emittedUserMessageIds?.has(info.id)) {
|
||||
return;
|
||||
}
|
||||
state.emittedUserMessageIds?.add(info.id);
|
||||
const clientMessageId = pendingSteer?.clientMessageId ?? state.pendingClientMessageId;
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
@@ -2617,7 +2640,7 @@ function appendOpenCodeUserMessageUpdated(
|
||||
type: "user_message",
|
||||
text,
|
||||
messageId: info.id,
|
||||
...(state.pendingClientMessageId ? { clientMessageId: state.pendingClientMessageId } : {}),
|
||||
...(clientMessageId ? { clientMessageId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3212,6 +3235,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
||||
private pendingUserMessageText: string | null = null;
|
||||
private pendingClientMessageId: string | null = null;
|
||||
private pendingSteerSubmissions: OpenCodePendingSteerSubmission[] = [];
|
||||
private emittedUserMessageIds = new Set<string>();
|
||||
private materializedParts = new Map<
|
||||
string,
|
||||
@@ -3376,6 +3400,100 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async steerActiveTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
if (this.closed || this.activeForegroundTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (await this.resolveSlashCommandInvocation(prompt)) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (this.activeForegroundTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
const promptId = createOpenCodeMessageId();
|
||||
const pending: OpenCodePendingSteerSubmission = {
|
||||
providerMessageId: promptId,
|
||||
text: buildOpenCodeUserTimelineText(prompt),
|
||||
clientMessageId: options.clientMessageId ?? null,
|
||||
};
|
||||
this.pendingSteerSubmissions.push(pending);
|
||||
|
||||
const parts = buildOpenCodePromptParts(prompt);
|
||||
const systemPrompt = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
|
||||
const effectiveVariant = this.config.thinkingOptionId ?? undefined;
|
||||
|
||||
try {
|
||||
const response = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
messageID: promptId,
|
||||
parts,
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
});
|
||||
if (response.error) {
|
||||
if (
|
||||
isOpenCodeDefinitiveSteerRejection(
|
||||
response.error,
|
||||
(response as unknown as { response?: { status?: number } }).response?.status,
|
||||
)
|
||||
) {
|
||||
this.removePendingSteerSubmission(promptId);
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
throw new Error(
|
||||
`OpenCode steer request failed: ${toDiagnosticErrorMessage(response.error)}`,
|
||||
);
|
||||
}
|
||||
if (options.clearPendingPermissions) {
|
||||
await this.clearPendingPermissionsForSteer();
|
||||
}
|
||||
return { status: "accepted" };
|
||||
} catch (error) {
|
||||
if (isOpenCodeDefinitiveSteerRejection(error)) {
|
||||
this.removePendingSteerSubmission(promptId);
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private removePendingSteerSubmission(providerMessageId: string): void {
|
||||
const index = this.pendingSteerSubmissions.findIndex(
|
||||
(submission) => submission.providerMessageId === providerMessageId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.pendingSteerSubmissions.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async clearPendingPermissionsForSteer(): Promise<void> {
|
||||
const requestIds = Array.from(this.pendingPermissions.keys());
|
||||
for (const requestId of requestIds) {
|
||||
if (!this.pendingPermissions.has(requestId)) continue;
|
||||
await this.respondToPermission(requestId, {
|
||||
behavior: "deny",
|
||||
message: "The user answered with a message instead of approving. Their message follows.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
await revertOpenCodeConversationAndFiles({
|
||||
client: this.client,
|
||||
@@ -4330,6 +4448,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.runningToolCalls.clear();
|
||||
this.subAgentsByCallId.clear();
|
||||
this.subAgentCallIdByChildSessionId.clear();
|
||||
this.pendingSteerSubmissions = [];
|
||||
this.pendingUserMessageText = null;
|
||||
this.pendingClientMessageId = null;
|
||||
this.activeDispatchMessageId = null;
|
||||
@@ -4359,6 +4478,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
this.pendingUserMessageText = null;
|
||||
this.pendingClientMessageId = null;
|
||||
this.pendingSteerSubmissions = [];
|
||||
this.turnState = { status: "idle" };
|
||||
this.abortController = null;
|
||||
this.notifySubscribers(event, turnId);
|
||||
@@ -4389,6 +4509,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
this.pendingUserMessageText = null;
|
||||
this.pendingClientMessageId = null;
|
||||
this.pendingSteerSubmissions = [];
|
||||
this.abortController = null;
|
||||
return abort;
|
||||
}
|
||||
@@ -4837,6 +4958,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
messageRoles: this.messageRoles,
|
||||
pendingUserMessageText: this.pendingUserMessageText,
|
||||
pendingClientMessageId: this.pendingClientMessageId,
|
||||
pendingSteerSubmissions: this.pendingSteerSubmissions,
|
||||
emittedUserMessageIds: this.emittedUserMessageIds,
|
||||
accumulatedUsage: this.accumulatedUsage,
|
||||
sessionTotalCostUsd: this.sessionTotalCostUsd,
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "../se
|
||||
interface OpenCodeResponse {
|
||||
data?: unknown;
|
||||
error?: unknown;
|
||||
response?: { status: number };
|
||||
}
|
||||
|
||||
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
||||
|
||||
Reference in New Issue
Block a user