diff --git a/packages/opencode/src/goal/loop.ts b/packages/opencode/src/goal/loop.ts index 77e3b65b33..fe41b9fd98 100644 --- a/packages/opencode/src/goal/loop.ts +++ b/packages/opencode/src/goal/loop.ts @@ -184,54 +184,61 @@ export const layer = Layer.effect( const msgs = yield* sessions.messages({ sessionID, limit: 20 }) const lastAssistant = [...msgs].reverse().find((m) => m.info.role === "assistant") - if (!lastAssistant) return + if (!lastAssistant) { + // No assistant message in the last 20 — the conversation may have + // been compacted or the initial kick failed after the stale-zombie + // guard window. Pause visibly instead of silently stalling. + const pauseMsg = "近期消息中无 assistant 回复,目标已暂停。使用 /goal resume 重试。" + yield* goal.pauseAndPublish(sessionID, pauseMsg).pipe(Effect.ignore) + yield* promptSvc.prompt({ sessionID, noReply: true, parts: [{ type: "text", text: `⏸ 目标已暂停 — ${pauseMsg}` }] }).pipe(Effect.ignore) + return + } const responseText = lastAssistant.parts .filter((p): p is Extract<(typeof lastAssistant.parts)[number], { type: "text" }> => p.type === "text") .map((p) => p.text) .join("\n") .slice(-4000) - if (!responseText) return - - // Judge LLM call: prefer the test-injected callable so e2e tests - // can script verdicts without Provider/network; otherwise build the - // production Provider → generateText path. The verdict logic below is - // unchanged — only the callLLM construction point moved. - const injected = Option.getOrUndefined(yield* Effect.serviceOption(GoalLoopJudgeLLM)) - const callLLM: JudgeCallLLM = - injected?.call ?? - ((opts) => - Effect.gen(function* () { - const defaultM = yield* provider.defaultModel() - // Judge is a ~200-token JSON binary classification — prefer the - // provider's small/fast model (config `small_model`, plugin hint, - // or the built-in haiku/flash/nano priority list). Fall back to - // the default model when no small model is resolvable, keeping - // the prior behavior byte-for-byte for those providers. - const small = yield* provider.getSmallModel(defaultM.providerID) - const model = small ?? (yield* provider.getModel(defaultM.providerID, defaultM.modelID)) - const language = yield* provider.getLanguage(model) - const result = yield* Effect.tryPromise({ - try: (signal) => - generateText({ - model: language, - system: opts.system, - prompt: opts.user, - temperature: opts.temperature, - maxOutputTokens: opts.maxTokens, - abortSignal: signal, - }), - catch: (e) => new Error(`judge LLM call failed: ${String(e)}`), - }).pipe(Effect.timeout(`${opts.timeout} seconds`)) - if (!result) return "" - return result.text - })) - - const verdict = yield* GoalJudge.run( - goalState.goal, - responseText, - goalState.subgoals ?? [], - callLLM, - ) + // When the last assistant turn produced no text (pure tool calls, + // reasoning-only, or a submit_result with no prose), the goal should + // NOT silently stall — the agent is making progress via tools. Skip + // the judge (there is nothing to classify) and continue directly, + // using a synthetic "continue" verdict so the loop dispatches the + // next turn. Previously this was a bare `return` that left the goal + // permanently "active" with no continuation — the agent appeared to + // stop working on its own. + const callLLM = Option.getOrUndefined(yield* Effect.serviceOption(GoalLoopJudgeLLM)) + const verdict = responseText + ? yield* GoalJudge.run( + goalState.goal, + responseText, + goalState.subgoals ?? [], + // Judge LLM call: prefer the test-injected callable so e2e tests + // can script verdicts without Provider/network; otherwise build the + // production Provider → generateText path. + callLLM?.call ?? + ((opts) => + Effect.gen(function* () { + const defaultM = yield* provider.defaultModel() + const small = yield* provider.getSmallModel(defaultM.providerID) + const model = small ?? (yield* provider.getModel(defaultM.providerID, defaultM.modelID)) + const language = yield* provider.getLanguage(model) + const result = yield* Effect.tryPromise({ + try: (signal) => + generateText({ + model: language, + system: opts.system, + prompt: opts.user, + temperature: opts.temperature, + maxOutputTokens: opts.maxTokens, + abortSignal: signal, + }), + catch: (e) => new Error(`judge LLM call failed: ${String(e)}`), + }).pipe(Effect.timeout(`${opts.timeout} seconds`)) + if (!result) return "" + return result.text + })), + ) + : { verdict: "continue" as const, reason: "上一轮无文本输出(纯工具调用),跳过判定直接继续", parseFailed: false } const updateResult = yield* goal.updateAfterJudge(sessionID, verdict.verdict, verdict.reason, verdict.parseFailed) if (!updateResult) return @@ -283,7 +290,14 @@ export const layer = Layer.effect( const currentStatus = yield* status.get(sessionID) if (currentStatus.type !== "idle") { - return // session no longer idle, skip continuation + // Session is no longer idle after the judge call (5-30s latency). + // Previously this was a bare `return` that left the goal silently + // "active" with no continuation. Pause with a visible reason so the + // user knows the loop was interrupted by a status change. + const pauseMsg = `judge 期间会话状态变化(${currentStatus.type}),目标已暂停` + yield* goal.pauseAndPublish(sessionID, pauseMsg).pipe(Effect.ignore) + yield* promptSvc.prompt({ sessionID, noReply: true, parts: [{ type: "text", text: `⏸ 目标已暂停 — ${pauseMsg}` }] }).pipe(Effect.ignore) + return } // Reload messages after judge LLM call — the snapshot from before judge @@ -339,10 +353,27 @@ export const layer = Layer.effect( .pipe( Effect.catchCause((cause) => Effect.gen(function* () { + // F1: Only pause for non-interrupt causes. An interrupt (user + // pressed ESC during continuation) is safe to drop because the + // session ALWAYS re-emits idle afterwards, which re-drives this + // loop: SessionRunState.cancel (run-state.ts) and the runner's + // onIdle callback both call status.set(idle), and + // SessionStatus.set (status.ts) publishes the Status+Idle event + // pair unconditionally — even when the session was already idle. + // That fresh idle event forks a new afterIdle fiber whose + // shouldPreempt guard detects the user's newer message and pauses + // there if needed. Pausing HERE would race that replacement + // afterIdle fiber and emit a spurious pause. Real dispatch + // failures (provider fault, session write error) still get the + // recoverable pause below. + if (Cause.interruptors(cause).size > 0) { + yield* Effect.logInfo("goal continuation interrupted (likely user ESC) — not pausing; shouldPreempt handles next cycle") + return + } + const errMsg = `continuation dispatch failed: ${Cause.pretty(cause)}` yield* Effect.logWarning("goal continuation dispatch failed", { error: Cause.pretty(cause) }) - yield* goal.pauseAndPublish(sessionID, `continuation dispatch failed: ${Cause.pretty(cause)}`).pipe( - Effect.ignore, - ) + yield* goal.pauseAndPublish(sessionID, errMsg).pipe(Effect.ignore) + yield* promptSvc.prompt({ sessionID, noReply: true, parts: [{ type: "text", text: `⏸ 目标已暂停 — ${errMsg}` }] }).pipe(Effect.ignore) }), ), ) diff --git a/packages/opencode/test/goal/e2e-loop.test.ts b/packages/opencode/test/goal/e2e-loop.test.ts index 42e7217037..d65bf6bc53 100644 --- a/packages/opencode/test/goal/e2e-loop.test.ts +++ b/packages/opencode/test/goal/e2e-loop.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Cause, Effect, Layer } from "effect" import { GoalLoop, GoalLoopJudgeLLM } from "@/goal/loop" import { Goal } from "@/goal/goal" import { GoalEvent } from "@/goal/events" @@ -47,6 +47,38 @@ const mkAssistant = () => parts: [{ type: "text", text: assistantText }], }) as never +// A user-only message window — no assistant turn exists. Drives afterIdle into +// the "no lastAssistant" branch (loop.ts branch 1 → visible pause). +const mkUser = () => + ({ + info: { role: "user", time: { created: Date.now() } }, + parts: [{ type: "text", text: "继续推进" }], + }) as never + +// An assistant turn that produced only tool calls (no text part). afterIdle's +// responseText filter (`p.type === "text"`) yields "" → the synthetic +// continue verdict skips the judge entirely (loop.ts branch 2 → no stall). +const mkAssistantTools = () => + ({ + info: { role: "assistant", time: { created: Date.now() } }, + parts: [{ type: "tool-call", toolCallId: "1", toolName: "run", input: {} }], + }) as never + +// Prompt mock that records every call (noReply flag + joined text) for branch +// assertions. Resolves void — these tests never drive a real agent turn from +// the mock; the goal state and event captures are the observable contract. +const recordingPrompt = (sink: { noReply?: boolean; text: string }[]) => + Layer.succeed(SessionPrompt.Service, { + prompt: (input: { noReply?: boolean; parts?: Array<{ type: string; text: string }> }) => + Effect.sync(() => { + sink.push({ + noReply: input.noReply, + text: input.parts?.map((p) => p.text).join("\n") ?? "", + }) + return undefined as never + }), + } as never) + describe("GoalLoop end-to-end — continue → done lifecycle (P2b)", () => { // Per-test mutable mock state (each it.instance runs in its own scope, but // these closures are shared across the single test below — fine since the @@ -255,3 +287,302 @@ describe("GoalLoop — continuation dispatch failure → recoverable pause (D1)" }), ) }) + +// ── Stall-prevention branch coverage ─────────────────────────────────── +// +// afterIdle has four historically-silent stall paths that now surface as +// visible pauses or documented continuations. Each test drives exactly one +// branch via the GoalLoopJudgeLLM injection point + mocked Session / +// SessionPrompt, with Goal / SessionStatus / EventV2Bridge real so goal +// state, the fibers map, and the event bus are exercised end-to-end. + +// Branch 1 (loop.ts): no assistant message in the last-20 window → the loop +// used to bare-return and leave the goal permanently "active" with no +// progress. It now publishes a visible pause + a noReply prompt. +describe("GoalLoop — no assistant in window → visible pause (branch 1)", () => { + const promptCalls: { noReply?: boolean; text: string }[] = [] + const sessionMock = Layer.succeed(Session.Service, { + messages: () => Effect.succeed([mkUser()]), + } as never) + const providerMock = Layer.succeed(Provider.Service, {} as never) + // The judge is unreachable on this path — branch 1 returns before it. + // Die loudly so a regression that reaches the judge fails the test. + const judgeMock = Layer.succeed( + GoalLoopJudgeLLM, + GoalLoopJudgeLLM.of({ call: () => Effect.die("branch 1 must not reach the judge") }), + ) + + const branchLayer = GoalLoop.layer.pipe( + Layer.provide(sessionMock), + Layer.provide(recordingPrompt(promptCalls)), + Layer.provide(providerMock), + Layer.provide(judgeMock), + Layer.provideMerge(Goal.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), + ) + const it = testEffect(branchLayer) + + it.instance("无 assistant 回复 → goal paused + 可见暂停提示 + noReply prompt", () => + Effect.gen(function* () { + promptCalls.length = 0 + const loop = yield* GoalLoop.Service + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + yield* loop.init() + const sid = SessionID.descending() + yield* goal.set(sid, "ship the feature", 10) + yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + + yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) + yield* pollWithTimeout( + Effect.gen(function* () { + const g = yield* goal.load(sid) + return g?.status === "paused" ? true : undefined + }), + "branch 1 never paused the goal", + "5 seconds", + ) + + const paused = yield* goal.load(sid) + expect(paused?.status).toBe("paused") + expect(String(paused?.paused_reason)).toContain("无 assistant 回复") + // Visible pause: a noReply prompt was injected (not a bare return). + expect(promptCalls.some((p) => p.noReply)).toBe(true) + }), + ) +}) + +// Branch 2 (loop.ts): the last assistant turn produced no text (pure tool +// calls / reasoning-only). The loop now synthesizes a "continue" verdict and +// skips the judge, instead of stalling. Proves the synthetic-continue path +// advances the turn budget without invoking the judge LLM. +describe("GoalLoop — empty assistant text → synthetic continue, no stall (branch 2)", () => { + let judgeCalls = 0 + const promptCalls: { noReply?: boolean; text: string }[] = [] + const reset = () => { + judgeCalls = 0 + promptCalls.length = 0 + } + + const sessionMock = Layer.succeed(Session.Service, { + messages: () => Effect.succeed([mkAssistantTools()]), + } as never) + const providerMock = Layer.succeed(Provider.Service, {} as never) + const judgeMock = Layer.succeed( + GoalLoopJudgeLLM, + GoalLoopJudgeLLM.of({ + call: () => + Effect.sync(() => { + judgeCalls += 1 + return JSON.stringify({ done: false, reason: "more steps" }) + }), + }), + ) + + const branchLayer = GoalLoop.layer.pipe( + Layer.provide(sessionMock), + Layer.provide(recordingPrompt(promptCalls)), + Layer.provide(providerMock), + Layer.provide(judgeMock), + Layer.provideMerge(Goal.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), + ) + const it = testEffect(branchLayer) + + it.instance("纯工具调用 → 跳过 judge,合成 continue,turns_used 推进不 stall", () => + Effect.gen(function* () { + reset() + const loop = yield* GoalLoop.Service + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + yield* loop.init() + const sid = SessionID.descending() + yield* goal.set(sid, "ship the feature", 10) + yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + + yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) + // The synthetic continue dispatches a continuation prompt (non-noReply). + // Poll on the dispatched prompt since turns_used is set just before it. + yield* pollWithTimeout( + Effect.sync(() => (promptCalls.some((p) => !p.noReply) ? true : undefined)), + "branch 2 never dispatched a continuation", + "5 seconds", + ) + + // Judge was never invoked — the empty-text short-circuit took over. + expect(judgeCalls).toBe(0) + const g = yield* goal.load(sid) + expect(g?.status).toBe("active") + expect(Number(g?.turns_used)).toBe(1) + }), + ) +}) + +// Branch 3 (loop.ts): after the judge call returns, the session status is no +// longer idle (5-30s of judge latency). The loop now pauses visibly instead of +// bare-returning. Status is pre-set to busy so afterIdle's post-judge status +// check observes a non-idle state; the raw idle-event publish drives afterIdle +// without clearing the stored busy entry. +describe("GoalLoop — status changed during judge → visible pause (branch 3)", () => { + let judgeCalls = 0 + const promptCalls: { noReply?: boolean; text: string }[] = [] + const reset = () => { + judgeCalls = 0 + promptCalls.length = 0 + } + + const sessionMock = Layer.succeed(Session.Service, { + messages: () => Effect.succeed([mkAssistant()]), + } as never) + const providerMock = Layer.succeed(Provider.Service, {} as never) + const judgeMock = Layer.succeed( + GoalLoopJudgeLLM, + GoalLoopJudgeLLM.of({ + call: () => + Effect.sync(() => { + judgeCalls += 1 + return JSON.stringify({ done: false, reason: "more steps" }) + }), + }), + ) + + const branchLayer = GoalLoop.layer.pipe( + Layer.provide(sessionMock), + Layer.provide(recordingPrompt(promptCalls)), + Layer.provide(providerMock), + Layer.provide(judgeMock), + Layer.provideMerge(Goal.defaultLayer), + // provideMerge (not provide): the test body yields SessionStatus.Service to + // pre-set busy, and afterIdle must read that SAME instance — a consumed + // (non-merged) SessionStatus would be invisible to the test body AND could + // diverge from the one afterIdle uses. + Layer.provideMerge(SessionStatus.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), + ) + const it = testEffect(branchLayer) + + it.instance("judge 期间 status 变非 idle → goal paused + 可见提示", () => + Effect.gen(function* () { + reset() + const loop = yield* GoalLoop.Service + const goal = yield* Goal.Service + const status = yield* SessionStatus.Service + const events = yield* EventV2Bridge.Service + yield* loop.init() + const sid = SessionID.descending() + yield* goal.set(sid, "ship the feature", 10) + // Make the session non-idle so afterIdle's post-judge status check sees + // busy. The raw idle-event publish below drives afterIdle WITHOUT + // touching the status map, so the busy entry persists. + yield* status.set(sid, { type: "busy" }) + yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + + yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) + yield* pollWithTimeout( + Effect.gen(function* () { + const g = yield* goal.load(sid) + return g?.status === "paused" ? true : undefined + }), + "branch 3 never paused the goal", + "5 seconds", + ) + + expect(judgeCalls).toBeGreaterThanOrEqual(1) + const paused = yield* goal.load(sid) + expect(paused?.status).toBe("paused") + expect(String(paused?.paused_reason)).toContain("状态变化") + expect(promptCalls.some((p) => p.noReply)).toBe(true) + }), + ) +}) + +// Branch 4 (loop.ts): the continuation dispatch fails with an INTERRUPT cause +// (user pressed ESC mid-dispatch). The loop logs and returns WITHOUT pausing, +// relying on the session always re-emitting idle (SessionStatus.set publishes +// idle unconditionally) to fork a fresh afterIdle — whose shouldPreempt guard +// handles the user's newer message. Pausing here would race that replacement +// fiber. Asserts: no pause published, goal stays active, and a second idle +// event re-drives the loop (proving it is not stalled by the dropped interrupt). +describe("GoalLoop — continuation interrupted → no pause, goal stays active (branch 4)", () => { + let judgeCalls = 0 + const reset = () => { + judgeCalls = 0 + } + + const sessionMock = Layer.succeed(Session.Service, { + messages: () => Effect.succeed([mkAssistant()]), + } as never) + // Continuation dispatch fails with an INTERRUPT cause — simulates user ESC + // mid-dispatch. catchCause sees interruptors > 0 → branch 4 (log + return). + const promptInterruptMock = Layer.succeed(SessionPrompt.Service, { + prompt: () => Effect.failCause(Cause.interrupt(0)), + } as never) + const providerMock = Layer.succeed(Provider.Service, {} as never) + const judgeMock = Layer.succeed( + GoalLoopJudgeLLM, + GoalLoopJudgeLLM.of({ + call: () => + Effect.sync(() => { + judgeCalls += 1 + return JSON.stringify({ done: false, reason: "more steps" }) + }), + }), + ) + + const branchLayer = GoalLoop.layer.pipe( + Layer.provide(sessionMock), + Layer.provide(promptInterruptMock), + Layer.provide(providerMock), + Layer.provide(judgeMock), + Layer.provideMerge(Goal.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provideMerge(EventV2Bridge.defaultLayer), + ) + const it = testEffect(branchLayer) + + it.instance("continuation 被中断 → 不暂停,goal 保持 active,后续 idle 重新驱动", () => + Effect.gen(function* () { + reset() + const loop = yield* GoalLoop.Service + const goal = yield* Goal.Service + const events = yield* EventV2Bridge.Service + const seen = yield* captureEvents(events) + yield* loop.init() + const sid = SessionID.descending() + yield* goal.set(sid, "ship the feature", 10) + yield* Effect.sleep(SUBSCRIPTION_SETTLE_MS) + + // Turn 1: idle → judge(continue) → continuation fails with interrupt → + // branch 4: log + return, NO pause. + yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) + // turns_used advancing proves updateAfterJudge ran (just before the + // failed continuation), so the cycle reached the dispatch step. + yield* pollWithTimeout( + Effect.gen(function* () { + const g = yield* goal.load(sid) + return Number(g?.turns_used) >= 1 ? true : undefined + }), + "branch 4: turns_used never advanced", + "5 seconds", + ) + + const afterInterrupt = yield* goal.load(sid) + expect(afterInterrupt?.status).toBe("active") // NOT paused + expect(afterInterrupt?.paused_reason).toBeUndefined() + // No goal.updated(paused) event was published by branch 4. + expect(seen.some((e) => e.type === GoalEvent.Updated.type && e.status === "paused")).toBe(false) + + // Turn 2: a fresh idle event re-drives afterIdle — the contract branch 4 + // relies on (SessionStatus always re-emits idle). The loop must NOT be + // stalled by the dropped interrupt; judge fires a second time. + yield* events.publish(SessionStatus.Event.Status, { sessionID: sid, status: { type: "idle" } }) + yield* pollWithTimeout( + Effect.sync(() => (judgeCalls >= 2 ? true : undefined)), + "branch 4: loop did not re-drive on second idle", + "5 seconds", + ) + }), + ) +})