feat(opencode): support mid-turn steering (#1981)

* feat(opencode): support mid-turn steering

Register the per-message Queue/Steer RPC on the OpenCode remote launcher so
a waiting-queue row can be delivered into the active turn as a concurrent
ACP session/prompt (soft inject, no cancel, no mode/model/effort mutation),
reusing the existing reservation/delivery-state protocol shared with Codex
and Cursor ACP.

The steer gate is closed synchronously by every cancelling or terminal path
(normal-prompt finally, abort, switch/exit, teardown, stderr stall cancel),
each steer operation is tracked from the moment its reservation is taken
until completion plus all restore/consumed bookkeeping settles, and the
foreground prompt drains those operations — abort-aware through the original
turn signal — before ready, thinking=false or the next batch. steerability is
published through the existing agentState.steeringActive flag so the web's
queued Steer button is reachable.

Gate the feature on the shared steering flavors (now including opencode) and
update the hub's supported-flavor wording.

* fix(opencode): acknowledge native steering acceptance

* fix(opencode): preserve steering delivery outcomes

* docs(opencode): remove obsolete abort-race comment

* fix(opencode): preserve steering resolution ownership

* fix(opencode): isolate ambiguous steering attempts

* fix(opencode): compare resolved steering settings

* fix(opencode): reconcile steering receipts after dispatch

* fix(opencode): distinguish pending steering delivery outcomes

* fix(opencode): restore receipts after successful native responses

Track native input requests separately from detached steer operations. Resume receipt correlation after inputs quarantined by cancellation return their own successful responses, while keeping uncertain writes on completion-based confirmation.

* fix(opencode): bound receipt proof to unambiguous native inputs

* feat(opencode): steer v2 inputs through native inbox

* fix(opencode): preserve v2 restoration and cancellation ownership

* docs(opencode): describe v2 inbox steering confirmation

* fix(steer): expire Claude confirmation with its owning worker
This commit is contained in:
Junmo Kim
2026-11-03 18:37:53 +08:00
committed by GitHub
parent fa02830a60
commit c4847b241e
37 changed files with 4101 additions and 144 deletions
+3
View File
@@ -1,3 +1,4 @@
import type { OpencodeSteerCancelHandler } from './utils/opencodeV2Steer';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import { logger } from '@/ui/logger';
import { runLocalRemoteSession } from '@/agent/loopBase';
@@ -25,6 +26,7 @@ interface OpencodeLoopOptions {
onSessionReady?: (session: OpencodeSession) => void;
onModelRollback?: (model: string | null) => void;
onReasoningEffortRollback?: (effort: string | null) => void;
onSteerCancelHandlerChange?: (handler: OpencodeSteerCancelHandler | null) => void;
onCompactAvailabilityChange?: (available: boolean) => void;
onClearRequested?: () => Promise<void>;
onClearCleanupComplete?: () => Promise<void>;
@@ -85,6 +87,7 @@ export async function opencodeLoop(opts: OpencodeLoopOptions): Promise<void> {
onModelRollback: opts.onModelRollback,
onReasoningEffortRollback: opts.onReasoningEffortRollback,
onCompactAvailabilityChange: opts.onCompactAvailabilityChange,
onSteerCancelHandlerChange: opts.onSteerCancelHandlerChange,
isLocalIdCancelled: opts.isLocalIdCancelled,
onClearRequested: opts.onClearRequested,
onClearCleanupComplete: opts.onClearCleanupComplete,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37
View File
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildCliArgs } from '@/runner/run';
import { parseRemoteAgentCommandOptions } from '@/commands/agentCommandOptions';
import { OPENCODE_PERMISSION_MODES } from '@hapi/protocol/modes';
import type { MessageQueue2 } from '@/utils/MessageQueue2';
import type { OpencodeMode } from './types';
const mockOpencodeSession = vi.hoisted(() => ({
setModel: vi.fn(),
@@ -471,6 +473,20 @@ describe('runOpencode set-session-config handler', () => {
expect(harness.session.sendAgentMessage).not.toHaveBeenCalled();
});
it('delegates native v2 cancellation before generic queue removal and clears the delegate on teardown', async () => {
await runOpencode({ startedBy: 'runner' });
const bind = harness.opencodeLoopArgs[0].onSteerCancelHandlerChange as
(handler: import('./utils/opencodeV2Steer').OpencodeSteerCancelHandler | null) => void;
const cancel = harness.session.onCancelQueuedMessage.mock.calls[0][0] as
(id: string) => boolean | Promise<unknown>;
const native = vi.fn((id: string) => id === 'native' ? Promise.resolve('in-flight' as const) : undefined);
bind(native);
await expect(cancel('native')).resolves.toBe('in-flight');
expect(native).toHaveBeenCalledWith('native');
bind(null);
expect(cancel('missing')).toBe(false);
});
it('releases the clear transition latch when the queued /clear is cancelled', async () => {
await runOpencode({ startedBy: 'runner' });
@@ -894,4 +910,25 @@ describe('runOpencode set-session-config handler', () => {
expect(isLocalIdCancelled!(lastLocalId)).toBe(true);
expect(isLocalIdCancelled!(lastLocalId)).toBe(false);
});
it('preserves dispatch and consumption outcomes through the registered cancel callback', async () => {
await runOpencode({});
const queue = harness.opencodeLoopArgs[0]?.messageQueue as MessageQueue2<OpencodeMode>;
const cancel = harness.session.onCancelQueuedMessage.mock.calls[0][0] as
(id: string) => boolean | 'in-flight' | 'indeterminate' | 'consumed';
queue.push('correction', { permissionMode: 'default', model: null }, 'reserved');
const reservation = queue.takeByLocalId('reserved')!;
expect(queue.beginReservationDispatch(reservation)).toBe(true);
expect(cancel('reserved')).toBe('in-flight');
expect(cancel('reserved')).toBe('in-flight');
expect(queue.commitReservation(reservation)).toBe(true);
expect(cancel('reserved')).toBe('consumed');
vi.spyOn(queue, 'cancelByLocalId').mockReturnValueOnce('indeterminate');
expect(cancel('unresolved')).toBe('indeterminate');
queue.push('user-resolvable', { permissionMode: 'default', model: null }, 'unknown');
const unknown = queue.takeByLocalId('unknown')!;
queue.beginReservationDispatch(unknown);
queue.markReservationIndeterminate(unknown);
expect(cancel('unknown')).toBe(true);
});
});
+7 -1
View File
@@ -1,3 +1,4 @@
import type { OpencodeSteerCancelHandler } from './utils/opencodeV2Steer';
import { logger } from '@/ui/logger';
import { randomUUID } from 'node:crypto';
import { opencodeLoop } from './loop';
@@ -438,9 +439,12 @@ export async function runOpencode(opts: {
});
});
let cancelNativeSteer: OpencodeSteerCancelHandler | null = null;
session.onCancelQueuedMessage((localId) => {
const nativeCancellation = cancelNativeSteer?.(localId);
if (nativeCancellation !== undefined) return nativeCancellation;
const removedFromQueue = messageQueue.cancelByLocalId(localId);
if (removedFromQueue) {
if (removedFromQueue === true) {
if (queuedClearLocalId === localId) {
queuedClearLocalId = null;
clearTransitionLatched = false;
@@ -457,6 +461,7 @@ export async function runOpencode(opts: {
logger.debug(`[opencode] cancelByLocalId(${localId}): removed from queue`);
return true;
}
if (removedFromQueue !== false) return removedFromQueue;
if (preparingLocalIds.has(localId)) {
cancelledBeforeEnqueue.add(localId);
logger.debug(`[opencode] cancelByLocalId(${localId}): marked for cancellation before enqueue`);
@@ -543,6 +548,7 @@ export async function runOpencode(opts: {
sessionWrapperRef.current = instance;
syncSessionMode();
},
onSteerCancelHandlerChange: (handler) => { cancelNativeSteer = handler; },
onCompactAvailabilityChange: (available) => {
compactSupported = available;
if (!available) {
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchOpencodeUserMessages } from './opencodeInputReceipt';
const context = { baseUrl: 'http://127.0.0.1:1234', sessionId: 'session/one' };
const user = { info: { id: 'user-1', role: 'user' }, parts: [{ type: 'text', text: 'correction' }] };
describe('fetchOpencodeUserMessages', () => {
it('reads native ids and joins text parts, excluding assistant output', async () => {
const fetchImpl = vi.fn(async (_url: string, _init?: RequestInit) => Response.json([
{ ...user, parts: [{ type: 'text', text: 'mid-' }, { type: 'text', text: 'turn' }] },
{ info: { role: 'assistant' }, parts: [{ type: 'text', text: 'mid-turn' }] }
]));
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl })).resolves.toEqual([{ id: 'user-1', text: 'mid-turn' }]);
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:1234/session/session%2Fone/message', expect.objectContaining({ method: 'GET', signal: expect.any(AbortSignal) }));
});
it.each([
null, {}, [null], [{ info: null }],
[{ ...user, info: { role: 'user' } }],
[{ ...user, info: { id: '', role: 'user' } }],
[{ ...user, parts: undefined }],
[{ ...user, parts: [null] }],
[{ ...user, parts: [{ type: 'text', text: 42 }] }],
[{ ...user, parts: [{ type: 'text', text: 'correction', synthetic: 'true' }] }],
[{ ...user, parts: [{ type: 'text', text: 'correction', ignored: {} }] }],
[user, user]
])('does not treat malformed or duplicate history as an input receipt: %j', async data => {
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json(data) })).resolves.toBeNull();
});
it.each([
{ parts: [{ type: 'file', mime: 'image/png', filename: 'image.png' }] },
{ parts: [] }
])('does not infer text receipt identity from media replay or incomplete user history', async ({ parts }) => {
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([{ ...user, parts }]) }))
.resolves.toBeNull();
});
it('returns unknown for an unavailable native endpoint', async () => {
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => new Response(null, { status: 404 }) })).resolves.toBeNull();
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => { throw new Error('offline'); } })).resolves.toBeNull();
});
it.each(['synthetic', 'ignored'])('retains an internal %s row identity without using its text as input proof', async flag => {
const helper = { ...user, parts: [{ type: 'text', text: 'correction', [flag]: true }] };
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([helper]) }))
.resolves.toEqual([{ id: 'user-1', text: null }]);
});
it('reads only submitted text beside synthetic or ignored helper parts', async () => {
const mixed = { ...user, parts: [
{ type: 'text', text: 'helper', synthetic: true },
{ type: 'text', text: 'correction' },
{ type: 'text', text: 'display only', ignored: true }
] };
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([mixed]) }))
.resolves.toEqual([{ id: 'user-1', text: 'correction' }]);
});
it.each([
{ parts: [{ type: 'compaction', auto: true }, { type: 'text', text: 'correction' }] },
{ parts: [{ type: 'text', text: 'correction', metadata: { compaction_continue: true } }] }
])('does not use native compaction controls or continuations as input proof', async ({ parts }) => {
await expect(fetchOpencodeUserMessages({ ...context, fetchImpl: async () => Response.json([{ ...user, parts }]) }))
.resolves.toEqual([{ id: 'user-1', text: null }]);
});
it('does not send without a native session context', async () => {
const fetchImpl = vi.fn();
await expect(fetchOpencodeUserMessages({ ...context, baseUrl: null, fetchImpl })).resolves.toBeNull();
await expect(fetchOpencodeUserMessages({ ...context, sessionId: null, fetchImpl })).resolves.toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
it('propagates caller cancellation and conservatively returns unknown', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true });
}));
const receipt = fetchOpencodeUserMessages({ ...context, signal: controller.signal, fetchImpl });
controller.abort();
await expect(receipt).resolves.toBeNull();
});
});
@@ -0,0 +1,62 @@
import type { FetchLike } from './opencodeCompactBridge';
/** Null text retains a native identity without treating internal content as input proof. */
export type OpencodeUserMessage = { id: string; text: string | null };
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Submitted native text can prove acceptance; internal rows retain identity only. */
export async function fetchOpencodeUserMessages(opts: {
baseUrl: string | null;
sessionId: string | null;
signal?: AbortSignal;
fetchImpl?: FetchLike;
}): Promise<OpencodeUserMessage[] | null> {
if (!opts.baseUrl || !opts.sessionId) return null;
try {
const response = await (opts.fetchImpl ?? fetch)(
`${opts.baseUrl}/session/${encodeURIComponent(opts.sessionId)}/message`,
{ method: 'GET', signal: AbortSignal.any([AbortSignal.timeout(1_000), ...(opts.signal ? [opts.signal] : [])]) }
);
if (!response.ok) return null;
const data: unknown = await response.json();
if (!Array.isArray(data)) return null;
const messages: OpencodeUserMessage[] = [];
const ids = new Set<string>();
for (const entry of data) {
if (!isRecord(entry) || !isRecord(entry.info)) return null;
if (entry.info.role !== 'user') continue;
const id = entry.info.id;
if (typeof id !== 'string' || !id || ids.has(id) || !Array.isArray(entry.parts)) return null;
let text = '';
let hasSubmittedText = false;
let hasInternalText = false;
let internal = entry.info.type === 'compaction' || entry.info.summary === true;
for (const part of entry.parts) {
if (!isRecord(part)) return null;
// Overflow replay can turn a file into unmarked placeholder
// text, so this history cannot establish text-only identity.
if (part.type === 'file') return null;
if (part.type === 'compaction'
|| (isRecord(part.metadata) && part.metadata.compaction_continue === true)) internal = true;
if (part.type !== 'text') continue;
if (typeof part.text !== 'string') return null;
if ((part.synthetic !== undefined && typeof part.synthetic !== 'boolean')
|| (part.ignored !== undefined && typeof part.ignored !== 'boolean')) return null;
if (part.synthetic === true || part.ignored === true) { hasInternalText = true; continue; }
hasSubmittedText = true;
text += part.text;
}
// updateMessage precedes updatePart: an incomplete ordinary row
// may still acquire text that a later compaction can replay.
if (!internal && !hasSubmittedText && !hasInternalText) return null;
ids.add(id);
messages.push({ id, text: !internal && hasSubmittedText ? text : null });
}
return messages;
} catch {
return null;
}
}
@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest';
import { admitOpencodeV2Steer, cancelOpencodeV2Steer, opencodeV2InputId, readOpencodeV2Input } from './opencodeV2Steer';
const context = { baseUrl: 'http://127.0.0.1:1234', password: 'test-only', sessionId: 'ses_native', inputId: 'msg_input' };
const item = { id: context.inputId, sessionID: context.sessionId, type: 'user', delivery: 'steer', payload: { text: 'correction' } };
const message = { id: context.inputId, sessionID: context.sessionId, type: 'user', text: 'correction' };
describe('OpenCode v2 steer receipts', () => {
it('uses a stable input identity scoped to both sessions, local id and text', () => {
const scope = { hapiSessionId: 'hapi', sessionId: 'ses_native', localId: 'local', text: 'correction' };
const id = opencodeV2InputId(scope);
expect(id).toMatch(/^msg_/);
expect(opencodeV2InputId(scope)).toBe(id);
for (const key of Object.keys(scope) as Array<keyof typeof scope>) {
expect(opencodeV2InputId({ ...scope, [key]: scope[key] + '-other' })).not.toBe(id);
}
});
it('posts durable native steer with exact identity and Basic auth', async () => {
const fetchImpl = vi.fn(async () => Response.json({ data: item }));
await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl })).resolves.toBe('admitted');
const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('http://127.0.0.1:1234/api/session/ses_native/prompt');
expect(init.method).toBe('POST');
expect(JSON.parse(String(init.body))).toEqual({ id: 'msg_input', text: 'correction', delivery: 'steer' });
expect(init.headers).toMatchObject({ authorization: 'Basic ' + Buffer.from('opencode:test-only').toString('base64') });
});
it('promotes an existing parked item instead of duplicating admission', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(Response.json({ data: { ...item, delivery: 'queue' } }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl })).resolves.toBe('admitted');
expect(fetchImpl.mock.calls[1][1]).toMatchObject({ method: 'PATCH', body: JSON.stringify({ delivery: 'steer' }) });
});
it.each([400, 401, 403, 404, 422])('distinguishes definite admission rejection HTTP%s', async status => {
await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl: async () => new Response(null, { status }) }))
.resolves.toBe('rejected');
});
it.each([{}, { data: { ...item, id: 'other' } }, { data: { ...item, sessionID: 'other' } }, { data: { ...item, payload: { text: 'other' } } }])('holds an unverified admission unknown: %j', async data => {
await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl: async () => Response.json(data) }))
.resolves.toBe('unknown');
});
it('retains a lost response as unknown without automatically posting again', async () => {
const fetchImpl = vi.fn(async () => { throw new Error('response lost'); });
await expect(admitOpencodeV2Steer({ ...context, text: 'correction', fetchImpl })).resolves.toBe('unknown');
expect(fetchImpl).toHaveBeenCalledTimes(1);
});
it('keeps admitted inbox work pending, separate from actual delivery', async () => {
await expect(readOpencodeV2Input({ ...context, fetchImpl: async () => Response.json({ data: [item] }) })).resolves.toBe('pending');
});
it('recovers a missed delivered event only from the exact projected user id', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(Response.json({ data: message }));
await expect(readOpencodeV2Input({ ...context, fetchImpl })).resolves.toBe('delivered');
expect(fetchImpl.mock.calls[1][0]).toContain('/message/msg_input');
});
it('does not treat missing inbox work alone as delivered or cancelled', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(new Response(null, { status: 404 }));
await expect(readOpencodeV2Input({ ...context, fetchImpl })).resolves.toBe('missing');
});
it.each([{ data: {} }, { data: [{ ...item, sessionID: 'other' }] }])('rejects malformed or cross-session inbox readback: %j', async data => {
await expect(readOpencodeV2Input({ ...context, fetchImpl: async () => Response.json(data) })).resolves.toBe('unknown');
});
it('confirms cancellation with ordered inbox and projection readback after DELETE', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(new Response(null, { status: 404 }));
await expect(cancelOpencodeV2Steer({ ...context, fetchImpl })).resolves.toBe('cancelled');
expect(fetchImpl.mock.calls.map(call => call[1]?.method ?? 'GET')).toEqual(['DELETE', 'GET', 'GET']);
});
it('does not mistake native no-op DELETE204 for successful cancellation after delivery', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(Response.json({ data: [] })).mockResolvedValueOnce(Response.json({ data: message }));
await expect(cancelOpencodeV2Steer({ ...context, fetchImpl })).resolves.toBe('delivered');
});
it('holds uncertain cancellation unknown', async () => {
await expect(cancelOpencodeV2Steer({ ...context, fetchImpl: async () => { throw new Error('lost cancel'); } })).resolves.toBe('unknown');
});
});
+127
View File
@@ -0,0 +1,127 @@
import { createHash } from 'node:crypto';
import { isObject } from '@hapi/protocol';
import type { FetchLike } from './opencodeCompactBridge';
export type OpencodeSteerCancelResult = boolean | 'in-flight' | 'indeterminate' | 'consumed';
export type OpencodeSteerCancelHandler = (localId: string) => Promise<OpencodeSteerCancelResult> | undefined;
type InputContext = {
baseUrl: string;
password: string;
sessionId: string;
inputId: string;
text?: string;
fetchImpl?: FetchLike;
};
export type OpencodeV2InputState = 'pending' | 'delivered' | 'missing' | 'unknown';
export type OpencodeV2Admission = 'admitted' | 'rejected' | 'unknown';
export type OpencodeV2Cancellation = 'cancelled' | 'delivered' | 'unknown';
/** Preserve one native identity across response loss/retry without matching text receipts. */
export function opencodeV2InputId(scope: {
hapiSessionId: string;
sessionId: string;
localId: string;
text: string;
}): string {
return 'msg_' + createHash('sha256').update(JSON.stringify([
scope.hapiSessionId, scope.sessionId, scope.localId, scope.text
])).digest('hex');
}
function sessionUrl(opts: InputContext): string {
return `${opts.baseUrl}/api/session/${encodeURIComponent(opts.sessionId)}`;
}
function request(opts: InputContext, url: string, init?: RequestInit): Promise<Response> {
return (opts.fetchImpl ?? fetch)(url, {
...init,
headers: {
authorization: 'Basic ' + Buffer.from(`opencode:${opts.password}`).toString('base64'),
'content-type': 'application/json'
},
// Bound only HTTP admission/readback, never model execution. This is
// the native transcript's existing read budget, below the hub RPC budget.
signal: AbortSignal.timeout(10_000)
});
}
function matchesUser(value: unknown, opts: InputContext, inbox: boolean): boolean {
if (!isObject(value) || value.id !== opts.inputId || value.sessionID !== opts.sessionId || value.type !== 'user') return false;
const text = inbox && isObject(value.payload) ? value.payload.text : value.text;
return typeof text === 'string' && (opts.text === undefined || text === opts.text);
}
/** OpenCode's experimental v2 API: admission is durable, but is not delivery. */
export async function admitOpencodeV2Steer(opts: InputContext & { text: string }): Promise<OpencodeV2Admission> {
try {
const response = await request(opts, `${sessionUrl(opts)}/prompt`, {
method: 'POST',
body: JSON.stringify({ id: opts.inputId, text: opts.text, delivery: 'steer' })
});
if ([400, 401, 403, 404, 422].includes(response.status)) return 'rejected';
if (!response.ok) return 'unknown';
const body: unknown = await response.json();
if (!isObject(body) || !matchesUser(body.data, opts, true) || !isObject(body.data)) return 'unknown';
if (body.data.delivery === 'queue') {
// A retry may find the same durable identity parked by the native
// client. Change its mode; a second POST must not create a copy.
const changed = await request(opts, `${sessionUrl(opts)}/inbox/${encodeURIComponent(opts.inputId)}`, {
method: 'PATCH', body: JSON.stringify({ delivery: 'steer' })
});
// It may have been delivered before PATCH. Let exact readback
// settle that race rather than classifying a conflict as rejection.
if (!changed.ok && changed.status !== 409) return 'unknown';
} else if (body.data.delivery !== 'steer') return 'unknown';
return 'admitted';
} catch {
return 'unknown';
}
}
/** Read inbox BEFORE projection: an absent pending item can already be delivered. */
export async function readOpencodeV2Input(opts: InputContext): Promise<OpencodeV2InputState> {
try {
const inboxResponse = await request(opts, `${sessionUrl(opts)}/inbox`);
if (!inboxResponse.ok) return 'unknown';
const inbox: unknown = await inboxResponse.json();
if (!isObject(inbox) || !Array.isArray(inbox.data)) return 'unknown';
for (const value of inbox.data) {
if (!isObject(value) || typeof value.id !== 'string' || value.sessionID !== opts.sessionId) return 'unknown';
if (value.id === opts.inputId) return matchesUser(value, opts, true) ? 'pending' : 'unknown';
}
const projection = await request(opts, `${sessionUrl(opts)}/message/${encodeURIComponent(opts.inputId)}`);
if (projection.status === 404) return 'missing';
if (!projection.ok) return 'unknown';
const body: unknown = await projection.json();
return isObject(body) && matchesUser(body.data, opts, false) ? 'delivered' : 'unknown';
} catch {
return 'unknown';
}
}
/** Caller must first know its admission POST settled; DELETE204 alone is a no-op too. */
export async function cancelOpencodeV2Steer(opts: InputContext): Promise<OpencodeV2Cancellation> {
try {
const response = await request(opts, `${sessionUrl(opts)}/inbox/${encodeURIComponent(opts.inputId)}`, { method: 'DELETE' });
if (!response.ok) return 'unknown';
const state = await readOpencodeV2Input(opts);
if (state === 'delivered') return 'delivered';
return state === 'missing' ? 'cancelled' : 'unknown';
} catch {
return 'unknown';
}
}
/** Re-steer a positively identified existing inbox entry without resubmitting uncertain input. */
export async function steerOpencodeV2Inbox(opts: InputContext): Promise<OpencodeV2Admission> {
try {
const response = await request(opts, `${sessionUrl(opts)}/inbox/${encodeURIComponent(opts.inputId)}`, {
method: 'PATCH', body: JSON.stringify({ delivery: 'steer' })
});
return response.ok || response.status === 409 ? 'admitted' : 'unknown';
} catch {
return 'unknown';
}
}