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:
Matt Cowger
2026-08-21 10:38:36 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 32d6b18dbf
commit bc02cfd8cd
4 changed files with 287 additions and 3 deletions
@@ -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,
@@ -6,6 +6,7 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "../se
interface OpenCodeResponse {
data?: unknown;
error?: unknown;
response?: { status: number };
}
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {