Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/domain/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 36 additions & 23 deletions src/opencode/lifecycle-ux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
Expand All @@ -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",
)
}
Expand Down
13 changes: 12 additions & 1 deletion src/runtime/progress.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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) {
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/runtime/todo-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions test/paused-steering-long-goals.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
})
Loading