From de4521f31e18cf7f232d1580a9e4c123f0ff35f4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 26 Jul 2026 19:01:29 +0900 Subject: [PATCH] feat(goal): sync blocked lifecycle and expose user abort --- .../coding-agent/src/core/agent-session.ts | 24 +- .../builtin/bash-timeout/timeout.ts | 5 +- .../extensions/builtin/external-versions.json | 4 +- .../core/extensions/builtin/goal/changes.md | 35 +++ .../builtin/goal/command-registration.ts | 4 +- .../core/extensions/builtin/goal/format.ts | 10 +- .../src/core/extensions/builtin/goal/index.ts | 75 ++--- .../builtin/goal/lifecycle-helpers.ts | 33 +++ .../extensions/builtin/goal/persistence.ts | 139 +++++++++ .../src/core/extensions/builtin/goal/store.ts | 271 ++++++------------ .../builtin/goal/tool-registration.ts | 56 ++-- .../extensions/builtin/goal/transitions.ts | 53 ++++ .../src/core/extensions/builtin/goal/types.ts | 14 +- .../src/core/extensions/builtin/goal/ui.ts | 2 + .../extensions/builtin/goal/validation.ts | 47 ++- .../extensions/builtin/todotools/changes.md | 16 ++ .../coding-agent/src/core/extensions/types.ts | 4 + .../modes/app-server/threads/goal-handlers.ts | 6 +- .../src/modes/app-server/threads/goal-wire.ts | 1 + .../test/suite/app-server-thread-goal.test.ts | 27 ++ .../test/suite/builtin-extension-sync.test.ts | 4 +- .../test/suite/goal-abort-extension.test.ts | 117 ++++++++ .../test/suite/goal-extension.test.ts | 81 +++++- .../test/suite/goal-modules.test.ts | 17 ++ .../test/suite/goal-store.test.ts | 10 +- .../test/suite/goal-turn-usage.test.ts | 20 ++ 26 files changed, 786 insertions(+), 289 deletions(-) create mode 100644 packages/coding-agent/src/core/extensions/builtin/goal/lifecycle-helpers.ts create mode 100644 packages/coding-agent/src/core/extensions/builtin/goal/persistence.ts create mode 100644 packages/coding-agent/src/core/extensions/builtin/goal/transitions.ts create mode 100644 packages/coding-agent/test/suite/goal-abort-extension.test.ts diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 47b48cc21..b5f894a87 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -585,6 +585,7 @@ export class AgentSession { private _retryPromise: Promise | undefined = undefined; private _retryResolve: (() => void) | undefined = undefined; private _userAbortPromise: Promise | undefined = undefined; + private _agentAbortSource: "user" | "system" | undefined = undefined; private _suppressQueuedContinuationAfterUserAbort = false; private _extensionEventSignal: AbortSignal | undefined = undefined; @@ -1629,7 +1630,19 @@ export class AgentSession { this._turnIndex = 0; await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { - await this._extensionRunner.emit({ type: "agent_end", messages: event.messages }); + const abortSource = this._agentAbortSource; + const aborted = + abortSource !== undefined || this._findLastAssistantInMessages(event.messages)?.stopReason === "aborted"; + try { + await this._extensionRunner.emit({ + type: "agent_end", + messages: event.messages, + ...(aborted ? { aborted: true } : {}), + ...(abortSource === undefined ? {} : { abortSource }), + }); + } finally { + this._agentAbortSource = undefined; + } } else if (event.type === "turn_start") { const extensionEvent: TurnStartEvent = { type: "turn_start", @@ -2914,7 +2927,7 @@ export class AgentSession { */ async abort(): Promise { this.abortCompaction(); - await this._abortActiveAgentAndRetry(); + await this._abortActiveAgentAndRetry("user"); } async waitForIdle(): Promise { @@ -3355,7 +3368,7 @@ export class AgentSession { admission.finishSessionWork(); } - private async _abortActiveAgentAndRetry(): Promise { + private async _abortActiveAgentAndRetry(source: "user" | "system"): Promise { this.abortRetry(); this.abortBranchSummary(); if (this._userAbortPromise) { @@ -3365,6 +3378,7 @@ export class AgentSession { } if (this.isStreaming) { this._suppressQueuedContinuationAfterUserAbort = true; + this._agentAbortSource = source; } const abortPromise = (async () => { @@ -3396,7 +3410,7 @@ export class AgentSession { // Keep the session subscriber attached until the aborted run emits // agent_end. That event clears the active-run and retry state that // waitForIdle() depends on. - await this._abortActiveAgentAndRetry(); + await this._abortActiveAgentAndRetry("system"); this._disconnectFromAgent(); disconnected = true; this._emit({ type: "compaction_start", reason: "manual" }); @@ -4704,7 +4718,7 @@ export class AgentSession { let disconnected = false; try { - await this._abortActiveAgentAndRetry(); + await this._abortActiveAgentAndRetry("system"); this._disconnectFromAgent(); disconnected = true; this._emit({ type: "compaction_start", reason: "extension" }); diff --git a/packages/coding-agent/src/core/extensions/builtin/bash-timeout/timeout.ts b/packages/coding-agent/src/core/extensions/builtin/bash-timeout/timeout.ts index dbf67aba2..0c4e9b76f 100644 --- a/packages/coding-agent/src/core/extensions/builtin/bash-timeout/timeout.ts +++ b/packages/coding-agent/src/core/extensions/builtin/bash-timeout/timeout.ts @@ -16,8 +16,9 @@ function parsePositiveInt(value: string | undefined): number | undefined { } export function resolveBashTimeoutDefaults(env: EnvLike): BashTimeoutDefaults { - const defaultSeconds = parsePositiveInt(env.PI_BASH_DEFAULT_TIMEOUT_SECONDS) ?? BASH_DEFAULT_TIMEOUT_SECONDS; - const rawMax = parsePositiveInt(env.PI_BASH_MAX_TIMEOUT_SECONDS) ?? BASH_MAX_TIMEOUT_SECONDS; + const { PI_BASH_DEFAULT_TIMEOUT_SECONDS: defaultTimeout, PI_BASH_MAX_TIMEOUT_SECONDS: maxTimeout } = env; + const defaultSeconds = parsePositiveInt(defaultTimeout) ?? BASH_DEFAULT_TIMEOUT_SECONDS; + const rawMax = parsePositiveInt(maxTimeout) ?? BASH_MAX_TIMEOUT_SECONDS; const maxSeconds = Math.max(rawMax, defaultSeconds); return { defaultSeconds, maxSeconds }; } diff --git a/packages/coding-agent/src/core/extensions/builtin/external-versions.json b/packages/coding-agent/src/core/extensions/builtin/external-versions.json index f29944ba2..908d79228 100644 --- a/packages/coding-agent/src/core/extensions/builtin/external-versions.json +++ b/packages/coding-agent/src/core/extensions/builtin/external-versions.json @@ -12,12 +12,12 @@ }, "todowrite": { "packageName": "pi-todotools", - "version": "0.1.1", + "version": "0.2.0", "source": "../pi-extensions/pi-todotools" }, "goal": { "packageName": "pi-goal", - "version": "0.2.1", + "version": "0.3.0", "source": "../pi-extensions/pi-goal" }, "websearch": { diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/changes.md b/packages/coding-agent/src/core/extensions/builtin/goal/changes.md index 98bb298ec..b7b304649 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/goal/changes.md @@ -160,3 +160,38 @@ codex-aligned tool naming, and budget-driven behavior removed. An optional - LOW: `builtin/index.ts` import block + `builtinExtensions` array if upstream reorders or adds builtins. - NONE for `extensions/types.ts` (untouched). + +## Sync from pi-goal 0.3.0 (2026-07-26) + +### Source + +- Canonical source: `code-yeongyu/pi-goal` 0.3.0, merged by + [pi-goal PR #1](https://github.com/code-yeongyu/pi-goal/pull/1). +- Version metadata was regenerated through `sync-builtin-extensions.mjs`; this + builtin remains a manual-merge package because of senpi-only structure. + +### What changed + +- Imported the blocked lifecycle (`blockedReason`/`blockedAt`), model-only + blocked/complete transitions, and blocked continuation suppression. +- `create_goal` now replaces a completed goal after JSONL archival; oversized + objectives are marker-budget truncated and preserve their full text in a + per-thread spill file. +- Aligned tool schemas and guidance with the 4,000-character, complete-replace, + and blocked-audit contract while retaining budget-free behavior. + +### Senpi conflict zone: abort detection + +- Standalone pi-goal 0.3.0 captures `ctx.signal` and treats any aborted signal + as a user interruption. Senpi deliberately does not retain that heuristic: + todo 11 supplies an internal agent-end aborted flag so only a real user abort + blocks an active goal. +- Follow up upstream: add an aborted flag and source to the published extension + API so standalone pi-goal can remove its `ctx.signal` heuristic too. + +### Expected merge conflict zones on the next sync + +- HIGH: `store.ts`/`persistence.ts` retain senpi's atomic writes and stale-brace + recovery while upstream owns the lifecycle persistence semantics. +- MEDIUM: `index.ts`, `tool-registration.ts`, and `ui.ts` retain senpi's split + registration, elapsed ticker, and core abort-event integration. diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/command-registration.ts b/packages/coding-agent/src/core/extensions/builtin/goal/command-registration.ts index a371b972a..cf29a5ec8 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/command-registration.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/command-registration.ts @@ -43,7 +43,7 @@ export function registerGoalCommand(pi: ExtensionAPI, deps: GoalCommandRegistrat if (command.status === "paused") { await deps.accountCurrentAgentTurn(ctx, "active"); } - const goal = await updateGoal(deps.goalStoreRef(ctx), { status: command.status }); + const goal = await updateGoal(deps.goalStoreRef(ctx), { status: command.status }, "user"); if (goal.status === "active") { deps.beginAgentGoalAccounting(goal); } else { @@ -89,7 +89,7 @@ async function setGoalObjective( if (current?.status === "active") { await deps.accountCurrentAgentTurn(ctx, "active"); } - const goal = current === null ? await createGoal(ref, objective) : await updateGoal(ref, { objective }); + const goal = current === null ? await createGoal(ref, objective) : await updateGoal(ref, { objective }, "user"); if (goal.status === "active") deps.beginAgentGoalAccounting(goal); deps.refreshGoalUi(ctx, goal); ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info"); diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/format.ts b/packages/coding-agent/src/core/extensions/builtin/goal/format.ts index 6d17b9528..57fd804fa 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/format.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/format.ts @@ -32,6 +32,8 @@ export function goalStatusLabel(status: GoalStatus): string { return "active"; case "paused": return "paused"; + case "blocked": + return "blocked"; case "complete": return "complete"; } @@ -45,6 +47,7 @@ export function formatGoalForTool(goal: Goal | null): string { `Time used: ${formatGoalElapsedSeconds(goal.timeUsedSeconds)}`, `Tokens used: ${formatTokensCompact(goal.tokensUsed)}`, ]; + if (goal.blockedReason) lines.push(`Blocked reason: ${goal.blockedReason}`); if (goal.completedAt) lines.push(`Completed at: ${new Date(goal.completedAt * 1000).toISOString()}`); return lines.join("\n"); } @@ -53,8 +56,9 @@ export function goalToolResponse(goal: Goal | null): GoalToolResponse { return { goal: goal === null ? null : goalToolSnapshot(goal) }; } -export function formatGoalToolResponse(goal: Goal | null): string { - return JSON.stringify(goalToolResponse(goal), null, 2); +export function formatGoalToolResponse(goal: Goal | null, notice?: string): string { + const response = JSON.stringify(goalToolResponse(goal), null, 2); + return notice === undefined ? response : `${response}\n${notice}`; } function goalToolSnapshot(goal: Goal): GoalToolSnapshot { @@ -66,6 +70,8 @@ function goalToolSnapshot(goal: Goal): GoalToolSnapshot { timeUsedSeconds: goal.timeUsedSeconds, createdAt: goal.createdAt, updatedAt: goal.updatedAt, + ...(goal.blockedReason === undefined ? {} : { blockedReason: goal.blockedReason }), + ...(goal.blockedAt === undefined ? {} : { blockedAt: goal.blockedAt }), }; } diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/index.ts b/packages/coding-agent/src/core/extensions/builtin/goal/index.ts index 5078132b9..e6492c6d7 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/index.ts @@ -1,8 +1,9 @@ import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; import { registerGoalCommand } from "./command-registration.ts"; -import { shouldQueueGoalContinuationAfterAgentEnd, shouldQueueGoalContinuationWhenIdle } from "./continuation.ts"; +import { shouldQueueGoalContinuationAfterAgentEnd } from "./continuation.ts"; import { GoalElapsedTicker } from "./elapsed-ticker.ts"; import { formatGoalForTool, goalStatusLabel } from "./format.ts"; +import { isResumeOfPausedGoal, queueGoalContinuation, queueHiddenGoalPrompt } from "./lifecycle-helpers.ts"; import { buildContinuationPrompt } from "./prompt.ts"; import { accountGoalUsage, readGoal, updateGoal } from "./store.ts"; import { goalStoreRef as buildGoalStoreRef } from "./store-ref.ts"; @@ -11,7 +12,6 @@ import { TurnUsageTracker } from "./turn-usage.ts"; import type { Goal, GoalAccountingMode, GoalStoreRef } from "./types.ts"; import { updateGoalUi } from "./ui.ts"; -const GOAL_CONTINUATION_MESSAGE_TYPE = "goal-continuation"; const RESUME_GOAL_CHOICE = "Resume goal"; const LEAVE_GOAL_PAUSED_CHOICE = "Leave paused"; const STALE_EXTENSION_CONTEXT_ERROR_PREFIX = "This extension ctx is stale after session replacement or reload."; @@ -24,7 +24,9 @@ type AgentGoalAccounting = { export default function goalExtension(pi: ExtensionAPI): void { let agentTurnInProgress = false; let agentGoalAccounting: AgentGoalAccounting | null = null; + let blockedThisTurnGoalId: string | null = null; let completedThisTurnGoalId: string | null = null; + let nextAgentStartWasUserTriggered = false; const turnUsage = new TurnUsageTracker(); const goalTicker = new GoalElapsedTicker({ @@ -42,6 +44,7 @@ export default function goalExtension(pi: ExtensionAPI): void { goalStoreRef: (ctx) => buildGoalStoreRef(ctx.sessionManager, ctx.cwd), accountCurrentAgentTurn, beginAgentGoalAccounting, + markGoalBlockedThisTurn, markGoalCompletedThisTurn, refreshGoalUi, }); @@ -66,16 +69,24 @@ export default function goalExtension(pi: ExtensionAPI): void { if (await maybePromptResumePausedGoal(pi, ctx, event.reason, goal)) { return; } - if (shouldQueueGoalContinuationWhenIdle(goal, ctx.isIdle(), ctx.hasPendingMessages())) { - queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal)); - } + if (goal) queueGoalContinuation(pi, ctx, goal); + }); + + pi.on("before_agent_start", async () => { + nextAgentStartWasUserTriggered = true; }); pi.on("agent_start", async (_event, ctx) => { + const userTriggered = nextAgentStartWasUserTriggered; + nextAgentStartWasUserTriggered = false; agentTurnInProgress = true; + blockedThisTurnGoalId = null; completedThisTurnGoalId = null; turnUsage.reset(); - const goal = await readGoal(goalStoreRef(ctx)); + let goal = await readGoal(goalStoreRef(ctx)); + if (userTriggered && goal?.status === "blocked") { + goal = await updateGoal(goalStoreRef(ctx), { status: "active" }, "user"); + } if (goal?.status === "active") { beginAgentGoalAccounting(goal); } else { @@ -88,10 +99,23 @@ export default function goalExtension(pi: ExtensionAPI): void { }); pi.on("agent_end", async (event, ctx) => { - const mode: GoalAccountingMode = completedThisTurnGoalId === null ? "active" : "activeOrComplete"; - const goal = await accountCurrentAgentTurn(ctx, mode, event.messages); + const mode: GoalAccountingMode = + blockedThisTurnGoalId !== null + ? "activeOrBlocked" + : completedThisTurnGoalId === null + ? "active" + : "activeOrComplete"; + let goal = await accountCurrentAgentTurn(ctx, mode, event.messages); agentTurnInProgress = false; + blockedThisTurnGoalId = null; completedThisTurnGoalId = null; + if (event.aborted === true && event.abortSource === "user" && goal?.status === "active") { + goal = await updateGoal( + goalStoreRef(ctx), + { status: "blocked", reason: "user interrupted the turn" }, + "model", + ); + } if (goal?.status === "active") { beginAgentGoalAccounting(goal); } else { @@ -100,7 +124,7 @@ export default function goalExtension(pi: ExtensionAPI): void { refreshGoalUiBestEffort(ctx, goal); if ( goal?.status === "active" && - !ctx.signal?.aborted && + !event.aborted && shouldQueueGoalContinuationAfterAgentEnd(goal, ctx.hasPendingMessages(), event.messages) ) { queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal)); @@ -131,7 +155,7 @@ export default function goalExtension(pi: ExtensionAPI): void { ]); if (choice !== RESUME_GOAL_CHOICE) return true; - const resumed = await updateGoal(goalStoreRef(ctx), { status: "active" }); + const resumed = await updateGoal(goalStoreRef(ctx), { status: "active" }, "user"); beginAgentGoalAccounting(resumed); refreshGoalUi(ctx, resumed); ctx.ui.notify(`Goal ${goalStatusLabel(resumed.status)}\n${formatGoalForTool(resumed)}`, "info"); @@ -146,6 +170,10 @@ export default function goalExtension(pi: ExtensionAPI): void { agentGoalAccounting = { goalId: goal.id, measuredFromMilliseconds: Date.now() }; } + function markGoalBlockedThisTurn(goal: Goal): void { + if (agentTurnInProgress) blockedThisTurnGoalId = goal.id; + } + function markGoalCompletedThisTurn(goal: Goal): void { if (!agentTurnInProgress) return; completedThisTurnGoalId = goal.id; @@ -156,6 +184,9 @@ export default function goalExtension(pi: ExtensionAPI): void { if (agentGoalAccounting?.goalId === goalId) { agentGoalAccounting = null; } + if (blockedThisTurnGoalId === goalId) { + blockedThisTurnGoalId = null; + } if (completedThisTurnGoalId === goalId) { completedThisTurnGoalId = null; } @@ -163,6 +194,7 @@ export default function goalExtension(pi: ExtensionAPI): void { function clearAgentGoalAccounting(): void { agentGoalAccounting = null; + blockedThisTurnGoalId = null; completedThisTurnGoalId = null; } @@ -210,29 +242,6 @@ export default function goalExtension(pi: ExtensionAPI): void { } } -function isResumeOfPausedGoal(ctx: ExtensionContext, sessionStartReason: string, goal: Goal | null): goal is Goal { - return ( - sessionStartReason === "resume" && - goal?.status === "paused" && - ctx.hasUI && - ctx.isIdle() && - !ctx.hasPendingMessages() - ); -} - -function queueGoalContinuation(pi: ExtensionAPI, ctx: ExtensionContext, goal: Goal): void { - if (shouldQueueGoalContinuationWhenIdle(goal, ctx.isIdle(), ctx.hasPendingMessages())) { - queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal)); - } -} - -function queueHiddenGoalPrompt(pi: ExtensionAPI, content: string): void { - pi.sendMessage( - { customType: GOAL_CONTINUATION_MESSAGE_TYPE, content, display: false }, - { triggerTurn: true, deliverAs: "followUp" }, - ); -} - function goalStoreRef(ctx: ExtensionContext): GoalStoreRef { return buildGoalStoreRef(ctx.sessionManager, ctx.cwd); } diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/lifecycle-helpers.ts b/packages/coding-agent/src/core/extensions/builtin/goal/lifecycle-helpers.ts new file mode 100644 index 000000000..a6e579fe0 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/lifecycle-helpers.ts @@ -0,0 +1,33 @@ +import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; +import { shouldQueueGoalContinuationWhenIdle } from "./continuation.ts"; +import { buildContinuationPrompt } from "./prompt.ts"; +import type { Goal } from "./types.ts"; + +const GOAL_CONTINUATION_MESSAGE_TYPE = "goal-continuation"; + +export function isResumeOfPausedGoal( + ctx: ExtensionContext, + sessionStartReason: string, + goal: Goal | null, +): goal is Goal { + return ( + sessionStartReason === "resume" && + goal?.status === "paused" && + ctx.hasUI && + ctx.isIdle() && + !ctx.hasPendingMessages() + ); +} + +export function queueGoalContinuation(pi: ExtensionAPI, ctx: ExtensionContext, goal: Goal): void { + if (shouldQueueGoalContinuationWhenIdle(goal, ctx.isIdle(), ctx.hasPendingMessages())) { + queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal)); + } +} + +export function queueHiddenGoalPrompt(pi: ExtensionAPI, content: string): void { + pi.sendMessage( + { customType: GOAL_CONTINUATION_MESSAGE_TYPE, content, display: false }, + { triggerTurn: true, deliverAs: "followUp" }, + ); +} diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/persistence.ts b/packages/coding-agent/src/core/extensions/builtin/goal/persistence.ts new file mode 100644 index 000000000..507e7df5f --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/persistence.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { InvalidGoalStoreError, UnsupportedGoalStoreVersionError } from "./errors.ts"; +import type { Goal, GoalFile, GoalStatus, GoalStoreRef } from "./types.ts"; +import { isRecord } from "./types.ts"; +import { isGoalStatus, isNonNegativeSafeInteger } from "./validation.ts"; + +const STORE_VERSION = 1; + +export function encodedThreadId(ref: GoalStoreRef): string { + return encodeURIComponent(ref.threadId); +} + +export function goalFilePath(ref: GoalStoreRef): string { + return join(ref.baseDir, `${encodedThreadId(ref)}.json`); +} + +export async function readGoalFile(ref: GoalStoreRef): Promise { + try { + return parseGoalFile(await readFile(goalFilePath(ref), "utf8")).goal; + } catch (error) { + if (isMissingFile(error)) return null; + throw error; + } +} + +export async function writeGoalFile(ref: GoalStoreRef, goal: Goal | null): Promise { + const filePath = goalFilePath(ref); + await mkdir(dirname(filePath), { recursive: true }); + await writeGoalFileAtomic(filePath, `${JSON.stringify({ version: STORE_VERSION, goal }, null, 2)}\n`); +} + +async function writeGoalFileAtomic(filePath: string, contents: string): Promise { + const tempPath = join(dirname(filePath), `.goal-${randomUUID()}.tmp`); + try { + await writeFile(tempPath, contents, { encoding: "utf8", mode: 0o600 }); + await rename(tempPath, filePath); + } catch (error) { + try { + await rm(tempPath, { force: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "goal store write failed and its temporary file could not be removed", + ); + } + throw error; + } +} + +function parseGoalFile(raw: string): GoalFile { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + const recovered = recoverGoalFileWithStaleClosingBraces(raw); + if (recovered === undefined) throw error; + try { + parsed = JSON.parse(recovered); + } catch { + throw error; + } + } + if (!isRecord(parsed)) throw new InvalidGoalStoreError("goal store must be a JSON object"); + if (parsed.version !== STORE_VERSION) throw new UnsupportedGoalStoreVersionError("unsupported goal store version"); + if (parsed.goal !== null && !isGoal(parsed.goal)) + throw new InvalidGoalStoreError("goal store contains an invalid goal"); + return { version: STORE_VERSION, goal: parsed.goal }; +} + +function recoverGoalFileWithStaleClosingBraces(raw: string): string | undefined { + let rootStart = 0; + while (rootStart < raw.length && /[\t\n\r ]/.test(raw[rootStart] ?? "")) rootStart += 1; + if (raw[rootStart] !== "{") return undefined; + + let depth = 0; + let inString = false; + let escaped = false; + for (let index = rootStart; index < raw.length; index += 1) { + const character = raw[index]; + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') inString = true; + else if (character === "{" || character === "[") depth += 1; + else if (character === "}" || character === "]") { + depth -= 1; + if (depth < 0) return undefined; + if (depth === 0) { + let hasStaleClosingBrace = false; + for (let suffixIndex = index + 1; suffixIndex < raw.length; suffixIndex += 1) { + const suffix = raw[suffixIndex]; + if (suffix === "}") hasStaleClosingBrace = true; + else if (suffix !== " " && suffix !== "\t" && suffix !== "\n" && suffix !== "\r") return undefined; + } + return hasStaleClosingBrace ? raw.slice(0, index + 1) : undefined; + } + } + } + return undefined; +} + +function isGoal(value: unknown): value is Goal { + if (!isRecord(value)) return false; + return ( + typeof value.id === "string" && + typeof value.threadId === "string" && + typeof value.objective === "string" && + isGoalStatus(value.status) && + (value.tokenBudget === undefined || isNonNegativeSafeInteger(value.tokenBudget)) && + hasValidBlockedFields(value, value.status) && + isNonNegativeSafeInteger(value.tokensUsed) && + isNonNegativeSafeInteger(value.timeUsedSeconds) && + isNonNegativeSafeInteger(value.createdAt) && + isNonNegativeSafeInteger(value.updatedAt) && + (value.lastStartedAt === undefined || isNonNegativeSafeInteger(value.lastStartedAt)) && + (value.completedAt === undefined || isNonNegativeSafeInteger(value.completedAt)) + ); +} + +function hasValidBlockedFields(value: Record, status: GoalStatus): boolean { + if (status === "blocked") { + return ( + typeof value.blockedReason === "string" && + value.blockedReason.trim().length > 0 && + isNonNegativeSafeInteger(value.blockedAt) + ); + } + return value.blockedReason === undefined && value.blockedAt === undefined; +} + +function isMissingFile(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/store.ts b/packages/coding-agent/src/core/extensions/builtin/goal/store.ts index 298c720cb..ae03ceb00 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/store.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/store.ts @@ -1,75 +1,54 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { - GoalAlreadyExistsError, - GoalNotFoundError, - InvalidGoalStoreError, - UnsupportedGoalStoreVersionError, -} from "./errors.ts"; -import type { Goal, GoalAccountingMode, GoalFile, GoalStoreRef, GoalUpdate, TokenUsageSnapshot } from "./types.ts"; -import { isRecord } from "./types.ts"; -import { - isGoalStatus, - isNonNegativeSafeInteger, - resolveTokenBudget, - validateObjective, - validateTokenBudget, -} from "./validation.ts"; +import { GoalAlreadyExistsError, GoalNotFoundError } from "./errors.ts"; +import { encodedThreadId, readGoalFile, writeGoalFile } from "./persistence.ts"; +import { transitionGoalStatus } from "./transitions.ts"; +import type { + Goal, + GoalAccountingMode, + GoalStoreRef, + GoalUpdate, + GoalUpdateSource, + TokenUsageSnapshot, +} from "./types.ts"; +import { resolveTokenBudget, validateObjective, validateTokenBudget } from "./validation.ts"; + +export { goalFilePath } from "./persistence.ts"; + +export function goalHistoryFilePath(ref: GoalStoreRef): string { + return join(ref.baseDir, `${encodedThreadId(ref)}.history.jsonl`); +} -const STORE_VERSION = 1; +export function objectiveFullTextFileName(ref: GoalStoreRef): string { + return `${encodedThreadId(ref)}.objective-full.txt`; +} -export function goalFilePath(ref: GoalStoreRef): string { - return join(ref.baseDir, `${encodeURIComponent(ref.threadId)}.json`); +export function objectiveFullTextFilePath(ref: GoalStoreRef): string { + return join(ref.baseDir, objectiveFullTextFileName(ref)); } export async function readGoal(ref: GoalStoreRef): Promise { - const filePath = goalFilePath(ref); - try { - const raw = await readFile(filePath, "utf8"); - return parseGoalFile(raw).goal; - } catch (error) { - if (isMissingFile(error)) return null; - throw error; - } + return readGoalFile(ref); } export async function writeGoal(ref: GoalStoreRef, goal: Goal | null): Promise { - const filePath = goalFilePath(ref); - await mkdir(dirname(filePath), { recursive: true }); - const file: GoalFile = { version: STORE_VERSION, goal }; - await writeGoalFileAtomic(filePath, `${JSON.stringify(file, null, 2)}\n`); -} - -async function writeGoalFileAtomic(filePath: string, contents: string): Promise { - const tempPath = join(dirname(filePath), `.goal-${randomUUID()}.tmp`); - try { - await writeFile(tempPath, contents, { encoding: "utf8", mode: 0o600 }); - await rename(tempPath, filePath); - } catch (error) { - try { - await rm(tempPath, { force: true }); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - "goal store write failed and its temporary file could not be removed", - ); - } - throw error; - } + await writeGoalFile(ref, goal); } export async function createGoal(ref: GoalStoreRef, objective: string, tokenBudget?: number): Promise { - if ((await readGoal(ref)) !== null) { + const validatedObjective = validateObjective(objective, objectiveFullTextFileName(ref)); + const current = await readGoal(ref); + if (current !== null && current.status !== "complete") { throw new GoalAlreadyExistsError("cannot create a new goal because this thread already has a goal"); } - - const normalizedObjective = validateObjective(objective); - const now = Math.trunc(Date.now() / 1000); + if (validatedObjective.truncated) await writeFullObjectiveText(ref, objective); + if (current?.status === "complete") await archiveGoal(ref, current); + const now = nowSeconds(); const goal: Goal = { id: randomUUID(), threadId: ref.threadId, - objective: normalizedObjective, + objective: validatedObjective.objective, status: "active", tokensUsed: 0, timeUsedSeconds: 0, @@ -82,19 +61,26 @@ export async function createGoal(ref: GoalStoreRef, objective: string, tokenBudg return goal; } -export async function updateGoal(ref: GoalStoreRef, update: GoalUpdate): Promise { +export async function updateGoal( + ref: GoalStoreRef, + update: GoalUpdate, + source: GoalUpdateSource = "model", +): Promise { const current = await readGoal(ref); if (!current) throw new GoalNotFoundError("cannot update goal: no goal exists"); - const objective = update.objective === undefined ? current.objective : validateObjective(update.objective); - const now = Math.trunc(Date.now() / 1000), - tokenBudget = resolveTokenBudget(current.tokenBudget, update.tokenBudget); - const hasObjectiveUpdate = update.objective !== undefined, - replacesGoal = hasObjectiveUpdate && (objective !== current.objective || current.status === "complete"); + const validatedObjective = + update.objective === undefined ? undefined : validateObjective(update.objective, objectiveFullTextFileName(ref)); + const objective = validatedObjective?.objective ?? current.objective; + const tokenBudget = resolveTokenBudget(current.tokenBudget, update.tokenBudget); + const now = nextUpdatedAt(current.updatedAt); + const hasObjectiveUpdate = update.objective !== undefined; + const replacesGoal = hasObjectiveUpdate && (objective !== current.objective || current.status === "complete"); const requestedStatus = update.status ?? (hasObjectiveUpdate ? "active" : undefined); if (replacesGoal) { const status = requestedStatus ?? "active"; + if (status === "blocked") throw new Error("objective replacement cannot create a blocked goal"); const next: Goal = { id: randomUUID(), threadId: ref.threadId, @@ -108,35 +94,37 @@ export async function updateGoal(ref: GoalStoreRef, update: GoalUpdate): Promise }; if (status === "active") next.lastStartedAt = now; if (status === "complete") next.completedAt = now; + if (validatedObjective?.truncated) await writeFullObjectiveText(ref, update.objective ?? ""); await writeGoal(ref, next); return next; } - const status = requestedStatus ?? current.status; - const next: Goal = { - ...current, - objective, - status, - updatedAt: now, - }; - Object.assign(next, tokenBudget === undefined ? { tokenBudget: undefined } : { tokenBudget }); - - if (status === "active" && current.status !== "active") { - next.lastStartedAt = now; - } else if (status !== "active") { - delete next.lastStartedAt; - } - - if (status === "complete") { - next.completedAt = current.completedAt ?? now; - } else { - delete next.completedAt; - } - + const next = transitionGoalStatus( + { ...current, objective }, + requestedStatus ?? current.status, + source, + update.reason, + now, + ); + if (tokenBudget === undefined) delete next.tokenBudget; + else next.tokenBudget = tokenBudget; + if (validatedObjective?.truncated) await writeFullObjectiveText(ref, update.objective ?? ""); await writeGoal(ref, next); return next; } +export async function archiveGoal(ref: GoalStoreRef, goal: Goal): Promise { + const filePath = goalHistoryFilePath(ref); + await mkdir(dirname(filePath), { recursive: true }); + await appendFile(filePath, `${JSON.stringify(goal)}\n`, "utf8"); +} + +async function writeFullObjectiveText(ref: GoalStoreRef, objective: string): Promise { + const filePath = objectiveFullTextFilePath(ref); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, objective, "utf8"); +} + export async function clearGoal(ref: GoalStoreRef): Promise { const hadGoal = (await readGoal(ref)) !== null; await writeGoal(ref, null); @@ -151,126 +139,29 @@ export async function accountGoalUsage( expectedGoalId?: string, ): Promise { const goal = await readGoal(ref); - if (!goal) return goal; - if (expectedGoalId !== undefined && goal.id !== expectedGoalId) return goal; - if (!canAccountGoalUsage(goal, mode)) return goal; - - const now = Math.trunc(Date.now() / 1000); + if (!goal || (expectedGoalId !== undefined && goal.id !== expectedGoalId) || !canAccountGoalUsage(goal, mode)) { + return goal; + } const next: Goal = { ...goal, - tokensUsed: goal.tokensUsed + goalTokenDeltaForUsage(usage), + tokensUsed: goal.tokensUsed + Math.max(0, usage.input) + Math.max(0, usage.output), timeUsedSeconds: goal.timeUsedSeconds + Math.max(0, Math.trunc(elapsedSeconds)), - updatedAt: now, + updatedAt: nextUpdatedAt(goal.updatedAt), }; await writeGoal(ref, next); return next; } function canAccountGoalUsage(goal: Goal, mode: GoalAccountingMode): boolean { - switch (mode) { - case "active": - return goal.status === "active"; - case "activeOrComplete": - return goal.status === "active" || goal.status === "complete"; - } + if (mode === "active") return goal.status === "active"; + if (mode === "activeOrBlocked") return goal.status === "active" || goal.status === "blocked"; + return goal.status === "active" || goal.status === "complete"; } -function goalTokenDeltaForUsage(usage: TokenUsageSnapshot): number { - return Math.max(0, usage.input) + Math.max(0, usage.output); +function nextUpdatedAt(previousUpdatedAt: number): number { + return Math.max(nowSeconds(), previousUpdatedAt + 1); } -function parseGoalFile(raw: string): GoalFile { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (error) { - if (!(error instanceof SyntaxError)) throw error; - const recovered = recoverGoalFileWithStaleClosingBraces(raw); - if (recovered === undefined) throw error; - try { - parsed = JSON.parse(recovered); - } catch { - throw error; - } - } - if (!isRecord(parsed)) throw new InvalidGoalStoreError("goal store must be a JSON object"); - if (parsed.version !== STORE_VERSION) throw new UnsupportedGoalStoreVersionError("unsupported goal store version"); - const goal = parsed.goal; - if (goal !== null && !isGoal(goal)) throw new InvalidGoalStoreError("goal store contains an invalid goal"); - return { - version: STORE_VERSION, - goal, - }; -} - -function recoverGoalFileWithStaleClosingBraces(raw: string): string | undefined { - let rootStart = 0; - while (rootStart < raw.length && /[\t\n\r ]/.test(raw[rootStart] ?? "")) rootStart += 1; - if (raw[rootStart] !== "{") return undefined; - - let depth = 0; - let inString = false; - let escaped = false; - for (let index = rootStart; index < raw.length; index += 1) { - const character = raw[index]; - if (inString) { - if (escaped) { - escaped = false; - } else if (character === "\\") { - escaped = true; - } else if (character === '"') { - inString = false; - } - continue; - } - - if (character === '"') { - inString = true; - } else if (character === "{" || character === "[") { - depth += 1; - } else if (character === "}" || character === "]") { - depth -= 1; - if (depth < 0) return undefined; - if (depth === 0) { - let hasStaleClosingBrace = false; - for (let suffixIndex = index + 1; suffixIndex < raw.length; suffixIndex += 1) { - const suffixCharacter = raw[suffixIndex]; - if (suffixCharacter === "}") { - hasStaleClosingBrace = true; - } else if ( - suffixCharacter !== " " && - suffixCharacter !== "\t" && - suffixCharacter !== "\n" && - suffixCharacter !== "\r" - ) { - return undefined; - } - } - return hasStaleClosingBrace ? raw.slice(0, index + 1) : undefined; - } - } - } - - return undefined; -} - -function isMissingFile(error: unknown): boolean { - return error instanceof Error && "code" in error && (error as { code: unknown }).code === "ENOENT"; -} - -function isGoal(value: unknown): value is Goal { - if (!isRecord(value)) return false; - return ( - typeof value.id === "string" && - typeof value.threadId === "string" && - typeof value.objective === "string" && - isGoalStatus(value.status) && - (value.tokenBudget === undefined || isNonNegativeSafeInteger(value.tokenBudget)) && - isNonNegativeSafeInteger(value.tokensUsed) && - isNonNegativeSafeInteger(value.timeUsedSeconds) && - isNonNegativeSafeInteger(value.createdAt) && - isNonNegativeSafeInteger(value.updatedAt) && - (value.lastStartedAt === undefined || isNonNegativeSafeInteger(value.lastStartedAt)) && - (value.completedAt === undefined || isNonNegativeSafeInteger(value.completedAt)) - ); +function nowSeconds(): number { + return Math.trunc(Date.now() / 1000); } diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/tool-registration.ts b/packages/coding-agent/src/core/extensions/builtin/goal/tool-registration.ts index 842007cb7..ba2d36b4e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/tool-registration.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/tool-registration.ts @@ -1,9 +1,10 @@ import { Type } from "typebox"; import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "../../types.ts"; import { formatGoalToolResponse } from "./format.ts"; -import { createGoal, readGoal, updateGoal } from "./store.ts"; +import { createGoal, objectiveFullTextFileName, readGoal, updateGoal } from "./store.ts"; import type { Goal, GoalAccountingMode, GoalStoreRef } from "./types.ts"; -import { COMPLETABLE_GOAL_STATUS_VALUES } from "./types.ts"; +import { MODEL_SETTABLE_GOAL_STATUS_VALUES } from "./types.ts"; +import { objectiveTruncationNotice, validateObjective } from "./validation.ts"; type GoalToolResult = AgentToolResult>; @@ -11,6 +12,7 @@ export type GoalToolRegistrationDeps = { readonly goalStoreRef: (ctx: ExtensionContext) => GoalStoreRef; readonly accountCurrentAgentTurn: (ctx: ExtensionContext, mode: GoalAccountingMode) => Promise; readonly beginAgentGoalAccounting: (goal: Goal) => void; + readonly markGoalBlockedThisTurn: (goal: Goal) => void; readonly markGoalCompletedThisTurn: (goal: Goal) => void; readonly refreshGoalUi: (ctx: ExtensionContext, goal: Goal | null) => void; }; @@ -20,27 +22,34 @@ export function registerGoalTools(pi: ExtensionAPI, deps: GoalToolRegistrationDe name: "create_goal", label: "Create Goal", description: - "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.\nFails if a goal already exists; use update_goal only for status.", + "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.\nObjectives are limited to 4,000 characters. For longer instructions, put the full objective in a file and refer to that file.\nReplaces the current goal when it is complete and archives it; fails if an unfinished goal exists.", parameters: Type.Object( { objective: Type.String({ description: - "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails.", + "Required. The concrete objective to start pursuing. Limit: 4,000 characters. For longer instructions, put the full objective in a file and refer to that file.", }), }, { additionalProperties: false }, ), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const ref = deps.goalStoreRef(ctx); - if ((await readGoal(ref)) !== null) { + const current = await readGoal(ref); + if (current !== null && current.status !== "complete") { throw new Error( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete", + "cannot create a new goal because this thread already has an unfinished goal; use update_goal only when the existing goal is complete", ); } + const validatedObjective = validateObjective(params.objective, objectiveFullTextFileName(ref)); const goal = await createGoal(ref, params.objective); deps.beginAgentGoalAccounting(goal); deps.refreshGoalUi(ctx, goal); - return toolText(formatGoalToolResponse(goal)); + return toolText( + formatGoalToolResponse( + goal, + validatedObjective.truncated ? objectiveTruncationNotice(objectiveFullTextFileName(ref)) : undefined, + ), + ); }, }); @@ -48,28 +57,37 @@ export function registerGoalTools(pi: ExtensionAPI, deps: GoalToolRegistrationDe name: "update_goal", label: "Update Goal", description: - "Update the existing goal.\nUse this tool only to mark the goal achieved.\nSet status to `complete` only when the objective has actually been achieved and no required work remains.\nDo not mark a goal complete merely because you are stopping work.\nYou cannot use this tool to pause or resume a goal; those status changes are controlled by the user or system.\nWhen marking the goal achieved with status `complete`, report the final elapsed time and token usage from the tool result to the user.", + "Update the existing goal.\nSet status to `complete` only when the objective has actually been achieved and no required work remains. Do not mark a goal complete merely because you are stopping work.\nSet status to `blocked` only after the same blocking condition recurs for at least 3 consecutive goal turns. After resuming, begin a fresh blocked audit after resume. Never mark a goal blocked merely because the work is hard, slow, or uncertain.\nA non-empty reason is required when blocking; reason must not be provided when completing.\nYou cannot use this tool to pause or resume a goal; those status changes are controlled by the user or system.\nWhen marking the goal achieved with status `complete`, report the final elapsed time and token usage from the tool result to the user.", parameters: Type.Object( { status: Type.Union( - COMPLETABLE_GOAL_STATUS_VALUES.map((status) => Type.Literal(status)), - { - description: - "Required. Set to complete only when the objective is achieved and no required work remains.", - }, + MODEL_SETTABLE_GOAL_STATUS_VALUES.map((status) => Type.Literal(status)), + { description: "Required. Set to complete when achieved or blocked with a non-empty reason." }, + ), + reason: Type.Optional( + Type.String({ + description: "Required and non-empty when status is blocked; rejected when status is complete.", + }), ), }, { additionalProperties: false }, ), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - if (params.status !== "complete") { - throw new Error( - "update_goal can only mark the existing goal complete; pause and resume are controlled by the user or system", - ); + const reason = params.reason?.trim(); + if (params.status === "blocked" && (reason === undefined || reason.length === 0)) { + throw new Error("reason is required when status is blocked"); + } + if (params.status === "complete" && params.reason !== undefined) { + throw new Error("reason must not be provided when status is complete"); } await deps.accountCurrentAgentTurn(ctx, "active"); - const goal = await updateGoal(deps.goalStoreRef(ctx), { status: "complete" }); - deps.markGoalCompletedThisTurn(goal); + const goal = await updateGoal( + deps.goalStoreRef(ctx), + params.status === "blocked" ? { status: "blocked", reason } : { status: "complete" }, + "model", + ); + if (goal.status === "blocked") deps.markGoalBlockedThisTurn(goal); + else deps.markGoalCompletedThisTurn(goal); deps.refreshGoalUi(ctx, goal); return toolText(formatGoalToolResponse(goal)); }, diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/transitions.ts b/packages/coding-agent/src/core/extensions/builtin/goal/transitions.ts new file mode 100644 index 000000000..55b1cd477 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/transitions.ts @@ -0,0 +1,53 @@ +import type { Goal, GoalStatus, GoalUpdateSource } from "./types.ts"; + +export function transitionGoalStatus( + current: Goal, + status: GoalStatus, + source: GoalUpdateSource, + reason: string | undefined, + updatedAt: number, +): Goal { + assertGoalTransition(current.status, status, source); + if (status === "complete" && reason !== undefined) + throw new Error("reason must not be provided when status is complete"); + + const next: Goal = { ...current, status, updatedAt }; + if (status === "blocked") applyBlockedFields(next, current, reason, updatedAt); + else { + delete next.blockedReason; + delete next.blockedAt; + } + if (status === "active" && current.status !== "active") next.lastStartedAt = updatedAt; + else if (status !== "active") delete next.lastStartedAt; + if (status === "complete") next.completedAt = current.completedAt ?? updatedAt; + else delete next.completedAt; + return next; +} + +function assertGoalTransition(current: GoalStatus, next: GoalStatus, source: GoalUpdateSource): void { + if (current === next || isAllowedTransition(current, next, source)) return; + throw new Error(`illegal goal transition: ${current} -> ${next}`); +} + +function isAllowedTransition(current: GoalStatus, next: GoalStatus, source: GoalUpdateSource): boolean { + if (source === "model") { + return ( + (current === "active" && (next === "blocked" || next === "complete")) || + (current === "blocked" && next === "complete") + ); + } + return ( + (current === "active" && next === "paused") || + (current === "paused" && next === "active") || + (current === "blocked" && next === "active") + ); +} + +function applyBlockedFields(next: Goal, current: Goal, reason: string | undefined, updatedAt: number): void { + if (current.status === "blocked") return; + const blockedReason = reason?.trim(); + if (blockedReason === undefined || blockedReason.length === 0) + throw new Error("reason is required when status is blocked"); + next.blockedReason = blockedReason; + next.blockedAt = updatedAt; +} diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/types.ts b/packages/coding-agent/src/core/extensions/builtin/goal/types.ts index 5e14f03c3..97da929c7 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/types.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/types.ts @@ -1,15 +1,16 @@ -export const GOAL_STATUS_VALUES = ["active", "paused", "complete"] as const; -export const COMPLETABLE_GOAL_STATUS_VALUES = ["complete"] as const; +export const GOAL_STATUS_VALUES = ["active", "paused", "blocked", "complete"] as const; +export const MODEL_SETTABLE_GOAL_STATUS_VALUES = ["complete", "blocked"] as const; export type GoalStatus = (typeof GOAL_STATUS_VALUES)[number]; -export type CompletableGoalStatus = (typeof COMPLETABLE_GOAL_STATUS_VALUES)[number]; +export type ModelSettableGoalStatus = (typeof MODEL_SETTABLE_GOAL_STATUS_VALUES)[number]; export type GoalStoreRef = { baseDir: string; threadId: string; }; -export type GoalAccountingMode = "active" | "activeOrComplete"; +export type GoalAccountingMode = "active" | "activeOrBlocked" | "activeOrComplete"; +export type GoalUpdateSource = "model" | "user"; export type Goal = { id: string; @@ -22,6 +23,8 @@ export type Goal = { createdAt: number; updatedAt: number; lastStartedAt?: number; + blockedReason?: string; + blockedAt?: number; completedAt?: number; }; @@ -41,6 +44,7 @@ export type TokenUsageSnapshot = { export type GoalUpdate = { objective?: string; status?: GoalStatus; + reason?: string; tokenBudget?: number | null; }; @@ -52,6 +56,8 @@ export type GoalToolSnapshot = { timeUsedSeconds: number; createdAt: number; updatedAt: number; + blockedReason?: string; + blockedAt?: number; }; export type GoalToolResponse = { diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/ui.ts b/packages/coding-agent/src/core/extensions/builtin/goal/ui.ts index fed576ba6..134f7d83b 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/ui.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/ui.ts @@ -21,6 +21,8 @@ export function goalStatusText(goal: Goal, liveElapsedSeconds?: number): string } case "paused": return "Goal paused (/goal resume)"; + case "blocked": + return goal.blockedReason ? `Goal blocked: ${goal.blockedReason}` : "Goal blocked"; case "complete": return "Goal achieved"; } diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/validation.ts b/packages/coding-agent/src/core/extensions/builtin/goal/validation.ts index 81b66f43f..c9a7ba136 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/validation.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/validation.ts @@ -1,19 +1,32 @@ import { GOAL_STATUS_VALUES, type GoalStatus } from "./types.ts"; export const MAX_OBJECTIVE_LENGTH = 4_000; -const GOAL_TOO_LONG_FILE_HINT = - "Put longer instructions in a file and refer to that file in the goal, for example: /goal follow the instructions in docs/goal.md."; +const WHITESPACE_LOOKBACK = 200; -export function validateObjective(value: string): string { +export type ValidatedObjective = { + objective: string; + truncated: boolean; + fullTextFileName?: string; +}; + +export function validateObjective(value: string, fullTextFileName: string): ValidatedObjective { const objective = value.trim(); if (objective.length === 0) throw new Error("objective must not be empty"); - const objectiveCharacters = [...objective].length; - if (objectiveCharacters > MAX_OBJECTIVE_LENGTH) { - throw new Error( - `Goal objective is too long: ${objectiveCharacters.toLocaleString()} characters. Limit: ${MAX_OBJECTIVE_LENGTH.toLocaleString()} characters. ${GOAL_TOO_LONG_FILE_HINT}`, - ); - } - return objective; + const codePoints = [...objective]; + if (codePoints.length <= MAX_OBJECTIVE_LENGTH) return { objective, truncated: false }; + + const marker = truncationMarker(fullTextFileName); + const payloadBudget = MAX_OBJECTIVE_LENGTH - [...marker].length; + const payload = codePoints.slice(0, nearestWhitespaceCut(codePoints, payloadBudget) ?? payloadBudget).join(""); + return { objective: `${payload}${marker}`, truncated: true, fullTextFileName }; +} + +export function truncationMarker(fullTextFileName: string): string { + return `… [truncated; full objective: ${fullTextFileName}]`; +} + +export function objectiveTruncationNotice(fullTextFileName: string): string { + return `Objective was truncated; full objective saved to ${fullTextFileName}.`; } export function isGoalStatus(value: unknown): value is GoalStatus { @@ -26,13 +39,17 @@ export function isNonNegativeSafeInteger(value: unknown): value is number { export function resolveTokenBudget(current: number | undefined, update: number | null | undefined): number | undefined { if (update === undefined) return current; - if (update === null) return undefined; - return validateTokenBudget(update); + return update === null ? undefined : validateTokenBudget(update); } export function validateTokenBudget(value: number): number { - if (!isNonNegativeSafeInteger(value)) { - throw new Error("token budget must be a non-negative integer"); - } + if (!isNonNegativeSafeInteger(value)) throw new Error("token budget must be a non-negative integer"); return value; } + +function nearestWhitespaceCut(codePoints: string[], payloadBudget: number): number | undefined { + for (let index = payloadBudget - 1; index >= Math.max(0, payloadBudget - WHITESPACE_LOOKBACK); index -= 1) { + if (/\s/u.test(codePoints[index] ?? "")) return index; + } + return undefined; +} diff --git a/packages/coding-agent/src/core/extensions/builtin/todotools/changes.md b/packages/coding-agent/src/core/extensions/builtin/todotools/changes.md index 700ee0ea5..b8f367b76 100644 --- a/packages/coding-agent/src/core/extensions/builtin/todotools/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/todotools/changes.md @@ -131,3 +131,19 @@ `renderResult` call site). - LOW: the shared `modes/interactive/components/todo-strike.ts` module (fork-only). + +## Sync provenance: pi-todotools 0.2.0 (2026-07-26) + +### Source + +- Canonical source: `code-yeongyu/pi-todotools` 0.2.0, merged by + [pi-todotools PR #13](https://github.com/code-yeongyu/pi-todotools/pull/13). +- Version metadata was regenerated through `sync-builtin-extensions.mjs`. + +### Diff result + +The functional state and operation logic matches the canonical phased port. The +remaining differences are intentional senpi adaptations: the `senpi.todo-state` +persistence key, TypeBox/internal imports, the `todowrite` builtin identity, +`todo-sidebar` widget renderer and completion animation, and the `/todo` +command suite. No behavior delta surfaced during the resync comparison. diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 286d6ea7e..8bddd1feb 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -874,6 +874,10 @@ export interface AgentStartEvent { export interface AgentEndEvent { type: "agent_end"; messages: AgentMessage[]; + /** True when the agent run ended through an abort rather than normal completion. */ + aborted?: boolean; + /** Present when the host can attribute the abort to a user action or internal operation. */ + abortSource?: "user" | "system"; } /** Fired after an agent run has fully settled and no automatic retry, compaction, or queued continuation will run. */ diff --git a/packages/coding-agent/src/modes/app-server/threads/goal-handlers.ts b/packages/coding-agent/src/modes/app-server/threads/goal-handlers.ts index 78494856c..db6ce5ce7 100644 --- a/packages/coding-agent/src/modes/app-server/threads/goal-handlers.ts +++ b/packages/coding-agent/src/modes/app-server/threads/goal-handlers.ts @@ -70,7 +70,9 @@ class ThreadGoalHandlers { objective, tokenBudget.present && tokenBudget.value !== null ? tokenBudget.value : undefined, ); - return status === undefined || status === "active" ? created : updateGoal(ref, { status }); + return status === undefined || status === "active" + ? created + : updateGoal(ref, { status }, status === "complete" ? "model" : "user"); } const update: GoalUpdate = { @@ -78,7 +80,7 @@ class ThreadGoalHandlers { ...(status === undefined ? {} : { status }), ...(tokenBudget.present ? { tokenBudget: tokenBudget.value } : {}), }; - return updateGoal(ref, update); + return updateGoal(ref, update, status === "complete" ? "model" : "user"); }); const response = { goal: toThreadGoal(goal) } satisfies ThreadGoalSetResponse; const notify = (): void => { diff --git a/packages/coding-agent/src/modes/app-server/threads/goal-wire.ts b/packages/coding-agent/src/modes/app-server/threads/goal-wire.ts index 4c4804d5a..121c3df0d 100644 --- a/packages/coding-agent/src/modes/app-server/threads/goal-wire.ts +++ b/packages/coding-agent/src/modes/app-server/threads/goal-wire.ts @@ -4,6 +4,7 @@ import type { ThreadGoal } from "../protocol/index.ts"; const GOAL_STATUS_TO_THREAD_STATUS = { active: "active", paused: "paused", + blocked: "blocked", complete: "complete", } as const satisfies Record; diff --git a/packages/coding-agent/test/suite/app-server-thread-goal.test.ts b/packages/coding-agent/test/suite/app-server-thread-goal.test.ts index 0103b3d11..310898c75 100644 --- a/packages/coding-agent/test/suite/app-server-thread-goal.test.ts +++ b/packages/coding-agent/test/suite/app-server-thread-goal.test.ts @@ -1,5 +1,7 @@ import { writeFile } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; +import { createGoal, updateGoal } from "../../src/core/extensions/builtin/goal/store.ts"; +import { goalStoreRef } from "../../src/core/extensions/builtin/goal/store-ref.ts"; import { cleanupRoots, createHarness, @@ -80,6 +82,31 @@ describe("app-server thread goal handlers", () => { }); }); + it("reads a persisted blocked goal while thread/goal/set continues to reject blocked", async () => { + // Given: a started persistent thread with an agent-created blocked goal. + const { connection, registry, root, threads } = await createHarness(); + const threadId = threadIdFromResponse( + await registry.dispatch(connection, { id: 12, method: "thread/start", params: { cwd: root } }), + ); + const thread = threads.getLoadedThread(threadId); + const ref = goalStoreRef(thread.session.sessionManager, thread.cwd); + await createGoal(ref, "Wait for the user decision"); + await updateGoal(ref, { status: "blocked", reason: "Waiting for user decision" }, "model"); + + // When: the persisted goal is read through the app-server wire. + const response = await registry.dispatch(connection, { + id: 13, + method: "thread/goal/get", + params: { threadId }, + }); + + // Then: blocked is exposed by get while set remains deliberately user-rejected. + expect(objectAt(responseResult(response), "goal")).toMatchObject({ + threadId, + status: "blocked", + }); + }); + it("broadcasts goal updates globally only after the response", async () => { // Given: two initialized connections, with only the requester subscribed to the thread. const deferredActions: Array<() => Promise | void> = []; diff --git a/packages/coding-agent/test/suite/builtin-extension-sync.test.ts b/packages/coding-agent/test/suite/builtin-extension-sync.test.ts index 6d509ae93..5a89cf35a 100644 --- a/packages/coding-agent/test/suite/builtin-extension-sync.test.ts +++ b/packages/coding-agent/test/suite/builtin-extension-sync.test.ts @@ -16,6 +16,8 @@ describe("synced builtin extensions", () => { expect(manifest.extensions?.["bash-timeout"]?.packageName).toBe("pi-bash-timeout"); expect(manifest.extensions?.["bash-timeout"]?.version).toBe("0.1.1"); expect(manifest.extensions?.todowrite?.packageName).toBe("pi-todotools"); - expect(manifest.extensions?.todowrite?.version).toBe("0.1.1"); + expect(manifest.extensions?.todowrite?.version).toBe("0.2.0"); + expect(manifest.extensions?.goal?.packageName).toBe("pi-goal"); + expect(manifest.extensions?.goal?.version).toBe("0.3.0"); }); }); diff --git a/packages/coding-agent/test/suite/goal-abort-extension.test.ts b/packages/coding-agent/test/suite/goal-abort-extension.test.ts new file mode 100644 index 000000000..63a19887a --- /dev/null +++ b/packages/coding-agent/test/suite/goal-abort-extension.test.ts @@ -0,0 +1,117 @@ +import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it } from "vitest"; +import goalExtension from "../../src/core/extensions/builtin/goal/index.ts"; +import { createGoal, readGoal } from "../../src/core/extensions/builtin/goal/store.ts"; +import { goalStoreRef } from "../../src/core/extensions/builtin/goal/store-ref.ts"; +import type { GoalStatus } from "../../src/core/extensions/builtin/goal/types.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +type AgentEndSnapshot = { + aborted: boolean | undefined; + abortSource: "user" | "system" | undefined; + status: GoalStatus | undefined; + tokensUsed: number | undefined; + pendingMessages: boolean; +}; + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve: (() => void) | undefined; + const promise = new Promise((next) => { + resolve = next; + }); + if (!resolve) throw new Error("Deferred resolver was not initialized"); + return { promise, resolve }; +} + +describe("goal abort lifecycle through the agent session", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); + + it("marks an ESC-aborted goal blocked only after usage accounting, suppresses continuation, and resumes for the next user run", async () => { + const streamStarted = deferred(); + const agentEnds: AgentEndSnapshot[] = []; + const statusesAtAgentStart: GoalStatus[] = []; + const harness = await createHarness({ + persistSession: true, + extensionFactories: [ + goalExtension, + (pi) => { + pi.on("message_update", (event) => { + if (event.message.role === "assistant") streamStarted.resolve(); + }); + pi.on("agent_start", async (_event, ctx) => { + const goal = await readGoal(goalStoreRef(ctx.sessionManager, ctx.cwd)); + if (goal) statusesAtAgentStart.push(goal.status); + }); + pi.on("agent_end", async (event, ctx) => { + const goal = await readGoal(goalStoreRef(ctx.sessionManager, ctx.cwd)); + agentEnds.push({ + aborted: event.aborted, + abortSource: event.abortSource, + status: goal?.status, + tokensUsed: goal?.tokensUsed, + pendingMessages: ctx.hasPendingMessages(), + }); + }); + }, + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Finish the interrupted task"); + harness.setResponses([fauxAssistantMessage("streaming response ".repeat(4_000))]); + + const interruptedRun = harness.session.prompt("start the active goal"); + await streamStarted.promise; + await harness.session.abort(); + await interruptedRun; + + expect(agentEnds).toEqual([ + expect.objectContaining({ + aborted: true, + abortSource: "user", + status: "blocked", + tokensUsed: expect.any(Number), + pendingMessages: false, + }), + ]); + expect(agentEnds[0]?.tokensUsed).toBeGreaterThan(0); + expect(await readGoal(ref)).toMatchObject({ + status: "blocked", + blockedReason: "user interrupted the turn", + }); + + harness.setResponses([ + fauxAssistantMessage([fauxToolCall("update_goal", { status: "complete" })], { stopReason: "toolUse" }), + fauxAssistantMessage("completed after resuming"), + ]); + await harness.session.prompt("continue after interruption"); + + expect(statusesAtAgentStart).toEqual(["active", "active"]); + expect((await readGoal(ref))?.status).toBe("complete"); + }); + + it("does not mark a normally completed agent run as aborted", async () => { + const observed: Array<{ aborted: boolean | undefined; abortSource: string | undefined }> = []; + const harness = await createHarness({ + extensionFactories: [ + (pi) => { + pi.on("agent_end", (event) => { + observed.push({ aborted: event.aborted, abortSource: event.abortSource }); + }); + }, + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + harness.setResponses([fauxAssistantMessage("normal completion")]); + + await harness.session.prompt("run normally"); + + expect(observed).toEqual([{ aborted: undefined, abortSource: undefined }]); + }); +}); diff --git a/packages/coding-agent/test/suite/goal-extension.test.ts b/packages/coding-agent/test/suite/goal-extension.test.ts index 0830db4d5..66f7dd507 100644 --- a/packages/coding-agent/test/suite/goal-extension.test.ts +++ b/packages/coding-agent/test/suite/goal-extension.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; @@ -104,12 +104,20 @@ describe("goal extension contract (budget-free)", () => { expect(serialized).not.toContain("budget"); }); - it("restricts update_goal to complete and drops budget language", () => { + it("exposes blocked updates with limit-aware goal guidance and no budget language", () => { const { tools } = createGoalHarness(); + const create = tools.get("create_goal"); const update = tools.get("update_goal"); const serialized = JSON.stringify(update).toLowerCase(); + expect(create?.description).toMatch(/4,000.*file/i); + expect(JSON.stringify(create?.parameters)).toMatch(/4,000.*file/i); + expect(create?.description).toMatch(/complete.*archive.*unfinished/i); expect(serialized).toContain("complete"); - expect(serialized).not.toContain("blocked"); + expect(serialized).toContain("blocked"); + expect(serialized).toContain("reason"); + expect(update?.description).toMatch(/3 consecutive goal turns/i); + expect(update?.description).toMatch(/fresh blocked audit after resume/i); + expect(update?.description).toMatch(/hard, slow, or uncertain/i); expect(serialized).not.toContain("budget"); expect(JSON.stringify(tools.get("get_goal")).toLowerCase()).not.toContain("budget"); }); @@ -137,13 +145,72 @@ describe("goal extension contract (budget-free)", () => { expect((await readGoal(ref))?.status).toBe("complete"); }); - it("refuses a second create_goal while a goal exists", async () => { + it("replaces a completed goal through create_goal, archives it, and rejects unfinished goals", async () => { const { tools } = createGoalHarness(); - const ctx = await makeCtx(); + const ctx = await makeCtx("thread/complete-create"); + const ref = storeRefFor(ctx); await tools.get("create_goal")?.execute("c1", { objective: "First" }, undefined, undefined, ctx); + await tools.get("update_goal")?.execute("u1", { status: "complete" }, undefined, undefined, ctx); + + const replacement = await tools + .get("create_goal") + ?.execute("c2", { objective: "Second" }, undefined, undefined, ctx); + expect(JSON.parse(textOf(replacement))).toMatchObject({ goal: { objective: "Second", status: "active" } }); + const history = await readFile(join(ref.baseDir, `${encodeURIComponent(ref.threadId)}.history.jsonl`), "utf8"); + expect(history.trim().split("\n")).toHaveLength(1); + expect(JSON.parse(history)).toMatchObject({ objective: "First", status: "complete" }); + + const unfinished = await makeCtx("thread-active-create"); + await tools.get("create_goal")?.execute("c3", { objective: "Active" }, undefined, undefined, unfinished); + await expect( + tools.get("create_goal")?.execute("c4", { objective: "Replacement" }, undefined, undefined, unfinished), + ).rejects.toThrow("unfinished goal"); + }); + + it("spills an oversized objective with a marker-aware stored objective and notice", async () => { + const { tools } = createGoalHarness(); + const ctx = await makeCtx("thread/oversized objective"); + const ref = storeRefFor(ctx); + const objective = "x".repeat(4_200); + + const result = await tools.get("create_goal")?.execute("c1", { objective }, undefined, undefined, ctx); + const goal = await readGoal(ref); + expect(textOf(result)).toContain("Objective was truncated; full objective saved to"); + expect([...String(goal?.objective)].length).toBeLessThanOrEqual(4_000); + expect(goal?.objective).toContain("[truncated; full objective:"); + expect(await readFile(join(ref.baseDir, `${encodeURIComponent(ref.threadId)}.objective-full.txt`), "utf8")).toBe( + objective, + ); + }); + + it("requires a reason to block and suppresses continuation while blocked", async () => { + const { tools, handlers, sent } = createGoalHarness(); + const ctx = await makeCtx(); + await tools.get("create_goal")?.execute("c1", { objective: "Wait for a decision" }, undefined, undefined, ctx); + await expect( + tools.get("update_goal")?.execute("u1", { status: "blocked" }, undefined, undefined, ctx), + ).rejects.toThrow("reason is required"); await expect( - tools.get("create_goal")?.execute("c2", { objective: "Second" }, undefined, undefined, ctx), - ).rejects.toThrow("already has a goal"); + tools + .get("update_goal") + ?.execute("u2", { status: "complete", reason: "not allowed" }, undefined, undefined, ctx), + ).rejects.toThrow("reason must not be provided"); + await tools + .get("update_goal") + ?.execute("u3", { status: "blocked", reason: "Waiting on a user decision" }, undefined, undefined, ctx); + await runHandlers( + handlers, + "agent_end", + { type: "agent_end", messages: [assistantMessageWithStopReason("stop")] }, + ctx, + ); + + expect(await readGoal(storeRefFor(ctx))).toMatchObject({ + status: "blocked", + blockedReason: "Waiting on a user decision", + blockedAt: expect.any(Number), + }); + expect(sent).toHaveLength(0); }); it("queues a hidden continuation prompt after agent_end while a goal is active", async () => { diff --git a/packages/coding-agent/test/suite/goal-modules.test.ts b/packages/coding-agent/test/suite/goal-modules.test.ts index 1190f42a2..a8b29656a 100644 --- a/packages/coding-agent/test/suite/goal-modules.test.ts +++ b/packages/coding-agent/test/suite/goal-modules.test.ts @@ -84,6 +84,13 @@ describe("goal continuation gating", () => { expect(shouldQueueGoalContinuationWhenIdle(active, false, false)).toBe(false); expect(shouldQueueGoalContinuationWhenIdle(active, true, true)).toBe(false); expect(shouldQueueGoalContinuationWhenIdle(makeGoal({ status: "paused" }), true, false)).toBe(false); + expect( + shouldQueueGoalContinuationWhenIdle( + makeGoal({ status: "blocked", blockedReason: "Waiting", blockedAt: 1 }), + true, + false, + ), + ).toBe(false); expect(shouldQueueGoalContinuationWhenIdle(null, true, false)).toBe(false); }); @@ -94,6 +101,13 @@ describe("goal continuation gating", () => { expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "complete" }), false, cleanMessages)).toBe( false, ); + expect( + shouldQueueGoalContinuationAfterAgentEnd( + makeGoal({ status: "blocked", blockedReason: "Waiting", blockedAt: 1 }), + false, + cleanMessages, + ), + ).toBe(false); expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false, [])).toBe(false); expect( shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false, [ @@ -164,6 +178,9 @@ describe("goal status UI", () => { expect(goalStatusText(makeGoal({ status: "active", timeUsedSeconds: 0 }))).toBe("Pursuing goal"); expect(goalStatusText(makeGoal({ status: "active", timeUsedSeconds: 65 }))).toBe("Pursuing goal (1m)"); expect(goalStatusText(makeGoal({ status: "paused" }))).toBe("Goal paused (/goal resume)"); + expect(goalStatusText(makeGoal({ status: "blocked", blockedReason: "Waiting for review", blockedAt: 1 }))).toBe( + "Goal blocked: Waiting for review", + ); expect(goalStatusText(makeGoal({ status: "complete" }))).toBe("Goal achieved"); }); diff --git a/packages/coding-agent/test/suite/goal-store.test.ts b/packages/coding-agent/test/suite/goal-store.test.ts index aaa9d888f..ea93ce211 100644 --- a/packages/coding-agent/test/suite/goal-store.test.ts +++ b/packages/coding-agent/test/suite/goal-store.test.ts @@ -233,12 +233,12 @@ describe("goal store (budget-free)", () => { const first = await createGoal(ref, "Original"); await accountGoalUsage(ref, { input: 23, output: 2, cacheRead: 0, cacheWrite: 4, totalTokens: 25 }, 70); - const paused = await updateGoal(ref, { status: "paused" }); + const paused = await updateGoal(ref, { status: "paused" }, "user"); expect(paused.id).toBe(first.id); expect(paused.tokensUsed).toBe(25); expect(paused.timeUsedSeconds).toBe(70); - const replaced = await updateGoal(ref, { objective: "Replacement" }); + const replaced = await updateGoal(ref, { objective: "Replacement" }, "user"); expect(replaced.id).not.toBe(first.id); expect(replaced.tokensUsed).toBe(0); expect(replaced.timeUsedSeconds).toBe(0); @@ -248,9 +248,9 @@ describe("goal store (budget-free)", () => { it("resumes a matching nonterminal goal when the objective is set again", async () => { const ref = await tempStore(); const first = await createGoal(ref, "Same"); - const paused = await updateGoal(ref, { status: "paused" }); + const paused = await updateGoal(ref, { status: "paused" }, "user"); - const resumed = await updateGoal(ref, { objective: "Same" }); + const resumed = await updateGoal(ref, { objective: "Same" }, "user"); expect(paused.id).toBe(first.id); expect(resumed.id).toBe(first.id); @@ -288,7 +288,7 @@ describe("goal store (budget-free)", () => { it("only accounts active usage unless the completing turn is finalized", async () => { const ref = await tempStore(); await createGoal(ref, "Tracked"); - await updateGoal(ref, { status: "paused" }); + await updateGoal(ref, { status: "paused" }, "user"); const activeOnly = await accountGoalUsage( ref, diff --git a/packages/coding-agent/test/suite/goal-turn-usage.test.ts b/packages/coding-agent/test/suite/goal-turn-usage.test.ts index c144af21e..e2b1c00be 100644 --- a/packages/coding-agent/test/suite/goal-turn-usage.test.ts +++ b/packages/coding-agent/test/suite/goal-turn-usage.test.ts @@ -126,6 +126,26 @@ describe("goal mid-turn token usage accounting", () => { expect(tokensUsedOf(completed)).toBe(150); }); + it("update_goal blocked mid-turn reports tokens accumulated from streamed assistant messages", async () => { + const harness = createGoalHarness(); + const ctx = await makeCtx(); + await harness.tools + .get("create_goal") + ?.execute("c1", { objective: "Wait for a decision" }, undefined, undefined, ctx); + await runHandlers(harness.handlers, "agent_start", { type: "agent_start" }, ctx); + await streamAssistantMessage(harness, ctx, assistantMessage(100, 50)); + + const blocked = await harness.tools + .get("update_goal") + ?.execute("u1", { status: "blocked", reason: "Waiting on a decision" }, undefined, undefined, ctx); + + expect(tokensUsedOf(blocked)).toBe(150); + expect(await readGoal(storeRefFor(ctx))).toMatchObject({ + status: "blocked", + blockedReason: "Waiting on a decision", + }); + }); + it("get_goal mid-turn reports tokens accumulated from streamed assistant messages", async () => { const harness = createGoalHarness(); const ctx = await makeCtx();