From 4007eeb19950754e8dda6a4b37b78fe16a98aec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 23:15:25 +0300 Subject: [PATCH 01/14] fix: resume auto-stalled goals from actionable steering --- src/opencode/lifecycle-ux.ts | 57 +++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/src/opencode/lifecycle-ux.ts b/src/opencode/lifecycle-ux.ts index 72a042f8..d7f63c5b 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,31 @@ 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, then preserve the + // original human message so core Goal steering owns and executes it. + const resumeOutput: any = { parts: [{ type: "text", text: "resume" }] } + await commandHook({ ...event, command: "goal", arguments: "resume" }, 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 +233,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 auto-paused after repeated no-progress turns. Send a concrete work instruction to resume and steer it, or use /goal resume." + : "Goal remains paused. Use /goal resume or send a short continuation message such as 'devam et' to continue the persisted Goal.", "warning", ) } From 39e46b6db5a88c7bbbde1608585a90bf3633c72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 23:15:33 +0300 Subject: [PATCH 02/14] fix: make no-progress guard tolerant of open todo plans --- src/runtime/progress.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/runtime/progress.ts b/src/runtime/progress.ts index ade7493a..ace0328b 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 From e59ad44da7812aad9ba11dacefda4f5027a87632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 23:15:46 +0300 Subject: [PATCH 03/14] fix: invalidate todo telemetry when goal contract changes --- src/domain/goal.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/goal.ts b/src/domain/goal.ts index 32a41d92..5557a11c 100644 --- a/src/domain/goal.ts +++ b/src/domain/goal.ts @@ -144,7 +144,8 @@ export function editGoal(goal: GoalState, input: { observedProgressRevision: goal.progressRevision + 1, progressFingerprints: [], progressNotes: goal.progressNotes, - ...(goal.todoPlan ? { todoPlan: goal.todoPlan } : {}), + // Todo telemetry belongs to the previous Goal contract. Do not carry it + // across revisions: a fresh native todowrite must establish the new plan. storageGeneration: goal.storageGeneration ?? 0, createdAt: goal.createdAt, } From 8205fbde495c9b5ae19986939d1334a1f03f746d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 23:16:04 +0300 Subject: [PATCH 04/14] test: cover paused steering and long todo stall recovery --- test/paused-steering-long-goals.test.mjs | 114 +++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 test/paused-steering-long-goals.test.mjs diff --git a/test/paused-steering-long-goals.test.mjs b/test/paused-steering-long-goals.test.mjs new file mode 100644 index 00000000..94fbff8e --- /dev/null +++ b/test/paused-steering-long-goals.test.mjs @@ -0,0 +1,114 @@ +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 { closeObservedTurn } from "../dist/runtime/progress.js" +import { observeTodoPlan } 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("Goal edit invalidates previous Todo telemetry until a fresh native plan is observed", () => { + let goal = createGoal({ sessionID: "todo-revision", objective: "old contract" }) + goal = observeTodoPlan(goal, [{ content: "Old task", status: "pending" }]) + assert.ok(goal.todoPlan) + + const edited = editGoal(goal, { objective: "new contract" }) + assert.equal(edited.revision, 2) + assert.equal(edited.todoPlan, undefined) +}) From 229d1b681fedadc3717c7aaefb018ac16135c494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 23:16:16 +0300 Subject: [PATCH 05/14] test: require fresh todo observation after goal edit --- test/todo-plan.test.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/todo-plan.test.mjs b/test/todo-plan.test.mjs index c08182df..0496ef68 100644 --- a/test/todo-plan.test.mjs +++ b/test/todo-plan.test.mjs @@ -43,9 +43,9 @@ test("native Todo telemetry is advisory, deterministic, and revision-bound", () const edited = editGoal(observed, { objective: "analyze the project and finish required gaps without API changes", now: 300 }) assert.equal(edited.revision, 2) - assert.equal(edited.todoPlan?.goalRevision, 1, "the previous plan stays visible only as stale telemetry") + assert.equal(edited.todoPlan, undefined, "a revised Goal contract requires a fresh native Todo observation") assert.equal(todoPlanIsCurrent(edited), false) - assert.match(formatTodoPlan(edited), /STALE r1/) + assert.equal(formatTodoPlan(edited), "not observed") }) test("current unfinished Todo plan vetoes completion without becoming evidence", () => { @@ -73,7 +73,7 @@ test("current unfinished Todo plan vetoes completion without becoming evidence", const edited = editGoal(open, { objective: "finish the required work without changing the public API", now: 300 }) const staleAudit = auditCompletion(edited) assert.equal(todoPlanIsCurrent(edited), false) - assert.equal(staleAudit.reasons.some((reason) => reason.includes("current native Todo plan still has unfinished work")), false, "stale advisory Todo telemetry must not veto a newer Goal revision") + assert.equal(staleAudit.reasons.some((reason) => reason.includes("current native Todo plan still has unfinished work")), false, "previous-revision Todo telemetry must not veto a newer Goal revision") }) test("malformed native Todo input and malformed advisory telemetry are ignored safely", () => { From 3124f67497187598956ac7632d2bcf36afafdb59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Sun, 23 Aug 2026 23:16:53 +0300 Subject: [PATCH 06/14] fix: consume internal resume ownership before paused steering --- src/opencode/lifecycle-ux.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/opencode/lifecycle-ux.ts b/src/opencode/lifecycle-ux.ts index d7f63c5b..223cc7da 100644 --- a/src/opencode/lifecycle-ux.ts +++ b/src/opencode/lifecycle-ux.ts @@ -198,10 +198,12 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): 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, then preserve the - // original human message so core Goal steering owns and executes it. + // 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) From 5d06a3485d6ed5756b6c40f01c4e4071a1ea3e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:20:23 +0300 Subject: [PATCH 07/14] Preserve stale Todo telemetry across Goal edits --- src/domain/goal.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/domain/goal.ts b/src/domain/goal.ts index 5557a11c..32a41d92 100644 --- a/src/domain/goal.ts +++ b/src/domain/goal.ts @@ -144,8 +144,7 @@ export function editGoal(goal: GoalState, input: { observedProgressRevision: goal.progressRevision + 1, progressFingerprints: [], progressNotes: goal.progressNotes, - // Todo telemetry belongs to the previous Goal contract. Do not carry it - // across revisions: a fresh native todowrite must establish the new plan. + ...(goal.todoPlan ? { todoPlan: goal.todoPlan } : {}), storageGeneration: goal.storageGeneration ?? 0, createdAt: goal.createdAt, } From 20427a3163b8cb5ffad8da05fda541ef39ff54e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:20:32 +0300 Subject: [PATCH 08/14] Require changed Todo plan after Goal revision --- src/runtime/todo-plan.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/runtime/todo-plan.ts b/src/runtime/todo-plan.ts index 1258a6ee..e581fb81 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 From fb0f720fa161c03d2828c7f61a60fa1c03d02f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:20:48 +0300 Subject: [PATCH 09/14] Preserve paused warning contract --- src/opencode/lifecycle-ux.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opencode/lifecycle-ux.ts b/src/opencode/lifecycle-ux.ts index 223cc7da..6a796c7d 100644 --- a/src/opencode/lifecycle-ux.ts +++ b/src/opencode/lifecycle-ux.ts @@ -236,7 +236,7 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): await showGoalToast( input.client, isAutoStallPause(goal) - ? "Goal auto-paused after repeated no-progress turns. Send a concrete work instruction to resume and steer it, or use /goal resume." + ? "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." : "Goal remains paused. Use /goal resume or send a short continuation message such as 'devam et' to continue the persisted Goal.", "warning", ) From d1f13c1eda4950a82e5763010c1f0c2d40c0339f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:21:00 +0300 Subject: [PATCH 10/14] Keep stale Todo telemetry assertions --- test/todo-plan.test.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/todo-plan.test.mjs b/test/todo-plan.test.mjs index 0496ef68..c08182df 100644 --- a/test/todo-plan.test.mjs +++ b/test/todo-plan.test.mjs @@ -43,9 +43,9 @@ test("native Todo telemetry is advisory, deterministic, and revision-bound", () const edited = editGoal(observed, { objective: "analyze the project and finish required gaps without API changes", now: 300 }) assert.equal(edited.revision, 2) - assert.equal(edited.todoPlan, undefined, "a revised Goal contract requires a fresh native Todo observation") + assert.equal(edited.todoPlan?.goalRevision, 1, "the previous plan stays visible only as stale telemetry") assert.equal(todoPlanIsCurrent(edited), false) - assert.equal(formatTodoPlan(edited), "not observed") + assert.match(formatTodoPlan(edited), /STALE r1/) }) test("current unfinished Todo plan vetoes completion without becoming evidence", () => { @@ -73,7 +73,7 @@ test("current unfinished Todo plan vetoes completion without becoming evidence", const edited = editGoal(open, { objective: "finish the required work without changing the public API", now: 300 }) const staleAudit = auditCompletion(edited) assert.equal(todoPlanIsCurrent(edited), false) - assert.equal(staleAudit.reasons.some((reason) => reason.includes("current native Todo plan still has unfinished work")), false, "previous-revision Todo telemetry must not veto a newer Goal revision") + assert.equal(staleAudit.reasons.some((reason) => reason.includes("current native Todo plan still has unfinished work")), false, "stale advisory Todo telemetry must not veto a newer Goal revision") }) test("malformed native Todo input and malformed advisory telemetry are ignored safely", () => { From 38fdada757ca197fd87693d7a8f82379e60df48d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:21:12 +0300 Subject: [PATCH 11/14] Test stale Todo revision barrier --- test/paused-steering-long-goals.test.mjs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/paused-steering-long-goals.test.mjs b/test/paused-steering-long-goals.test.mjs index 94fbff8e..ff212a26 100644 --- a/test/paused-steering-long-goals.test.mjs +++ b/test/paused-steering-long-goals.test.mjs @@ -6,7 +6,7 @@ import path from "node:path" import OpenCodeGoalPlugin, { createGoal, editGoal, pauseGoal } from "../dist/index.js" import { GoalStore } from "../dist/persistence/store.js" import { closeObservedTurn } from "../dist/runtime/progress.js" -import { observeTodoPlan } from "../dist/runtime/todo-plan.js" +import { observeTodoPlan, todoPlanIsCurrent } from "../dist/runtime/todo-plan.js" async function readOnlyGoal(root) { const dir = path.join(root, ".opencode", "goals") @@ -103,12 +103,22 @@ test("open long Todo plans receive a bounded adaptive no-progress window", () => assert.equal(goal.stalledTurns, 12) }) -test("Goal edit invalidates previous Todo telemetry until a fresh native plan is observed", () => { +test("Goal edit keeps stale Todo telemetry and rejects unchanged re-observation", () => { let goal = createGoal({ sessionID: "todo-revision", objective: "old contract" }) - goal = observeTodoPlan(goal, [{ content: "Old task", status: "pending" }]) + 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, undefined) + 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) }) From 1b4084ab51378c8d7b8bc35c2a713669e68b2408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:22:40 +0300 Subject: [PATCH 12/14] Make default Goal turn budget unlimited --- src/domain/goal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/goal.ts b/src/domain/goal.ts index 32a41d92..5bb55aec 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, From 23f5e84de83ccd09a0aa4010eaf2cfdbad841bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:22:58 +0300 Subject: [PATCH 13/14] Cover unlimited default turn budget --- test/paused-steering-long-goals.test.mjs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/paused-steering-long-goals.test.mjs b/test/paused-steering-long-goals.test.mjs index ff212a26..63693321 100644 --- a/test/paused-steering-long-goals.test.mjs +++ b/test/paused-steering-long-goals.test.mjs @@ -5,6 +5,7 @@ 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" @@ -103,6 +104,25 @@ test("open long Todo plans receive a bounded adaptive no-progress window", () => 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" }] From b27b3e79e1330790ff1a18c8572717467903132d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20TEKTA=C5=9E?= Date: Mon, 24 Aug 2026 03:24:28 +0300 Subject: [PATCH 14/14] Preserve paused resume guidance --- src/opencode/lifecycle-ux.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opencode/lifecycle-ux.ts b/src/opencode/lifecycle-ux.ts index 6a796c7d..38da9c34 100644 --- a/src/opencode/lifecycle-ux.ts +++ b/src/opencode/lifecycle-ux.ts @@ -236,7 +236,7 @@ export function installGoalLifecycleUX(input: PluginInput, hooks: PluginHooks): await showGoalToast( input.client, 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." + ? "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", )