diff --git a/src/domain/goal.ts b/src/domain/goal.ts index 32a41d9..5bb55ae 100644 --- a/src/domain/goal.ts +++ b/src/domain/goal.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto" import type { FileRequirementInput, GoalBudget, GoalExecutionContext, GoalRequirement, GoalRequirementSource, GoalState, VerificationKind } from "./types.js" const DEFAULT_BUDGET: GoalBudget = { - maxTurns: 30, + maxTurns: 0, maxTokens: 0, maxCost: 0, maxRuntimeMs: 60 * 60_000, diff --git a/src/opencode/lifecycle-ux.ts b/src/opencode/lifecycle-ux.ts index 72a042f..38da9c3 100644 --- a/src/opencode/lifecycle-ux.ts +++ b/src/opencode/lifecycle-ux.ts @@ -39,6 +39,20 @@ function isNaturalResumeMessage(text: string): boolean { ]).has(normalized) } +function isAutoStallPause(goal: GoalState): boolean { + return goal.status === "paused" + && /^Paused after \d+ continuation turns without host-observed progress\.$/.test(goal.stopReason ?? "") +} + +function isActionablePausedSteering(text: string): boolean { + const normalized = normalizedContinuationIntent(text) + if (!normalized || normalized.startsWith("/")) return false + if (/[??]$/.test(text.trim())) return false + if (/^(ne|neden|niye|nasıl|nasil|what|why|how|when|where|who)\b/.test(normalized)) return false + if (/\b(status|durum|özet|ozet|summary)\b/.test(normalized)) return false + return /\b(devam|yap|düzelt|duzelt|ekle|çıkar|cikar|sil|bitir|tamamla|uygula|incele|araştır|arastir|test|denetle|kontrol et|fix|implement|add|remove|delete|finish|complete|apply|review|research|test|check|update|change|refactor|build|create|use|work on)\b/.test(normalized) +} + function conflictMessage(goal: GoalState): string { const resume = goal.status === "paused" ? "\n- /goal resume — resume the current paused Goal." : "" return [ @@ -105,10 +119,6 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): if (parsed.action === "create" && parsed.objective) { const goal = await store.load(event.sessionID) if (goal && goal.status !== "completed") { - // Seed the existing command-ownership chain through a read-only status - // command, then translate only the user-visible text. This keeps the - // warning from being mistaken for a normal human message that pauses - // or mutates the current Goal. await commandHook({ ...event, arguments: "status" }, output) const owned = textFromParts(output.parts) const shown = conflictMessage(goal) @@ -137,10 +147,6 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): await commandHook(event, output) if (["create", "edit", "resume", "clear"].includes(parsed.action)) pausedChatNotices.delete(event.sessionID) - // `noReply` is command-hook metadata and is not guaranteed to survive into - // the later chat.message payload. Remember the concrete command response so - // doctor/status/contract/history and lifecycle command messages are never - // misclassified as ordinary foreground steering by this outer UX wrapper. const commandOutput = textFromParts(output?.parts ?? []) if (commandOutput) commandOutputs.set(event.sessionID, commandOutput) } @@ -167,7 +173,7 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): } const synthetic = (output as any)?.noReply === true || isSyntheticHostMessage(output?.parts ?? []) - if (!synthetic && isNaturalResumeMessage(shown)) { + if (!synthetic) { let paused: GoalState | null try { paused = await store.load(event.sessionID) @@ -176,12 +182,7 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): else throw error } - if (paused?.status === "paused") { - // Route natural-language resume through the normal /goal resume command - // chain instead of mutating persistence directly. This preserves budget, - // ownership, restricted-agent, and other lifecycle guards. The command - // output is then rebound to this foreground message so the same turn - // becomes an ordinary Goal-owned continuation turn. + if (paused?.status === "paused" && isNaturalResumeMessage(shown)) { const resumeOutput: any = { parts: [{ type: "text", text: "resume" }] } await commandHook({ ...event, command: "goal", arguments: "resume" }, resumeOutput) const resumeText = textFromParts(resumeOutput.parts) @@ -194,23 +195,33 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): } return } + + if (paused && isAutoStallPause(paused) && isActionablePausedSteering(shown)) { + // An automatic no-progress pause is a safety backstop, not a user intent + // boundary. Resume through the normal command chain, consume that + // internal command ownership, then preserve the original human message + // so core Goal steering owns and executes the actual instruction. + const resumeOutput: any = { parts: [{ type: "text", text: "resume" }] } + await commandHook({ ...event, command: "goal", arguments: "resume" }, resumeOutput) + await chatHook(event, resumeOutput) + const resumed = await store.load(event.sessionID) + pausedChatNotices.delete(event.sessionID) + await chatHook(event, output) + if (resumed?.status === "active") { + await showGoalToast(input.client, "Auto-paused Goal resumed from your new work instruction.", "success") + } + return + } } await chatHook(event, output) - // Host-generated synthetic task notifications are not foreground user - // steering. `noReply` is kept as a best-effort extra signal, but command - // ownership above does not rely on it crossing the host hook boundary. if (synthetic) return let goal: GoalState | null try { goal = await store.load(event.sessionID) } catch (error) { - // Paused-chat guidance is advisory. Unsupported/corrupt Goal storage must - // remain inspectable through /goal doctor instead of being converted into - // a foreground-chat failure by this UX wrapper. Other persistence failures - // still surface normally rather than being hidden by notification logic. if (error instanceof GoalStoreIntegrityError) return throw error } @@ -224,7 +235,9 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): pausedChatNotices.set(event.sessionID, key) await showGoalToast( input.client, - "Goal remains paused. Use /goal resume or send a short continuation message such as 'devam et' to continue the persisted Goal.", + isAutoStallPause(goal) + ? "Goal remains paused. It auto-paused after repeated no-progress turns; send a concrete work instruction to resume and steer it, or use /goal resume / 'devam et'." + : "Goal remains paused. Use /goal resume or send a short continuation message such as 'devam et' to continue the persisted Goal.", "warning", ) } diff --git a/src/runtime/progress.ts b/src/runtime/progress.ts index ade7493..ace0328 100644 --- a/src/runtime/progress.ts +++ b/src/runtime/progress.ts @@ -1,5 +1,6 @@ import type { GoalState } from "../domain/types.js" import { settleReachedGoalBudget } from "./accounting.js" +import { todoPlanIsCurrent } from "./todo-plan.js" export function addProgressNote(goal: GoalState, input: { summary: string; next?: string; now?: number }): GoalState { const now = input.now ?? Date.now() @@ -10,6 +11,16 @@ export function addProgressNote(goal: GoalState, input: { summary: string; next? } } +function defaultStallLimit(goal: GoalState): number { + if (!todoPlanIsCurrent(goal) || !goal.todoPlan) return 3 + const openItems = goal.todoPlan.pending + goal.todoPlan.inProgress + if (openItems <= 0) return 3 + // Long native Todo plans naturally contain reconnaissance, verification, and + // read-only turns that may not create a fresh mutation fingerprint. Keep the + // guard bounded, but scale its tolerance with remaining plan size. + return Math.min(12, Math.max(4, 3 + Math.ceil(openItems / 10))) +} + export function closeObservedTurn(goal: GoalState, input: { maxStalledTurns?: number; now?: number } = {}): GoalState { const now = input.now ?? Date.now() if (goal.pendingContinuation && goal.usage.turns === 0) { @@ -21,7 +32,7 @@ export function closeObservedTurn(goal: GoalState, input: { maxStalledTurns?: nu skipNextStallCheck, ...settled } = goal - const limit = Math.max(1, input.maxStalledTurns ?? 3) + const limit = Math.max(1, input.maxStalledTurns ?? defaultStallLimit(settled)) const madeProgress = settled.progressRevision > settled.observedProgressRevision // A verifier/provider/transport failure is not an agent no-progress turn. // Consume the one-shot exemption without manufacturing a progress fingerprint diff --git a/src/runtime/todo-plan.ts b/src/runtime/todo-plan.ts index 1258a6e..e581fb8 100644 --- a/src/runtime/todo-plan.ts +++ b/src/runtime/todo-plan.ts @@ -74,6 +74,13 @@ export function summarizeTodoPlan(goalRevision: number, todos: NativeTodoItem[], export function observeTodoPlan(goal: GoalState, todos: NativeTodoItem[], observedAt = Date.now()): GoalState { const next = summarizeTodoPlan(goal.revision, todos, observedAt) const previous = validGoalTodoPlan(goal.todoPlan) ? goal.todoPlan : undefined + + // After a Goal edit, preserve the old Todo snapshot as visibly stale and do + // not let an unchanged native list become current merely because OpenCode + // re-emitted it. A genuinely rebuilt/changed plan gets a new digest and can + // then bind to the new Goal revision. + if (previous && previous.goalRevision !== goal.revision && previous.digest === next.digest) return goal + if ( previous?.goalRevision === next.goalRevision && previous.digest === next.digest diff --git a/test/paused-steering-long-goals.test.mjs b/test/paused-steering-long-goals.test.mjs new file mode 100644 index 0000000..6369332 --- /dev/null +++ b/test/paused-steering-long-goals.test.mjs @@ -0,0 +1,144 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import OpenCodeGoalPlugin, { createGoal, editGoal, pauseGoal } from "../dist/index.js" +import { GoalStore } from "../dist/persistence/store.js" +import { accountAssistantUsage } from "../dist/runtime/accounting.js" +import { closeObservedTurn } from "../dist/runtime/progress.js" +import { observeTodoPlan, todoPlanIsCurrent } from "../dist/runtime/todo-plan.js" + +async function readOnlyGoal(root) { + const dir = path.join(root, ".opencode", "goals") + const files = await readdir(dir) + assert.equal(files.length, 1) + return JSON.parse(await readFile(path.join(dir, files[0]), "utf8")) +} + +function fakeClient() { + const toasts = [] + return { + client: { + session: { + prompt() { return Promise.resolve({}) }, + abort() { return Promise.resolve(true) }, + }, + tui: { + showToast(arg) { + toasts.push(arg) + return Promise.resolve({}) + }, + }, + }, + toasts, + } +} + +async function command(hooks, argumentsText, sessionID = "session-long") { + const output = { parts: [{ type: "text", text: argumentsText }] } + await hooks["command.execute.before"]({ command: "goal", sessionID, arguments: argumentsText }, output) + return output +} + +async function foregroundChat(hooks, text, messageID, sessionID = "session-long") { + const output = { message: { id: messageID }, parts: [{ type: "text", text }] } + await hooks["chat.message"]({ sessionID, messageID, agent: "build" }, output) + return output +} + +test("actionable foreground instruction resumes an auto-stalled Goal without rewriting the instruction", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-auto-stall-steering-")) + try { + const fake = fakeClient() + const hooks = await OpenCodeGoalPlugin({ client: fake.client, directory: root }) + const store = new GoalStore(root) + const reason = "Paused after 3 continuation turns without host-observed progress." + + await command(hooks, "finish the project") + const active = await store.load("session-long") + assert.ok(active) + await store.save(pauseGoal(active, reason)) + + const output = await foregroundChat(hooks, "önce 12. haritayı düzelt", "human-steer") + const persisted = await readOnlyGoal(root) + assert.equal(persisted.status, "active") + assert.equal(persisted.stalledTurns, 0) + assert.equal(output.parts[0].text, "önce 12. haritayı düzelt", "the human steering instruction must remain intact") + assert.ok(fake.toasts.some((item) => /new work instruction/.test(item?.body?.message ?? ""))) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test("explicit user pause is not silently resumed by an unrelated work instruction", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "opencode-goal-explicit-pause-")) + try { + const fake = fakeClient() + const hooks = await OpenCodeGoalPlugin({ client: fake.client, directory: root }) + + await command(hooks, "finish the project") + await command(hooks, "pause") + await foregroundChat(hooks, "şimdi başka dosyayı düzelt", "human-after-pause") + + const persisted = await readOnlyGoal(root) + assert.equal(persisted.status, "paused") + assert.equal(persisted.stopReason, "paused by user") + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test("open long Todo plans receive a bounded adaptive no-progress window", () => { + let goal = createGoal({ sessionID: "long-plan", objective: "finish one hundred concrete tasks" }) + goal = observeTodoPlan(goal, Array.from({ length: 100 }, (_, index) => ({ + content: `Task ${index + 1}`, + status: index === 0 ? "in_progress" : "pending", + }))) + + for (let turn = 0; turn < 11; turn += 1) goal = closeObservedTurn(goal) + assert.equal(goal.status, "active") + assert.equal(goal.stalledTurns, 11) + goal = closeObservedTurn(goal) + assert.equal(goal.status, "paused") + assert.equal(goal.stalledTurns, 12) +}) + +test("new Goals have no implicit cumulative turn cap while explicit caps remain hard guards", () => { + let unlimited = createGoal({ sessionID: "turn-budget-default", objective: "finish a long plan" }) + assert.equal(unlimited.budget.maxTurns, 0) + for (let turn = 1; turn <= 40; turn += 1) { + unlimited = accountAssistantUsage(unlimited, { messageID: `m-${turn}` }) + unlimited = closeObservedTurn(unlimited, { maxStalledTurns: 100 }) + } + assert.equal(unlimited.status, "active") + assert.equal(unlimited.usage.turns, 40) + + let bounded = createGoal({ sessionID: "turn-budget-explicit", objective: "bounded work", budget: { maxTurns: 2 } }) + bounded = accountAssistantUsage(bounded, { messageID: "b-1" }) + bounded = closeObservedTurn(bounded, { maxStalledTurns: 100 }) + bounded = accountAssistantUsage(bounded, { messageID: "b-2" }) + bounded = closeObservedTurn(bounded, { maxStalledTurns: 100 }) + assert.equal(bounded.status, "budget_limited") + assert.match(bounded.stopReason ?? "", /turns 2 \/ 2/) +}) + +test("Goal edit keeps stale Todo telemetry and rejects unchanged re-observation", () => { + let goal = createGoal({ sessionID: "todo-revision", objective: "old contract" }) + const oldTodos = [{ content: "Old task", status: "pending" }] + goal = observeTodoPlan(goal, oldTodos) + assert.ok(goal.todoPlan) + + const edited = editGoal(goal, { objective: "new contract" }) + assert.equal(edited.revision, 2) + assert.equal(edited.todoPlan?.goalRevision, 1) + assert.equal(todoPlanIsCurrent(edited), false) + + const unchanged = observeTodoPlan(edited, oldTodos) + assert.strictEqual(unchanged, edited, "the old native Todo list must not become current merely by being emitted again") + assert.equal(unchanged.todoPlan?.goalRevision, 1) + + const rebuilt = observeTodoPlan(edited, [{ content: "New contract task", status: "pending" }]) + assert.equal(rebuilt.todoPlan?.goalRevision, 2) + assert.equal(todoPlanIsCurrent(rebuilt), true) +})