fix(sessions): stop reporting a dispatched prompt that never landed

`prompt_async` answers 204 as soon as OpenCode forks the run and reports
every later failure only on the session event stream, so an unusable
model, agent, or variant produced a session with no message while the
result still claimed `promptDispatched: true`.

Validate an explicitly requested model, agent, and variant against the
directory's own agent and provider lists before any session, worktree, or
goal is created, and confirm a new user message actually reached the
session before reporting the dispatch. A failed or empty lookup never
turns a valid selection into a rejection.
This commit is contained in:
Bohdan Triapitsyn
2026-08-04 11:08:28 +03:00
parent f710b49854
commit 9d3d5c0852
3 changed files with 255 additions and 21 deletions
@@ -32,7 +32,15 @@ other.
requires observed activity or a newly completed assistant message.
- Timeout and cancellation are failures, never authoritative idle results.
- Validation that protects side effects runs before session creation or
dispatch.
dispatch. An explicitly requested model, agent, or variant is checked against
the directory's own OpenCode agent and provider lists before any session,
worktree, or goal is created, because `prompt_async` accepts an unusable
selection and then fails only on the event stream. A failed or empty lookup
never turns a valid selection into a rejection.
- `promptDispatched` reports an observed dispatch, never an accepted request.
After `prompt_async` the service confirms a new user message reached the
session; when it does not, the result reports `promptDispatched: false` with
`promptError` instead of claiming success.
- Send and fork dispatches without an explicit model/agent/variant reuse the
target session's last user-message selection before falling back to the
configured defaults; only session creation resolves defaults directly.
@@ -272,6 +272,41 @@ const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated
: { ok: false, status: 400, error: validated.error || 'Invalid directory' };
};
const PROMPT_LANDED_TIMEOUT_MS = 5_000;
const PROMPT_LANDED_POLL_MS = 150;
const latestUserMessageID = async ({ client, sessionID, directory }) => {
let response;
try {
response = await client.session.messages({ sessionID, directory, limit: 100 });
} catch {
return { ok: false, messageID: null };
}
const messages = Array.isArray(response?.data) ? response.data : [];
let latest = null;
for (const message of messages) {
const info = message?.info;
if (info?.role !== 'user') continue;
if (!latest || (info.time?.created || 0) >= (latest.time?.created || 0)) latest = info;
}
return { ok: true, messageID: asNonEmptyString(latest?.id) };
};
// `prompt_async` answers 204 as soon as OpenCode forks the run, and every later
// failure is reported only on the session event stream. Confirm the prompt was
// actually recorded so `promptDispatched` never claims a dispatch that vanished.
const waitForPromptLanded = async ({ client, sessionID, directory, baselineUserMessageID }) => {
const deadline = Date.now() + PROMPT_LANDED_TIMEOUT_MS;
for (;;) {
const latest = await latestUserMessageID({ client, sessionID, directory });
// A failed lookup is not authoritative evidence that the prompt was lost.
if (!latest.ok) return true;
if (latest.messageID && latest.messageID !== baselineUserMessageID) return true;
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, PROMPT_LANDED_POLL_MS));
}
};
const resolveWorktreeInput = (payload) => {
if (!payload?.worktree || typeof payload.worktree !== 'object') return null;
const name = asNonEmptyString(payload.worktree.name);
@@ -322,6 +357,48 @@ export const createOpenChamberSessionService = (dependencies) => {
return null;
};
// Explicit model/agent/variant are never checked by `prompt_async`: an unknown
// agent makes the forked run fail silently, leaving a session with no message.
// Reject them before any session, worktree, or goal side effect happens.
const validateRequestedSelection = async ({ directory, requestedModel, requestedAgent, requestedVariant }) => {
if (!requestedModel && !requestedAgent && !requestedVariant) return;
const authHeaders = getOpenCodeAuthHeaders();
const { providers, agents } = await fetchSelectionInputs({
buildOpenCodeUrl,
authHeaders,
directory,
readSettingsFromDiskMigrated,
});
// An empty list means the lookup failed or returned nothing authoritative;
// it must not turn a valid selection into a rejection.
if (requestedAgent && agents.length > 0) {
const agent = agents.find((entry) => entry?.name === requestedAgent) || null;
if (!agent) {
throw new OpenChamberControlError(`Unknown agent '${requestedAgent}' for ${directory}`, 400);
}
if (!isPrimaryAgentMode(agent.mode)) {
throw new OpenChamberControlError(`Agent '${requestedAgent}' is a subagent and cannot receive a prompt directly`, 400);
}
}
if (requestedModel && providers.length > 0) {
if (!hasProviderModel(providers, requestedModel.providerID, requestedModel.modelID)) {
throw new OpenChamberControlError(
`Unknown model '${requestedModel.providerID}/${requestedModel.modelID}' for ${directory}`,
400,
);
}
if (requestedVariant
&& !resolveVariant(providers, requestedModel.providerID, requestedModel.modelID, requestedVariant)) {
throw new OpenChamberControlError(
`Unknown variant '${requestedVariant}' for model '${requestedModel.providerID}/${requestedModel.modelID}'`,
400,
);
}
}
};
const dispatchPrompt = async ({
client,
baseUrl,
@@ -417,6 +494,7 @@ export const createOpenChamberSessionService = (dependencies) => {
throw markGoalPartial(error);
}
} else {
const baseline = await latestUserMessageID({ client, sessionID, directory });
try {
await runPromptAsync({
baseUrl,
@@ -438,6 +516,22 @@ export const createOpenChamberSessionService = (dependencies) => {
} catch (error) {
throw markGoalPartial(error);
}
const landed = await waitForPromptLanded({
client,
sessionID,
directory,
baselineUserMessageID: baseline.messageID,
});
if (!landed) {
return {
model,
agent,
variant,
promptDispatched: false,
dispatchedAsCommand: false,
promptError: 'OpenCode accepted the prompt but it never appeared in the session',
};
}
}
return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) };
@@ -470,13 +564,23 @@ export const createOpenChamberSessionService = (dependencies) => {
if (payload?.worktree && !worktreeInput) {
throw new OpenChamberControlError('worktree.name is required when worktree is provided', 400);
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
if (prompt) {
await validateRequestedSelection({
directory: resolvedDirectory.directory,
requestedModel: model,
requestedAgent: agent,
requestedVariant: variant,
});
}
if (worktreeInput) {
worktree = await createWorktree(resolvedDirectory.directory, worktreeInput);
sessionDirectory = worktree.path;
}
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
@@ -514,6 +618,7 @@ export const createOpenChamberSessionService = (dependencies) => {
...(prompt && dispatch.agent ? { agent: dispatch.agent } : {}),
...(prompt && dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: dispatch.promptDispatched,
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
@@ -566,6 +671,13 @@ export const createOpenChamberSessionService = (dependencies) => {
directory = resolvedDirectory.directory;
if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250);
await validateRequestedSelection({
directory,
requestedModel,
requestedAgent: asNonEmptyString(payload.agent),
requestedVariant: asNonEmptyString(payload.variant),
});
const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
const authHeaders = getOpenCodeAuthHeaders();
const client = createOpencodeClient({ baseUrl, headers: authHeaders });
@@ -608,7 +720,8 @@ export const createOpenChamberSessionService = (dependencies) => {
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: true,
promptDispatched: dispatch.promptDispatched,
...(dispatch.promptError ? { promptError: dispatch.promptError } : {}),
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
@@ -624,7 +737,7 @@ export const createOpenChamberSessionService = (dependencies) => {
model: dispatch.model,
...(dispatch.agent ? { agent: dispatch.agent } : {}),
...(dispatch.variant ? { variant: dispatch.variant } : {}),
promptDispatched: true,
promptDispatched: dispatch.promptDispatched,
dispatchedAsCommand: dispatch.dispatchedAsCommand,
...(goalInput.enabled ? { goalEnabled: true } : {}),
...(goalInput.tokenBudget ? { goalTokenBudget: goalInput.tokenBudget } : {}),
@@ -11,6 +11,53 @@ const createWorktreeMock = vi.fn(async () => ({
const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } }));
const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } }));
const sessionMessagesMock = vi.fn(async () => ({ data: [] }));
let existingSessionMessages = [];
let dispatchedUserMessageSeq = 0;
// The service confirms a prompt landed by watching for a new user message, so
// the default mock behaves like OpenCode recording each dispatched prompt.
const setSessionMessages = (messages) => {
existingSessionMessages = messages;
};
const recordedSessionMessages = async () => {
dispatchedUserMessageSeq += 1;
return {
data: [
...existingSessionMessages,
{
info: {
id: `msg_dispatched_${dispatchedUserMessageSeq}`,
role: 'user',
time: { created: 1000 + dispatchedUserMessageSeq },
},
},
],
};
};
// Selection inputs are fetched whenever a request names a model, agent, or
// variant, so every prompt-dispatching fetch mock must answer them.
const selectionInputResponse = (url) => {
const text = String(url);
if (text.includes('/config/providers')) {
return {
ok: true,
json: async () => ({
providers: [
{ id: 'openai', models: [{ id: 'gpt-5.5', variants: { high: {} } }] },
{ id: 'anthropic', models: [{ id: 'claude-sonnet-5', variants: { high: {} } }] },
],
}),
};
}
if (text.includes('/agent')) {
return { ok: true, json: async () => [{ name: 'build', mode: 'primary' }, { name: 'plan', mode: 'primary' }] };
}
if (text.includes('/config')) return { ok: true, json: async () => ({}) };
return null;
};
const sessionCommandMock = vi.fn(async () => ({ data: {} }));
const commandListMock = vi.fn(async () => ({ data: [] }));
globalThis.__openchamberCreateWorktreeMock = createWorktreeMock;
@@ -62,8 +109,10 @@ describe('openchamber session routes', () => {
createWorktreeMock.mockClear();
sessionCreateMock.mockClear();
sessionForkMock.mockClear();
existingSessionMessages = [];
dispatchedUserMessageSeq = 0;
sessionMessagesMock.mockReset();
sessionMessagesMock.mockResolvedValue({ data: [] });
sessionMessagesMock.mockImplementation(recordedSessionMessages);
sessionCommandMock.mockReset();
sessionCommandMock.mockResolvedValue({ data: {} });
commandListMock.mockReset();
@@ -319,13 +368,11 @@ describe('openchamber session routes', () => {
it('sends a goal prompt to an existing session after creating goal metadata', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
const createSessionGoal = vi.fn(async () => undefined);
globalThis.fetch = fetchMock;
try {
sessionMessagesMock.mockResolvedValue({
data: [{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }],
});
setSessionMessages([{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } }]);
const { app } = createApp({ createSessionGoal });
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
@@ -370,7 +417,7 @@ describe('openchamber session routes', () => {
template: 'Take $ARGUMENTS from issue through a verified pull request. Confirm the PR covers $ARGUMENTS.',
}],
});
globalThis.fetch = vi.fn();
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url));
try {
const { app } = createApp({ createSessionGoal });
const response = await request(app)
@@ -393,7 +440,7 @@ describe('openchamber session routes', () => {
}));
expect(createSessionGoal.mock.invocationCallOrder[0]).toBeLessThan(sessionCommandMock.mock.invocationCallOrder[0]);
expect(response.body).toMatchObject({ goalEnabled: true, dispatchedAsCommand: true });
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(globalThis.fetch.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
@@ -401,11 +448,10 @@ describe('openchamber session routes', () => {
it('reuses the previous session selection when send omits model, agent, and variant', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '' }));
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
globalThis.fetch = fetchMock;
try {
sessionMessagesMock.mockResolvedValue({
data: [
setSessionMessages([
{
info: {
id: 'msg_user',
@@ -416,8 +462,7 @@ describe('openchamber session routes', () => {
},
},
{ info: { id: 'msg_before', role: 'assistant', time: { created: 10, completed: 20 } } },
],
});
]);
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions/ses_source/send')
@@ -449,7 +494,7 @@ describe('openchamber session routes', () => {
it('forks from a message, dispatches the prompt, and emits the new session', async () => {
const originalFetch = globalThis.fetch;
const emitSessionCreatedEvent = vi.fn();
globalThis.fetch = vi.fn(async () => ({ ok: true, text: async () => '' }));
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, text: async () => '' });
try {
const { app } = createApp({ emitSessionCreatedEvent });
const response = await request(app)
@@ -519,7 +564,7 @@ describe('openchamber session routes', () => {
it('reports the forked session when prompt dispatch fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => ({ ok: false, status: 500, text: async () => 'dispatch failed' }));
globalThis.fetch = vi.fn(async (url) => selectionInputResponse(url) || { ok: false, status: 500, text: async () => 'dispatch failed' });
try {
const { app } = createApp();
const response = await request(app)
@@ -584,9 +629,77 @@ describe('openchamber session routes', () => {
}
});
it('rejects an unknown agent before creating a session or worktree', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({
directory: '/repo/app',
prompt: 'Run this',
agent: 'not-an-agent',
worktree: { name: 'side-task' },
})
.expect(400, { error: "Unknown agent 'not-an-agent' for /repo/app" });
expect(createWorktreeMock).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([url]) => String(url) === 'http://opencode.test/session?directory=%2Frepo%2Fapp')).toBe(false);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('rejects an unknown model and an unknown variant before dispatching', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) });
globalThis.fetch = fetchMock;
try {
const { app } = createApp();
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-nope' })
.expect(400, { error: "Unknown model 'openai/gpt-nope' for /repo/app" });
await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5', variant: 'ultra' })
.expect(400, { error: "Unknown variant 'ultra' for model 'openai/gpt-5.5'" });
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it('reports promptDispatched false when the accepted prompt never reaches the session', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async (url) => {
if (String(url).includes('/prompt_async')) return { ok: true, text: async () => '' };
return selectionInputResponse(url) || { ok: true, json: async () => ({ id: 'ses_123' }) };
});
globalThis.fetch = fetchMock;
sessionMessagesMock.mockResolvedValue({ data: [] });
try {
const { app } = createApp();
const response = await request(app)
.post('/api/openchamber/sessions')
.send({ directory: '/repo/app', prompt: 'Run this', model: 'openai/gpt-5.5' })
.expect(200);
expect(response.body.sessionId).toBe('ses_123');
expect(response.body.promptDispatched).toBe(false);
expect(response.body.promptError).toBeTruthy();
} finally {
globalThis.fetch = originalFetch;
}
}, 20_000);
it('does not retry a failed slash command as a normal prompt', async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn();
const fetchMock = vi.fn(async (url) => selectionInputResponse(url));
commandListMock.mockResolvedValue({ data: [{ name: 'review' }] });
sessionCommandMock.mockRejectedValue(new Error('command response failed'));
globalThis.fetch = fetchMock;
@@ -604,7 +717,7 @@ describe('openchamber session routes', () => {
.expect(500);
expect(sessionCommandMock).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/prompt_async'))).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}