From da561324c3381f765ad57617924be1d727d03fb3 Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Tue, 3 Nov 2026 15:45:57 +0530 Subject: [PATCH 1/2] fix(session): drop the clarification option from the run-mode nudge and apply the completion contract to every agent in run mode In headless `altimate-code run` nobody can answer a question, but the post-compaction nudge offered "stop and ask for clarification" as one of its three options, so runs ended on a question instead of finishing. And the run-mode completion contract (end with a trailing `DONE`) was gated on an allowlist holding only `builder`, so custom agents and task subagents ran without it. - `COMPLETION_NUDGE` (sent only when `ALTIMATE_RUN_MODE` is on) now offers two options: continue (picking the most reasonable interpretation and stating the assumption when unsure) or reply `DONE`. It says no one can answer questions during the run. - `completionInstruction()` returns the contract for every agent in run mode; the `COMPLETION_CONTRACT_AGENTS` allowlist and the `agent` input are removed. Task subagents are prompted through the same loop, so they get it too. Interactive sessions still get nothing. - The interactive compaction continuation in `compaction.ts` is unchanged and is now pinned byte-for-byte by tests. - Loop-level test: a custom subagent started from a subtask receives the contract in run mode and not in interactive mode. Co-Authored-By: Claude Fable 5.1 --- packages/opencode/src/session/compaction.ts | 6 +- packages/opencode/src/session/prompt.ts | 5 +- packages/opencode/src/session/termination.ts | 45 ++++++++------ .../compaction-summarizer-integrity.test.ts | 12 +++- packages/opencode/test/session/prompt.test.ts | 58 +++++++++++++++++++ .../opencode/test/session/termination.test.ts | 34 ++++++++--- 6 files changed, 123 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d2f9d05cf7..bf90d9274d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1765,9 +1765,9 @@ When constructing the summary, try to stick to this template: : "" // altimate_change end // altimate_change start — completion-aware termination path: - // (b) the continue message carries the three-option completion-aware nudge - // (continue / ask for clarification / assert DONE), giving a finished - // session a termination path. Delivered via the NudgeArbiter (Global + // (b) the run-mode continue message carries the completion-aware nudge + // (continue / assert DONE — no clarification option, since nobody can + // answer in a headless run), giving a finished session a termination path. Delivered via the NudgeArbiter (Global // rule 5): this injection point registers its candidate and takes the // single winner, so pending lower-precedence directives (starvation // breaker, budget reminder) are consumed here and the injected turn diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bd832d27ab..0c57bbc2d9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2069,11 +2069,10 @@ export namespace SessionPrompt { // used to sit in builder.txt, but builder is a PRIMARY agent, so it also // reached interactive chat, where nothing interprets or strips the token // and the user saw a literal DONE on every final answer. Scoped to run - // mode AND to builder, which reproduces the previous run-mode behaviour - // exactly — builder was the only agent prompt that carried it. + // mode, where it reaches every agent — including custom agents and task + // subagents, whose child sessions are prompted through this same loop. const completionInstruction = SessionTermination.completionInstruction({ runMode: Flag.ALTIMATE_RUN_MODE, - agent: agent.name, }) if (completionInstruction) system.push(completionInstruction) // altimate_change end diff --git a/packages/opencode/src/session/termination.ts b/packages/opencode/src/session/termination.ts index b2bd16e158..3d1f123e39 100644 --- a/packages/opencode/src/session/termination.ts +++ b/packages/opencode/src/session/termination.ts @@ -173,8 +173,9 @@ export function explicitDoneStop(input: { return isExplicitDone(lastText.text) } +// altimate_change start — run-mode completion contract for every agent /** - * Run-mode completion instruction for builder. + * Run-mode completion instruction. * * This wording lived in `builder.txt`, but builder is a PRIMARY agent, so a * static instruction there also governs interactive chat — where nothing @@ -182,10 +183,9 @@ export function explicitDoneStop(input: { * final answer, including mid-conversation on follow-ups. `isExplicitDone()` * is only consumed by the run-mode accounting path. * - * Injected only in run mode, and only for the agents named in - * COMPLETION_CONTRACT_AGENTS below (builder). Byte-identical to builder's - * original run-mode behaviour, from when this text was still static in - * `builder.txt`. Prompt-visible text — changes need extra review. + * Injected only in run mode. Byte-identical to builder's original run-mode + * behaviour, from when this text was still static in `builder.txt`. + * Prompt-visible text — changes need extra review. */ export const RUN_MODE_COMPLETION_INSTRUCTION = "**Signal completion explicitly**: only after every requirement above is satisfied, end your final " + @@ -193,27 +193,34 @@ export const RUN_MODE_COMPLETION_INSTRUCTION = "while work or verification remains." /** - * Agents that receive the run-mode completion-token contract. builder is the - * historical (and currently sole) carrier. + * The sole gate for injecting the completion-token contract into a prompt. + * In run mode it applies to every agent — builder, custom agents and + * subagents started by the task tool — because nobody can answer a question + * in a headless run, so any agent without the contract can end on one. + * Interactive sessions never receive it. */ -const COMPLETION_CONTRACT_AGENTS = new Set(["builder"]) - -/** The sole gate for injecting the completion-token contract into a prompt. */ -export function completionInstruction(input: { runMode: boolean; agent: string }): string | undefined { - return input.runMode && COMPLETION_CONTRACT_AGENTS.has(input.agent) ? RUN_MODE_COMPLETION_INSTRUCTION : undefined +export function completionInstruction(input: { runMode: boolean }): string | undefined { + return input.runMode ? RUN_MODE_COMPLETION_INSTRUCTION : undefined } +// altimate_change end +// altimate_change start — run-mode post-compaction nudge without a clarification option /** - * Three-option completion-aware post-compaction nudge. Replaces the + * Completion-aware post-compaction nudge, sent only in run mode (interactive + * compaction keeps its ordinary continuation in compaction.ts). Replaces the * two-option "Continue … or stop and ask for clarification" text, which gave a - * finished session no way to terminate. Prompt-visible text — changes need - * extra review. + * finished session no way to terminate. It offers no "ask for clarification" + * option: nobody can answer in a headless run, and that option is how runs + * ended on a question instead of finishing. Prompt-visible text — changes + * need extra review. */ export const COMPLETION_NUDGE = - "Context was compacted; the summary above is the record of the work so far. Choose exactly one: " + - "(1) if concrete next steps remain toward the original task, continue with them; " + - "(2) if you are blocked or unsure how to proceed, stop and ask for clarification; " + - `(3) if the deliverable is complete and verified, reply with ${DONE_TOKEN} alone on the final line and stop.` + "Context was compacted; the summary above is the record of the work so far. " + + "No one can answer questions during this run, so do not end your turn with a question. Choose exactly one: " + + "(1) if concrete next steps remain toward the original task, continue with them; if you are unsure how to " + + "proceed, pick the most reasonable interpretation, state your assumption, and continue; " + + `(2) if the deliverable is complete and verified, reply with ${DONE_TOKEN} alone on the final line and stop.` +// altimate_change end /** * One-shot confirm-DONE challenge injected by the run-mode diff --git a/packages/opencode/test/session/compaction-summarizer-integrity.test.ts b/packages/opencode/test/session/compaction-summarizer-integrity.test.ts index 3b73401e8f..526d725558 100644 --- a/packages/opencode/test/session/compaction-summarizer-integrity.test.ts +++ b/packages/opencode/test/session/compaction-summarizer-integrity.test.ts @@ -463,7 +463,7 @@ describe("session.compaction summarizer integrity (/ item 3)", () => { // ─── Harness reliability (b)+(d) (item 1): completion-aware continue nudge via the // nudge arbiter, and the mechanism-accurate overflow notice ────────────────────── describe("session.compaction continue-nudge termination path (/d)", () => { - test("continue message carries the three-option completion-aware nudge", async () => { + test("continue message carries the run-mode completion-aware nudge", async () => { const sessionID = freshSessionID() const { messages, markerID } = history(sessionID) processBehaviors = [writeSummary("a real summary")] @@ -474,7 +474,9 @@ describe("session.compaction continue-nudge termination path (/d)", () => { const continuePart = store.parts.find((p) => p.type === "text" && p.synthetic) expect(continuePart?.text).toContain(SessionTermination.COMPLETION_NUDGE) expect(continuePart?.text).toContain("reply with DONE") - expect(continuePart?.text).toContain("ask for clarification") + // altimate_change start — the run-mode nudge offers no clarification stop + expect(continuePart?.text).not.toContain("clarification") + // altimate_change end }) test("interactive compaction retains the ordinary continuation and never injects run-only DONE instructions", async () => { @@ -487,7 +489,11 @@ describe("session.compaction continue-nudge termination path (/d)", () => { expect(result).toBe("continue") const continuePart = store.parts.find((p) => p.type === "text" && p.synthetic) - expect(continuePart?.text).toContain("Continue if you have next steps") + // altimate_change start — byte-for-byte pin of the interactive continuation + expect(continuePart?.text).toContain( + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", + ) + // altimate_change end expect(continuePart?.text).not.toContain(SessionTermination.COMPLETION_NUDGE) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 438376b0a7..ce4f7731c1 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1842,6 +1842,64 @@ it.instance("subtask child inherits parent session external_directory allow", () }), ) +// altimate_change start — run mode gives a custom subagent started by the task tool the +// completion contract; interactive mode gives it nothing. Neither mode's prompts offer a +// clarification stop. +for (const runMode of [true, false]) { + it.instance(`a custom subagent's prompt ${runMode ? "carries" : "omits"} the completion contract (run mode: ${runMode})`, () => + Effect.gen(function* () { + const previous = process.env.ALTIMATE_RUN_MODE + process.env.ALTIMATE_RUN_MODE = runMode ? "1" : "0" + try { + const { llm } = yield* useServerConfig((url) => ({ + ...providerCfg(url), + agent: { reviewer: { mode: "subagent", description: "Reviews a change.", prompt: "You review changes." } }, + })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Parent" }) + yield* llm.text("Reviewed the cache key path.\nDONE") + yield* llm.text("All done.\nDONE") + const msg = yield* user(chat.id, "hello") + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: chat.id, + type: "subtask", + prompt: "look into the cache key path", + description: "inspect bug", + agent: "reviewer", + model: ref, + }) + + yield* prompt.loop({ sessionID: chat.id }) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + const child = yield* MessageV2.filterCompactedEffect(kids[0]!.id) + expect(child.some((item) => item.info.role === "assistant" && item.info.agent === "reviewer")).toBe(true) + const inputs = yield* llm.inputs + expect(inputs.length).toBeGreaterThanOrEqual(1) + // The first request is the reviewer child's own turn. + expect(systemBytes(inputs[0]!)).toContain("You review changes.") + for (const input of inputs) { + const system = requestMessages(input) + .filter((message) => message.role === "system") + .map((message) => requestText(message.content)) + .join("\n") + if (runMode) expect(system).toContain(SessionTermination.RUN_MODE_COMPLETION_INSTRUCTION) + else expect(system).not.toContain(SessionTermination.RUN_MODE_COMPLETION_INSTRUCTION) + expect(JSON.stringify(input.messages)).not.toContain("stop and ask for clarification") + } + } finally { + if (previous === undefined) delete process.env.ALTIMATE_RUN_MODE + else process.env.ALTIMATE_RUN_MODE = previous + } + }), + ) +} +// altimate_change end + noLLMServer.instance("prompt tools replace previous prompt tool rules", () => Effect.gen(function* () { const prompt = yield* SessionPrompt.Service diff --git a/packages/opencode/test/session/termination.test.ts b/packages/opencode/test/session/termination.test.ts index 13e2bdaba3..c1d45300c5 100644 --- a/packages/opencode/test/session/termination.test.ts +++ b/packages/opencode/test/session/termination.test.ts @@ -159,15 +159,27 @@ describe("SessionTermination.explicitDoneStop (stop-path decision)", () => { }) describe("SessionTermination directive texts (/c/d wording contracts)", () => { - test("the completion nudge offers all three options and instructs the DONE token", () => { + // altimate_change start — the run-mode nudge has no clarification option + test("the run-mode completion nudge offers continue or DONE, never a clarification stop", () => { const nudge = SessionTermination.COMPLETION_NUDGE expect(nudge).toContain("(1)") expect(nudge).toContain("(2)") - expect(nudge).toContain("(3)") - expect(nudge).toContain("ask for clarification") + expect(nudge).not.toContain("(3)") + expect(nudge).not.toContain("clarification") + expect(nudge).toContain("do not end your turn with a question") expect(nudge).toContain(`reply with ${SessionTermination.DONE_TOKEN}`) }) + test("the interactive compaction continuation is unchanged and stays out of the run-mode nudge", async () => { + const compaction = await Bun.file(new URL("../../src/session/compaction.ts", import.meta.url).pathname).text() + // Byte-for-byte pin of the interactive continuation text. + expect(compaction).toContain( + '"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."', + ) + expect(compaction).toMatch(/if \(Flag\.ALTIMATE_RUN_MODE\) \{\s*NudgeArbiter\.register\(/) + }) + // altimate_change end + test("the confirm challenge asks for DONE or what remains — outcome-neutral", () => { const challenge = SessionTermination.CONFIRM_DONE_CHALLENGE expect(challenge).toContain(SessionTermination.DONE_TOKEN) @@ -257,19 +269,23 @@ describe("builder completion contract", () => { // The instruction still reaches an ordinary non-compacted RUN — the wording is // unchanged, it simply moved out of the static prompt file so that it is // injected per-run rather than shipped to every surface. - test("only a run-mode builder receives the trailing DONE contract", () => { - const instruction = SessionTermination.completionInstruction({ runMode: true, agent: "builder" }) + // altimate_change start — run mode gives every agent the contract; interactive gives none + test("run mode receives the trailing DONE contract; interactive never does", () => { + const instruction = SessionTermination.completionInstruction({ runMode: true }) + expect(instruction).toBe(SessionTermination.RUN_MODE_COMPLETION_INSTRUCTION) expect(instruction).toContain("literal token `DONE` on its own") expect(instruction).toContain("Do not emit `DONE` while work or verification remains") - expect(SessionTermination.completionInstruction({ runMode: false, agent: "builder" })).toBeUndefined() - expect(SessionTermination.completionInstruction({ runMode: true, agent: "plan" })).toBeUndefined() + expect(SessionTermination.completionInstruction({ runMode: false })).toBeUndefined() }) - test("prompt assembly wires the contract to the run-mode flag", async () => { + test("prompt assembly wires the contract to the run-mode flag only, not to an agent allowlist", async () => { const prompt = await Bun.file(new URL("../../src/session/prompt.ts", import.meta.url).pathname).text() expect(prompt).toMatch( - /SessionTermination\.completionInstruction\(\{\s*runMode: Flag\.ALTIMATE_RUN_MODE,\s*agent: agent\.name,\s*\}\)/, + /SessionTermination\.completionInstruction\(\{\s*runMode: Flag\.ALTIMATE_RUN_MODE,\s*\}\)/, ) + const termination = await Bun.file(new URL("../../src/session/termination.ts", import.meta.url).pathname).text() + expect(termination).not.toContain("COMPLETION_CONTRACT_AGENTS") + // altimate_change end expect(prompt).toMatch(/if \(completionInstruction\) system\.push\(completionInstruction\)/) expect(prompt).not.toMatch(/ALTIMATE_CODE_HEADLESS[^\n]*RUN_MODE_COMPLETION_INSTRUCTION/) }) From 2760562b2eb620b40fbf50ef373f442c5848dc5b Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Thu, 5 Nov 2026 16:01:42 +0530 Subject: [PATCH 2/2] fix(run): strip the completion token at the output boundary and give a blocked run its own exit - `run` (run mode): a trailing `DONE` line is removed from stdout, JSON `text` events and `--output`; `done_reason` detection is unchanged. With `ALTIMATE_RUN_MODE=0` text is printed as written - `task` tool (run mode): the child's trailing `DONE` is removed from ``; when its last reply is a bare `DONE`, the result is its latest earlier report in that prompt - Contract and post-compaction nudge gain a third ending: when something that cannot be obtained is missing, or the only way forward is a destructive or irreversible guess, state what was completed, end with `BLOCKED: ` and stop without `DONE`; the "unsure" clause is limited to safe, reversible choices - `done_reason=blocked` (new): `run` exits 1 and prints a stderr line in text mode; a blocked reply to the idle-done challenge is not treated as a decline - Contract wording: "when the assigned task is fully completed and verified", and says not to end the turn with a question - Run-mode overflow notice no longer asks the user to re-supply anything; the interactive notice and continuation are unchanged - `plan` gets no contract (its prompt ends with a question or `plan_exit`); structured-output turns get no contract - Tests: output boundary through the real `run` process, blocked `done_reason`, task result stripping and bare-`DONE` recovery, loop-level custom primary agent and plan agent, run/interactive overflow notice Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/cli/cmd/run-accounting.ts | 20 +++- packages/opencode/src/cli/cmd/run-output.ts | 12 +- packages/opencode/src/cli/cmd/run.ts | 31 +++-- packages/opencode/src/session/compaction.ts | 12 +- packages/opencode/src/session/prompt.ts | 12 +- packages/opencode/src/session/task-output.ts | 27 +++++ packages/opencode/src/session/termination.ts | 103 ++++++++++++---- packages/opencode/src/tool/task.ts | 8 +- .../opencode/test/cli/cmd/run-output.test.ts | 23 ++++ .../opencode/test/cli/run-accounting.test.ts | 26 ++++ .../test/cli/run/run-mode-output.test.ts | 96 +++++++++++++++ .../compaction-summarizer-integrity.test.ts | 30 ++++- packages/opencode/test/session/prompt.test.ts | 47 ++++++++ .../opencode/test/session/termination.test.ts | 98 +++++++++++---- packages/opencode/test/tool/task.test.ts | 112 ++++++++++++++++++ 15 files changed, 593 insertions(+), 64 deletions(-) create mode 100644 packages/opencode/test/cli/run/run-mode-output.test.ts diff --git a/packages/opencode/src/cli/cmd/run-accounting.ts b/packages/opencode/src/cli/cmd/run-accounting.ts index ecd915bcb3..4e1b16db5e 100644 --- a/packages/opencode/src/cli/cmd/run-accounting.ts +++ b/packages/opencode/src/cli/cmd/run-accounting.ts @@ -23,7 +23,10 @@ export type WhyHarnessStopped = "budget-exhausted" | "timeout" | "error" | "idle // idle-done confirm challenge (idle_heuristic). "none" = the session ended // without any completion assertion — bare finishReason "stop" is NEVER // reported as done. -export type DoneReason = "explicit_done" | "idle_heuristic" | "none" +// altimate_change start — "blocked" = the model ended on a `BLOCKED: ` +// line: it reported that it cannot proceed, so the task did not happen. +export type DoneReason = "explicit_done" | "idle_heuristic" | "blocked" | "none" +// altimate_change end export type Termination = { why_model_stopped: WhyModelStopped why_harness_stopped: WhyHarnessStopped @@ -107,6 +110,9 @@ export function create() { let lastTextMessageID: string | undefined // altimate_change end let lastTextExplicitDone = false + // altimate_change start — run-mode blocked ending + let lastTextBlocked = false + // altimate_change end let lastTextFromChallenge = false let budgetExhausted = false // altimate_change start — an answered time-budget finalization recovers the exit status only @@ -187,6 +193,9 @@ export function create() { if (isCompactionStep(messageID)) return if (synthetic) return lastTextExplicitDone = SessionTermination.isExplicitDone(text) + // altimate_change start — run-mode blocked ending + lastTextBlocked = !lastTextExplicitDone && SessionTermination.isBlocked(text) + // altimate_change end lastTextMessageID = messageID lastTextFromChallenge = lastTextExplicitDone && challengeReplyActive }, @@ -333,6 +342,15 @@ export function create() { // the idle-done confirm challenge, it is honestly attributed to the // heuristic, not to unprompted model completion. const done: DoneReason = (() => { + // altimate_change start — a blocked ending on the finish message is reported, never as done + if ( + lastFinishReason === "stop" && + lastTextBlocked && + lastTextMessageID !== undefined && + lastTextMessageID === lastFinishMessageID + ) + return "blocked" + // altimate_change end if (lastFinishReason !== "stop" || !explicitDoneOnFinishMessage) return "none" return lastTextFromChallenge ? "idle_heuristic" : "explicit_done" })() diff --git a/packages/opencode/src/cli/cmd/run-output.ts b/packages/opencode/src/cli/cmd/run-output.ts index 913db5390c..adb2bef468 100644 --- a/packages/opencode/src/cli/cmd/run-output.ts +++ b/packages/opencode/src/cli/cmd/run-output.ts @@ -6,7 +6,7 @@ // Everything is still kept so a run that ended without a final reply (silent turn, // error line) writes what it has instead of the placeholder. -import { DONE_TOKEN } from "../../session/termination" +import { DONE_TOKEN, stripCompletionToken } from "../../session/termination" export const NO_TEXT_OUTPUT = "(no text output — tool-only response)" @@ -17,6 +17,9 @@ const ACKNOWLEDGEMENT = new RegExp(`^${DONE_TOKEN}\\.?$`, "i") type CollectedText = { text: string; partID?: string } export class OutputCollector { + // In run mode the completion token is a signal to the harness, not part of the answer. + constructor(private readonly options: { stripCompletionToken?: boolean } = {}) {} + private all: CollectedText[] = [] private final: CollectedText[] = [] private previous: CollectedText[] = [] @@ -30,7 +33,12 @@ export class OutputCollector { /** Completed assistant text. Synthetic/compaction text is kept for the fallback only. */ text(text: string, opts: { synthetic?: boolean; compaction?: boolean; partID?: string } = {}) { - const trimmed = text.trim() + const trimmed = (this.options.stripCompletionToken ? stripCompletionToken(text) : text).trim() + if (!trimmed && text.trim() && !opts.synthetic && !opts.compaction && this.final.length === 0) { + // A bare completion token acknowledges the reply it confirms. + this.final = this.previous + return + } if (!trimmed) return const part = { text: trimmed, partID: opts.partID } this.all.push(part) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f6208dbbb5..19a2048930 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -651,8 +651,10 @@ You are speaking to a non-technical business executive. Follow these rules stric - "Query Duration" not "total_elapsed_time" — "Data Processed" not "bytes_scanned" — "Monthly Cost" not "credits_used * 3.00"` async function execute(sdk: OpencodeClient) { - // altimate_change start — collect completed assistant text for run output - const outputParts = new OutputCollector() + // altimate_change start — collect completed assistant text for run output; in run mode the + // completion token is stripped at every output boundary + const stripDone = Flag.ALTIMATE_RUN_MODE + const outputParts = new OutputCollector({ stripCompletionToken: stripDone }) // altimate_change end // altimate_change start — a turn must end with text (#1334). Track whether // the assistant said anything at all, and the last tool failure, so a @@ -1224,15 +1226,18 @@ You are speaking to a non-technical business executive. Follow these rules stric } // altimate_change end // altimate_change start — upstream_fix: --output collects text in JSON mode too - const text = part.text.trim() - if (args.output && text) { - outputParts.text(text, { + if (args.output && part.text.trim()) { + outputParts.text(part.text, { partID: part.id, synthetic: part.synthetic === true, compaction: accounting.isCompactionStep(part.messageID), }) } - if (emit("text", { part })) continue + // Run mode: stdout and JSON text events carry the answer without the + // completion token; done_reason still records it. + const shown = stripDone ? SessionTermination.stripCompletionToken(part.text) : part.text + if (emit("text", { part: shown === part.text ? part : { ...part, text: shown } })) continue + const text = shown.trim() if (!text) continue // altimate_change end if (!process.stdout.isTTY) { @@ -2006,7 +2011,10 @@ You are speaking to a non-technical business executive. Follow these rules stric // branch with a normal text-only stop. That has already returned from // SessionPrompt.loop, so enqueue one explicit continuation turn instead // of silently finalizing the run at rc 0 with done_reason=none. - if (!accounting.fatal && !challengeConfirmed) { + // altimate_change start — a blocked reply to the challenge is a final report, not a decline + const challengeBlocked = accounting.termination().done_reason === "blocked" + if (!accounting.fatal && !challengeConfirmed && !challengeBlocked) { + // altimate_change end NudgeArbiter.register(sessionID, { source: "termination_challenge", kind: "continue_after_decline", @@ -2232,6 +2240,15 @@ You are speaking to a non-technical business executive. Follow these rules stric // (not process.exit) so pending stdout/trace writes still flush. if (accounting.fatal) process.exitCode = 1 // altimate_change end + // altimate_change start — a blocked run did not do its task: exit nonzero and say why + // (done_reason=blocked is also in the termination record/JSON event). + if (!accounting.fatal && termination.done_reason === "blocked") { + process.exitCode = 1 + if (args.format !== "json") { + process.stderr.write("\n! run blocked: the model reported it cannot proceed (done_reason=blocked)\n") + } + } + // altimate_change end } if (args.attach) { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bf90d9274d..3935e5bea4 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -1766,15 +1766,17 @@ When constructing the summary, try to stick to this template: // altimate_change end // altimate_change start — completion-aware termination path: // (b) the run-mode continue message carries the completion-aware nudge - // (continue / assert DONE — no clarification option, since nobody can - // answer in a headless run), giving a finished session a termination path. Delivered via the NudgeArbiter (Global + // (continue / assert DONE / report BLOCKED — no clarification option, + // since nobody can answer in a headless run), giving a finished or + // blocked session a termination path. Delivered via the NudgeArbiter (Global // rule 5): this injection point registers its candidate and takes the // single winner, so pending lower-precedence directives (starvation // breaker, budget reminder) are consumed here and the injected turn // never carries two system-authored directive blocks. The termination // nudge has top precedence, so it always wins at this site. // (d) the overflow notice is mechanism-accurate — the old text falsely - // blamed "large media attachments" (see SessionTermination.OVERFLOW_NOTICE). + // blamed "large media attachments" (see SessionTermination.OVERFLOW_NOTICE); + // in run mode it does not ask the absent user to re-supply anything. let continuation = "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." if (Flag.ALTIMATE_RUN_MODE) { @@ -1791,7 +1793,9 @@ When constructing the summary, try to stick to this template: NudgeArbiter.take(input.sessionID, input.nudgeGeneration)?.text ?? SessionTermination.COMPLETION_NUDGE } const text = - (input.overflow ? SessionTermination.OVERFLOW_NOTICE + "\n\n" : "") + + (input.overflow + ? SessionTermination.overflowNotice({ runMode: Flag.ALTIMATE_RUN_MODE }) + "\n\n" + : "") + continuation + // altimate_change end // altimate_change start — state ledger diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 53702e3cb9..cfbe178613 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -2071,11 +2071,13 @@ export namespace SessionPrompt { // used to sit in builder.txt, but builder is a PRIMARY agent, so it also // reached interactive chat, where nothing interprets or strips the token // and the user saw a literal DONE on every final answer. Scoped to run - // mode, where it reaches every agent — including custom agents and task - // subagents, whose child sessions are prompted through this same loop. - const completionInstruction = SessionTermination.completionInstruction({ - runMode: Flag.ALTIMATE_RUN_MODE, - }) + // mode, where it reaches every agent but plan — including custom agents and + // task subagents, whose child sessions are prompted through this same loop. + // A structured-output turn ends by capturing the schema result instead. + const completionInstruction = + lastUser.format?.type === "json_schema" + ? undefined + : SessionTermination.completionInstruction({ runMode: Flag.ALTIMATE_RUN_MODE, agent: agent.name }) if (completionInstruction) system.push(completionInstruction) // altimate_change end const format = lastUser.format ?? { type: "text" } diff --git a/packages/opencode/src/session/task-output.ts b/packages/opencode/src/session/task-output.ts index 88fe1923c9..accad025ea 100644 --- a/packages/opencode/src/session/task-output.ts +++ b/packages/opencode/src/session/task-output.ts @@ -1,5 +1,8 @@ import { MessageV2 } from "./message-v2" import type { MessageID, SessionID } from "./schema" +// altimate_change start — run-mode task report without the completion token +import { stripCompletionToken } from "./termination" +// altimate_change end export async function interruptedTaskOutput(sessionID: SessionID, parentID?: MessageID): Promise { for await (const message of MessageV2.stream(sessionID)) { @@ -22,3 +25,27 @@ export async function interruptedTaskOutput(sessionID: SessionID, parentID?: Mes ].join("\n") } } + +// altimate_change start — run-mode task report without the completion token +/** + * Run-mode task report: the child's final text without the completion token. When the + * child's last reply is a bare token (the post-compaction nudge's acknowledgement), the + * report is its most recent earlier non-summary assistant text in this prompt. + */ +export async function runModeTaskReport(sessionID: SessionID, text: string, promptID?: MessageID): Promise { + const stripped = stripCompletionToken(text) + if (stripped.trim() || !text.trim()) return stripped + for await (const message of MessageV2.stream(sessionID)) { + if (promptID && message.info.id === promptID) break + if (message.info.role !== "assistant" || message.info.summary) continue + const report = stripCompletionToken( + message.parts + .filter((part): part is MessageV2.TextPart => part.type === "text" && !part.ignored && !part.synthetic) + .map((part) => part.text) + .join("\n"), + ).trim() + if (report) return report + } + return stripped +} +// altimate_change end diff --git a/packages/opencode/src/session/termination.ts b/packages/opencode/src/session/termination.ts index 3d1f123e39..268dc3cf0f 100644 --- a/packages/opencode/src/session/termination.ts +++ b/packages/opencode/src/session/termination.ts @@ -21,6 +21,10 @@ /** The literal completion token the nudge/challenge instruct the model to emit. */ export const DONE_TOKEN = "DONE" +// altimate_change start — run-mode blocked ending +/** Prefix of the final line a run-mode model writes when it cannot proceed. */ +export const BLOCKED_PREFIX = "BLOCKED:" +// altimate_change end // Standalone final plaintext line only. The earlier trailing-token regex // accepted code-fenced, inline-code, indented, and quoted text whose content @@ -99,8 +103,36 @@ function isInsideHtmlBlock(lines: string[]): boolean { return close !== undefined } +// altimate_change start — the final-line checks are shared by the DONE and BLOCKED detectors /** True when the text ends with an explicit completion assertion (see module header). */ export function isExplicitDone(text: string): boolean { + return finalAssertionLine(text, (last) => last === DONE_TOKEN) +} + +/** + * True when the final plaintext line reports a blocker: `BLOCKED: `, unindented, + * outside code fences and HTML blocks, with a non-empty reason. Same placement rules as + * `isExplicitDone`, so quoted or demonstration text never counts. + */ +export function isBlocked(text: string): boolean { + return finalAssertionLine( + text, + (last) => last.startsWith(BLOCKED_PREFIX) && last.slice(BLOCKED_PREFIX.length).trim().length > 0, + ) +} + +/** + * The text a reader sees: a trailing completion token is a signal to the harness, not + * part of the answer, so it is removed together with the blank lines before it. Text + * whose final line is not a completion assertion is returned unchanged. + */ +export function stripCompletionToken(text: string): string { + if (!isExplicitDone(text)) return text + const lines = text.replace(/\r\n?/g, "\n").replace(/\s+$/, "").split("\n") + return lines.slice(0, -1).join("\n").trimEnd() +} + +function finalAssertionLine(text: string, matches: (last: string) => boolean): boolean { // Normalize line endings FIRST. On CRLF input the interior lines keep a // trailing `\r`, which fails the closing fence's whitespace-only check and // leaves every fence permanently open (a genuine DONE is then rejected); @@ -111,7 +143,7 @@ export function isExplicitDone(text: string): boolean { // Require an unindented token. CommonMark permits up to three leading // spaces in several block constructs; accepting them lets a nested list // demonstration (`- Expected marker:` then ` DONE`) terminate the run. - if (last !== DONE_TOKEN) return false + if (!matches(last)) return false // Reject a final line inside an unclosed code fence — the block's content is // quoted material, not an assertion. Fence state follows CommonMark: a fence // opens with a run of >= 3 backticks or tildes (an info string, e.g. an @@ -150,6 +182,7 @@ export function isExplicitDone(text: string): boolean { if (open) return false return !isInsideHtmlBlock(outsideFence) } +// altimate_change end /** * Stop-path decision: should a turn that would otherwise trigger @@ -173,7 +206,7 @@ export function explicitDoneStop(input: { return isExplicitDone(lastText.text) } -// altimate_change start — run-mode completion contract for every agent +// altimate_change start — run-mode completion contract for every agent but plan /** * Run-mode completion instruction. * @@ -181,26 +214,35 @@ export function explicitDoneStop(input: { * static instruction there also governs interactive chat — where nothing * interprets or strips the token and the user saw a literal `DONE` on every * final answer, including mid-conversation on follow-ups. `isExplicitDone()` - * is only consumed by the run-mode accounting path. + * is only consumed by the run-mode accounting path, and the `run` output + * boundaries strip the token before a reader sees the text. * - * Injected only in run mode. Byte-identical to builder's original run-mode - * behaviour, from when this text was still static in `builder.txt`. - * Prompt-visible text — changes need extra review. + * Injected only in run mode. It names the three ways a run-mode turn may end: + * the token after the assigned task is done, a `BLOCKED:` line when the task + * cannot proceed, or no ending at all while work remains. Prompt-visible text — + * changes need extra review. */ export const RUN_MODE_COMPLETION_INSTRUCTION = - "**Signal completion explicitly**: only after every requirement above is satisfied, end your final " + - `response with the literal token \`${DONE_TOKEN}\` on its own final line. Do not emit \`${DONE_TOKEN}\` ` + - "while work or verification remains." + "**Signal completion explicitly**: no one can answer questions during this run, so do not end your turn with a " + + "question. Only when the assigned task is fully completed and verified, end your final response with the literal " + + `token \`${DONE_TOKEN}\` on its own final line. Do not emit \`${DONE_TOKEN}\` while work or verification remains. ` + + "If you cannot proceed because something you cannot obtain is missing, or the only way forward is a destructive " + + "or irreversible action on a guess, state what was completed, end with a final line " + + `\`${BLOCKED_PREFIX} \`, and stop without \`${DONE_TOKEN}\`.` + +/** Agents whose own prompt defines how a turn ends; the contract would contradict it. */ +const CONTRACT_EXCLUDED_AGENTS = new Set(["plan"]) /** * The sole gate for injecting the completion-token contract into a prompt. - * In run mode it applies to every agent — builder, custom agents and - * subagents started by the task tool — because nobody can answer a question - * in a headless run, so any agent without the contract can end on one. + * In run mode it applies to builder, custom agents and subagents started by the + * task tool, so each has a defined way to finish or report a blocker. The plan + * agent is excluded: its prompt ends a turn with a question or `plan_exit`. * Interactive sessions never receive it. */ -export function completionInstruction(input: { runMode: boolean }): string | undefined { - return input.runMode ? RUN_MODE_COMPLETION_INSTRUCTION : undefined +export function completionInstruction(input: { runMode: boolean; agent: string }): string | undefined { + if (!input.runMode || CONTRACT_EXCLUDED_AGENTS.has(input.agent)) return undefined + return RUN_MODE_COMPLETION_INSTRUCTION } // altimate_change end @@ -208,18 +250,22 @@ export function completionInstruction(input: { runMode: boolean }): string | und /** * Completion-aware post-compaction nudge, sent only in run mode (interactive * compaction keeps its ordinary continuation in compaction.ts). Replaces the - * two-option "Continue … or stop and ask for clarification" text, which gave a - * finished session no way to terminate. It offers no "ask for clarification" - * option: nobody can answer in a headless run, and that option is how runs - * ended on a question instead of finishing. Prompt-visible text — changes - * need extra review. + * "Continue … or stop and ask for clarification" text, which gave a finished + * session no way to terminate. It offers no "ask for clarification" option: + * nobody can answer in a headless run, and that option is how runs ended on a + * question instead of finishing. A blocked run gets its own exit (3), so the + * model is not pushed into a false `DONE` or a destructive guess. Prompt-visible + * text — changes need extra review. */ export const COMPLETION_NUDGE = "Context was compacted; the summary above is the record of the work so far. " + "No one can answer questions during this run, so do not end your turn with a question. Choose exactly one: " + - "(1) if concrete next steps remain toward the original task, continue with them; if you are unsure how to " + - "proceed, pick the most reasonable interpretation, state your assumption, and continue; " + - `(2) if the deliverable is complete and verified, reply with ${DONE_TOKEN} alone on the final line and stop.` + "(1) if concrete next steps remain toward the original task, continue with them; if you are unsure between " + + "safe, reversible choices, pick the most reasonable interpretation, state your assumption, and continue; " + + `(2) if the deliverable is complete and verified, reply with ${DONE_TOKEN} alone on the final line and stop; ` + + "(3) if you cannot proceed because something you cannot obtain is missing, or the only way forward is a " + + "destructive or irreversible action on a guess, state what was completed, end with a final line " + + `${BLOCKED_PREFIX} , and stop without ${DONE_TOKEN}.` // altimate_change end /** @@ -293,4 +339,17 @@ export const OVERFLOW_NOTICE = "attachments) may have been dropped from context. If information you need is missing from the summary, " + "re-read the relevant files or ask the user to re-supply it." +// altimate_change start — run mode has no user to re-supply anything +/** Run-mode overflow notice: same mechanism text, without asking the absent user. */ +export const RUN_MODE_OVERFLOW_NOTICE = + "The previous request exceeded the model's context limit before a response could be generated. Older " + + "messages were compacted into the summary above, and oversized content (large tool outputs or file " + + "attachments) may have been dropped from context. If information you need is missing from the summary, " + + "re-read the relevant files; no one can re-supply it during this run." + +export function overflowNotice(input: { runMode: boolean }): string { + return input.runMode ? RUN_MODE_OVERFLOW_NOTICE : OVERFLOW_NOTICE +} +// altimate_change end + export * as SessionTermination from "./termination" diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 5a62dbd733..74e3b46b51 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -26,7 +26,8 @@ import { RunBudget } from "@/altimate/run-budget" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" // altimate_change start — upstream_fix: recover committed child findings when cancellation interrupts a task. -import { interruptedTaskOutput } from "../session/task-output" +import { interruptedTaskOutput, runModeTaskReport } from "../session/task-output" +import { Flag } from "../flag/flag" // altimate_change end const log = Log.create({ service: "tool.task" }) @@ -252,7 +253,10 @@ export const TaskTool = Tool.define("task", async (ctx) => { }) if (ctx.abort.aborted) partialOutput ??= await interruptedTaskOutput(session.id, messageID) - const text = result?.parts.findLast((x) => x.type === "text")?.text ?? "" + const lastText = result?.parts.findLast((x) => x.type === "text")?.text ?? "" + // Run mode: the child's completion token is a harness signal, not part of its report, + // and a bare-token acknowledgement must not replace the report it confirms. + const text = Flag.ALTIMATE_RUN_MODE ? await runModeTaskReport(session.id, lastText, messageID) : lastText const output = partialOutput ?? [ `task_id: ${session.id} (for resuming to continue this task if needed)`, diff --git a/packages/opencode/test/cli/cmd/run-output.test.ts b/packages/opencode/test/cli/cmd/run-output.test.ts index cb19651d28..913b27e249 100644 --- a/packages/opencode/test/cli/cmd/run-output.test.ts +++ b/packages/opencode/test/cli/cmd/run-output.test.ts @@ -109,4 +109,27 @@ describe("run --output collector", () => { out.text(" ") expect(out.content()).toBe(NO_TEXT_OUTPUT) }) + + // altimate_change start — the run-mode completion token is a harness signal, not part of the answer + test("run mode strips a trailing DONE line from the reply", () => { + const out = new OutputCollector({ stripCompletionToken: true }) + out.stepStart() + out.text('{"title":"x"}\n\nDONE') + expect(out.content()).toBe('{"title":"x"}') + }) + + test("run mode keeps a DONE that is quoted inside a code fence", () => { + const out = new OutputCollector({ stripCompletionToken: true }) + out.stepStart() + out.text("Example:\n```\nDONE") + expect(out.content()).toBe("Example:\n```\nDONE") + }) + + test("without run mode the reply is written as the model gave it", () => { + const out = new OutputCollector() + out.stepStart() + out.text('{"title":"x"}\n\nDONE') + expect(out.content()).toBe('{"title":"x"}\n\nDONE') + }) + // altimate_change end }) diff --git a/packages/opencode/test/cli/run-accounting.test.ts b/packages/opencode/test/cli/run-accounting.test.ts index 0338839614..832c8873e3 100644 --- a/packages/opencode/test/cli/run-accounting.test.ts +++ b/packages/opencode/test/cli/run-accounting.test.ts @@ -281,6 +281,32 @@ describe("RunAccounting retry classification", () => { }) describe("RunAccounting done_reason + idle-done bookkeeping", () => { + // altimate_change start — a blocked ending is reported as blocked, never as done + test("stop + a final BLOCKED: line reports done_reason=blocked", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onText("m1", "Read the config.\nBLOCKED: the warehouse password is missing") + acc.onStepFinish("m1", "stop") + const t = acc.termination() + expect(t.done_reason).toBe("blocked") + expect(t.why_model_stopped).toBe("stop") + expect(t.why_harness_stopped).toBe("none") + }) + + test("a BLOCKED line followed by DONE is a completion, and a later plain reply clears blocked", () => { + const acc = RunAccounting.create() + acc.onAssistantMessage({ id: "m1", agent: "build" }) + acc.onText("m1", "BLOCKED: example of the format\nDONE") + acc.onStepFinish("m1", "stop") + expect(acc.termination().done_reason).toBe("explicit_done") + acc.onAssistantMessage({ id: "m2", agent: "build" }) + acc.onText("m2", "BLOCKED: no access") + acc.onText("m2", "Actually the file was readable; continuing.") + acc.onStepFinish("m2", "stop") + expect(acc.termination().done_reason).toBe("none") + }) + // altimate_change end + test("bare finishReason stop is NEVER reported as done", () => { const acc = RunAccounting.create() acc.onAssistantMessage({ id: "m1", agent: "build" }) diff --git a/packages/opencode/test/cli/run/run-mode-output.test.ts b/packages/opencode/test/cli/run/run-mode-output.test.ts new file mode 100644 index 0000000000..39375ccf5f --- /dev/null +++ b/packages/opencode/test/cli/run/run-mode-output.test.ts @@ -0,0 +1,96 @@ +// altimate_change start — run-mode output boundary: the completion token is a signal to the +// harness, not part of the answer, and a blocked run is reported as blocked. +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import path from "node:path" +import { cliIt } from "../../lib/cli-process" +import { testProviderConfig } from "../../lib/test-provider" + +const config = (url: string) => + JSON.stringify({ ...testProviderConfig(url), experimental: { auto_mcp_discovery: false } }) + +describe("opencode run: run-mode output boundary", () => { + cliIt.concurrent( + "a trailing DONE line stays out of stdout and --output; done_reason is still explicit_done", + ({ llm, home, opencode }) => + Effect.gen(function* () { + const outputPath = path.join(home, "answer.json") + yield* llm.text('{"title":"x"}\n\nDONE') + const result = yield* opencode.run("Return the title as JSON only.", { + extraArgs: ["--output", outputPath], + bunRun: true, + env: { OPENCODE_CONFIG_CONTENT: config(llm.url) }, + }) + opencode.expectExit(result, 0) + expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe('{"title":"x"}') + expect(result.stdout).toContain('{"title":"x"}') + expect(result.stdout).not.toMatch(/^DONE$/m) + }), + 90_000, + ) + + cliIt.concurrent( + "--format json: the text event carries the answer without DONE and termination reports explicit_done", + ({ llm, home, opencode }) => + Effect.gen(function* () { + const outputPath = path.join(home, "answer.json") + yield* llm.text('{"title":"x"}\n\nDONE') + const result = yield* opencode.run("Return the title as JSON only.", { + format: "json", + extraArgs: ["--output", outputPath], + bunRun: true, + env: { OPENCODE_CONFIG_CONTENT: config(llm.url) }, + }) + opencode.expectExit(result, 0) + expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe('{"title":"x"}') + const events = opencode.parseJsonEvents(result.stdout) + const texts = events + .filter((event) => event.type === "text") + .map((event) => (event.part as { text: string }).text) + expect(texts).toEqual(['{"title":"x"}']) + expect(events.filter((event) => event.type === "termination")).toMatchObject([{ done_reason: "explicit_done" }]) + }), + 90_000, + ) + + cliIt.concurrent( + "with run mode off, the text is printed as the model wrote it", + ({ llm, home, opencode }) => + Effect.gen(function* () { + const outputPath = path.join(home, "answer.txt") + yield* llm.text("Print the word.\nDONE") + const result = yield* opencode.run("Print the word DONE on its own line.", { + extraArgs: ["--output", outputPath], + bunRun: true, + env: { OPENCODE_CONFIG_CONTENT: config(llm.url), ALTIMATE_RUN_MODE: "0" }, + }) + opencode.expectExit(result, 0) + expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe("Print the word.\nDONE") + }), + 90_000, + ) + + cliIt.concurrent( + "a blocked reply ends the run with done_reason=blocked and exit 1, never as done", + ({ llm, home, opencode }) => + Effect.gen(function* () { + const outputPath = path.join(home, "answer.txt") + const reply = "Read the connection config; no credential is set.\nBLOCKED: the warehouse password is missing" + yield* llm.text(reply) + const result = yield* opencode.run("Load the table.", { + format: "json", + extraArgs: ["--output", outputPath], + bunRun: true, + env: { OPENCODE_CONFIG_CONTENT: config(llm.url) }, + }) + opencode.expectExit(result, 1) + expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe(reply) + const events = opencode.parseJsonEvents(result.stdout) + expect(events.filter((event) => event.type === "termination")).toMatchObject([ + { done_reason: "blocked", why_harness_stopped: "none" }, + ]) + }), + 90_000, + ) +}) +// altimate_change end diff --git a/packages/opencode/test/session/compaction-summarizer-integrity.test.ts b/packages/opencode/test/session/compaction-summarizer-integrity.test.ts index 526d725558..24b27cb734 100644 --- a/packages/opencode/test/session/compaction-summarizer-integrity.test.ts +++ b/packages/opencode/test/session/compaction-summarizer-integrity.test.ts @@ -536,10 +536,38 @@ describe("session.compaction continue-nudge termination path (/d)", () => { expect(result).toBe("continue") const continuePart = store.parts.find((p) => p.type === "text" && p.synthetic) - expect(continuePart?.text).toContain(SessionTermination.OVERFLOW_NOTICE) + // altimate_change start — run mode: the overflow notice does not ask the absent user + expect(continuePart?.text).toContain(SessionTermination.RUN_MODE_OVERFLOW_NOTICE) + expect(continuePart?.text).not.toContain("ask the user") + // altimate_change end expect(continuePart?.text).not.toContain("large media attachments") expect(continuePart?.text).toContain("context limit") // The completion nudge still follows the notice. expect(continuePart?.text).toContain("reply with DONE") }) + + // altimate_change start — interactive overflow compaction keeps the notice byte-for-byte + test("interactive overflow compaction keeps the original overflow notice", async () => { + process.env.ALTIMATE_RUN_MODE = "0" + const sessionID = freshSessionID() + const { messages, markerID } = history(sessionID) + processBehaviors = [writeSummary("a real summary")] + + const result = await SessionCompaction.process({ + sessionID, + messages, + parentID: markerID, + abort: new AbortController().signal, + auto: true, + overflow: true, + }) + + expect(result).toBe("continue") + const continuePart = store.parts.find((p) => p.type === "text" && p.synthetic) + expect(continuePart?.text).toContain( + SessionTermination.OVERFLOW_NOTICE + + "\n\nContinue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", + ) + }) + // altimate_change end }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index ce4f7731c1..5ed848b15f 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1898,6 +1898,53 @@ for (const runMode of [true, false]) { }), ) } + +// altimate_change start — a custom PRIMARY agent gets the contract in run mode; plan never does +for (const agentName of ["auditor", "plan"]) { + it.instance(`a run-mode ${agentName === "plan" ? "plan agent's prompt omits" : "custom primary agent's prompt carries"} the completion contract`, () => + Effect.gen(function* () { + const previous = process.env.ALTIMATE_RUN_MODE + process.env.ALTIMATE_RUN_MODE = "1" + try { + const { llm } = yield* useServerConfig((url) => ({ + ...providerCfg(url), + agent: { auditor: { mode: "primary", description: "Audits a change.", prompt: "You audit changes." } }, + })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Primary" }) + yield* llm.text("Audited.\nDONE") + const msg = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: agentName, + model: ref, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID: chat.id, type: "text", text: "audit it" }) + + yield* prompt.loop({ sessionID: chat.id }) + + const inputs = yield* llm.inputs + expect(inputs.length).toBeGreaterThanOrEqual(1) + const system = requestMessages(inputs[0]!) + .filter((message) => message.role === "system") + .map((message) => requestText(message.content)) + .join("\n") + if (agentName === "plan") expect(system).not.toContain(SessionTermination.RUN_MODE_COMPLETION_INSTRUCTION) + else { + expect(system).toContain("You audit changes.") + expect(system).toContain(SessionTermination.RUN_MODE_COMPLETION_INSTRUCTION) + } + } finally { + if (previous === undefined) delete process.env.ALTIMATE_RUN_MODE + else process.env.ALTIMATE_RUN_MODE = previous + } + }), + ) +} +// altimate_change end // altimate_change end noLLMServer.instance("prompt tools replace previous prompt tool rules", () => diff --git a/packages/opencode/test/session/termination.test.ts b/packages/opencode/test/session/termination.test.ts index c1d45300c5..d571da32e8 100644 --- a/packages/opencode/test/session/termination.test.ts +++ b/packages/opencode/test/session/termination.test.ts @@ -159,15 +159,19 @@ describe("SessionTermination.explicitDoneStop (stop-path decision)", () => { }) describe("SessionTermination directive texts (/c/d wording contracts)", () => { - // altimate_change start — the run-mode nudge has no clarification option - test("the run-mode completion nudge offers continue or DONE, never a clarification stop", () => { + // altimate_change start — the run-mode nudge: continue, DONE, or report a blocker; never a question + test("the run-mode completion nudge offers continue, DONE or BLOCKED, never a clarification stop", () => { const nudge = SessionTermination.COMPLETION_NUDGE expect(nudge).toContain("(1)") expect(nudge).toContain("(2)") - expect(nudge).not.toContain("(3)") + expect(nudge).toContain("(3)") expect(nudge).not.toContain("clarification") expect(nudge).toContain("do not end your turn with a question") expect(nudge).toContain(`reply with ${SessionTermination.DONE_TOKEN}`) + // The guess clause is limited to safe, reversible choices; a blocker gets its own exit. + expect(nudge).toContain("safe, reversible") + expect(nudge).toContain(`${SessionTermination.BLOCKED_PREFIX} `) + expect(nudge).toContain(`without ${SessionTermination.DONE_TOKEN}`) }) test("the interactive compaction continuation is unchanged and stays out of the run-mode nudge", async () => { @@ -198,12 +202,28 @@ describe("SessionTermination directive texts (/c/d wording contracts)", () => { expect(SessionTermination.OVERFLOW_NOTICE).toContain("context limit") }) + // altimate_change start — the run-mode overflow notice never asks the user; the interactive one is unchanged + test("the run-mode overflow notice does not ask the user to re-supply anything", () => { + expect(SessionTermination.RUN_MODE_OVERFLOW_NOTICE).not.toContain("ask the user") + expect(SessionTermination.RUN_MODE_OVERFLOW_NOTICE).toContain("context limit") + expect(SessionTermination.overflowNotice({ runMode: true })).toBe(SessionTermination.RUN_MODE_OVERFLOW_NOTICE) + expect(SessionTermination.overflowNotice({ runMode: false })).toBe( + "The previous request exceeded the model's context limit before a response could be generated. Older " + + "messages were compacted into the summary above, and oversized content (large tool outputs or file " + + "attachments) may have been dropped from context. If information you need is missing from the summary, " + + "re-read the relevant files or ask the user to re-supply it.", + ) + }) + // altimate_change end + test("no vertical/product tokens in any directive text (leak-lens hard requirement)", () => { for (const text of [ SessionTermination.COMPLETION_NUDGE, SessionTermination.CONFIRM_DONE_CHALLENGE, SessionTermination.CONTINUE_AFTER_DECLINED_CHALLENGE, SessionTermination.OVERFLOW_NOTICE, + SessionTermination.RUN_MODE_OVERFLOW_NOTICE, + SessionTermination.RUN_MODE_COMPLETION_INSTRUCTION, ]) { expect(text.toLowerCase()).not.toContain("dbt") expect(text.toLowerCase()).not.toContain("warehouse") @@ -265,31 +285,33 @@ describe("SessionTermination.isExplicitDone — fence-state conformance", () => expect(SessionTermination.isExplicitDone(["```html", "