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
@@ -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 } : {}),