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
@@ -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