diff --git a/AGENTS.md b/AGENTS.md index 472c83e193..692fefd006 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,19 +5,27 @@ ## Git Workflow (铁律) ``` -{type}_{name} ──merge──▶ dev ──push──▶ main +feat/* ──┐ +fix/* ───┤ +debug/* ─┼──merge──▶ dev ──[ TDD 覆盖率 + CI + E2E 全绿 ]──▶ dev 版本 ──push──▶ main +docs/* ──┤ +refactor/*┤ +test/* ──┤ +chore/* ─┘ ``` +**各类 `{type}/*` 分支汇总到 `dev`。`dev` 是唯一的质量门禁**:只有 TDD 覆盖率 + CI + E2E 全部通过,才产出可发布的 `dev` 版本;通过后才能 push 到 `main`。 + | Branch | CI/TDD | Purpose | |--------|--------|---------| -| `{type}_{name}` | ❌ 不跑 | 功能/调试/重构等开发,频繁变更 | -| `dev` | ✅ **只有 dev 会触发 TDD + CI E2E 的 GitHub Actions** | 集成测试门禁,全绿才能推进 | +| `{type}/{name}` | ❌ 不跑 | 功能/修复/调试/文档/重构/测试/杂务等开发,频繁变更 | +| `dev` | ✅ **只有 dev 会触发 TDD 覆盖率 + CI + E2E 的 GitHub Actions** | 质量门禁,全绿才产出 dev 版本 | | `main` | ❌ 不跑 | 发布专用,只接受 dev 验证通过的代码 | **流程**: -1. 从 `dev` 切出 `{type}_{name}` 分支开发 +1. 从 `dev` 切出 `{type}/{name}` 分支开发 2. 完成后合并到 `dev` -3. `dev` 必须全绿(TDD + CI E2E,由 GitHub Actions 配置触发) +3. `dev` 必须全绿(TDD 覆盖率 + CI + E2E,由 GitHub Actions 配置触发)→ 产出 dev 版本 4. 验证通过后才能 push 到 `main` 5. `main` 只用于发版(`fork-release` 手动触发) @@ -25,9 +33,9 @@ CI 配置:`test.yml` 和 `typecheck.yml` 仅在 push 到 `dev` 时触发,`ca ## Branch Names -Format: `{type}_{short-name}` where `type` is one of: `feat`, `debug`, `refactor`, `test`, `chore`. The short name uses hyphens, at most three words. +Format: `{type}/{short-name}` where `type` is one of: `feat`, `fix`, `debug`, `docs`, `refactor`, `test`, `chore`. The short name uses hyphens, at most three words. -Examples: `feat_session-recovery`, `debug_goal-loop`, `refactor_dag-spawn`, `chore_regenerate-sdk`. +Examples: `feat/session-recovery`, `fix/scroll-state`, `debug/goal-loop`, `docs/branch-naming`, `refactor/dag-spawn`, `test/auth-flow`, `chore/regenerate-sdk`. ## Commits and PR Titles diff --git a/packages/opencode/src/goal/goal.ts b/packages/opencode/src/goal/goal.ts index 0d95c7fee3..6e4ad1109f 100644 --- a/packages/opencode/src/goal/goal.ts +++ b/packages/opencode/src/goal/goal.ts @@ -275,20 +275,12 @@ export const layer = Layer.effect( const markDone = Effect.fn("Goal.markDone")(function* (sessionID: SessionID, reason: string) { // User/tool-initiated completion: stop the running loop fiber, then // perform terminal cleanup (publish done-updated → delete → publish cleared). + // State transitions are budget-neutral — turns_used counts continuation + // dispatches only (see spec: turn-budget-counts-continuation-dispatches-only), + // so markDone does NOT increment. deleteAndPublishDone loads the current + // row (preserving whatever turns_used a prior continue dispatch set) and + // re-renders the done snapshot from it. yield* clearFiber(sessionID) - // Increment turns_used so this completion path reports the same - // "N turns" count as updateAfterJudge (which also += 1 before marking - // done). Without this, status lines and event payloads disagree on - // whether the terminal turn was consumed. - const current = yield* loadState(sessionID) - if (current) { - const incremented = new GoalState.Info({ - ...current, - turns_used: (current.turns_used + 1) as any, - last_turn_at: Date.now(), - }) - yield* saveState(sessionID, incremented) - } return yield* deleteAndPublishDone(sessionID, reason) }) @@ -367,14 +359,24 @@ export const layer = Layer.effect( const updated = new GoalState.Info({ ...state, status: "done", - turns_used: (state.turns_used + 1) as any, + // State transitions are budget-neutral — a `done` verdict drives no + // continuation dispatch, so it must NOT consume budget. turns_used + // reflects only continuation dispatches (see spec: + // turn-budget-counts-continuation-dispatches-only). + turns_used: state.turns_used, last_turn_at: now, last_verdict: "done", last_reason: reason, consecutive_parse_failures: newParseFailures as any, }) yield* saveState(sessionID, updated) - yield* publishGoal(sessionID, updated) + // Do NOT publish goal.updated here. deleteAndPublishDone is the SOLE + // owner of the terminal event sequence (goal.updated(done) → delete → + // goal.cleared); publishing here would double-fire goal.updated(done) + // on every judge-declared completion (see spec: + // terminal-event-contract-publishes-exactly-once). We still saveState + // so deleteAndPublishDone can load the done row and re-render the + // snapshot. loop.ts invokes deleteAndPublishDone after this returns. return { state: updated, shouldContinue: false, diff --git a/packages/opencode/src/goal/judge.ts b/packages/opencode/src/goal/judge.ts index 94cbd3bba5..d1ff485f8e 100644 --- a/packages/opencode/src/goal/judge.ts +++ b/packages/opencode/src/goal/judge.ts @@ -56,11 +56,19 @@ export const run = Effect.fn("Goal.Judge.run")(function* ( timeout: GoalPrompts.DEFAULT_JUDGE_TIMEOUT, }).pipe( Effect.map((text) => parseJudgeResponse(text)), - // Fail-open on transport error + // Transport errors (timeout, network, non-JSON transport-level failure) + // count toward the pause budget (D5). Previously they returned + // parseFailed: false, which reset consecutive_parse_failures and let a + // flaky provider alternate bad-JSON and timeout indefinitely without + // ever hitting MAX_CONSECUTIVE_PARSE_FAILURES. Returning parseFailed: true + // feeds them through the same auto-pause path as parse failures, treating + // "judge is unreliable" uniformly regardless of failure mode. The verdict + // stays "continue" so a single transient blip does not stall the loop; + // it only pauses after MAX_CONSECUTIVE_PARSE_FAILURES in a row. Effect.orElseSucceed((): JudgeResult => ({ verdict: "continue", - reason: "judge transport error", - parseFailed: false, + reason: "judge transport error (timeout or network) — counting toward pause budget", + parseFailed: true, })), ) }) diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index 83e48c46c6..30eef54e7f 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -47,6 +47,33 @@ export function shouldPreempt( return lastUserAt > lastAsstAt } +/** + * Pure predicate for the zombie-goal freshness guard (D6). Returns true when a + * goal is "orphaned": active, has run zero continuations (turns_used === 0), + * was created more than FRESHNESS_THRESHOLD ago, and the initial kick never + * produced an assistant message (provider error, model refusal, empty response). + * + * Used by GoalLoop.afterIdle to convert the silent orphan state into a visible, + * recoverable pause. Without it, every subsequent afterIdle would abort at the + * `if (!lastAssistant) return` line and the goal would sit permanently "active" + * with no progress. + * + * `now` defaults to Date.now() for production; tests pass an explicit value for + * determinism. + */ +export function isStaleZombie( + state: { status: string; turns_used: number; created_at: number }, + hasAssistant: boolean, + now: number = Date.now(), +): boolean { + return ( + state.status === "active" && + Number(state.turns_used) === 0 && + !hasAssistant && + now - state.created_at > GoalPrompts.FRESHNESS_THRESHOLD + ) +} + export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -81,6 +108,37 @@ export const layer = Layer.effect( const goalState = yield* goal.load(sessionID) if (!goalState || goalState.status !== "active") return + // Zombie-goal freshness guard (D6). If the goal is active but has run + // zero continuations and is older than FRESHNESS_THRESHOLD, the initial + // kick may have failed silently (provider error, model refusal, empty + // response). Without this guard every subsequent afterIdle aborts at the + // `if (!lastAssistant) return` line below, leaving the goal permanently + // "active" with no progress — a silent orphan. Convert that into a + // visible, recoverable pause so the user can /goal resume. + // + // The probe loads only 1 message (not the full 20) so we don't pay for + // the whole message window just to discover staleness; the stale path + // returns early so the limit:20 load below never runs when the guard + // fires. Uses pauseAndPublish (fiber-safe) — NOT goal.pause — because + // we ARE the loop fiber tracked in the fibers map (same self-interrupt + // hazard discipline as the done / shouldPreempt branches below). + if ( + Number(goalState.turns_used) === 0 && + Date.now() - goalState.created_at > GoalPrompts.FRESHNESS_THRESHOLD + ) { + const probeMsgs = yield* sessions.messages({ sessionID, limit: 1 }) + const hasAssistant = probeMsgs.some((m) => m.info.role === "assistant") + if (isStaleZombie(goalState, hasAssistant)) { + yield* goal + .pauseAndPublish( + sessionID, + `initial kick produced no assistant response within ${GoalPrompts.FRESHNESS_THRESHOLD / 1000}s — likely provider error or model refusal. Use /goal resume to retry.`, + ) + .pipe(Effect.ignore) + return + } + } + const msgs = yield* sessions.messages({ sessionID, limit: 20 }) const lastAssistant = [...msgs].reverse().find((m) => m.info.role === "assistant") if (!lastAssistant) return @@ -185,22 +243,30 @@ export const layer = Layer.effect( const reloadedState = yield* goal.load(sessionID) if (!reloadedState || reloadedState.status !== "active") return - const continuationText = GoalPrompts.renderContinuation(reloadedState.goal, reloadedState.subgoals ?? []) - // Surface the per-turn progress indicator visibly (e.g. - // "↻ 继续推进目标(2/10):…"). updateAfterJudge computed this message; - // emit it as a noReply non-synthetic part so it renders in the transcript - // without spawning another agent turn. The continuation prompt below - // (ignored) is what actually drives the next loop iteration. - yield* promptSvc.prompt({ - sessionID, - noReply: true, - parts: [{ type: "text", text: updateResult.message }], - }).pipe(Effect.ignore) + // Single merged continuation injection (D4.2). This replaces the former + // two-call sequence (a `noReply` progress line + an `ignored:true` + // continuation). The merged prompt carries goal text, subgoals, the + // turns/budget line, and the last judge reason, plus the autonomous-mode + // frame — and it is BOTH the user-visible per-turn progress line AND the + // prompt that drives the next agent turn. + // + // It is deliberately a plain text part: no `noReply` (so it spawns the + // next agent turn) and no `ignored` (so it renders in the transcript AND + // reaches the model — `ignored:true` text parts are filtered out of model + // messages in MessageV2.toModelMessagesEffect). Driving + visibility + + // model-reachability are all required by D4.2. + const continuationText = GoalPrompts.renderContinuation({ + goal: reloadedState.goal, + subgoals: reloadedState.subgoals ?? [], + turnsUsed: Number(reloadedState.turns_used), + maxTurns: Number(reloadedState.max_turns), + lastJudgeReason: reloadedState.last_reason, + }) yield* promptSvc.prompt({ sessionID, - parts: [{ type: "text", text: continuationText, ignored: true }], + parts: [{ type: "text", text: continuationText }], }) // NOTE: We deliberately DO NOT call goal.clearLoopFiber here. The diff --git a/packages/opencode/src/goal/prompts.ts b/packages/opencode/src/goal/prompts.ts index 40458d684d..f0465e9cb2 100644 --- a/packages/opencode/src/goal/prompts.ts +++ b/packages/opencode/src/goal/prompts.ts @@ -1,30 +1,16 @@ +import { GoalState } from "./state" + export * as GoalPrompts from "./prompts" export const DEFAULT_MAX_TURNS = 20 export const DEFAULT_JUDGE_TIMEOUT = 30_000 export const MAX_CONSECUTIVE_PARSE_FAILURES = 3 export const JUDGE_RESPONSE_SNIPPET_CHARS = 4000 - -export const CONTINUATION_PROMPT_TEMPLATE = `[Continuing toward your standing goal] -Goal: {goal} - -You are in autonomous mode — interactive questions are disabled and will not receive answers. Do not ask the user for clarification or confirmation. Make all decisions independently based on your best judgment. - -Continue working toward this goal. Take the next concrete step. -If you believe the goal is complete, state so explicitly and stop. -If you are completely blocked and cannot make any progress, state the blocker explicitly and stop.` - -export const CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE = `[Continuing toward your standing goal] -Goal: {goal} - -Additional criteria the user added mid-loop: -{subgoals} - -You are in autonomous mode — interactive questions are disabled and will not receive answers. Do not ask the user for clarification or confirmation. Make all decisions independently based on your best judgment. - -Continue working toward this goal. Take the next concrete step. -If you believe the goal is complete, state so explicitly and stop. -If you are completely blocked and cannot make any progress, state the blocker explicitly and stop.` +// Zombie-goal freshness guard threshold (D6). A goal that is still active with +// turns_used 0 after this many ms, and whose initial kick produced no assistant +// message, is treated as orphaned and auto-paused so the user can recover via +// /goal resume instead of the goal sitting silently "active" forever. +export const FRESHNESS_THRESHOLD = 120_000 export const JUDGE_SYSTEM_PROMPT = `You are an autonomous-goal completion judge. You will receive: @@ -64,12 +50,74 @@ Agent's most recent response (last {snippetChars} chars): Is the goal done? For each sub-goal, provide concrete evidence it was met. Do not accept vague claims like "all requirements met".` -export function renderContinuation(goal: string, subgoals: ReadonlyArray): string { - if (subgoals.length === 0) - return CONTINUATION_PROMPT_TEMPLATE.replace("{goal}", goal) - return CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE - .replace("{goal}", goal) - .replace("{subgoals}", subgoals.map((s, i) => `${i + 1}. ${s}`).join("\n")) +export interface ContinuationInput { + readonly goal: string + readonly subgoals: ReadonlyArray + readonly turnsUsed: number + readonly maxTurns: number + readonly lastJudgeReason?: string +} + +// Renders the single merged continuation injection (D4.2). Carries goal text, +// subgoals, turns/budget, the last judge reason (labeled), and the autonomous-mode +// frame. This is both the user-visible per-turn progress line AND the prompt that +// drives the next agent turn — it must reach the model (no `ignored` flag at the +// call site) and render in the transcript (no `noReply`). +export function renderContinuation(input: ContinuationInput): string { + const remaining = Math.max(0, input.maxTurns - input.turnsUsed) + const lines = [ + "[Continuing toward your standing goal]", + `Goal: ${input.goal}`, + `Turns: ${input.turnsUsed}/${input.maxTurns} (${remaining} remaining)`, + ] + if (input.subgoals.length > 0) { + lines.push("Subgoals:") + lines.push(...input.subgoals.map((s, i) => `${i + 1}. ${s}`)) + } + if (input.lastJudgeReason) lines.push(`Judge feedback: ${input.lastJudgeReason}`) + lines.push("") + lines.push( + "You are in autonomous mode — interactive questions are disabled and will not receive answers. Do not ask the user for clarification or confirmation. Make all decisions independently based on your best judgment.", + ) + lines.push("") + lines.push("Continue working toward this goal. Take the next concrete step.") + lines.push("If you believe the goal is complete, state so explicitly and stop.") + lines.push( + "If you are completely blocked and cannot make any progress, state the blocker explicitly and stop.", + ) + return lines.join("\n") +} + +// Renders the dynamic system-prompt fragment for an active/paused goal (D4.1). +// Pure: injected into the system prompt by SystemPrompt.goal(sessionID). +export function renderGoalSystemBlock(state: GoalState.Info): string { + const turnsUsed = Number(state.turns_used) + const maxTurns = Number(state.max_turns) + const remaining = Math.max(0, maxTurns - turnsUsed) + const subgoals = state.subgoals ?? [] + const lines = [ + "## Current Goal (autonomous loop)", + `Goal: ${state.goal}`, + `Status: ${state.status}`, + `Turns: ${turnsUsed}/${maxTurns} (${remaining} remaining)`, + ] + if (subgoals.length > 0) { + lines.push("Subgoals:") + lines.push(...subgoals.map((s, i) => ` ${i + 1}. ${s}`)) + } else { + lines.push("Subgoals: none") + } + if (state.status === "paused" && state.paused_reason) { + lines.push(`Paused because: ${state.paused_reason}`) + } + if (state.last_verdict) { + lines.push( + state.last_reason + ? `Last judge verdict: ${state.last_verdict} — ${state.last_reason}` + : `Last judge verdict: ${state.last_verdict}`, + ) + } + return lines.join("\n") } export function renderJudgeUserPrompt( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 026af0ce66..7aa7cfe6c3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1462,7 +1462,7 @@ export const layer = Layer.effect( instruction.system().pipe(Effect.orDie), sys.mcp(agent, session.permission), sys.hooks(), - sys.goal(), + sys.goal(sessionID), MessageV2.toModelMessagesEffect(msgs, model), ]) const system = [ diff --git a/packages/opencode/src/session/prompt/goal.txt b/packages/opencode/src/session/prompt/goal.txt index 35b6a9c1d9..d3a59883d4 100644 --- a/packages/opencode/src/session/prompt/goal.txt +++ b/packages/opencode/src/session/prompt/goal.txt @@ -2,7 +2,9 @@ OpenCode has a built-in **autonomous goal** feature. Use `/goal ` to set a persistent goal that the agent will work toward autonomously across multiple turns. -A `goal` **tool** is available during any turn. Use it to query the current goal and, when the goal is genuinely done, self-declare completion so the goal loop exits immediately instead of waiting for an external judge. +While a goal is active or paused, a **live "Current Goal" block** is injected into the system prompt at the start of every turn — it carries the goal text, status, turns used/remaining, subgoals, and the last judge verdict. You do not need to call a tool to learn this state; read it from the system prompt. + +A `goal` **tool** is also available. Use `goal(action: "complete")` to self-declare completion when the goal is genuinely done, so the loop exits immediately instead of waiting for the external judge. `goal(action: "status")` is an optional check-in (see below). ## Commands (user-facing; only you or the user can issue these) @@ -19,14 +21,9 @@ A `goal` **tool** is available during any turn. Use it to query the current goal ## Tool (agent-facing; call this during your turn) -- `goal(action: "status")` — Current goal text, status, turns used/remaining, subgoals, and pause reason. Returns clear information when no goal is active. Call proactively before claiming progress, so you know whether a goal loop is running and how much budget remains. +- `goal(action: "status")` — Current goal text, status, turns used/remaining, subgoals, and pause reason. This is an OPTIONAL check-in: the same live state is already in your system prompt each turn, so you do not need to call `status` to know whether a goal loop is running or how much budget remains. Use it only for a deliberate mid-turn re-check (e.g., after a long operation that may have changed state) or to inspect `pausedReason`. - `goal(action: "complete", reason: "...")` — Declare the goal achieved. **This bypasses the external judge and ends the loop immediately.** Pass a one-sentence summary of what was delivered (e.g., "3 tests written and passing; refactor verified."). The goal is then auto-cleared. -### When to call `goal(status)` - -- Once per non-trivial turn, to know whether you are in a goal loop and what turns of budget are left. -- When subgoals exist, to check which ones remain before finishing. - ### When to call `goal(complete)` - When the goal produced verifiable deliverables (files written, tests passing, a diagnosis, a concrete answer) and no subgoals remain. diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 980e8a1dc6..0fa5a5a190 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -1,5 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { InstanceState } from "@/effect/instance-state" @@ -24,6 +24,9 @@ import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Reference } from "@opencode-ai/core/reference" import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Goal } from "@/goal/goal" +import { GoalPrompts } from "@/goal/prompts" +import type { SessionID } from "@/session/schema" export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) @@ -46,7 +49,7 @@ export interface Interface { readonly skills: (agent: Agent.Info) => Effect.Effect readonly mcp: (agent: Agent.Info, permission?: PermissionV1.Ruleset) => Effect.Effect readonly hooks: () => Effect.Effect - readonly goal: () => Effect.Effect + readonly goal: (sessionID: SessionID) => Effect.Effect } export class Service extends Context.Service()("@opencode/SystemPrompt") {} @@ -57,6 +60,13 @@ export const layer = Layer.effect( const skill = yield* Skill.Service const mcp = yield* MCP.Service const locations = yield* LocationServiceMap + // Goal.Service is resolved lazily (serviceOption) rather than declared as a + // hard construction dependency, mirroring src/session/prompt.ts and + // src/tool/goal.ts. Keeping it optional lets the system prompt degrade to a + // terse "no active goal" note in runtimes that omit Goal (some headless / test + // entry points), and avoids dragging Goal's transitive deps into every + // SystemPrompt consumer. + const goalSvc = Option.getOrUndefined(yield* Effect.serviceOption(Goal.Service)) return Service.of({ environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { @@ -133,8 +143,18 @@ export const layer = Layer.effect( return [PROMPT_HOOKS] }), - goal: Effect.fn("SystemPrompt.goal")(function* () { - return [PROMPT_GOAL] + goal: Effect.fn("SystemPrompt.goal")(function* (sessionID: SessionID) { + // No Goal service wired into this entry point → degrade to a terse note. + if (!goalSvc) return ["No autonomous goal is active for this session."] + const state = yield* goalSvc.load(sessionID) + // Only active/paused goals carry a live-state block. `done` is transient + // (auto-cleared) and treated the same as "no goal" here. + if (!state || (state.status !== "active" && state.status !== "paused")) + return ["No autonomous goal is active for this session."] + // Active/paused: the trimmed static mechanism + the live-state block. + // The mechanism is intentionally NOT injected when no goal is active, to + // avoid prompt bloat (spec: no-active-goal-injected-as-terse-note). + return [PROMPT_GOAL, GoalPrompts.renderGoalSystemBlock(state)] }), }) }), diff --git a/packages/opencode/src/tool/goal.txt b/packages/opencode/src/tool/goal.txt index ca9f2fedd0..af165dbb5f 100644 --- a/packages/opencode/src/tool/goal.txt +++ b/packages/opencode/src/tool/goal.txt @@ -2,14 +2,14 @@ Interact with the autonomous goal loop that drives your session (when one is run ## Actions -- `status` — Query the current goal: its text, status, turns used/remaining, subgoals, and pause reason (if any). Returns clear information when no goal is active. Use this to know whether you are inside a goal loop, how many turns the budget has left, and whether subgoals remain. +- `status` — Query the current goal: its text, status, turns used/remaining, subgoals, and pause reason (if any). Returns clear information when no goal is active. - `complete` — Declare the current goal achieved. Bypasses the external judge model and ends the loop immediately. Pass `reason` as a one-sentence summary of what was delivered (e.g., "created `src/foo.ts` and all 7 tests pass"). After `complete`, the goal is auto-cleared; the next call to `status` will report "no goal". ## When to use `status` -- At the start of any non-trivial turn, when you are not sure whether a goal loop is running — this costs nothing and tells you the budget you are consuming. -- After each significant step, to report progress against the goal's turns budget. -- When you suspect the goal might be close to budget exhaustion and you need to decide whether to compress work or extend. +`status` is OPTIONAL. While a goal is active, a live "Current Goal" block (goal text, status, turns used/remaining, subgoals, last judge verdict) is already injected into your system prompt at the start of every turn — you do not need to call `status` to discover whether a goal loop is running or how much budget remains. Reach for `status` only as a deliberate check-in: +- After a long operation, to re-verify state mid-turn (in case the budget or status shifted). +- To inspect `pausedReason` when a goal appears stalled. ## When to use `complete` diff --git a/packages/opencode/test/goal/goal.test.ts b/packages/opencode/test/goal/goal.test.ts new file mode 100644 index 0000000000..95f9d8d958 --- /dev/null +++ b/packages/opencode/test/goal/goal.test.ts @@ -0,0 +1,627 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect, Layer } from "effect" +import { Goal } from "@/goal/goal" +import { GoalEvent } from "@/goal/events" +import { GoalPrompts } from "@/goal/prompts" +import { EventV2Bridge } from "@/event-v2-bridge" +import { SessionStatus } from "@/session/status" +import { Database } from "@opencode-ai/core/database/database" +import { SessionID } from "@/session/schema" +import { testEffect } from "../lib/effect" + +// Build the layer so EventV2Bridge.Service is SHARED between Goal's internals +// (which publish via it) and this test (which subscribes via it). +// `Layer.provideMerge` exposes the built EventV2Bridge in the output context +// AND feeds the same instance into Goal.layer — a plain `Layer.provide` would +// consume it internally and the test's `yield* EventV2Bridge.Service` would +// resolve to a different instance, missing every published event. +// Each test uses a unique SessionID so rows never collide across tests +// (goal_state.session_id is the primary key). +const testLayer = Goal.layer.pipe( + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), +) + +const it = testEffect(testLayer) + +type CapturedEvent = { + type: string + status?: string + turnsUsed?: number + subgoals?: ReadonlyArray +} + +const captureEvents = (events: EventV2Bridge.Service["Service"]) => + Effect.gen(function* () { + const seen: CapturedEvent[] = [] + const unsubscribe = yield* events.listen((event) => + Effect.sync(() => { + // goal.updated carries { sessionID, goal: { status, turnsUsed, subgoals, ... } }; + // goal.cleared carries only { sessionID }. + const goal = ( + event.data as { goal?: { status?: string; turnsUsed?: number; subgoals?: ReadonlyArray } } + ).goal + seen.push({ + type: event.type, + status: goal?.status, + turnsUsed: goal?.turnsUsed, + subgoals: goal?.subgoals, + }) + }), + ) + yield* Effect.addFinalizer(() => unsubscribe) + return seen + }) + +// Returns the single goal.updated(done) event from a capture, failing the test +// loudly if there isn't exactly one (used by markDone / terminal-flow tests). +const doneUpdated = (events: ReadonlyArray) => { + const done = events.filter((e) => e.type === GoalEvent.Updated.type && e.status === "done") + expect(done.length).toBe(1) + return done[0] +} + +// Forks a synthetic "loop fiber" that blocks forever and records whether it has +// been interrupted. The Goal service's fiber map (`fibers`) is private inside +// its layer closure, so fiber-map behavior can only be observed through +// interruption side effects: register the tracked fiber, trigger the action +// under test, then read `holder.interrupted`. +// +// The `ready` deferred is awaited before returning so the child has STARTED and +// registered its onInterrupt finalizer before the caller touches the map — +// without it, an interrupt fired before the child scheduled could miss the +// finalizer and the test would race (see AGENTS.md "Synchronizing With +// Concurrent Work": wait on a published readiness signal, never Effect.sleep). +const trackedFiber = () => + Effect.gen(function* () { + const ready = yield* Deferred.make() + const holder = { interrupted: false } + const fiber = yield* Effect.gen(function* () { + yield* Deferred.succeed(ready, undefined) + yield* Effect.never + }).pipe( + Effect.onInterrupt(() => Effect.sync(() => (holder.interrupted = true))), + Effect.forkChild, + ) + yield* Deferred.await(ready) + return { fiber, holder } + }) + +describe("Goal.updateAfterJudge — continue branch", () => { + // §2.1 baseline: continue increments turns_used exactly once and publishes + // goal.updated(active). This is the ONE branch that is correct pre-fix and + // must stay correct after the bug fixes. + it.live("continue verdict increments turns_used by exactly one and publishes goal.updated", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + seen.length = 0 // drop the set() goal.updated(active) + + const result = yield* goal.updateAfterJudge(sessionID, "continue", "more steps", false) + + expect(result?.shouldContinue).toBe(true) + const loaded = yield* goal.load(sessionID) + expect(Number(loaded?.turns_used)).toBe(1) + expect(loaded?.status).toBe("active") + + const updates = seen.filter((e) => e.type === GoalEvent.Updated.type) + expect(updates.length).toBe(1) + expect(updates[0].status).toBe("active") + expect(updates[0].turnsUsed).toBe(1) + }), + ) +}) + +describe("Goal.updateAfterJudge — done branch (turn budget)", () => { + // §2.2 — done is a STATE TRANSITION, not a continuation dispatch, so it must + // NOT consume budget. Pre-fix this fails (code does +1); post-§3 it passes. + it.live("done verdict does not increment turns_used (state transitions are budget-neutral)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + yield* goal.set(sessionID, "ship feature X", 10) + const before = yield* goal.load(sessionID) + const n = Number(before?.turns_used) + + yield* goal.updateAfterJudge(sessionID, "done", "delivered", false) + + const after = yield* goal.load(sessionID) + expect(after?.status).toBe("done") + expect(Number(after?.turns_used)).toBe(n) + }), + ) +}) + +describe("Goal.updateAfterJudge — done branch (terminal event contract)", () => { + // §2.3 — updateAfterJudge's done branch must NOT publish goal.updated; only + // deleteAndPublishDone owns the terminal sequence. Pre-fix this fails (code + // publishes); post-§4 it passes. + it.live("done verdict does not publish goal.updated (single-owner: deleteAndPublishDone)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "ship feature X", 10) + seen.length = 0 + + yield* goal.updateAfterJudge(sessionID, "done", "delivered", false) + + const updates = seen.filter((e) => e.type === GoalEvent.Updated.type) + expect(updates.length).toBe(0) + }), + ) + + // §4.3 — full judge-done flow: updateAfterJudge persists the done row WITHOUT + // publishing, then deleteAndPublishDone publishes the terminal sequence + // exactly once: goal.updated(done) → goal.cleared, no duplicate updated. + it.live("full judge-done flow publishes goal.updated(done) -> goal.cleared exactly once", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "ship feature X", 10) + seen.length = 0 + + yield* goal.updateAfterJudge(sessionID, "done", "delivered", false) + yield* goal.deleteAndPublishDone(sessionID, "delivered") + + const types = seen.map((e) => e.type) + expect(types).toEqual([GoalEvent.Updated.type, GoalEvent.Cleared.type]) + doneUpdated(seen) + const cleared = seen.filter((e) => e.type === GoalEvent.Cleared.type) + expect(cleared.length).toBe(1) + + // row is gone after the terminal sequence + const loaded = yield* goal.load(sessionID) + expect(loaded).toBeUndefined() + }), + ) +}) + +describe("Goal.markDone — turns_used is budget-neutral", () => { + // §2.4 — user/agent-initiated completion on a goal that never ran a continue + // dispatch. turns_used must stay at its current value (budget counts + // continuation dispatches only). Pre-fix this fails (+1); post-§3 it passes. + // The row is deleted by deleteAndPublishDone, so turns_used is read from the + // published goal.updated(done) payload. + it.live("markDone on a fresh active goal does not increment turns_used", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "ship feature X", 10) + const before = yield* goal.load(sessionID) + seen.length = 0 + + yield* goal.markDone(sessionID, "agent self-declared") + + const event = doneUpdated(seen) + expect(event.turnsUsed).toBe(Number(before?.turns_used)) + }), + ) + + // §2.5 — agent self-declares completion mid-loop: a continue dispatch already + // incremented turns_used (N → N+1); markDone must NOT add a second increment. + // The reported count reflects only the continuation dispatch, not the + // completion call (reasoner C1). Pre-fix this fails (double-count → N+2); + // post-§3 it passes (N+1). + it.live("markDone after a continue dispatch does not double-count", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "ship feature X", 10) + // Simulate one continuation dispatch (the budget-consuming event). + yield* goal.updateAfterJudge(sessionID, "continue", "more steps", false) + const continued = yield* goal.load(sessionID) + seen.length = 0 + + yield* goal.markDone(sessionID, "agent self-declared") + + const event = doneUpdated(seen) + // Only the continue increment; markDone adds nothing. + expect(event.turnsUsed).toBe(Number(continued?.turns_used)) + }), + ) +}) + +// --------------------------------------------------------------------------- +// §5 — Expand state-machine coverage (lock the contract). All PASS against +// current post-bug-fix behavior; they exist to catch regressions when §6-§10 +// land. +// --------------------------------------------------------------------------- + +describe("Goal.set — saves active row + publishes goal.updated(active)", () => { + // §5.1 — the entry point of the lifecycle. Locks the initial row shape and + // the published event: status active, turns_used 0, empty subgoals. + it.live("set persists an active row with turns_used 0 / subgoals [] and publishes goal.updated(active)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + const state = yield* goal.set(sessionID, "build feature X", 10) + + expect(state.status).toBe("active") + expect(Number(state.turns_used)).toBe(0) + expect(state.subgoals).toEqual([]) + + const loaded = yield* goal.load(sessionID) + expect(loaded?.status).toBe("active") + expect(Number(loaded?.turns_used)).toBe(0) + expect(loaded?.subgoals).toEqual([]) + + const updates = seen.filter((e) => e.type === GoalEvent.Updated.type) + expect(updates.length).toBe(1) + expect(updates[0].status).toBe("active") + expect(updates[0].turnsUsed).toBe(0) + expect(updates[0].subgoals).toEqual([]) + }), + ) +}) + +describe("Goal.pause — active→paused, clears loop fiber, publishes goal.updated(paused)", () => { + // §5.2 — pause must (a) transition to paused, (b) clear the loop fiber via + // clearFiber (verified through the tracked fiber's interrupt side effect), + // and (c) publish goal.updated(paused) carrying the reason. + it.live("pause transitions to paused, interrupts the loop fiber, and publishes with the reason", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + const tracked = yield* trackedFiber() + yield* goal.registerLoopFiber(sessionID, tracked.fiber) + seen.length = 0 + + const result = yield* goal.pause(sessionID, "user-paused: checking in") + + expect(result?.status).toBe("paused") + expect(result?.paused_reason).toBe("user-paused: checking in") + // pause() calls clearFiber → Fiber.interrupt on the registered loop fiber + expect(tracked.holder.interrupted).toBe(true) + + const loaded = yield* goal.load(sessionID) + expect(loaded?.status).toBe("paused") + + const updates = seen.filter((e) => e.type === GoalEvent.Updated.type) + expect(updates.length).toBe(1) + expect(updates[0].status).toBe("paused") + }), + ) +}) + +describe("Goal.resume — preserves turns_used (no fresh budget), resets parse failures", () => { + // §5.3 — CRITICAL regression guard: resume must NOT reset turns_used. A + // paused goal that exhausted its budget would otherwise get a fresh full + // budget on every resume, defeating max_turns as a runaway guard. Also + // resets consecutive_parse_failures so a resumed goal gets a clean slate + // for judge-parse-failure auto-pause. + it.live("resume transitions paused→active, preserves turns_used, and resets consecutive_parse_failures", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + // One continuation dispatch with a parse failure → turns_used=1, cpf=1 + yield* goal.updateAfterJudge(sessionID, "continue", "more steps", true) + const beforePause = yield* goal.load(sessionID) + expect(Number(beforePause?.turns_used)).toBe(1) + expect(Number(beforePause?.consecutive_parse_failures)).toBe(1) + // User-initiated pause preserves turns_used + cpf + yield* goal.pause(sessionID, "user paused") + seen.length = 0 + + const result = yield* goal.resume(sessionID) + + expect(result?.status).toBe("active") + // turns_used preserved — NOT reset to 0 + expect(Number(result?.turns_used)).toBe(Number(beforePause?.turns_used)) + // parse-failure counter reset on resume + expect(Number(result?.consecutive_parse_failures)).toBe(0) + + const loaded = yield* goal.load(sessionID) + expect(loaded?.status).toBe("active") + expect(Number(loaded?.turns_used)).toBe(1) + expect(Number(loaded?.consecutive_parse_failures)).toBe(0) + + const updates = seen.filter((e) => e.type === GoalEvent.Updated.type) + expect(updates.length).toBe(1) + expect(updates[0].status).toBe("active") + }), + ) + + // §5.4 — budget-exhausted pause: resume flips to active but keeps turns_used + // intact (== max_turns). The next judge iteration immediately re-pauses; the + // dispatch layer surfaces a warning (goal.ts:506-509). This locks that resume + // does NOT silently grant a fresh budget. + it.live("resume on a budget-exhausted paused goal keeps turns_used at max (no budget reset)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + + // max_turns=2: a second continue verdict trips the budget-pause branch + yield* goal.set(sessionID, "build feature X", 2) + yield* goal.updateAfterJudge(sessionID, "continue", "step 1", false) // turns_used 1 + yield* goal.updateAfterJudge(sessionID, "continue", "step 2", false) // turns_used 2 >= max → paused + + const paused = yield* goal.load(sessionID) + expect(paused?.status).toBe("paused") + expect(Number(paused?.turns_used)).toBe(2) + + const result = yield* goal.resume(sessionID) + + // Active again, but turns_used unchanged — immediately re-exhaustible. + expect(result?.status).toBe("active") + expect(Number(result?.turns_used)).toBe(2) + expect(Number(result?.turns_used) >= Number(result?.max_turns)).toBe(true) + }), + ) +}) + +describe("Goal.clear — deletes row, clears loop fiber, publishes goal.cleared", () => { + // §5.5 — clear tears down everything: row deleted, loop fiber interrupted, + // exactly one goal.cleared published, and NO goal.updated (clear is not a + // state transition, it is removal). + it.live("clear removes the row, interrupts the loop fiber, and publishes exactly one goal.cleared", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + const tracked = yield* trackedFiber() + yield* goal.registerLoopFiber(sessionID, tracked.fiber) + seen.length = 0 + + yield* goal.clear(sessionID) + + expect(tracked.holder.interrupted).toBe(true) + const loaded = yield* goal.load(sessionID) + expect(loaded).toBeUndefined() + + const cleared = seen.filter((e) => e.type === GoalEvent.Cleared.type) + expect(cleared.length).toBe(1) + const updates = seen.filter((e) => e.type === GoalEvent.Updated.type) + expect(updates.length).toBe(0) + }), + ) +}) + +describe("Goal.registerLoopFiber — interrupts the previous fiber before storing the new one", () => { + // §5.6 — registering a new fiber for a session that already has one must + // interrupt the old one first (prevents a leaked/orphaned loop fiber when a + // new afterIdle run supersedes the prior). Verified by: (a) old fiber + // interrupted, (b) new fiber intact, (c) a subsequent clearLoopFiber + // interrupts the NEW fiber (proves it was actually stored). + it.live("registering a new fiber interrupts the previously-registered fiber for the same session", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + + const first = yield* trackedFiber() + yield* goal.registerLoopFiber(sessionID, first.fiber) + expect(first.holder.interrupted).toBe(false) + + const second = yield* trackedFiber() + yield* goal.registerLoopFiber(sessionID, second.fiber) + + // previous fiber interrupted by the re-register + expect(first.holder.interrupted).toBe(true) + // new fiber is intact and is now the one stored in the map + expect(second.holder.interrupted).toBe(false) + // clearing now interrupts the NEW fiber, proving it was stored + yield* goal.clearLoopFiber(sessionID) + expect(second.holder.interrupted).toBe(true) + }), + ) +}) + +describe("Goal fiber-safe terminal paths — do NOT touch the fiber map", () => { + // §5.7 — deleteAndPublishDone and pauseAndPublish are called from INSIDE the + // loop fiber itself (loop.ts done / shouldPreempt branches). They must NOT + // manage the fiber map — doing so would self-interrupt before the terminal + // / pause event reaches the bus (the event would never be published). This + // locks the self-interrupt-hazard discipline: caller manages the fiber. + it.live("deleteAndPublishDone leaves the registered loop fiber intact", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + const tracked = yield* trackedFiber() + yield* goal.registerLoopFiber(sessionID, tracked.fiber) + + yield* goal.deleteAndPublishDone(sessionID, "judge done") + + // fiber NOT interrupted — map untouched + expect(tracked.holder.interrupted).toBe(false) + // the map still holds it: clearing now interrupts the registered fiber + yield* goal.clearLoopFiber(sessionID) + expect(tracked.holder.interrupted).toBe(true) + }), + ) + + it.live("pauseAndPublish leaves the registered loop fiber intact", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + const tracked = yield* trackedFiber() + yield* goal.registerLoopFiber(sessionID, tracked.fiber) + + yield* goal.pauseAndPublish(sessionID, "loop self-pause") + + // fiber NOT interrupted — map untouched + expect(tracked.holder.interrupted).toBe(false) + // the map still holds it: clearing now interrupts the registered fiber + yield* goal.clearLoopFiber(sessionID) + expect(tracked.holder.interrupted).toBe(true) + }), + ) +}) + +// --------------------------------------------------------------------------- +// §9 — Transport errors count toward pause budget (D5). Transport failures +// (timeout, network) now return parseFailed: true from the judge, feeding the +// same consecutive_parse_failures counter as parse failures. Three in a row +// triggers auto-pause; alternating transport/parse failures must NOT reset the +// counter (pre-fix transport returned parseFailed: false, which reset it to 0 +// and let a flaky provider burn the full budget without ever pausing). +// --------------------------------------------------------------------------- + +describe("Goal.updateAfterJudge — transport failures trigger auto-pause (D5)", () => { + // §9.3a — three consecutive transport failures (parseFailed: true, simulating + // what judge.ts now returns on timeout/network) must reach + // MAX_CONSECUTIVE_PARSE_FAILURES (3) and auto-pause on the third. + it.live("three consecutive transport failures auto-pause the goal", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + seen.length = 0 + + // Two transport failures — still active, counter climbing 1 → 2 + const r1 = yield* goal.updateAfterJudge(sessionID, "continue", "transport error 1", true) + const r2 = yield* goal.updateAfterJudge(sessionID, "continue", "transport error 2", true) + expect(r1?.shouldContinue).toBe(true) + expect(r2?.shouldContinue).toBe(true) + + const midState = yield* goal.load(sessionID) + expect(midState?.status).toBe("active") + expect(Number(midState?.consecutive_parse_failures)).toBe(2) + + // Third transport failure — counter reaches 3 → auto-pause + const r3 = yield* goal.updateAfterJudge(sessionID, "continue", "transport error 3", true) + expect(r3?.shouldContinue).toBe(false) + + const finalState = yield* goal.load(sessionID) + expect(finalState?.status).toBe("paused") + expect(Number(finalState?.consecutive_parse_failures)).toBeGreaterThanOrEqual( + GoalPrompts.MAX_CONSECUTIVE_PARSE_FAILURES, + ) + + const paused = seen.filter((e) => e.type === GoalEvent.Updated.type && e.status === "paused") + expect(paused.length).toBe(1) + }), + ) + + // §9.3b — alternating transport + parse failures. Before §9, transport errors + // returned parseFailed: false which reset consecutive_parse_failures to 0 on + // every transport blip, so alternating transport/parse/transport never + // reached the threshold. After §9, both failure modes set parseFailed: true, + // so the counter climbs monotonically across the mix and pauses on the 3rd. + it.live("alternating transport + parse failures still triggers auto-pause", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + + // transport-fail (parseFailed: true) → counter 1 + yield* goal.updateAfterJudge(sessionID, "continue", "transport error", true) + let state = yield* goal.load(sessionID) + expect(Number(state?.consecutive_parse_failures)).toBe(1) + expect(state?.status).toBe("active") + + // parse-fail (parseFailed: true) → counter 2 + yield* goal.updateAfterJudge(sessionID, "continue", "无法解析", true) + state = yield* goal.load(sessionID) + expect(Number(state?.consecutive_parse_failures)).toBe(2) + expect(state?.status).toBe("active") + + // transport-fail (parseFailed: true) → counter 3 → PAUSE + const r3 = yield* goal.updateAfterJudge(sessionID, "continue", "transport error", true) + expect(r3?.shouldContinue).toBe(false) + + state = yield* goal.load(sessionID) + expect(state?.status).toBe("paused") + }), + ) +}) + +// --------------------------------------------------------------------------- +// §10 — Zombie-goal freshness guard (D6). When afterIdle detects an active goal +// with turns_used 0, no assistant message, and created_at older than +// FRESHNESS_THRESHOLD, it calls pauseAndPublish with a freshness reason. This +// tests that the pause transition (the mechanism the guard uses) publishes the +// paused event with the freshness reason — the guard's predicate logic itself +// is locked in loop.test.ts (isStaleZombie). +// --------------------------------------------------------------------------- + +describe("Goal.pauseAndPublish — freshness-guard pause (D6)", () => { + // §10.3 — the exact pause transition afterIdle's freshness guard performs: + // pauseAndPublish with the freshness reason string. Verifies the goal flips + // to paused, the reason is persisted, and goal.updated(paused) fires on the + // bus so the TUI/SSE surfaces the orphaned goal instead of leaving it silent. + it.live("freshness pause transitions active→paused and publishes with the reason", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + const sessionID = SessionID.descending() + + yield* goal.set(sessionID, "build feature X", 10) + seen.length = 0 + + const reason = `initial kick produced no assistant response within ${GoalPrompts.FRESHNESS_THRESHOLD / 1000}s — likely provider error or model refusal. Use /goal resume to retry.` + const result = yield* goal.pauseAndPublish(sessionID, reason) + + expect(result?.status).toBe("paused") + expect(result?.paused_reason).toBe(reason) + + const loaded = yield* goal.load(sessionID) + expect(loaded?.status).toBe("paused") + expect(loaded?.paused_reason).toBe(reason) + + const paused = seen.filter((e) => e.type === GoalEvent.Updated.type && e.status === "paused") + expect(paused.length).toBe(1) + }), + ) + + // §10.4 — a fresh goal (within threshold) does NOT hit the freshness guard. + // pauseAndPublish with a freshness reason is never invoked; the goal stays + // active. This is verified at the predicate level in loop.test.ts + // (isStaleZombie returns false for fresh goals); here we confirm a fresh + // goal row remains active and unpauseable by anything other than an explicit + // pause call — the guard's absence is the expected behavior. + it.live("fresh goal stays active (freshness guard does not fire)", () => + Effect.gen(function* () { + const goal = yield* Goal.Service + const sessionID = SessionID.descending() + + // A freshly-set goal: created_at is now, turns_used 0 — the exact state + // the guard checks, but within the threshold so the predicate is false. + yield* goal.set(sessionID, "build feature X", 10) + + const state = yield* goal.load(sessionID) + expect(state?.status).toBe("active") + expect(Number(state?.turns_used)).toBe(0) + // created_at is recent (within the last second), well inside the threshold + expect(Date.now() - Number(state?.created_at)).toBeLessThan(GoalPrompts.FRESHNESS_THRESHOLD) + }), + ) +}) diff --git a/packages/opencode/test/goal/judge.test.ts b/packages/opencode/test/goal/judge.test.ts new file mode 100644 index 0000000000..99b3b814bd --- /dev/null +++ b/packages/opencode/test/goal/judge.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { GoalJudge } from "@/goal/judge" + +describe("parseJudgeResponse", () => { + // §1.2 — clean JSON parses directly (step 2) + test("clean JSON object returns matching verdict", () => { + const result = GoalJudge.parseJudgeResponse('{"done": true, "reason": "all tests pass"}') + expect(result).toEqual({ verdict: "done", reason: "all tests pass", parseFailed: false }) + }) + + test("clean JSON with done=false returns continue", () => { + const result = GoalJudge.parseJudgeResponse('{"done": false, "reason": "still working"}') + expect(result).toEqual({ verdict: "continue", reason: "still working", parseFailed: false }) + }) + + // §1.3 — markdown-fenced JSON strips fences (step 1) + test("markdown-fenced JSON strips fences and parses", () => { + const raw = "```json\n{\"done\": false, \"reason\": \"more steps remain\"}\n```" + const result = GoalJudge.parseJudgeResponse(raw) + expect(result).toEqual({ verdict: "continue", reason: "more steps remain", parseFailed: false }) + }) + + test("markdown-fenced without language tag also strips", () => { + const raw = "```\n{\"done\": true, \"reason\": \"done\"}\n```" + const result = GoalJudge.parseJudgeResponse(raw) + expect(result).toEqual({ verdict: "done", reason: "done", parseFailed: false }) + }) + + // §1.4 — JSON embedded in prose: regex step extracts first {...} block (step 3) + test("JSON embedded in prose is extracted by regex fallback", () => { + const raw = 'Sure! {"done": true, "reason": "shipped"} Thanks' + const result = GoalJudge.parseJudgeResponse(raw) + expect(result).toEqual({ verdict: "done", reason: "shipped", parseFailed: false }) + }) + + // §1.5 — unparseable input falls through all steps (step 4) + test("unparseable prose returns continue with parseFailed true", () => { + const result = GoalJudge.parseJudgeResponse("I think it's done") + expect(result).toEqual({ + verdict: "continue", + reason: "无法解析 judge 输出", + parseFailed: true, + }) + }) + + test("empty string returns parseFailed", () => { + const result = GoalJudge.parseJudgeResponse("") + expect(result.parseFailed).toBe(true) + expect(result.verdict).toBe("continue") + }) + + test("valid JSON but wrong shape (missing reason) returns parseFailed", () => { + const result = GoalJudge.parseJudgeResponse('{"done": true}') + expect(result.parseFailed).toBe(true) + }) + + // §1.6 — nested-brace reason. NOTE: this contradicts tasks.md §1.6, which + // claims this input hits "step 4 fallback, parseFailed: true." It does not: + // step 2 runs `JSON.parse` on the whole string, and JSON.parse correctly + // handles braces inside string literals, so `{"reason": "set up {config}"}` + // parses cleanly. The regex limitation (`\{[^{}]*\}` cannot span nested + // braces) only manifests at STEP 3, and step 3 is only reached when step 2 + // has already FAILED — i.e. when the verdict JSON is embedded in prose. + // See the next test for the case that actually demonstrates the limitation. + // Asserting the real current behavior keeps RED-1 green. + test("nested-brace reason parses via step 2 (JSON.parse handles braces in strings)", () => { + const raw = '{"done": true, "reason": "set up {config}"}' + const result = GoalJudge.parseJudgeResponse(raw) + expect(result).toEqual({ verdict: "done", reason: "set up {config}", parseFailed: false }) + }) + + // The genuine step-3 regex limitation: verdict JSON embedded in prose where + // the reason itself contains a nested brace. Step 2 fails (not pure JSON), + // so step 3 runs. `\{[^{}]*\}` cannot span the outer object (it forbids inner + // braces), so it instead matches the innermost `{config}`, which is not valid + // verdict JSON → falls through to step 4 (parseFailed: true). A future + // balanced-brace extractor would fix this; locked here so the limitation is + // visible and a fix is detectable. + test("nested-brace reason embedded in prose hits the step-3 regex limitation", () => { + const raw = 'Sure! {"done": true, "reason": "set up {config}"} done' + const result = GoalJudge.parseJudgeResponse(raw) + expect(result.parseFailed).toBe(true) + expect(result.verdict).toBe("continue") + }) +}) + +describe("GoalJudge.run — transport failures count toward pause budget (D5)", () => { + // §9.2 — when the injected callLLM fails (timeout, network error, rejection), + // the orElseSucceed fallback MUST return parseFailed: true (not false) so the + // failure increments consecutive_parse_failures via updateAfterJudge's + // `parseFailed ? count + 1 : 0` logic. Pre-fix this returned parseFailed: + // false, which reset the counter and let a flaky provider burn the full + // max_turns budget without ever pausing. + test("transport failure (Effect.fail) returns parseFailed: true", () => + Effect.gen(function* () { + const result = yield* GoalJudge.run( + "build feature X", + "some agent response", + [], + () => Effect.fail(new Error("timeout")), + ) + expect(result.verdict).toBe("continue") + expect(result.parseFailed).toBe(true) + }).pipe(Effect.runPromise), + ) + + test("transport failure reason names the failure mode", () => + Effect.gen(function* () { + const result = yield* GoalJudge.run( + "build feature X", + "some agent response", + [], + () => Effect.fail(new Error("network down")), + ) + // The reason must name the transport failure so the pause message + // (when it eventually fires after MAX_CONSECUTIVE_PARSE_FAILURES) + // can distinguish transport unreliability from parse failures. + expect(result.reason).toMatch(/transport/i) + expect(result.reason).toMatch(/timeout|network/i) + }).pipe(Effect.runPromise), + ) + + test("non-Error rejection also returns parseFailed: true", () => + Effect.gen(function* () { + const result = yield* GoalJudge.run( + "build feature X", + "some agent response", + [], + () => Effect.fail(new Error("ECONNRESET")), + ) + expect(result.parseFailed).toBe(true) + expect(result.verdict).toBe("continue") + }).pipe(Effect.runPromise), + ) +}) diff --git a/packages/opencode/test/goal/loop.test.ts b/packages/opencode/test/goal/loop.test.ts new file mode 100644 index 0000000000..c8aa9720ff --- /dev/null +++ b/packages/opencode/test/goal/loop.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import { GoalLoop } from "@/goal/loop" +import { GoalPrompts } from "@/goal/prompts" + +type Msg = Parameters[0][number] + +const mk = (role: "user" | "assistant", created: number): Msg => ({ + info: { role, time: { created } }, +}) + +describe("shouldPreempt", () => { + // §1.7 — last user message newer than last assistant → preempt (true) + test("user message newer than last assistant returns true", () => { + const msgs = [mk("assistant", 100), mk("user", 200)] + expect(GoalLoop.shouldPreempt(msgs)).toBe(true) + }) + + // §1.8 — last assistant newer → no preempt (false) + test("assistant message newer than last user returns false", () => { + const msgs = [mk("user", 100), mk("assistant", 200)] + expect(GoalLoop.shouldPreempt(msgs)).toBe(false) + }) + + // §1.9 — missing user OR assistant → defensive false + test("missing user message returns false", () => { + const msgs = [mk("assistant", 100), mk("assistant", 200)] + expect(GoalLoop.shouldPreempt(msgs)).toBe(false) + }) + + test("missing assistant message returns false", () => { + const msgs = [mk("user", 100), mk("user", 200)] + expect(GoalLoop.shouldPreempt(msgs)).toBe(false) + }) + + test("empty message list returns false", () => { + expect(GoalLoop.shouldPreempt([])).toBe(false) + }) + + // strict `>` comparison: equal timestamps are NOT a preempt + test("equal timestamps return false (strict greater-than)", () => { + const msgs = [mk("assistant", 200), mk("user", 200)] + expect(GoalLoop.shouldPreempt(msgs)).toBe(false) + }) + + // tracks the MAXIMUM timestamp per role across interleaved messages + test("uses the most recent timestamp per role regardless of order", () => { + const msgs = [ + mk("assistant", 500), + mk("user", 100), + mk("assistant", 200), + mk("user", 600), + ] + // lastUserAt = 600, lastAsstAt = 500 → preempt + expect(GoalLoop.shouldPreempt(msgs)).toBe(true) + }) + + // messages missing `time.created` are skipped (defensive) + test("messages missing created timestamp are skipped", () => { + const msgs = [ + { info: { role: "assistant", time: { created: 100 } } }, + { info: { role: "user", time: {} } }, + ] as ReadonlyArray + // no valid user timestamp → false + expect(GoalLoop.shouldPreempt(msgs)).toBe(false) + }) +}) + +describe("isStaleZombie — freshness guard predicate (D6)", () => { + // Helper: builds a goal-state-shaped object for the predicate. created_at is + // expressed relative to a fixed `now` to keep tests deterministic. + const state = (overrides: Partial<{ status: string; turns_used: number; created_at: number }> = {}) => ({ + status: "active", + turns_used: 0, + created_at: 0, + ...overrides, + }) + const NOW = 1_000_000 + + // §10.3 — the fire condition: active, turns_used 0, older than the threshold, + // and no assistant message. This is exactly the orphan state afterIdle must + // convert into a visible pause. + test("stale active goal with zero turns and no assistant → true", () => { + const s = state({ created_at: NOW - GoalPrompts.FRESHNESS_THRESHOLD - 1 }) + expect(GoalLoop.isStaleZombie(s, false, NOW)).toBe(true) + }) + + // §10.4 — fresh goal: created within the threshold. Must NOT pause even with + // no assistant message — the initial kick may just be slow, not failed. + test("fresh active goal (within threshold) → false", () => { + const s = state({ created_at: NOW - 1000 }) + expect(GoalLoop.isStaleZombie(s, false, NOW)).toBe(false) + }) + + // Exactly at the threshold is NOT stale (strict >). + test("goal exactly at threshold boundary → false (strict greater-than)", () => { + const s = state({ created_at: NOW - GoalPrompts.FRESHNESS_THRESHOLD }) + expect(GoalLoop.isStaleZombie(s, false, NOW)).toBe(false) + }) + + // Has an assistant message → not orphaned, the initial kick succeeded. + test("stale goal but assistant message exists → false", () => { + const s = state({ created_at: NOW - GoalPrompts.FRESHNESS_THRESHOLD - 1 }) + expect(GoalLoop.isStaleZombie(s, true, NOW)).toBe(false) + }) + + // Already ran continuations → turns_used > 0, not a zombie. + test("stale goal but turns_used > 0 → false", () => { + const s = state({ turns_used: 3, created_at: NOW - GoalPrompts.FRESHNESS_THRESHOLD - 1 }) + expect(GoalLoop.isStaleZombie(s, false, NOW)).toBe(false) + }) + + // Not active (paused/done) → predicate short-circuits; pauseAndPublish would + // be a no-op anyway, but the guard must not fire. + test("paused goal → false", () => { + const s = state({ status: "paused", created_at: NOW - GoalPrompts.FRESHNESS_THRESHOLD - 1 }) + expect(GoalLoop.isStaleZombie(s, false, NOW)).toBe(false) + }) +}) diff --git a/packages/opencode/test/goal/prompts.test.ts b/packages/opencode/test/goal/prompts.test.ts new file mode 100644 index 0000000000..234bc05378 --- /dev/null +++ b/packages/opencode/test/goal/prompts.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { GoalState } from "@/goal/state" +import { GoalPrompts } from "@/goal/prompts" + +// Decode a plain object into a branded GoalState.Info so tests stay free of +// `as any` brand casts. Decoding also exercises the optional/withDecodingDefault +// fields (subgoals defaults to [], optional verdict/reason stay undefined). +function mkState(overrides: Partial<{ + goal: string + status: "active" | "paused" | "done" + turns_used: number + max_turns: number + created_at: number + last_turn_at: number + last_verdict: "done" | "continue" | "skipped" + last_reason: string + paused_reason: string + subgoals: ReadonlyArray +}>): GoalState.Info { + return Schema.decodeUnknownSync(GoalState.Info)({ + goal: "ship the feature", + status: "active", + turns_used: 3, + max_turns: 20, + created_at: 1000, + last_turn_at: 2000, + last_verdict: "continue", + last_reason: "making progress", + consecutive_parse_failures: 0, + subgoals: [], + ...overrides, + }) +} + +describe("GoalPrompts.renderGoalSystemBlock (D4.1 dynamic system prompt)", () => { + test("active goal with subgoals renders structured live-state block", () => { + const block = GoalPrompts.renderGoalSystemBlock( + mkState({ + goal: "Add login page", + status: "active", + turns_used: 3, + max_turns: 20, + subgoals: ["write tests", "wire route"], + last_verdict: "continue", + last_reason: "tests passing, route pending", + }), + ) + + expect(block).toContain("## Current Goal (autonomous loop)") + expect(block).toContain("Goal: Add login page") + expect(block).toContain("Status: active") + expect(block).toContain("Turns: 3/20 (17 remaining)") + expect(block).toContain("Subgoals:") + expect(block).toContain("1. write tests") + expect(block).toContain("2. wire route") + expect(block).toContain("Last judge verdict: continue — tests passing, route pending") + }) + + test("paused goal surfaces the paused reason", () => { + const block = GoalPrompts.renderGoalSystemBlock( + mkState({ + status: "paused", + paused_reason: "budget exhausted", + turns_used: 20, + max_turns: 20, + }), + ) + + expect(block).toContain("Status: paused") + expect(block).toContain("Paused because: budget exhausted") + expect(block).toContain("Turns: 20/20 (0 remaining)") + }) + + test("goal with no subgoals reports none", () => { + const block = GoalPrompts.renderGoalSystemBlock(mkState({ subgoals: [] })) + expect(block).toContain("Subgoals: none") + expect(block).not.toMatch(/Subgoals:\n/) + }) + + test("goal without a prior verdict omits the judge line", () => { + const block = GoalPrompts.renderGoalSystemBlock( + mkState({ last_verdict: undefined, last_reason: undefined }), + ) + expect(block).not.toContain("Last judge verdict") + }) + + test("verdict present without reason still renders the verdict", () => { + const block = GoalPrompts.renderGoalSystemBlock( + mkState({ last_verdict: "continue", last_reason: undefined }), + ) + expect(block).toContain("Last judge verdict: continue") + expect(block).not.toContain("Last judge verdict: continue —") + }) +}) + +describe("GoalPrompts.renderContinuation (D4.2 merged injection)", () => { + test("renders goal, turns/budget, and the autonomous-mode frame", () => { + const text = GoalPrompts.renderContinuation({ + goal: "Add login page", + subgoals: [], + turnsUsed: 3, + maxTurns: 20, + }) + + expect(text).toContain("[Continuing toward your standing goal]") + expect(text).toContain("Goal: Add login page") + expect(text).toContain("Turns: 3/20 (17 remaining)") + expect(text).toContain("autonomous mode") + expect(text).toContain("Do not ask the user for clarification or confirmation.") + expect(text).toContain("Take the next concrete step.") + }) + + test("numbers subgoals when present", () => { + const text = GoalPrompts.renderContinuation({ + goal: "Add login page", + subgoals: ["write tests", "wire route"], + turnsUsed: 1, + maxTurns: 10, + }) + + expect(text).toContain("Subgoals:") + expect(text).toContain("1. write tests") + expect(text).toContain("2. wire route") + }) + + test("omits the subgoals block when there are none", () => { + const text = GoalPrompts.renderContinuation({ + goal: "Add login page", + subgoals: [], + turnsUsed: 1, + maxTurns: 10, + }) + expect(text).not.toContain("Subgoals:") + }) + + test("labels the last judge reason when provided", () => { + const text = GoalPrompts.renderContinuation({ + goal: "Add login page", + subgoals: [], + turnsUsed: 2, + maxTurns: 10, + lastJudgeReason: "needs error handling", + }) + expect(text).toContain("Judge feedback: needs error handling") + }) + + test("omits the judge feedback line when no reason is given", () => { + const text = GoalPrompts.renderContinuation({ + goal: "Add login page", + subgoals: [], + turnsUsed: 2, + maxTurns: 10, + }) + expect(text).not.toContain("Judge feedback:") + }) + + test("clamps remaining turns at zero when budget exhausted", () => { + const text = GoalPrompts.renderContinuation({ + goal: "Add login page", + subgoals: [], + turnsUsed: 20, + maxTurns: 20, + }) + expect(text).toContain("Turns: 20/20 (0 remaining)") + }) +})