diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4b910..29f965f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Re-check session status and recent messages after an auto-continue cooldown, pause immediately for human intervention, Plan-agent switches, permission rejection, aborts, and provider errors, and abort an accepted continuation when the user takes control. +- Persist one continuation claim per goal execution epoch and source assistant turn to prevent sequential duplicate idle events, while preserving the initiating agent, model, and variant on every continuation prompt. + ## 0.6.3 — 2026-07-11 - Clarify the textual completion protocol so models keep evidence and completion on consecutive plain-text lines instead of wrapping markers in Markdown or separating them with blank lines. diff --git a/README.md b/README.md index c9173ac..c2758f1 100644 --- a/README.md +++ b/README.md @@ -193,10 +193,10 @@ An ordered sequence, run as a strict pipeline: ## How it works 1. When you set a goal, the plugin stores it in session memory and injects it into the system prompt so the assistant keeps it in view on every turn. -2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done. +2. Each time the session goes idle, the plugin sends a continuation prompt containing the goal, the remaining budget, and a completion audit asking the assistant to verify the current state before declaring done. Continuations retain the agent, provider/model, and variant that initiated the goal. Before sending after a cooldown, the plugin re-checks that the session is still idle and no human message, newer assistant turn, Plan-agent switch, rejected permission, abort, or provider error has superseded the request. 3. The plugin stops auto-continuing when the assistant ends a response with a substantiated `[goal:complete]` or `[goal:blocked]`, or when a safety limit is reached. A `[goal:complete]` is only honored when it is preceded by a `[goal:evidence]` line; a `[goal:blocked]` is only honored when a concrete blocker is stated. Unsubstantiated claims are rejected and the plugin re-prompts for the missing evidence or blocker. 4. If OpenCode compacts the session, the plugin injects a deterministic summary into the compaction context so the goal survives the compaction and the assistant keeps the thread. The summary — objective, status, budget usage, recent checkpoints, and recent lifecycle events — is reconstructed from the plugin's persisted goal record rather than from chat memory, so it is stable and reproducible. While a goal is active, the plugin also disables OpenCode's generic post-compaction auto-continue so it does not race the plugin's own continuation. -5. If you send a message of your own while the goal is running, the plugin treats it as the latest instruction and pauses auto-continue so it does not talk over you. The plugin's own continuation prompts are ignored for this check (they are not "your" messages). Run `/goal resume` to hand control back to the goal loop. +5. If you send a message of your own while the goal is running, the plugin treats it as the latest instruction, pauses auto-continue, and asks OpenCode to abort an already accepted continuation so it does not talk over you. The plugin's own continuation prompts are ignored for this check (they are not "your" messages). A durable claim on the source assistant turn also prevents different idle event IDs from sending the same continuation twice. Run `/goal resume` to hand control back to the goal loop. ## Completion markers diff --git a/scripts/behavior-benchmark.mjs b/scripts/behavior-benchmark.mjs index 31c6e7c..d820531 100644 --- a/scripts/behavior-benchmark.mjs +++ b/scripts/behavior-benchmark.mjs @@ -24,17 +24,29 @@ function assistantMessage(sessionID, text, id = `assistant-${sessionID}`) { function createHost(messageForSession = () => "Working with tools.") { const prompts = [] const notices = [] + const sourceTurns = new Map() return { prompts, notices, client: { app: { log: async () => {} }, session: { - messages: async ({ path }) => ({ - data: [assistantMessage(path.id, messageForSession(path.id))], - }), + messages: async ({ path }) => { + const turn = sourceTurns.get(path.id) || 0 + return { + data: [ + assistantMessage( + path.id, + messageForSession(path.id, turn), + `assistant-${path.id}-${turn}`, + ), + ], + } + }, promptAsync: async (input) => { prompts.push(input) + const sessionID = input?.sessionID || input?.path?.id + if (sessionID) sourceTurns.set(sessionID, (sourceTurns.get(sessionID) || 0) + 1) return {} }, }, @@ -157,8 +169,7 @@ results.push(await scenario("false-completion", 20, async () => { results.push(await scenario("loop-circuit-breaker", 15, async () => { const sessionID = "benchmark-loop" - let turn = 0 - const host = createHost(() => `Still discussing the work, turn ${turn++}.`) + const host = createHost((_sessionID, turn) => `Still discussing the work, turn ${turn}.`) const hooks = await createHooks(host) await goalCommand(hooks, sessionID, "stop self-chat loops") await idle(hooks, sessionID, "loop-1") diff --git a/src/goal-plugin.js b/src/goal-plugin.js index ae127d3..c6f7a67 100644 --- a/src/goal-plugin.js +++ b/src/goal-plugin.js @@ -87,7 +87,10 @@ function createRuntimeState() { seenOutputTokens: new Map(), activeContinues: new Map(), continuationControllers: new Map(), + promptInFlightSessions: new Set(), seenIdleEventIDs: new Set(), + sessionStatuses: new Map(), + sessionExecutionContexts: new Map(), readOnlyCommandGuards: new Set(), ledgerSink: null, persistenceLease: null, @@ -242,7 +245,13 @@ function makeContinuationPart(text) { } function getSessionID(event) { - return event?.properties?.sessionID || event?.properties?.info?.sessionID || null + return ( + event?.properties?.sessionID || + event?.properties?.info?.sessionID || + event?.data?.sessionID || + event?.data?.info?.sessionID || + null + ) } function isIdleEvent(event) { @@ -252,12 +261,76 @@ function isIdleEvent(event) { ) } -function isAbortErrorEvent(event) { - if (event?.type !== "session.error") return false - const error = event?.properties?.error +function normalizeExecutionContext(value) { + if (!isPlainObject(value)) return null + const model = isPlainObject(value.model) ? value.model : {} + const boundedContextText = (candidate) => { + if (typeof candidate !== "string") return "" + const normalized = candidate.trim() + return normalized.length <= MAX_GOAL_META_LENGTH ? normalized : "" + } + const agent = boundedContextText(value.agent) + const providerID = boundedContextText(model.providerID) + const modelID = + boundedContextText(model.modelID) || boundedContextText(model.id) + const variantValue = value.variant ?? model.variant + const variant = boundedContextText(variantValue) + if (!agent && !(providerID && modelID) && !variant) return null + return { + ...(agent ? { agent } : {}), + ...(providerID && modelID ? { model: { providerID, modelID } } : {}), + ...(variant ? { variant } : {}), + } +} + +function continuationContextInput(goal) { + const context = normalizeExecutionContext(goal?.executionContext) + return context ? { ...context } : {} +} + +function isPlanAgent(agent) { + return typeof agent === "string" && agent.trim().toLowerCase() === "plan" +} + +function terminalEvent(event) { + const permissionReply = String( + event?.properties?.reply ?? + event?.properties?.response ?? + event?.data?.reply ?? + event?.data?.response ?? + "", + ) + if (event?.type === "permission.replied" && /^(?:reject(?:ed)?|deny|denied)$/i.test(permissionReply)) { + return { + sessionID: getSessionID(event), + stopReason: "permission rejected", + status: "Goal paused after a permission request was rejected.", + history: "Paused after OpenCode reported a rejected permission request.", + } + } + + let error = null + if (event?.type === "session.error") { + error = event?.properties?.error || event?.data?.error + } else if (event?.type === "message.updated") { + error = messageInfoFromEvent(event)?.error + } + if (!error) return null + const name = String(error?.name || error?.data?.name || "") const message = String(error?.message || error?.data?.message || "") - return name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`) + const aborted = name === "MessageAbortedError" || /\babort(?:ed)?\b/i.test(`${name} ${message}`) + const summary = summarizeText(`${name}${message ? `: ${message}` : ""}`, 240) || "unknown provider error" + return { + sessionID: getSessionID(event) || messageSessionID(messageInfoFromEvent(event)), + stopReason: aborted ? "user interrupted" : "provider error", + status: aborted + ? "Goal paused after user interruption." + : `Goal paused after a terminal provider error: ${summary}`, + history: aborted + ? "Paused after OpenCode reported that the active turn was aborted." + : `Paused after OpenCode reported a terminal provider error: ${summary}`, + } } function summarizeText(text, limit = CHECKPOINT_CHAR_LIMIT) { @@ -706,7 +779,10 @@ function clearRuntimeState() { seenOutputTokens.clear() activeContinues.clear() runtime.continuationControllers.clear() + runtime.promptInFlightSessions.clear() runtime.seenIdleEventIDs.clear() + runtime.sessionStatuses.clear() + runtime.sessionExecutionContexts.clear() runtime.readOnlyCommandGuards.clear() } @@ -805,6 +881,7 @@ function resetGoalBudget(goal) { goal.promptFailures = 0 goal.formatFailures = 0 goal.lastAssistantMessageID = "" + goal.continuationClaim = null goal.skipNextTerminalCheck = false goal.history = [...(goal.history || [])].slice(-MAX_HISTORY_ENTRIES) } @@ -1109,6 +1186,18 @@ function normalizePersistedGoal(rawGoal) { stopReason: typeof rawGoal.stopReason === "string" ? rawGoal.stopReason : "", promptFailures: toNonNegativeInteger(rawGoal.promptFailures), formatFailures: toNonNegativeInteger(rawGoal.formatFailures), + executionContext: normalizeExecutionContext(rawGoal.executionContext), + continuationClaim: + isPlainObject(rawGoal.continuationClaim) && + typeof rawGoal.continuationClaim.runId === "string" && + rawGoal.continuationClaim.runId.length <= MAX_GOAL_META_LENGTH && + typeof rawGoal.continuationClaim.sourceAssistantMessageID === "string" && + rawGoal.continuationClaim.sourceAssistantMessageID.length <= MAX_GOAL_META_LENGTH + ? { + runId: rawGoal.continuationClaim.runId, + sourceAssistantMessageID: rawGoal.continuationClaim.sourceAssistantMessageID, + } + : null, messageIDs: Array.isArray(rawGoal.messageIDs) ? rawGoal.messageIDs.slice(-MAX_MESSAGE_IDS_PER_GOAL).filter((messageID) => typeof messageID === "string" && messageID.length <= MAX_GOAL_META_LENGTH) : [], @@ -1181,6 +1270,9 @@ function deserializeGoal(goal) { "Recovered persisted goal state after plugin restart; auto-continue remains paused until you resume.", ) } + // Recovered goals always require an explicit resume, which starts a fresh + // execution epoch and makes any pre-crash continuation claim obsolete. + hydrated.continuationClaim = null return hydrated } @@ -1898,7 +1990,8 @@ function messageRole(message) { } function messageID(message) { - return message?.info?.id || message?.id || "" + const id = message?.info?.id || message?.id || "" + return typeof id === "string" && id.length <= MAX_GOAL_META_LENGTH ? id : "" } function messageSessionID(message) { @@ -1986,6 +2079,9 @@ function messageInfoFromEvent(event) { event?.properties?.info, event?.properties?.message?.info, event?.properties?.message, + event?.data?.info, + event?.data?.message?.info, + event?.data?.message, ] return candidates.find(isPlainObject) || null } @@ -2049,6 +2145,35 @@ function findLatestAssistantMessage(messages) { return [...(messages || [])].reverse().find((message) => messageRole(message) === "assistant") || null } +function findLatestExecutionContext(messages) { + for (const message of [...(messages || [])].reverse()) { + if (messageRole(message) !== "user") continue + const info = isPlainObject(message?.info) ? message.info : message + const context = normalizeExecutionContext(info) + if (context) return context + } + return null +} + +function continuationSnapshot(messages) { + const list = Array.isArray(messages) ? messages : [] + const latestAssistant = findLatestAssistantMessage(list) + const latestRealUser = [...list] + .reverse() + .find((message) => messageRole(message) === "user" && !isPluginContinuationMessage(message)) + const latestRelevant = [...list] + .reverse() + .find((message) => + (messageRole(message) === "assistant" || messageRole(message) === "user") && + !isPluginContinuationMessage(message), + ) + return { + latestAssistantID: messageID(latestAssistant), + latestRealUserMessageID: messageID(latestRealUser), + latestRelevantMessageID: messageID(latestRelevant), + } +} + // The plugin drives auto-continue by sending its own prompts via promptAsync, // which appear in the session as user-role messages. Every such prompt is // framed inside , so a user message containing that marker @@ -2136,6 +2261,10 @@ function buildGoalState(sessionID, condition, options, meta = {}, lastStatus = " stopReason: "", promptFailures: 0, formatFailures: 0, + executionContext: normalizeExecutionContext( + meta.executionContext || currentRuntime().sessionExecutionContexts.get(sessionID), + ), + continuationClaim: null, messageIDs: new Set(), history: [], checkpoints: [], @@ -2882,6 +3011,117 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const agentToolHandlers = buildAgentToolHandlers({ defaultGoalOptions, persist, persistTerminalState, completionAuditor, commandName }) + const abortAcceptedContinuation = async (sessionID) => { + const runtimeState = currentRuntime() + runtimeState.continuationControllers.get(sessionID)?.abort() + if ( + !runtimeState.promptInFlightSessions.has(sessionID) || + typeof client?.session?.abort !== "function" + ) { + return + } + try { + await sessionApi.abort(sessionID) + } catch (error) { + await logPluginError(client, "Failed to abort an accepted auto-continue after intervention", error) + } + } + + const pauseActiveGoal = async ( + sessionID, + { stopReason: reason, status, history, abortAccepted = false }, + ) => { + const goal = goalStates.get(sessionID) + if (!goal) return false + currentRuntime().continuationControllers.get(sessionID)?.abort() + goal.stopped = true + goal.stopReason = reason + goal.lastStatus = `${status} Run /${commandName} resume to continue.` + goal.continuationClaim = null + pushHistory(goal, "paused", history) + activeContinues.delete(sessionID) + await persist() + if (abortAccepted) await abortAcceptedContinuation(sessionID) + return true + } + + const claimContinuationSource = async ( + sessionID, + goalID, + runID, + baselineMessages, + { refreshMessages = false } = {}, + ) => { + const goalBeforeRefresh = activeGoal(sessionID, goalID, runID) + if (!goalBeforeRefresh) return null + const hostMessages = refreshMessages + ? await sessionApi.messages(sessionID, { + limit: goalBeforeRefresh.options.maxRecentMessages, + }) + : baselineMessages + const goal = activeGoal(sessionID, goalID, runID) + if (!goal) return null + const messages = Array.isArray(hostMessages) + ? hostMessages.slice(-goal.options.maxRecentMessages) + : [] + const baseline = continuationSnapshot(baselineMessages) + const refreshed = continuationSnapshot(messages) + + if (currentRuntime().sessionStatuses.get(sessionID) !== "idle") return null + + const currentContext = currentRuntime().sessionExecutionContexts.get(sessionID) + if (isPlanAgent(currentContext?.agent)) { + await pauseActiveGoal(sessionID, { + stopReason: "plan agent active", + status: "Auto-continue paused because the active agent switched to Plan.", + history: "Paused before auto-continue because the active session agent switched to Plan.", + }) + return null + } + + const newHumanMessage = + refreshed.latestRealUserMessageID && + refreshed.latestRealUserMessageID !== baseline.latestRealUserMessageID + if (newHumanMessage || userInterventionDetected(messages, goal)) { + await pauseActiveGoal(sessionID, { + stopReason: "user intervention", + status: "Auto-continue paused because a new human message arrived; the latest instruction wins.", + history: "Paused auto-continue after a real user message arrived; latest instruction wins.", + }) + return null + } + + if ( + refreshed.latestAssistantID !== baseline.latestAssistantID || + refreshed.latestRelevantMessageID !== baseline.latestRelevantMessageID + ) { + return null + } + + if (!goal.executionContext) { + goal.executionContext = findLatestExecutionContext(messages) + } + const sourceAssistantMessageID = refreshed.latestAssistantID || "" + if ( + goal.continuationClaim?.runId === runID && + goal.continuationClaim?.sourceAssistantMessageID === sourceAssistantMessageID + ) { + return null + } + + goal.continuationClaim = { runId: runID, sourceAssistantMessageID } + const claimPersisted = await persist() + if (!claimPersisted && persistenceOptions.persistState) { + goal.continuationClaim = null + goal.stopped = true + goal.stopReason = "continuation claim persistence failed" + goal.lastStatus = `Auto-continue paused because its source-turn claim could not be persisted. Run /${commandName} resume after fixing storage.` + pushHistory(goal, "paused", "Paused because the durable continuation source claim could not be persisted.") + return null + } + return goal + } + const hooks = { config: async (config) => { applyNativeGoalConfig(config, { @@ -2890,6 +3130,36 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) }) if (pluginOptions.completionAudit) verifierRegistrationReady = true }, + "chat.params": async (input) => { + if (!input?.sessionID) return + const context = normalizeExecutionContext({ + agent: input.agent, + model: input.model, + variant: input?.message?.model?.variant, + }) + if (context) currentRuntime().sessionExecutionContexts.set(input.sessionID, context) + }, + "chat.message": async (input, output) => { + const sessionID = input?.sessionID + if (!sessionID) return + const context = normalizeExecutionContext(input) + if (context) currentRuntime().sessionExecutionContexts.set(sessionID, context) + + const message = { role: "user", parts: Array.isArray(output?.parts) ? output.parts : [] } + if (isPluginContinuationMessage(message)) return + const text = getText(message.parts) + const commandPrefix = `/${commandName}` + if (text === commandPrefix || text.startsWith(`${commandPrefix} `)) return + + const goal = goalStates.get(sessionID) + if (!goal || goal.stopped) return + await pauseActiveGoal(sessionID, { + stopReason: "user intervention", + status: "Auto-continue paused because a new human message arrived; the latest instruction wins.", + history: "Paused immediately when a new human message arrived; latest instruction wins.", + abortAccepted: true, + }) + }, "tool.execute.before": async (input) => { const sessionID = input?.sessionID if (!sessionID || !currentRuntime().readOnlyCommandGuards.has(sessionID)) return @@ -2985,11 +3255,15 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) output.parts = [makeTextPart(`No active goal. Set one with \`/${commandName} \`.`)] return } + currentRuntime().continuationControllers.get(sessionID)?.abort() goal.stopped = true goal.stopReason = "paused" goal.lastStatus = "Goal paused." + goal.continuationClaim = null + activeContinues.delete(sessionID) pushHistory(goal, "paused", "User paused the active goal.") await persist() + await abortAcceptedContinuation(sessionID) output.parts = [makeTextPart(`Goal paused: ${goal.condition}`)] return } @@ -3051,6 +3325,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) goal.noProgressTurns = 0 goal.noToolCallTurns = 0 goal.formatFailures = 0 + goal.continuationClaim = null goal.lastStatus = "Goal objective updated." pushHistory(goal, "edited", `Objective updated to: ${summarizeText(newObjective, 400)}`) await persist() @@ -3324,17 +3599,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) }, event: async ({ event }) => { - if (isAbortErrorEvent(event)) { + if (event?.type === "session.status") { const sessionID = getSessionID(event) - const goal = goalStates.get(sessionID) - if (!goal) return - currentRuntime().continuationControllers.get(sessionID)?.abort() - goal.stopped = true - goal.stopReason = "user interrupted" - goal.lastStatus = `Goal paused after user interruption. Run /${commandName} resume to continue.` - pushHistory(goal, "paused", "Paused after OpenCode reported that the user interrupted the active turn.") - activeContinues.delete(sessionID) - await persist() + const status = event?.properties?.status?.type || event?.data?.status?.type + if (sessionID && status) currentRuntime().sessionStatuses.set(sessionID, status) + } + + if (event?.type === "session.updated") { + const sessionID = getSessionID(event) + const context = normalizeExecutionContext(event?.properties?.info || event?.data?.info) + if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context) + } + + if (event?.type === "message.updated") { + const message = messageInfoFromEvent(event) + if (messageRole(message) === "user") { + const context = normalizeExecutionContext(message) + const sessionID = messageSessionID(message) || getSessionID(event) + if (sessionID && context) currentRuntime().sessionExecutionContexts.set(sessionID, context) + } + } + + const terminal = terminalEvent(event) + if (terminal?.sessionID) { + await pauseActiveGoal(terminal.sessionID, { + ...terminal, + abortAccepted: true, + }) return } @@ -3410,6 +3701,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) if (!isIdleEvent(event)) return const sessionID = getSessionID(event) + // Deprecated session.idle carries no status object but is itself an + // authoritative idle signal. Current session.status events were recorded + // above before entering this branch. + if (event?.type === "session.idle") { + currentRuntime().sessionStatuses.set(sessionID, "idle") + } currentRuntime().readOnlyCommandGuards.delete(sessionID) const eventID = typeof event?.id === "string" ? event.id : "" const seenIdleEventIDs = currentRuntime().seenIdleEventIDs @@ -3429,6 +3726,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const continueToken = randomUUID() const continueController = new AbortController() + let claimedSourceAssistantMessageID = "" activeContinues.set(sessionID, continueToken) currentRuntime().continuationControllers.set(sessionID, continueController) try { @@ -3440,9 +3738,12 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) : [] const activeGoalAfterMessages = activeGoal(sessionID, goalID, runID) if (!activeGoalAfterMessages) return + if (!activeGoalAfterMessages.executionContext) { + activeGoalAfterMessages.executionContext = findLatestExecutionContext(messages) + } const latestAssistant = findLatestAssistantMessage(messages) - const latestAssistantID = latestAssistant?.info?.id || "" + const latestAssistantID = messageID(latestAssistant) const latestText = getText(latestAssistant?.parts) const latestOutputTokens = latestAssistant ? outputTokensForMessage(latestAssistant) : null const previousAssistantText = activeGoalAfterMessages.lastAssistantText @@ -3462,16 +3763,20 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) // since the last auto-continue, stop driving the loop and defer to the // human. They can /goal resume to hand control back to the plugin. if (userInterventionDetected(messages, activeGoalAfterMessages)) { - activeGoalAfterMessages.stopped = true - activeGoalAfterMessages.stopReason = "user intervention" - activeGoalAfterMessages.lastStatus = - `Auto-continue paused: you sent a new message, so the latest instruction wins. Run /${commandName} resume to continue the goal.` - pushHistory( - activeGoalAfterMessages, - "paused", - "Paused auto-continue after a real user message arrived; latest instruction wins.", - ) - await persist() + await pauseActiveGoal(sessionID, { + stopReason: "user intervention", + status: "Auto-continue paused because a new human message arrived; the latest instruction wins.", + history: "Paused auto-continue after a real user message arrived; latest instruction wins.", + }) + return + } + + const sourceAssistantMessageID = latestAssistantID || "" + if ( + activeGoalAfterMessages.continuationClaim?.runId === runID && + activeGoalAfterMessages.continuationClaim?.sourceAssistantMessageID === + sourceAssistantMessageID + ) { return } @@ -3612,14 +3917,35 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) const limitReason = stopReason(activeGoalAfterMessages) if (limitReason) { if (!activeGoalAfterMessages.budgetWrapupSent) { - activeGoalAfterMessages.budgetWrapupSent = true - activeGoalAfterMessages.stopped = true - activeGoalAfterMessages.stopReason = limitReason - activeGoalAfterMessages.lastStatus = `${limitReason}; requested final handoff.` - pushHistory(activeGoalAfterMessages, "limit", `${limitReason}; requested a final handoff.`) - await sessionApi.promptAsync(sessionID, { - parts: [makeContinuationPart(buildContinueMessage(activeGoalAfterMessages, { budgetWrapup: true }))], - }) + const claimedGoal = await claimContinuationSource( + sessionID, + goalID, + runID, + messages, + ) + if (!claimedGoal) return + claimedSourceAssistantMessageID = + claimedGoal.continuationClaim?.sourceAssistantMessageID || "" + claimedGoal.budgetWrapupSent = true + claimedGoal.stopped = true + claimedGoal.stopReason = limitReason + claimedGoal.lastStatus = `${limitReason}; requested final handoff.` + pushHistory(claimedGoal, "limit", `${limitReason}; requested a final handoff.`) + await persist() + currentRuntime().promptInFlightSessions.add(sessionID) + let response + try { + response = await sessionApi.promptAsync(sessionID, { + ...continuationContextInput(claimedGoal), + parts: [makeContinuationPart(buildContinueMessage(claimedGoal, { budgetWrapup: true }))], + }) + } finally { + currentRuntime().promptInFlightSessions.delete(sessionID) + } + if (response?.error) { + claimedGoal.lastStatus = `${limitReason}; final handoff request failed: ${response.error.name || "unknown error"}.` + pushHistory(claimedGoal, "error", claimedGoal.lastStatus) + } } else { activeGoalAfterMessages.stopped = true activeGoalAfterMessages.stopReason = limitReason @@ -3741,6 +4067,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } const elapsedSinceLastContinue = Date.now() - activeGoalAfterMessages.lastContinueAt + let cooldownWaited = false if ( activeGoalAfterMessages.lastContinueAt && elapsedSinceLastContinue < activeGoalAfterMessages.options.minDelayMs @@ -3750,10 +4077,19 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) continueController.signal, ) if (!delayCompleted) return + cooldownWaited = true } - const activeGoalBeforePrompt = activeGoal(sessionID, goalID, runID) + const activeGoalBeforePrompt = await claimContinuationSource( + sessionID, + goalID, + runID, + messages, + { refreshMessages: cooldownWaited }, + ) if (!activeGoalBeforePrompt) return + claimedSourceAssistantMessageID = + activeGoalBeforePrompt.continuationClaim?.sourceAssistantMessageID || "" const budgetWrapup = budgetWrapupNeeded(activeGoalBeforePrompt) if (budgetWrapup) { @@ -3810,22 +4146,33 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } } - const response = await sessionApi.promptAsync(sessionID, { - parts: [ - makeContinuationPart( - buildContinueMessage(activeGoalBeforePrompt, { - budgetWrapup, - completionUnverified, - blockerUnstated, - }), - ), - ], - }) + currentRuntime().promptInFlightSessions.add(sessionID) + let response + try { + response = await sessionApi.promptAsync(sessionID, { + ...continuationContextInput(activeGoalBeforePrompt), + parts: [ + makeContinuationPart( + buildContinueMessage(activeGoalBeforePrompt, { + budgetWrapup, + completionUnverified, + blockerUnstated, + }), + ), + ], + }) + } finally { + currentRuntime().promptInFlightSessions.delete(sessionID) + } if (response.error) { const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID) const message = `Auto-continue failed: ${response.error.name || "unknown error"}` - if (activeGoalAfterPrompt) { + if ( + activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID === + claimedSourceAssistantMessageID + ) { + activeGoalAfterPrompt.continuationClaim = null activeGoalAfterPrompt.promptFailures += 1 activeGoalAfterPrompt.lastStatus = message pushHistory(activeGoalAfterPrompt, "error", message) @@ -3838,7 +4185,10 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) await logPluginError(client, message, response.error) } else { const activeGoalAfterPrompt = currentGoal(sessionID, goalID, runID) - if (activeGoalAfterPrompt) { + if ( + activeGoalAfterPrompt?.continuationClaim?.sourceAssistantMessageID === + claimedSourceAssistantMessageID + ) { // Decrement rather than reset: an alternating error/success pattern // should still accumulate toward the circuit-breaker cap over time, // matching the formatFailures approach for the same reason. @@ -3856,6 +4206,13 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } catch (error) { const activeGoalAfterError = currentGoal(sessionID, goalID, runID) if (activeGoalAfterError) { + if ( + claimedSourceAssistantMessageID && + activeGoalAfterError.continuationClaim?.sourceAssistantMessageID === + claimedSourceAssistantMessageID + ) { + activeGoalAfterError.continuationClaim = null + } activeGoalAfterError.promptFailures += 1 const message = `Auto-continue failed: ${error?.message || error}` activeGoalAfterError.lastStatus = message @@ -3869,6 +4226,7 @@ async function createGoalPlugin({ client, directory } = {}, pluginOptions = {}) } await logPluginError(client, "Auto-continue failed", error) } finally { + currentRuntime().promptInFlightSessions.delete(sessionID) // Only delete our own entry. If cleanupGoal already removed it (because // the goal completed) and a new handler has since set a fresh token, // we must not clobber the new handler's guard. diff --git a/test/goal-plugin.test.js b/test/goal-plugin.test.js index 9f65ab8..72da41f 100644 --- a/test/goal-plugin.test.js +++ b/test/goal-plugin.test.js @@ -74,12 +74,17 @@ function textPart(text) { return { type: "text", text } } -function message(text, tokens = { input: 1, output: 100, reasoning: 0 }) { +function message( + text, + tokens = { input: 1, output: 100, reasoning: 0 }, + id = "msg-assistant", + sessionID = "session-1", +) { return { info: { - id: "msg-assistant", + id, role: "assistant", - sessionID: "session-1", + sessionID, tokens, }, parts: [textPart(text)], @@ -114,6 +119,7 @@ function pluginContinuationMessage(id = "msg-plugin") { async function createHooks(overrides = {}) { const calls = [] + const aborts = [] const logs = [] const client = { app: { @@ -129,6 +135,13 @@ async function createHooks(overrides = {}) { overrides.promptAsync || (async (input) => { calls.push(input) + overrides.onPromptAsync?.(input) + return {} + }), + abort: + overrides.abort || + (async (input) => { + aborts.push(input) return {} }), }, @@ -137,7 +150,7 @@ async function createHooks(overrides = {}) { { client }, { persistState: false, ...(overrides.options || {}) }, ) - return { calls, hooks, logs } + return { aborts, calls, hooks, logs } } test("exports v1 OpenCode plugin module shape", () => { @@ -778,9 +791,279 @@ test("pause during an in-flight idle handler prevents promptAsync", async () => assert.equal(currentGoal("session-1").stopped, true) }) +test("different idle event IDs cannot continue the same assistant source turn twice", async () => { + const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + + await hooks.event({ + event: { id: "idle-a", type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + await hooks.event({ + event: { id: "idle-b", type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + + assert.equal(calls.length, 1) + assert.equal(currentGoal("session-1").turnCount, 1) + assert.equal(currentGoal("session-1").noToolCallTurns, 0) + assert.equal(currentGoal("session-1").noProgressTurns, 0) + assert.equal(currentGoal("session-1").continuationClaim.sourceAssistantMessageID, "msg-assistant") +}) + +test("a human message arriving during cooldown is re-read and pauses before promptAsync", async () => { + let sourceTurn = 0 + let fetchCount = 0 + let secondFetchStarted + const secondFetch = new Promise((resolve) => { + secondFetchStarted = resolve + }) + const recentMessages = [userMessage("initial request", "user-initial"), message("step zero", undefined, "msg-cooldown-0")] + const { calls, hooks } = await createHooks({ + messages: async () => { + fetchCount += 1 + if (fetchCount === 2) secondFetchStarted() + return { data: recentMessages } + }, + onPromptAsync: () => { + sourceTurn += 1 + }, + options: { minDelayMs: 100 }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { id: "idle-first", type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + recentMessages.splice( + 0, + recentMessages.length, + userMessage("initial request", "user-initial"), + pluginContinuationMessage("plugin-turn-0"), + message("step one", undefined, `msg-cooldown-${sourceTurn}`), + ) + + const idle = hooks.event({ + event: { id: "idle-second", type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + await secondFetch + recentMessages.push(userMessage("stop and do this instead", "user-during-cooldown")) + await idle + + assert.equal(calls.length, 1) + assert.equal(currentGoal("session-1").stopped, true) + assert.equal(currentGoal("session-1").stopReason, "user intervention") +}) + +test("a busy status arriving during cooldown suppresses the stale continuation", async () => { + let sourceTurn = 0 + let fetchCount = 0 + let secondFetchStarted + const secondFetch = new Promise((resolve) => { + secondFetchStarted = resolve + }) + const recentMessages = [message("step zero", undefined, "msg-status-0")] + const { calls, hooks } = await createHooks({ + messages: async () => { + fetchCount += 1 + if (fetchCount === 2) secondFetchStarted() + return { data: recentMessages } + }, + onPromptAsync: () => { + sourceTurn += 1 + }, + options: { minDelayMs: 100 }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + recentMessages.splice(0, 1, message("step one", undefined, `msg-status-${sourceTurn}`)) + + const idle = hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + await secondFetch + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "busy" } } }, + }) + await idle + + assert.equal(calls.length, 1) + assert.equal(currentGoal("session-1").stopped, false) +}) + +test("continuations preserve the goal-initiating agent, model, and variant", async () => { + const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) + await hooks["chat.message"]( + { + sessionID: "session-1", + agent: "build", + model: { providerID: "openrouter", modelID: "deepseek/deepseek-r1" }, + variant: "high", + }, + { message: { role: "user" }, parts: [textPart("/goal ship it")] }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + + assert.equal(calls.length, 1) + assert.equal(calls[0].body.agent, "build") + assert.deepEqual(calls[0].body.model, { providerID: "openrouter", modelID: "deepseek/deepseek-r1" }) + assert.equal(calls[0].body.variant, "high") +}) + +test("switching the active session agent to Plan pauses before continuing", async () => { + const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { + type: "session.updated", + properties: { + sessionID: "session-1", + info: { sessionID: "session-1", agent: "Plan", model: { providerID: "openai", id: "gpt-5" } }, + }, + }, + }) + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + + assert.equal(calls.length, 0) + assert.equal(currentGoal("session-1").stopped, true) + assert.equal(currentGoal("session-1").stopReason, "plan agent active") +}) + +test("message aborts and provider errors pause before a following idle", async (t) => { + for (const scenario of [ + { + name: "message.updated abort", + event: { + type: "message.updated", + properties: { + info: { + id: "assistant-aborted", + role: "assistant", + sessionID: "session-1", + error: { name: "MessageAbortedError", data: { message: "interrupted" } }, + }, + }, + }, + reason: "user interrupted", + }, + { + name: "session.error provider failure", + event: { + type: "session.error", + properties: { + sessionID: "session-1", + error: { name: "ProviderAuthError", data: { message: "token expired" } }, + }, + }, + reason: "provider error", + }, + ]) { + await t.test(scenario.name, async () => { + const { calls, hooks } = await createHooks({ options: { minDelayMs: 1 } }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ event: scenario.event }) + await hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + assert.equal(calls.length, 0) + assert.equal(currentGoal("session-1").stopped, true) + assert.equal(currentGoal("session-1").stopReason, scenario.reason) + }) + } +}) + +test("legacy and v2 permission rejection shapes both pause the goal", async (t) => { + for (const properties of [ + { sessionID: "session-1", permissionID: "perm-1", response: "rejected" }, + { sessionID: "session-1", requestID: "perm-2", reply: "reject" }, + ]) { + await t.test(properties.response ? "legacy response" : "v2 reply", async () => { + const { hooks } = await createHooks() + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ event: { type: "permission.replied", properties } }) + assert.equal(currentGoal("session-1").stopped, true) + assert.equal(currentGoal("session-1").stopReason, "permission rejected") + }) + } +}) + +test("a human message aborts an already accepted continuation", async () => { + let resolvePrompt + let promptStarted + const started = new Promise((resolve) => { + promptStarted = resolve + }) + const pendingPrompt = new Promise((resolve) => { + resolvePrompt = resolve + }) + const { aborts, hooks } = await createHooks({ + promptAsync: async () => { + promptStarted() + await pendingPrompt + return {} + }, + options: { minDelayMs: 1 }, + }) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-1", arguments: "ship it" }, + { parts: [] }, + ) + + const idle = hooks.event({ + event: { type: "session.status", properties: { sessionID: "session-1", status: { type: "idle" } } }, + }) + await started + await hooks["chat.message"]( + { + sessionID: "session-1", + agent: "build", + model: { providerID: "openai", modelID: "gpt-5" }, + }, + { message: { role: "user" }, parts: [textPart("stop; I need to change direction")] }, + ) + resolvePrompt() + await idle + + assert.equal(aborts.length, 1) + assert.equal(aborts[0].path.id, "session-1") + assert.equal(currentGoal("session-1").stopped, true) + assert.equal(currentGoal("session-1").stopReason, "user intervention") +}) + test("near-zero repeated output pauses after the configured grace window", async () => { + let sourceTurn = 0 const { calls, hooks } = await createHooks({ - messages: async () => ({ data: [message("ok", { input: 1, output: 5, reasoning: 0 })] }), + messages: async () => ({ + data: [message("ok", { input: 1, output: 5, reasoning: 0 }, `msg-low-${sourceTurn}`)], + }), + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1, noProgressTokenThreshold: 50, noProgressTurnsBeforePause: 2 }, }) await hooks["command.execute.before"]( @@ -824,9 +1107,15 @@ test("messageHasToolCall detects tool/subtask parts", () => { }) test("continuation turns with no tool calls pause after the grace window", async () => { + let sourceTurn = 0 const { calls, hooks } = await createHooks({ // High output (so the low-output check never fires) but text-only: no tools. - messages: async () => ({ data: [message("Thinking out loud about the plan.")] }), + messages: async () => ({ + data: [message("Thinking out loud about the plan.", undefined, `msg-talk-${sourceTurn}`)], + }), + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1, noToolCallTurnsBeforePause: 2 }, }) await hooks["command.execute.before"]( @@ -847,8 +1136,16 @@ test("continuation turns with no tool calls pause after the grace window", async }) test("continuation turns that use tools do not trip the no-tool-call gate", async () => { + let sourceTurn = 0 const { calls, hooks } = await createHooks({ - messages: async () => ({ data: [toolMessage("Ran the build.")] }), + messages: async () => { + const next = toolMessage("Ran the build.") + next.info.id = `msg-tool-${sourceTurn}` + return { data: [next] } + }, + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1, noToolCallTurnsBeforePause: 2 }, }) await hooks["command.execute.before"]( @@ -879,23 +1176,23 @@ test("plugin option zero disables the no-tool-call heuristic", () => { }) test("short assistant updates that change content do not immediately count as stalled", async () => { - let callCount = 0 + let sourceTurn = 0 const { calls, hooks } = await createHooks({ - messages: async () => { - callCount += 1 - return { + messages: async () => ({ data: [ { info: { - id: `msg-${callCount}`, + id: `msg-${sourceTurn}`, role: "assistant", sessionID: "session-changing", tokens: { input: 1, output: 5, reasoning: 0 }, }, - parts: [textPart(callCount === 1 ? "step one" : "step two")], + parts: [textPart(sourceTurn === 0 ? "step one" : "step two")], }, ], - } + }), + onPromptAsync: () => { + sourceTurn += 1 }, options: { minDelayMs: 1, noProgressTokenThreshold: 50, noProgressTurnsBeforePause: 2 }, }) @@ -927,15 +1224,13 @@ test("tool-calling turns with low output tokens are not counted as stalled", asy // tools with very little prose output (< 50 tokens, no text body), so two consecutive // such turns would incorrectly pause the goal. The fix: !latestHasToolCall is now // a required condition for lowOutputLooksStalled. - let callCount = 0 + let sourceTurn = 0 const { calls, hooks } = await createHooks({ - messages: async () => { - callCount += 1 - return { + messages: async () => ({ data: [ { info: { - id: `msg-tool-${callCount}`, + id: `msg-tool-${sourceTurn}`, role: "assistant", sessionID: "session-tool-stall", tokens: { input: 1, output: 5, reasoning: 50000 }, @@ -944,7 +1239,9 @@ test("tool-calling turns with low output tokens are not counted as stalled", asy parts: [{ type: "tool", tool: "bash", state: { status: "completed" } }], }, ], - } + }), + onPromptAsync: () => { + sourceTurn += 1 }, options: { minDelayMs: 1, noProgressTokenThreshold: 50, noProgressTurnsBeforePause: 2 }, }) @@ -1035,8 +1332,14 @@ test("maxRecentMessages is forwarded to the recent-message lookup", async () => }) test("stopped goals can be resumed", async () => { + let sourceTurn = 0 const { hooks } = await createHooks({ - messages: async () => ({ data: [message("ok", { input: 1, output: 5, reasoning: 0 })] }), + messages: async () => ({ + data: [message("ok", { input: 1, output: 5, reasoning: 0 }, `msg-stopped-${sourceTurn}`)], + }), + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1, noProgressTokenThreshold: 50, noProgressTurnsBeforePause: 1 }, }) await hooks["command.execute.before"]( @@ -1144,7 +1447,12 @@ test("inspection, pause, and clear commands block mutation tools when command te }) test("resume after a limit stop starts a fresh local budget", async () => { + let sourceTurn = 0 const { calls, hooks } = await createHooks({ + messages: async () => ({ data: [message("still working", undefined, `msg-limit-${sourceTurn}`)] }), + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1, maxTurns: 1 }, }) await hooks["command.execute.before"]( @@ -1594,8 +1902,9 @@ test("adjacent flags do not corrupt each other and still surface missing values" }) test("no-progress pause takes precedence over budget wrap-up threshold", async () => { + let recentMessage = message("ok", { input: 1, output: 5, reasoning: 0 }, "msg-budget-initial") const { calls, hooks } = await createHooks({ - messages: async () => ({ data: [message("ok", { input: 1, output: 5, reasoning: 0 })] }), + messages: async () => ({ data: [recentMessage] }), options: { minDelayMs: 1, maxTokens: 100, @@ -1615,6 +1924,11 @@ test("no-progress pause takes precedence over budget wrap-up threshold", async ( properties: { sessionID: "session-1", status: { type: "idle" } }, }, }) + recentMessage = message( + "ok", + { input: 80, output: 5, reasoning: 0 }, + "msg-budget-low-output", + ) await hooks.event({ event: { type: "message.updated", @@ -1805,6 +2119,75 @@ test("persisted running goals are recovered in paused state after restart", asyn } }) +test("continuation source claims and initiating execution context persist before promptAsync", async () => { + const dir = await mkdtemp(join(tmpdir(), "goal-plugin-claim-test-")) + const stateFilePath = join(dir, "state.json") + let hooks + let recoveredHooks + + try { + const client = { + app: { log: async () => {} }, + session: { + messages: async () => ({ + data: [message("still working", undefined, "assistant-durable-source", "session-durable-claim")], + }), + promptAsync: async () => ({}), + }, + } + hooks = await GoalPlugin( + { client }, + { persistState: true, stateFilePath, minDelayMs: 1 }, + ) + await hooks["chat.message"]( + { + sessionID: "session-durable-claim", + agent: "build", + model: { providerID: "openrouter", modelID: "model-a" }, + variant: "high", + }, + { message: { role: "user" }, parts: [textPart("/goal ship it")] }, + ) + await hooks["command.execute.before"]( + { command: "goal", sessionID: "session-durable-claim", arguments: "ship it" }, + { parts: [] }, + ) + await hooks.event({ + event: { + type: "session.status", + properties: { sessionID: "session-durable-claim", status: { type: "idle" } }, + }, + }) + + const persisted = JSON.parse(await readFile(stateFilePath, "utf8")) + const goal = persisted.goals.find((entry) => entry.sessionID === "session-durable-claim") + assert.deepEqual(goal.executionContext, { + agent: "build", + model: { providerID: "openrouter", modelID: "model-a" }, + variant: "high", + }) + assert.deepEqual(goal.continuationClaim, { + runId: goal.runId, + sourceAssistantMessageID: "assistant-durable-source", + }) + + await hooks.dispose() + hooks = null + recoveredHooks = await GoalPlugin( + { client }, + { persistState: true, stateFilePath, minDelayMs: 1 }, + ) + const recovered = currentGoal("session-durable-claim") + assert.equal(recovered.stopped, true) + assert.equal(recovered.continuationClaim, null) + assert.deepEqual(recovered.executionContext, goal.executionContext) + } finally { + await hooks?.dispose() + await recoveredHooks?.dispose() + await rm(dir, { recursive: true, force: true }) + } +}) + test("corrupt persisted state is preserved and not overwritten on startup", async () => { const dir = await mkdtemp(join(tmpdir(), "goal-plugin-test-")) const stateFilePath = join(dir, "state.json") @@ -2039,8 +2422,14 @@ test("repeated [goal:complete]-without-evidence re-prompts pause the goal after // Regression: completionUnverified re-prompts never counted toward promptFailures, // so a model that consistently omits [goal:evidence] would loop until a hard limit. // formatFailures counter now caps this at maxPromptFailures consecutive format failures. + let sourceTurn = 0 const { calls, hooks } = await createHooks({ - messages: async () => ({ data: [message("All done!\n\n[goal:complete]")] }), + messages: async () => ({ + data: [message("All done!\n\n[goal:complete]", undefined, `msg-fmt-complete-${sourceTurn}`)], + }), + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1, maxPromptFailures: 2 }, }) await hooks["command.execute.before"]( @@ -2068,8 +2457,14 @@ test("repeated [goal:complete]-without-evidence re-prompts pause the goal after }) test("repeated [goal:blocked]-without-blocker re-prompts pause the goal after maxPromptFailures", async () => { + let sourceTurn = 0 const { calls, hooks } = await createHooks({ - messages: async () => ({ data: [message("[goal:blocked]")] }), + messages: async () => ({ + data: [message("[goal:blocked]", undefined, `msg-fmt-blocked-${sourceTurn}`)], + }), + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1, maxPromptFailures: 2 }, }) await hooks["command.execute.before"]( @@ -2094,14 +2489,19 @@ test("repeated [goal:blocked]-without-blocker re-prompts pause the goal after ma }) test("formatFailures resets to zero when the model produces a valid response", async () => { - let turnCount = 0 + let sourceTurn = 0 const { calls, hooks } = await createHooks({ - messages: async () => { - turnCount += 1 - // Turn 1: invalid (no evidence); Turn 2: valid response - return { - data: [turnCount < 2 ? message("All done!\n\n[goal:complete]") : message("Still working on it.")], - } + messages: async () => ({ + data: [ + message( + sourceTurn === 0 ? "All done!\n\n[goal:complete]" : "Still working on it.", + undefined, + `msg-fmt-reset-${sourceTurn}`, + ), + ], + }), + onPromptAsync: () => { + sourceTurn += 1 }, options: { minDelayMs: 1, maxPromptFailures: 3 }, }) @@ -4887,16 +5287,19 @@ test("agent clearGoal removes background goals so they do not resurrect on resta test("formatFailures decrements by 1 on a clean turn instead of resetting to 0", async () => { // Scenario: 2 consecutive format failures, then one clean turn. // Old behavior: formatFailures → 0. New: formatFailures → 1. - let msgSeq = 0 + let sourceTurn = 0 const { hooks } = await createHooks({ messages: async () => ({ data: [ { - info: { id: `msg-ff-${++msgSeq}`, role: "assistant", sessionID: "ff-decrement-s1", tokens: { input: 1, output: 200, reasoning: 0 } }, + info: { id: `msg-ff-${sourceTurn}`, role: "assistant", sessionID: "ff-decrement-s1", tokens: { input: 1, output: 200, reasoning: 0 } }, parts: [textPart("Almost done!\n[goal:complete]")], // no [goal:evidence] → format failure }, ], }), + onPromptAsync: () => { + sourceTurn += 1 + }, options: { minDelayMs: 1 }, }) @@ -4916,16 +5319,19 @@ test("formatFailures decrements by 1 on a clean turn instead of resetting to 0", assert.equal(currentGoal("ff-decrement-s1").formatFailures, 2) // Now switch to a clean turn (no completion marker, plenty of output). - msgSeq = 0 + let cleanSourceTurn = 0 const { hooks: hooks2 } = await createHooks({ messages: async () => ({ data: [ { - info: { id: `msg-clean-${++msgSeq}`, role: "assistant", sessionID: "ff-decrement-s2", tokens: { input: 1, output: 200, reasoning: 0 } }, + info: { id: `msg-clean-${cleanSourceTurn}`, role: "assistant", sessionID: "ff-decrement-s2", tokens: { input: 1, output: 200, reasoning: 0 } }, parts: [textPart("Still working on it, making good progress.")], }, ], }), + onPromptAsync: () => { + cleanSourceTurn += 1 + }, options: { minDelayMs: 1 }, }) // Set up a fresh goal with formatFailures pre-set to 2 to test the decrement.