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:
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user