diff --git a/.agents/skills/senpi-qa/scripts/lib/mock-loop-ttsr.mjs b/.agents/skills/senpi-qa/scripts/lib/mock-loop-ttsr.mjs index 959b56c3d..c0c250c4b 100644 --- a/.agents/skills/senpi-qa/scripts/lib/mock-loop-ttsr.mjs +++ b/.agents/skills/senpi-qa/scripts/lib/mock-loop-ttsr.mjs @@ -1,4 +1,4 @@ -import { writeFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { createChecks, evidenceDir, guardRealAuth, installCleanupHooks } from "./common.mjs"; @@ -20,6 +20,7 @@ function writeTtsrEvidence(slug, scenarioName, result, server) { writeFileSync(join(dir, `${scenarioName}-stdout.txt`), `${result.stdout}\n${result.stderr}`); writeFileSync(join(dir, `${scenarioName}-requests.json`), JSON.stringify(server.requests, null, 2)); process.stderr.write(`evidence: ${dir}\n`); + return dir; } const REPEATED_STATUS_TURNS = [ @@ -28,28 +29,91 @@ const REPEATED_STATUS_TURNS = [ "I read this as continue supervising the portable matrix; it has started cleanly with 3 checks green and 6 gates pending.", ]; +function writeGoalMonitorFixture(box) { + const eventLogPath = join(box.dir, "goal-monitor-events.jsonl"); + const extensionPath = join(box.dir, "goal-monitor-extension.mjs"); + const source = ` +import { appendFileSync } from "node:fs"; + +const eventLogPath = ${JSON.stringify(eventLogPath)}; +const record = (event) => appendFileSync(eventLogPath, JSON.stringify(event) + "\\n"); + +export default function(pi) { + pi.on("session_start", () => { + pi.events?.emit("terminal_monitor_state", { activeCount: 1 }); + record({ type: "monitor_state", activeCount: 1 }); + }); + pi.on("agent_end", (event) => { + record({ type: "agent_end", aborted: event.aborted, abortSource: event.abortSource }); + }); + pi.on("tool_result", (event) => { + if (event.toolName === "create_goal") record({ type: "goal_created" }); + }); + pi.events?.on("goal_continuation_scheduled", (data) => { + record({ type: "goal_continuation_scheduled", data }); + }); +} +`; + writeFileSync(extensionPath, source); + return { extraArgs: ["--extension", extensionPath], eventLogPath }; +} + +function readGoalState(box) { + const goalDir = join(box.sessionDir, "extensions", "goal"); + if (!existsSync(goalDir)) return undefined; + const goalFile = readdirSync(goalDir) + .filter((name) => name.endsWith(".json")) + .map((name) => join(goalDir, name)) + .at(0); + return goalFile === undefined ? undefined : JSON.parse(readFileSync(goalFile, "utf8")); +} + +function readFixtureEvents(path) { + if (!existsSync(path)) return []; + return readFileSync(path, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + async function runRepetitiveTurnsScenario({ apiName, driveTurn, evidenceSlug, checks, guard, finalMarker, scenarioName }) { - const { box, server, result } = await driveTurn({ + const { box, server, result, prepared } = await driveTurn({ apiName, turns: [ { text: REPEATED_STATUS_TURNS[0] }, + { toolCalls: [{ name: "create_goal", args: { objective: "Keep the live monitor wait active" } }] }, { text: REPEATED_STATUS_TURNS[1] }, - { text: REPEATED_STATUS_TURNS[2] }, { text: finalMarker }, ], prompt: `Report status repeatedly and finish with ${finalMarker}.`, extraArgs: ["--approve"], - followUpPrompts: ["continue", "continue"], + followUpPrompts: ["Create a Goal and continue monitoring"], + prepareSandbox: writeGoalMonitorFixture, timeoutMs: 180000, }); try { const output = `${result.stdout}\n${result.stderr}`; const allBodies = JSON.stringify(server.requests.map((r) => r.body ?? r.raw ?? "")); + const goalState = readGoalState(box); + const fixtureEvents = readFixtureEvents(prepared.eventLogPath); + const goalCreatedIndex = fixtureEvents.findIndex((event) => event.type === "goal_created"); + const systemAbortIndex = fixtureEvents.findIndex( + (event) => event.type === "agent_end" && event.aborted === true && event.abortSource === "system", + ); + const recoveryIndex = fixtureEvents.findIndex( + (event, index) => index > systemAbortIndex && event.type === "agent_end" && event.abortSource === undefined, + ); + const monitorScheduleIndex = fixtureEvents.findIndex( + (event, index) => + index > goalCreatedIndex && + event.type === "goal_continuation_scheduled" && + event.data?.activeMonitorCount === 1, + ); checks.ok(`${scenarioName}: CLI exits zero`, result.code === 0 && !result.timedOut, `code=${result.code}`); checks.ok( `${scenarioName}: cross-turn repetition triggered an extra bounded turn`, - server.requests.length > 2, + server.requests.length === 4, `requests=${server.requests.length}`, ); checks.ok( @@ -58,8 +122,34 @@ async function runRepetitiveTurnsScenario({ apiName, driveTurn, evidenceSlug, ch `interruptPresent=${allBodies.includes("repetitive-turns")}`, ); checks.ok(`${scenarioName}: recovery answer returned`, output.includes(finalMarker), `marker=${finalMarker}`); + const hiddenRuntimeError = /Agent is already processing|Extension error \([^)]*\): This extension ctx is stale/.test(output); + checks.ok( + `${scenarioName}: no hidden runtime or stale-context errors`, + !hiddenRuntimeError, + `hiddenRuntimeError=${hiddenRuntimeError}`, + ); + checks.ok( + `${scenarioName}: final persisted Goal remains active`, + goalState?.goal?.status === "active", + `status=${goalState?.goal?.status ?? "missing"}`, + ); + checks.ok( + `${scenarioName}: active Goal exists before the TTSR system abort`, + goalCreatedIndex >= 0 && systemAbortIndex > goalCreatedIndex, + `goalCreatedIndex=${goalCreatedIndex} systemAbortIndex=${systemAbortIndex}`, + ); + checks.ok( + `${scenarioName}: TTSR system abort is followed by recovery with monitor wait live`, + recoveryIndex > systemAbortIndex && + monitorScheduleIndex > goalCreatedIndex && + monitorScheduleIndex < recoveryIndex, + `systemAbortIndex=${systemAbortIndex} recoveryIndex=${recoveryIndex} monitorScheduleIndex=${monitorScheduleIndex}`, + ); guard.assertUnchanged(); - if (evidenceSlug) writeTtsrEvidence(evidenceSlug, scenarioName, result, server); + if (evidenceSlug) { + const dir = writeTtsrEvidence(evidenceSlug, scenarioName, result, server); + writeFileSync(join(dir, `${scenarioName}-state.json`), JSON.stringify({ goalState, fixtureEvents }, null, 2)); + } } finally { await server.stop(); box.cleanup(); diff --git a/.agents/skills/senpi-qa/scripts/mock-loop.mjs b/.agents/skills/senpi-qa/scripts/mock-loop.mjs index b4d77e377..758d9df48 100644 --- a/.agents/skills/senpi-qa/scripts/mock-loop.mjs +++ b/.agents/skills/senpi-qa/scripts/mock-loop.mjs @@ -149,7 +149,7 @@ async function driveTurn({ followArgs.push(followUp); const next = await runCli(followArgs, { env: hermeticEnv(box.env), cwd: box.cwd, timeoutMs }); combined = { - code: next.code, + code: combined.code === 0 ? next.code : combined.code, stdout: `${combined.stdout}\n${next.stdout}`, stderr: `${combined.stderr}\n${next.stderr}`, timedOut: combined.timedOut || next.timedOut, diff --git a/packages/coding-agent/AGENTS.md b/packages/coding-agent/AGENTS.md index d8042ff0e..de08046bb 100644 --- a/packages/coding-agent/AGENTS.md +++ b/packages/coding-agent/AGENTS.md @@ -8,6 +8,8 @@ src/cli.ts, cli-main.ts, main.ts Bootstrap, args, mode dispatch src/package-manager-cli.ts install/update/config subcommands (incl. `senpi update --models`) src/core/agent-session.ts Session lifecycle and runtime +src/core/agent-abort-provenance.ts Abort ownership across retries and event dispatch +src/core/agent-settled-delivery.ts Cancellable extension messages after settlement src/core/dynamic-prompt/ Dynamic system-prompt assembly + workstation facts src/core/model-runtime.ts Model runtime bootstrap src/core/model-config.ts Per-model config resolution diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4af00ecde..c0c0bb306 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,6 +12,16 @@ ### Fixed +- Fixed system-owned TTSR interruptions being recorded as user interruptions, which could block an active Goal even + while a background child or monitor could still resume the run. System-owned provider-error shells and consecutive + remediation generations now retain system provenance, while a late Escape cancels corrective and provider-retry + work through `agent_settled` even when it arrives after TTSR or extension dispatch. Settlement-triggered messages + are held until every handler completes and discarded on cancellation, with Goal's single-flight admission released + so `/goal resume` can recover normally. A terminal system error with no retry or monitor now launches a guarded + Goal-owned recovery after settlement instead of leaving an active Goal idle, and any guard that blocks that + recovery immediately updates accounting and the visible Goal status. Stream-rule + and Goal cache-warm status now render through one durable notice owner instead of duplicate transient and persisted UI messages + ([#733](https://github.com/code-yeongyu/senpi/pull/733)). - Fixed required compaction fatally ending a turn once the per-turn soft cap (3 accepted or ineffective compactions) was reached. Compaction admission is now bounded only by the absolute session cap (10) and the failure circuit breaker, so long turns that legitimately need more than three compactions keep running diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index b45d8ed53..0dfc866bc 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,3 +1,47 @@ +## Joined user aborts override system provenance (2026-08-05) + +### What changed + +- `AgentSession` now promotes an in-flight system-owned abort to user-owned when + an explicit user abort joins the same operation. +- Joining an existing abort awaits the shared promise without issuing a second + `agent.abort()` call, and a later system abort cannot downgrade user provenance. +- A later recovery generation with no active provenance issues its own + `agent.abort()` and records a fresh source instead of incorrectly joining the + prior generation's completed abort. +- User intent that arrives while `agent_end` handlers are dispatching promotes + the shared event in place. A late join that occurs after an earlier handler + already observed system provenance emits one `session_abort` before + `agent_settled`, so TTSR corrective follow-ups and provider retries admitted + before dispatch cannot outrun the user cancellation. +- The same cancellation boundary remains open through the public `agent_end` + notification, covering Escape handlers that run after extension dispatch but + before retry and settlement processing. +- The boundary now remains mutable through `agent_settled` dispatch as well. + Extension messages requested from that event are held by + `agent-settled-delivery.ts` until every handler and public listener completes; + a user abort drops the held actions before one can become a corrective + provider turn, without disturbing user-owned steering or follow-up queues. +- System-owned aborts no longer set the user-only queued-continuation suppression + latch; a user join still sets it before awaiting the shared abort. + +### Why + +- TTSR can begin a corrective system abort immediately before the user presses + Escape. The old early-return path kept `"system"` provenance and invoked the + underlying abort twice, so Goal could ignore the user's durable stop intent. + +### Why this cannot be expressed externally + +- Abort provenance, shared-promise ownership, and queued-continuation suppression + are private `AgentSession` lifecycle state. + +### Expected merge conflict zones + +- `core/agent-abort-provenance.ts`, `core/agent-settled-delivery.ts`, and + `core/agent-session.ts` around `_emitExtensionEvent`, `_emitAgentSettled`, + `abort`, and `_abortActiveAgentAndRetry`. + ## Required-recovery admission supersession and bounded fallback sizing (2026-08-03) ### What changed diff --git a/packages/coding-agent/src/core/agent-abort-provenance.ts b/packages/coding-agent/src/core/agent-abort-provenance.ts new file mode 100644 index 000000000..cfa73981f --- /dev/null +++ b/packages/coding-agent/src/core/agent-abort-provenance.ts @@ -0,0 +1,96 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentEndEvent } from "./extensions/types.ts"; + +type AbortSource = NonNullable; + +export type JoinedAbort = { + readonly abortCurrentAgent: boolean; + readonly userOwned: boolean; +}; + +export class AgentAbortProvenance { + #source: AbortSource | undefined; + #agentEndEvent: AgentEndEvent | undefined; + #settlingAgentEndEvent: AgentEndEvent | undefined; + #agentEndBoundaryOpen = false; + #lateUserJoin = false; + #lateUserJoinDelivered = false; + + get hasOpenAgentEndBoundary(): boolean { + return this.#agentEndBoundaryOpen || this.#agentEndEvent !== undefined; + } + + begin(source: AbortSource): boolean { + this.#source = source; + this.#settlingAgentEndEvent = undefined; + this.#agentEndBoundaryOpen = false; + this.#lateUserJoin = false; + this.#lateUserJoinDelivered = false; + return source === "user"; + } + + join(source: AbortSource, isStreaming: boolean): JoinedAbort { + if (source === "user" && (this.#agentEndEvent !== undefined || this.#agentEndBoundaryOpen)) { + this.#source = "user"; + if (!this.#lateUserJoinDelivered) this.#lateUserJoin = true; + const event = this.#agentEndEvent ?? this.#settlingAgentEndEvent; + if (event !== undefined) { + event.aborted = true; + event.abortSource = "user"; + } + return { abortCurrentAgent: false, userOwned: true }; + } + if (source === "user" && this.#source !== undefined) { + this.#source = "user"; + return { abortCurrentAgent: false, userOwned: true }; + } + if (this.#source === undefined) { + if (!isStreaming) return { abortCurrentAgent: false, userOwned: false }; + this.#source = source; + return { abortCurrentAgent: true, userOwned: source === "user" }; + } + return { abortCurrentAgent: false, userOwned: false }; + } + + beginAgentEnd(messages: AgentMessage[], willRetry: boolean, abortedWithoutSource: boolean): AgentEndEvent { + const event: AgentEndEvent = { + type: "agent_end", + messages, + willRetry, + ...(this.#source !== undefined || abortedWithoutSource ? { aborted: true } : {}), + ...(this.#source === undefined ? {} : { abortSource: this.#source }), + }; + this.#agentEndEvent = event; + this.#settlingAgentEndEvent = undefined; + this.#agentEndBoundaryOpen = false; + this.#lateUserJoin = false; + this.#lateUserJoinDelivered = false; + return event; + } + + endAgentEnd(event: AgentEndEvent): void { + if (this.#agentEndEvent === event) { + this.#agentEndEvent = undefined; + this.#settlingAgentEndEvent = event; + this.#agentEndBoundaryOpen = true; + } + this.#source = undefined; + } + + takeLateUserJoin(): boolean { + const lateUserJoin = this.#lateUserJoin; + this.#lateUserJoin = false; + if (lateUserJoin) this.#lateUserJoinDelivered = true; + return lateUserJoin; + } + + closeAgentEndBoundary(): void { + this.#agentEndBoundaryOpen = false; + this.#settlingAgentEndEvent = undefined; + } + + joinOpenBoundary(source: AbortSource): JoinedAbort | undefined { + if (!this.#agentEndBoundaryOpen && this.#agentEndEvent === undefined) return undefined; + return this.join(source, false); + } +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 1b0f1a65a..7fa4059f0 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -61,6 +61,8 @@ import { getThemeByName, theme } from "../modes/interactive/theme/theme.ts"; import { stripFrontmatter } from "../utils/frontmatter.ts"; import { resolvePath } from "../utils/paths.ts"; import { sleep } from "../utils/sleep.ts"; +import { AgentAbortProvenance } from "./agent-abort-provenance.ts"; +import { AgentSettledDelivery, type DeferredAgentSettledAction } from "./agent-settled-delivery.ts"; import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.ts"; import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { @@ -644,8 +646,10 @@ 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 readonly _abortProvenance = new AgentAbortProvenance(); + private readonly _agentSettledDelivery = new AgentSettledDelivery(); private _suppressQueuedContinuationAfterUserAbort = false; + private _userAbortGeneration = 0; /** Set when clearQueue({ abortWillFollow: true }) drains queues immediately before abort(). */ private _hadClearedQueuedMessages = false; private _extensionEventSignal: AbortSignal | undefined = undefined; @@ -1247,16 +1251,24 @@ export class AgentSession { await this.agent.waitForIdle(); } if (!this._isAgentRunActive) { + this._abortProvenance.closeAgentEndBoundary(); this._resolveIdleWaitIfIdle(); return; } this._isAgentRunActive = false; + let deferredActions: DeferredAgentSettledAction[] = []; + this._agentSettledDelivery.begin(this._userAbortGeneration); try { await this._extensionRunner.emit({ type: "agent_settled" }); this._emit({ type: "agent_settled" }); + if (this._abortProvenance.takeLateUserJoin()) await this._emitSessionAbort(); + deferredActions = this._agentSettledDelivery.finish(this._userAbortGeneration); } finally { + this._agentSettledDelivery.cancel(); + this._abortProvenance.closeAgentEndBoundary(); this._resolveIdleWaitIfIdle(); } + for (const action of deferredActions) action(); } private async _promptAgent(messages: AgentMessage | AgentMessage[]): Promise { @@ -1634,9 +1646,13 @@ export class AgentSession { } finally { this._extensionEventSignal = undefined; } + if (event.type === "agent_end" && this._abortProvenance.takeLateUserJoin()) await this._emitSessionAbort(); // Notify all listeners this._emit(event.type === "agent_end" ? { ...event, willRetry: agentEndWillRetry } : event); + if (event.type === "agent_end") { + if (this._abortProvenance.takeLateUserJoin()) await this._emitSessionAbort(); + } // Handle session persistence if (event.type === "message_end") { @@ -1722,7 +1738,9 @@ export class AgentSession { const retryableError = this._isRetryableError(msg); const hardErrorFallbackEligible = this._isHardErrorFallbackEligible(msg); const retryCanAdmitProvider = - this.settingsManager.getRetrySettings().enabled && (retryableError || hardErrorFallbackEligible); + !userAbortSuppressedQueuedContinuation && + this.settingsManager.getRetrySettings().enabled && + (retryableError || hardErrorFallbackEligible); let compactedBeforeRetry = false; if ( retryCanAdmitProvider && @@ -1735,18 +1753,21 @@ export class AgentSession { } let retryOutcome: "continued" | "blocked" | "not-handled" = "not-handled"; - if (!retryContinuationBlocked) { + if (!retryContinuationBlocked && !userAbortSuppressedQueuedContinuation) { if (retryableError) { retryOutcome = await this._handleRetryableError(msg); } else if (hardErrorFallbackEligible) { retryOutcome = await this._handleRetryableError(msg, { hardErrorFallback: true }); } } - if (retryOutcome === "continued") return; + if (retryOutcome === "continued") { + this._abortProvenance.closeAgentEndBoundary(); + return; + } this._resolveRetry(); retryContinuationBlocked ||= retryOutcome === "blocked"; - if (!retryContinuationBlocked) { + if (!retryContinuationBlocked && !userAbortSuppressedQueuedContinuation) { if (compactedBeforeRetry && this.agent.hasQueuedMessages()) { // Accepted recovery supersedes the stored admission rejection: the // queued continuation is about to run, so the originating prompt must @@ -1796,6 +1817,8 @@ export class AgentSession { } if (!launchedContinuation) { await this._emitAgentSettled(); + } else { + this._abortProvenance.closeAgentEndBoundary(); } } } @@ -1815,6 +1838,11 @@ export class AgentSession { this._cumulativeHintedWaitMs = 0; } + private async _emitSessionAbort(): Promise { + await this._extensionRunner.emit({ type: "session_abort" }); + this._emit({ type: "session_abort" }); + } + /** * Arm the probe-back scheduler for a tier-2 demoted selector. The scheduler * will fire at most two probes (half-hint, then deadline) and clear the @@ -1959,19 +1987,15 @@ export class AgentSession { this._turnIndex = 0; await this._extensionRunner.emit({ type: "agent_start" }); } else if (event.type === "agent_end") { - const abortSource = this._agentAbortSource; - const aborted = - abortSource !== undefined || this._findLastAssistantInMessages(event.messages)?.stopReason === "aborted"; + const extensionEvent = this._abortProvenance.beginAgentEnd( + event.messages, + agentEndWillRetry, + this._findLastAssistantInMessages(event.messages)?.stopReason === "aborted", + ); try { - await this._extensionRunner.emit({ - type: "agent_end", - messages: event.messages, - willRetry: agentEndWillRetry, - ...(aborted ? { aborted: true } : {}), - ...(abortSource === undefined ? {} : { abortSource }), - }); + await this._extensionRunner.emit(extensionEvent); } finally { - this._agentAbortSource = undefined; + this._abortProvenance.endAgentEnd(extensionEvent); } } else if (event.type === "turn_start") { const extensionEvent: TurnStartEvent = { @@ -3139,6 +3163,7 @@ export class AgentSession { message: Pick, "customType" | "content" | "display" | "details">, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, ): Promise { + const userAbortGeneration = this._userAbortGeneration; const appMessage = { role: "custom" as const, customType: message.customType, @@ -3160,6 +3185,7 @@ export class AgentSession { try { if (waitForExistingSessionWork) { await this._waitForSettledSessionWork(); + if (userAbortGeneration !== this._userAbortGeneration) return; finishSessionWork = this._sessionWorkBarrier.begin(); } @@ -3367,30 +3393,20 @@ export class AgentSession { * Abort current operation and wait for agent to become idle. */ async abort(): Promise { - // Capture gap-state BEFORE _abortActiveAgentAndRetry resets retry/compaction state. - // A mid-run abort (actively streaming with no pending retry) is delivered via - // agent_end (abortSource "user") — no session_abort needed. The gap case is when - // no agent_end will fire to carry the abort signal: - // - retry backoff (_retryAbortController defined — the error agent_end already - // fired, agent.abort() during backoff is a no-op, no new agent_end) - // - compaction (!isStreaming && isCompacting) - // - queued continuation that was already cleared by the caller (TUI clears queues - // before calling abort, so pendingMessageCount is 0 but _hadClearedQueuedMessages - // records that messages were present) - // Purely-idle defensive aborts (e.g. RPC session close on an idle session) must not - // fire session_abort. + // Streaming aborts are carried by agent_end provenance; only gaps need session_abort. const wasMidRun = this.isStreaming && this._retryAbortController === undefined; const hadRetryBackoff = this._retryAbortController !== undefined; const hadCompactionOrPending = !this.isStreaming && (this.isCompacting || this.pendingMessageCount > 0); const hadClearedQueues = this._hadClearedQueuedMessages; + const joinedAgentEndBoundary = this._abortProvenance.hasOpenAgentEndBoundary; this._hadClearedQueuedMessages = false; - const shouldEmitAbort = !wasMidRun && (hadRetryBackoff || hadCompactionOrPending || hadClearedQueues); + const shouldEmitAbort = + !joinedAgentEndBoundary && !wasMidRun && (hadRetryBackoff || hadCompactionOrPending || hadClearedQueues); this.abortCompaction(); await this._abortActiveAgentAndRetry("user"); if (!shouldEmitAbort) return; try { - await this._extensionRunner.emit({ type: "session_abort" }); - this._emit({ type: "session_abort" }); + await this._emitSessionAbort(); } catch { // Extension runner may be torn down during RPC close — best-effort. } @@ -3852,18 +3868,29 @@ export class AgentSession { admission.finishSessionWork(); } + private _recordUserAbort(): void { + this._suppressQueuedContinuationAfterUserAbort = true; + this._userAbortGeneration += 1; + } + private async _abortActiveAgentAndRetry(source: "user" | "system"): Promise { this.abortRetry(); this.abortBranchSummary(); + if (this._userAbortPromise === undefined) { + const boundaryJoin = this._abortProvenance.joinOpenBoundary(source); + if (boundaryJoin !== undefined) { + if (boundaryJoin.userOwned) this._recordUserAbort(); + return; + } + } if (this._userAbortPromise) { - this.agent.abort(); + const joined = this._abortProvenance.join(source, this.isStreaming); + if (joined.userOwned) this._recordUserAbort(); + if (joined.abortCurrentAgent) this.agent.abort(); await this._userAbortPromise; return; } - if (this.isStreaming) { - this._suppressQueuedContinuationAfterUserAbort = true; - this._agentAbortSource = source; - } + if (this.isStreaming && this._abortProvenance.begin(source)) this._recordUserAbort(); const abortPromise = (async () => { this.agent.abort(); @@ -5186,13 +5213,16 @@ export class AgentSession { runner.bindCore( { sendMessage: (message, options) => { - this.sendCustomMessage(message, options).catch((err) => { - runner.emitError({ - extensionPath: RUNTIME_EXTENSION_PATH, - event: "send_message", - error: err instanceof Error ? err.message : String(err), + const send = () => + this.sendCustomMessage(message, options).catch((err) => { + runner.emitError({ + extensionPath: RUNTIME_EXTENSION_PATH, + event: "send_message", + error: err instanceof Error ? err.message : String(err), + }); }); - }); + if (this._agentSettledDelivery.defer(send)) return; + send(); }, sendUserMessage: (content, options) => { this.sendUserMessage(content, options).catch((err) => { @@ -5254,11 +5284,9 @@ export class AgentSession { getAgentDir: () => this._agentDir, isProjectTrusted: () => this.settingsManager.isProjectTrusted(), getSignal: () => this._extensionEventSignal ?? this.agent.signal, - abort: () => { - if (this._extensionAbortHandler) { - this._extensionAbortHandler(); - return; - } + abort: (source = "user") => { + if (source === "system") return void this._abortActiveAgentAndRetry("system"); + if (this._extensionAbortHandler) return this._extensionAbortHandler(); void this.abort(); }, hasPendingMessages: () => this.pendingMessageCount > 0, diff --git a/packages/coding-agent/src/core/agent-settled-delivery.ts b/packages/coding-agent/src/core/agent-settled-delivery.ts new file mode 100644 index 000000000..94e104a3c --- /dev/null +++ b/packages/coding-agent/src/core/agent-settled-delivery.ts @@ -0,0 +1,29 @@ +export type DeferredAgentSettledAction = () => void; + +export class AgentSettledDelivery { + #generation: number | undefined; + #actions: DeferredAgentSettledAction[] = []; + + begin(userAbortGeneration: number): void { + this.#generation = userAbortGeneration; + this.#actions = []; + } + + defer(action: DeferredAgentSettledAction): boolean { + if (this.#generation === undefined) return false; + this.#actions.push(action); + return true; + } + + finish(userAbortGeneration: number): DeferredAgentSettledAction[] { + const actions = this.#generation === userAbortGeneration ? this.#actions : []; + this.#generation = undefined; + this.#actions = []; + return actions; + } + + cancel(): void { + this.#generation = undefined; + this.#actions = []; + } +} diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/AGENTS.md b/packages/coding-agent/src/core/extensions/builtin/goal/AGENTS.md index eeb37ec31..3b550ca48 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/AGENTS.md +++ b/packages/coding-agent/src/core/extensions/builtin/goal/AGENTS.md @@ -12,19 +12,23 @@ hidden continuation prompts. ``` goal/ ├── index.ts # Extension entry — tools + /goal command + session/agent lifecycle + usage accounting +├── agent-end-continuation.ts # Agent-end routing into Goal continuation ownership ├── store.ts # File persistence: read/write/create/update/clear/accountGoalUsage ├── types.ts # Goal (+ inert tokenBudget compatibility metadata), GoalStatus, GoalFile, refs, snapshots ├── validation.ts # validateObjective (trim + max length) ├── continuation.ts # shouldQueueGoalContinuation* gating predicates +├── monitor-continuation-types.ts # Monitor scheduler lifecycle contracts +├── last-assistant-message.ts # Shared last-assistant lookup for terminal classification ├── prompt.ts # buildContinuationPrompt (untrusted-objective + completion audit) ├── format.ts # Tool/UI formatting + goalToolResponse snapshot ├── command.ts # parseGoalCommand (show|pause|resume|clear|setObjective) ├── ui.ts # ctx.ui.setStatus footer segment for the active goal -├── cache-warm.ts # Cache-warm metrics estimator + scheduled/resumed notices + goal-cache-warmup entry contract -├── cache-warm-renderer.ts # TUI entry renderer for goal-cache-warmup custom entries +├── cache-warm.ts # Cache-warm metrics/formatting + goal-cache-warmup entry contract +├── cache-warm-renderer.ts # Scheduled/resumed TUI renderer for goal-cache-warmup entries ├── elapsed-ticker.ts # GoalElapsedTicker + goalLiveElapsedSeconds (live footer refresh) ├── wait-progress.ts # Pure continuation-wait progress bar + label formatting ├── wait-ticker.ts # GoalWaitTicker (live footer countdown lifecycle) +├── terminal-provider-error.ts # Terminal provider-failure classification ├── errors.ts # Goal{AlreadyExists,NotFound}/store error classes └── changes.md # Fork tracker (port + budget behavior removal + wire compatibility) ``` @@ -49,12 +53,20 @@ recovery bullets otherwise. Accepted direct input disarms a pending continuation and a clean accepted user turn arms a visible 10-second grace countdown before the Goal resumes; mechanically blocked Goals are reactivated on accepted input, including admitted steering. A `length` stop gets exactly one minimal truncation recovery before the goal -blocks on repetition, terminal provider errors block the goal only when `AgentEndEvent.willRetry` -is false and count as mechanical (a new user message resumes the goal, and the blocked notice -says so), while intentional blocks — a user interrupt or a model-declared `update_goal` block — -stay non-recoverable. Resumed sessions with 8+ trailing historical continuation entries suppress -session-start auto-resume. `tokenBudget` remains inert compatibility metadata only; this -policy is budget-free by design. +blocks on repetition. Terminal provider errors block the goal only when +`AgentEndEvent.willRetry` is false and the abort is not explicitly system-owned; those +blocks count as mechanical, so a new user message resumes the goal and the blocked notice +says so. A terminal system error instead preserves the active Goal: it schedules the live +monitor wait when one exists, or queues a guarded hidden `systemRecovery` continuation +after `agent_settled` when no monitor or retry can resume the run. Staging recovery until +settlement makes an error-compatible idle turn while preserving late user cancellation; +canceling the staged delivery also releases the single-flight latch so `/goal resume` +can start fresh recovery. +Intentional blocks — a user interrupt or a model-declared `update_goal` block — stay +non-recoverable. Resumed sessions with 8+ +trailing historical continuation entries suppress session-start auto-resume. +`tokenBudget` remains inert compatibility metadata only; this policy is budget-free by +design. ## RESTART RESUME PROMPT diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/agent-end-continuation.ts b/packages/coding-agent/src/core/extensions/builtin/goal/agent-end-continuation.ts new file mode 100644 index 000000000..c03ad8502 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/agent-end-continuation.ts @@ -0,0 +1,25 @@ +import type { AgentEndEvent, ExtensionContext } from "../../types.ts"; +import type { MonitorAwareGoalContinuation } from "./monitor-continuation.ts"; +import type { Goal } from "./types.ts"; + +interface GoalAgentEndOptions { + readonly ctx: ExtensionContext; + readonly event: AgentEndEvent; + readonly goal: Goal | null; +} + +export async function continueGoalAfterAgentEnd( + monitor: MonitorAwareGoalContinuation, + options: GoalAgentEndOptions, +): Promise { + if (options.event.aborted === true && options.event.abortSource === "system") { + return monitor.afterSystemAbort({ + ctx: options.ctx, + event: options.event, + goal: options.goal, + messages: options.event.messages, + willRetry: options.event.willRetry === true, + }); + } + return monitor.afterAgentEnd({ ctx: options.ctx, goal: options.goal, messages: options.event.messages }); +} diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts index 72c080105..62e06ccd8 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts @@ -54,38 +54,6 @@ export function estimateCacheWarmMetrics( }; } -export function buildCacheWarmScheduledNotice( - delayMs: number, - activeMonitorCount: number, - cache: GoalCacheWarmMetrics | undefined, -): string { - const base = `${monitorsOnDuty(activeMonitorCount)} - goal continuation deferred ${formatDeferredDelay(delayMs)}`; - if (cache === undefined || cache.cachedTokens <= 0) { - return `${base} so the monitor can wake us the moment decisive output lands.`; - } - const warmTokens = `~${formatWarmTokenCount(cache.cachedTokens)} tokens`; - if (cache.ttlSeconds === undefined) { - return `${base}. The timed wake keeps ${warmTokens} warm instead of re-paying a cold read.`; - } - return `${base}. The timed wake stays inside the ${formatCacheTtl(cache.ttlSeconds)} prompt-cache TTL, keeping ${warmTokens} warm instead of re-paying a cold read.`; -} - -export function buildCacheWarmResumedNotice( - waitedMs: number, - activeMonitorCount: number, - cache: GoalCacheWarmMetrics | undefined, -): string { - const stillOnDuty = - activeMonitorCount === 1 ? "1 monitor still on duty" : `${activeMonitorCount} monitors still on duty`; - const head = `Cache-warm wake after ${formatWakeDuration(waitedMs)} - ${stillOnDuty}.`; - if (cache === undefined || cache.cachedTokens <= 0) return `${head} Continuing the goal.`; - const savings = - cache.estimatedSavedUsd !== undefined && cache.estimatedSavedUsd > 0 - ? ` (est. ${formatSavedUsd(cache.estimatedSavedUsd)} saved vs a cold re-read)` - : ""; - return `${head} ~${formatWarmTokenCount(cache.cachedTokens)} tokens stayed warm in the prompt cache${savings}. Continuing the goal.`; -} - export function formatWarmTokenCount(tokens: number): string { if (tokens >= 1_000_000) return `${trimTrailingZero((tokens / 1_000_000).toFixed(1))}M`; if (tokens >= 1000) return `${trimTrailingZero((tokens / 1000).toFixed(1))}K`; @@ -115,19 +83,6 @@ export function formatSavedUsd(value: number): string { return `$${value.toFixed(2)}`; } -function monitorsOnDuty(count: number): string { - return count === 1 ? "1 monitor on duty" : `${count} monitors on duty`; -} - -function formatDeferredDelay(delayMs: number): string { - const seconds = Math.round(delayMs / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const restSeconds = seconds % 60; - if (restSeconds === 0) return minutes === 1 ? "1 minute" : `${minutes} minutes`; - return `${minutes}m ${restSeconds}s`; -} - function trimTrailingZero(value: string): string { return value.endsWith(".0") ? value.slice(0, -2) : value; } 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 d52352ca9..a71813844 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/goal/changes.md @@ -1,5 +1,76 @@ # goal Extension Changes +## System-owned aborts stay active through Goal recovery (2026-08-05) + +### What changed + +- Explicit `abortSource: "system"` terminal abort events no longer enter Goal's + retries-exhausted provider-error blocking branch, including TTSR's + provider-error shell with `stopReason: "error"`. +- A system-owned aborted `agent_end` is treated as the start of extension-owned + recovery rather than as a clean user turn: it preserves any existing timer, + avoids arming user grace, and lets the recovery end arm the live monitor wait. + If no automatic retry remains, an active monitor wait is armed immediately so + the Goal still has a live resumption channel. +- If a system-owned provider error has neither an automatic retry nor an active + monitor, Goal stages its hidden `systemRecovery` continuation until + `agent_settled`. This launches recovery from the idle-compatible path instead + of leaving a native follow-up stranded behind the error stop, while a user + abort during `agent_end` or settlement cancels the staged delivery and clears + its single-flight latch, so an explicit `/goal resume` can admit a fresh + continuation. The path bypasses only idle/terminal-stop eligibility and + retains the persisted cap, repetition, pending-message, and single-flight + guards. +- If one of those guards blocks recovery during `agent_settled`, the returned + Goal status now flows through the same accounting and TUI refresh path as an + `agent_end` continuation decision; the footer no longer remains + `Pursuing goal` after persistence has changed the Goal to blocked. +- Provenance-free terminal aborted responses still block as provider failures, + while explicit user aborts retain the dedicated `user interrupted the turn` + block. +- Production-shaped coverage includes `willRetry: false`, an aborted assistant + message, active monitor state, and the combined TTSR recovery continuation. + +### Why + +- TTSR owns a corrective recovery turn after its system abort. Treating that + abort as a provider failure transiently blocked the Goal, disarmed monitor + continuation ownership, and contradicted the internal-interruption contract. +- Restricting the exemption to explicit system provenance preserves existing + protection for provider-originated terminal aborts with no source. + +### Why an extension couldn't do it + +- The classification and resulting Goal status transition are private to this + builtin's `agent_end` handler. + +### Expected merge conflict zones + +- `agent-end-continuation.ts`, `continuation.ts`, and + `monitor-continuation.ts` around system-abort staging and settlement routing. + +## Cache-warm waits are widget-owned (2026-08-05) + +### What changed + +- Monitor-delayed Goal continuations no longer emit transient scheduled/resumed + `ctx.ui.notify` messages. +- The now-dead notice builders and their prose-only tests were removed. +- The durable `goal-cache-warmup` entry remains the single notice box for the + cache-warm story, while the `goal-wait` status ticker remains the live + countdown surface. + +### Why + +- The transient notifications repeated the same scheduled/resumed event already + rendered by the durable entry. One event now has one display owner without + changing the continuation timer, prompt-cache metrics, wake event, or hidden + Goal continuation message. + +### Expected merge conflict zones + +- LOW in `monitor-continuation.ts` around monitor schedule and resume reporting. + ## Cache-warm entry renderer delegates to the shared notice kit (2026-08-04) ### What changed @@ -426,7 +497,7 @@ surface; no core extension API change is required. ### What changed - `monitor-continuation.ts` counts consecutive monitor-wait continuations per goal - (`GOAL_MONITOR_STALL_THRESHOLD = 3`). From the third consecutive delayed continuation + (`GOAL_STALL_TOOLLESS_THRESHOLD = 3`). From the third consecutive delayed continuation fired while monitors stayed active, the hidden continuation prompt is prefixed with a `` block (`buildMonitorStallNotice` in `prompt.ts`) telling the agent the repeated wait looks abnormal and to actively inspect the monitored state diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/continuation.ts b/packages/coding-agent/src/core/extensions/builtin/goal/continuation.ts index 2649bcf49..9dc8ec399 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/continuation.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/continuation.ts @@ -10,7 +10,7 @@ export const GOAL_REPETITION_HASH_STREAK = 3; export const GOAL_LENGTH_RECOVERY_LIMIT = 1; export const GOAL_USER_GRACE_DELAY_MS = 10_000; -export type GoalContinuationPath = "immediate" | "monitorDelayed" | "userGrace" | "sessionStart"; +export type GoalContinuationPath = "immediate" | "monitorDelayed" | "userGrace" | "sessionStart" | "systemRecovery"; export type GoalContinuationInput = { readonly goal: Goal | null; @@ -155,6 +155,7 @@ export function continuationTurnUsedTools(messages: readonly AgentMessage[]): bo function isEligibleForGoalContinuation(input: GoalContinuationInput): boolean { if (input.goal?.status !== "active" || input.hasPendingMessages) return false; + if (input.path === "systemRecovery") return true; if (input.path === "immediate") { return input.lastStopReason !== undefined && isContinuableStopReason(input.lastStopReason); } 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 89c9d778f..a8218458d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/index.ts @@ -1,6 +1,7 @@ import { GOAL_CONTINUATION_MESSAGE_TYPE } from "../../../messages.ts"; import type { SessionEntry } from "../../../session-manager.ts"; -import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "../../types.ts"; +import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; +import { continueGoalAfterAgentEnd } from "./agent-end-continuation.ts"; import { GOAL_CACHE_WARMUP_ENTRY_TYPE } from "./cache-warm.ts"; import { renderGoalCacheWarmupEntry } from "./cache-warm-renderer.ts"; import { registerGoalCommand } from "./command-registration.ts"; @@ -14,6 +15,7 @@ import { MonitorAwareGoalContinuation } from "./monitor-continuation.ts"; import { migrateLegacyGoalFile } from "./persistence.ts"; import { accountGoalUsage, readGoal, updateGoal } from "./store.ts"; import { goalStoreRef as buildGoalStoreRef } from "./store-ref.ts"; +import { didTerminalProviderErrorEndTurn } from "./terminal-provider-error.ts"; import { staleGoalTodoReminder, todoResultAddsOpenTasks } from "./todo-gate.ts"; import { registerGoalTools } from "./tool-registration.ts"; import { TurnUsageTracker } from "./turn-usage.ts"; @@ -211,19 +213,20 @@ export default function goalExtension(pi: ExtensionAPI): void { clearAgentGoalAccounting(); } refreshGoalUiBestEffort(ctx, goal); - const continuationGoal = await monitorContinuation.afterAgentEnd({ ctx, goal, messages: event.messages }); + const continuationGoal = await continueGoalAfterAgentEnd(monitorContinuation, { ctx, event, goal }); if (continuationGoal !== goal) { goal = continuationGoal; - if (goal?.status === "active") { - beginAgentGoalAccounting(goal); - } else { - clearAgentGoalAccounting(); - } - refreshGoalUiBestEffort(ctx, goal); + syncContinuationGoal(ctx, goal); } }); + pi.on("agent_settled", async (_event, ctx) => { + const goal = await monitorContinuation.afterAgentSettled(); + if (goal !== undefined) syncContinuationGoal(ctx, goal); + }); + pi.on("session_abort", async (_event, ctx) => { + continuationPending = false; const goal = await readGoal(goalStoreRef(ctx)); if (goal?.status !== "active") return; const accounted = await accountCurrentAgentTurn(ctx, "active"); @@ -323,6 +326,12 @@ export default function goalExtension(pi: ExtensionAPI): void { completedThisTurnGoalId = null; } + function syncContinuationGoal(ctx: ExtensionContext, goal: Goal | null): void { + if (goal?.status === "active") beginAgentGoalAccounting(goal); + else clearAgentGoalAccounting(); + refreshGoalUiBestEffort(ctx, goal); + } + function refreshGoalUi(ctx: ExtensionContext, goal: Goal | null): void { monitorContinuation.syncGoal(goal); const accounting = agentGoalAccounting; @@ -384,16 +393,6 @@ function countTrailingGoalContinuationEntries(entries: readonly SessionEntry[]): return count; } -function didTerminalProviderErrorEndTurn(event: AgentEndEvent): boolean { - if (event.willRetry !== false) return false; - for (let index = event.messages.length - 1; index >= 0; index--) { - const message = event.messages[index]; - if (message?.role !== "assistant") continue; - return message.stopReason === "error" || (message.stopReason === "aborted" && event.abortSource !== "user"); - } - return false; -} - function goalStoreRef(ctx: ExtensionContext): GoalStoreRef { return buildGoalStoreRef(ctx.sessionManager, ctx.cwd); } diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/last-assistant-message.ts b/packages/coding-agent/src/core/extensions/builtin/goal/last-assistant-message.ts new file mode 100644 index 000000000..c2455e2c8 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/last-assistant-message.ts @@ -0,0 +1,11 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; + +export function lastAssistantMessage( + messages: readonly AgentMessage[], +): Extract | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") return message; + } + return undefined; +} diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation-types.ts b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation-types.ts new file mode 100644 index 000000000..35596d29a --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation-types.ts @@ -0,0 +1,24 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import type { AgentEndEvent, ExtensionContext } from "../../types.ts"; +import type { GoalContinuationVerdict } from "./continuation.ts"; +import type { Goal } from "./types.ts"; +import type { GoalWaitKind } from "./wait-progress.ts"; + +export interface AgentEndOptions { + readonly ctx: ExtensionContext; + readonly goal: Goal | null; + readonly messages: readonly AgentMessage[]; +} + +export interface SystemAbortOptions extends AgentEndOptions { + readonly event: AgentEndEvent; + readonly willRetry: boolean; +} + +export type ContinuingGoalContinuationVerdict = Extract; +export type DelayedContinuationKind = GoalWaitKind; + +export type GoalContinuationAdmission = { + readonly goal: Goal; + readonly admitted: boolean; +}; diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts index a36acc4de..da1b1b36b 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/monitor-continuation.ts @@ -2,8 +2,6 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; import { isTerminalMonitorStateEvent, TERMINAL_MONITOR_STATE_EVENT } from "../monitor-state-event.ts"; import { - buildCacheWarmResumedNotice, - buildCacheWarmScheduledNotice, estimateCacheWarmMetrics, GOAL_CACHE_WARMUP_ENTRY_TYPE, type GoalCacheWarmMetrics, @@ -12,49 +10,38 @@ import { import { continuationTurnUsedTools, evaluateGoalContinuation, - GOAL_STALL_TOOLLESS_THRESHOLD, GOAL_USER_GRACE_DELAY_MS, type GoalContinuationInput, type GoalContinuationPath, - type GoalContinuationVerdict, hasGoalContinuationProgress, hashAssistantText, normalizeAssistantText, } from "./continuation.ts"; +import { lastAssistantMessage } from "./last-assistant-message.ts"; import { admitAndQueueGoalContinuation, buildCurrentGoalContinuationSignature, lastAssistantText, } from "./lifecycle-helpers.ts"; +import type { + AgentEndOptions, + ContinuingGoalContinuationVerdict, + DelayedContinuationKind, + GoalContinuationAdmission, + SystemAbortOptions, +} from "./monitor-continuation-types.ts"; import { buildContinuationPrompt, buildGoalStallNotice, buildTruncationRecoveryPrompt } from "./prompt.ts"; import { resetContinuationStreak } from "./store.ts"; import { goalStoreRef } from "./store-ref.ts"; import { collectAssistantUsage } from "./turn-usage.ts"; import type { Goal, TokenUsageSnapshot } from "./types.ts"; -import type { GoalWaitKind } from "./wait-progress.ts"; import type { GoalWaitTicker } from "./wait-ticker.ts"; export const GOAL_MONITOR_CONTINUATION_DELAY_MS = 240_000; export const GOAL_CONTINUATION_SCHEDULED_EVENT = "goal_continuation_scheduled"; export const GOAL_CONTINUATION_RESUMED_EVENT = "goal_continuation_resumed"; -export const GOAL_MONITOR_CONTINUATION_NOTICE = "Goal continuation scheduled in 4 minutes while a monitor is active."; -export const GOAL_MONITOR_STALL_THRESHOLD = GOAL_STALL_TOOLLESS_THRESHOLD; export const GOAL_MONITOR_STALL_EVENT = "goal_monitor_continuation_stall"; -interface AgentEndOptions { - readonly ctx: ExtensionContext; - readonly goal: Goal | null; - readonly messages: readonly AgentMessage[]; -} - -type ContinuingGoalContinuationVerdict = Extract; -type DelayedContinuationKind = GoalWaitKind; - -type GoalContinuationAdmission = { - readonly goal: Goal; - readonly admitted: boolean; -}; - export class MonitorAwareGoalContinuation { readonly #pi: ExtensionAPI; readonly #isContinuationPending: () => boolean; @@ -78,6 +65,7 @@ export class MonitorAwareGoalContinuation { #scheduledCache: GoalCacheWarmMetrics | undefined; #heldTimer: { kind: DelayedContinuationKind; remainingMs: number } | undefined; #directInputHolds = new Set(); + #pendingSystemRecovery: SystemAbortOptions | undefined; constructor( pi: ExtensionAPI, @@ -172,6 +160,28 @@ export class MonitorAwareGoalContinuation { return goal; } + async afterSystemAbort(options: SystemAbortOptions): Promise { + this.noteContinuationStarted(); + this.#pendingSystemRecovery = undefined; + this.#ctx = options.ctx; + this.#goal = options.goal; + this.#lastAgentEndMessages = options.messages; + this.#lastTurnUsage = collectAssistantUsage([...options.messages]); + if (options.willRetry || options.goal?.status !== "active") return options.goal; + if (this.#activeMonitorCount > 0) this.#schedule(options.goal, "monitor"); + else if (lastAssistantMessage(options.messages)?.stopReason === "error") { + this.#pendingSystemRecovery = options; + } + return options.goal; + } + + async afterAgentSettled(): Promise { + const pending = this.#pendingSystemRecovery; + this.#pendingSystemRecovery = undefined; + if (pending === undefined || pending.goal === null || pending.event.abortSource === "user") return undefined; + return (await this.#admitAndQueue(pending.ctx, pending.goal, "systemRecovery", pending.messages)).goal; + } + syncGoal(goal: Goal | null): void { if (goal?.id !== this.#goal?.id) this.#resetContinuationState(); this.#goal = goal; @@ -223,7 +233,7 @@ export class MonitorAwareGoalContinuation { this.#resetContinuationState(); } - /** A queued hidden continuation has started, so the next end is not user-initiated. */ + /** A hidden continuation or system recovery has started, so the next end is not user-initiated. */ noteContinuationStarted(): void { this.#endedTurnWasUserInitiated = false; } @@ -247,9 +257,6 @@ export class MonitorAwareGoalContinuation { const cache = estimateCacheWarmMetrics(this.#ctx?.model, process.env, this.#lastTurnUsage); this.#scheduledCache = cache; this.#scheduledAtMs = Date.now(); - if (this.#ctx?.hasUI) { - this.#ctx.ui.notify(buildCacheWarmScheduledNotice(delayMs, this.#activeMonitorCount, cache), "info"); - } this.#pi.events?.emit(GOAL_CONTINUATION_SCHEDULED_EVENT, { goalId: goal.id, delayMs, @@ -332,9 +339,6 @@ export class MonitorAwareGoalContinuation { activeMonitorCount: this.#activeMonitorCount, ...(cache !== undefined ? { cache } : {}), }); - if (ctx.hasUI) { - ctx.ui.notify(buildCacheWarmResumedNotice(waitedMs, this.#activeMonitorCount, cache), "info"); - } } async #admitAndQueue( @@ -371,7 +375,7 @@ export class MonitorAwareGoalContinuation { path: GoalContinuationPath, messages: readonly AgentMessage[], ): Omit { - const lastAssistant = findLastAssistantMessage(messages); + const lastAssistant = lastAssistantMessage(messages); return { isIdle: ctx.isIdle(), hasPendingMessages: ctx.hasPendingMessages(), @@ -433,11 +437,12 @@ export class MonitorAwareGoalContinuation { } #resetLengthRecoveryAfterCleanStop(goal: Goal | null, messages: readonly AgentMessage[]): void { - if (goal === null || findLastAssistantMessage(messages)?.stopReason !== "stop") return; + if (goal === null || lastAssistantMessage(messages)?.stopReason !== "stop") return; this.#consecutiveLengthRecoveries.delete(goal.id); } #resetContinuationState(): void { + this.#pendingSystemRecovery = undefined; this.#consecutiveLengthRecoveries.clear(); this.#recentNormalizedOutputHashes = []; this.#resetToollessContinuationStreak(); @@ -459,13 +464,3 @@ export class MonitorAwareGoalContinuation { this.#waitTicker?.stop(); } } - -function findLastAssistantMessage( - messages: readonly AgentMessage[], -): Extract | undefined { - for (let index = messages.length - 1; index >= 0; index--) { - const message = messages[index]; - if (message?.role === "assistant") return message; - } - return undefined; -} diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/terminal-provider-error.ts b/packages/coding-agent/src/core/extensions/builtin/goal/terminal-provider-error.ts new file mode 100644 index 000000000..306af4e0b --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/terminal-provider-error.ts @@ -0,0 +1,9 @@ +import type { AgentEndEvent } from "../../types.ts"; +import { lastAssistantMessage } from "./last-assistant-message.ts"; + +export function didTerminalProviderErrorEndTurn(event: AgentEndEvent): boolean { + if (event.abortSource === "system") return false; + if (event.willRetry !== false) return false; + const message = lastAssistantMessage(event.messages); + return message?.stopReason === "error" || (message?.stopReason === "aborted" && event.abortSource === undefined); +} diff --git a/packages/coding-agent/src/core/extensions/builtin/ttsr/changes.md b/packages/coding-agent/src/core/extensions/builtin/ttsr/changes.md index 32b0aa5dc..162b8daf5 100644 --- a/packages/coding-agent/src/core/extensions/builtin/ttsr/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/ttsr/changes.md @@ -1,5 +1,66 @@ # TTSR Fork Tracker +## 2026-08-05 - One activation, one visible record + +### What changed and why + +- TTSR now persists only the shared `rule-activation` entry for each remediation. +- The old private `ttsr-injection` custom entry is no longer written, and the + transient `ctx.ui.notify("Stream rule triggered…")` warning is removed. +- The hidden `ttsr-injection` custom message remains because it is the + model-facing corrective nudge, not a user-facing duplicate. +- Session rehydration reads typed TTSR `rule-activation` entries while retaining + read compatibility with legacy private entries already stored in old sessions. + +### Ownership contract + +- One logical stream-rule activation has one persisted display owner: + `rule-activation`. +- The renderer owns the single TUI notice box. Presentation must not also flow + through a transient notify or a second display-only custom entry. + +### Coverage and expected conflict zones + +- `test/ttsr/extension-wiring.test.ts` pins one activation entry, zero private + entries, zero transient notices, and a preserved hidden nudge. +- Persistence, coordinator-race, and cross-turn tests now assert against the + shared activation record while retaining legacy rehydration coverage. +- MEDIUM in `index.ts` around `recordInjection` and session rehydration. + +## 2026-08-05 - System-owned remediation aborts + +### What changed and why + +- All TTSR remediation aborts now call `ctx.abort("system")`. +- The host reports those turns as `agent_end.abortSource === "system"` instead + of `"user"`, so an active Goal remains active while the hidden corrective + nudge and any live monitor/background completion channel resume the run. +- Explicit user interrupts still use the default user source and keep the + existing intentional Goal block. +- If a user interrupt joins an in-flight TTSR system abort, the resulting + user-owned settlement mutates the retained `agent_end` through the end of + `agent_settled`. TTSR checks that shared event before requesting its nudge, + while the host defers earlier settlement requests until every handler + completes, so neither handler order can run a corrective turn after Escape. +- An automatic provider retry starts a fresh TTSR detection generation even + though agent-core does not emit a new `turn_start`, so consecutive leaking + generations each receive their own system abort and provenance. + +### Coverage and expected conflict zones + +- `test/suite/goal-abort-extension.test.ts` combines Goal + TTSR + an active + monitor and pins system attribution, active Goal state, and user-abort + regression behavior. +- `test/suite/goal-ttsr-user-abort-race.test.ts` pins the joined-abort ordering, + one underlying abort, user provenance, durable Goal block, and no corrective + follow-up turn. +- `test/suite/goal-ttsr-settlement-race.test.ts` pins both `agent_settled` + handler orders, Goal recovery launch after a terminal system error, and stale + recovery removal on public-boundary cancellation. +- `test/suite/goal-system-abort-monitor.test.ts` pins the Goal-side system-abort + policy independently of the detector. +- LOW in `index.ts` at the three `ctx.abort("system")` call sites. + ## 2026-08-04 - Cross-turn repetitive-turns detection ### What changed and why diff --git a/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts b/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts index ff3ea0dd4..caa10a3b0 100644 --- a/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/ttsr/index.ts @@ -1,7 +1,8 @@ import { getKeybindings } from "@earendil-works/pi-tui"; -import type { ExtensionAPI, ExtensionContext, MessageUpdateEvent } from "../../types.ts"; +import type { AgentEndEvent, ExtensionAPI, ExtensionContext, MessageUpdateEvent } from "../../types.ts"; import { appendRuleActivation, registerRuleActivationRenderer } from "../rule-activation/index.ts"; +import { parseRuleActivationDetails, RULE_ACTIVATION_ENTRY_TYPE } from "../rule-activation/types.ts"; import { BUILTIN_TTSR_RULES } from "./builtin-rules.ts"; import { registerTtsrCommands, type TtsrPublicState } from "./commands.ts"; import { claimAbort, createGenerationState, markUserCancelled } from "./coordinator.ts"; @@ -70,6 +71,7 @@ export default function ttsrExtension(pi: ExtensionAPI): void { let pendingRemediation: PendingRemediation | null = null; let pendingRuleNudge: PendingRuleNudge | null = null; let pendingNudge: TtsrNudgeMessage | null = null; + let settlingAgentEnd: AgentEndEvent | null = null; let disabled = false; const repetitiveTurns = new RepetitiveTurnsLane(); @@ -83,13 +85,16 @@ export default function ttsrExtension(pi: ExtensionAPI): void { } } + function resetGenerationState(): void { + generation += 1; + genState = createGenerationState(); + pendingRemediation = null; + pendingRuleNudge = null; + repetitiveTurns.resetTurn(); + watcher?.reset(); + } + function recordInjection(owner: string, observed: readonly string[], retryMode: "nudge" | "provider-error"): void { - pi.appendEntry(TTSR_INJECTION_CUSTOM_TYPE, { - rules: observed, - owner, - remediation: retryMode, - at: Date.now(), - }); appendRuleActivation(pi, { kind: "ttsr", owner, @@ -98,15 +103,6 @@ export default function ttsrExtension(pi: ExtensionAPI): void { }); } - function notify(ctx: ExtensionContext, owner: string): void { - if (ctx.mode !== "tui") return; - try { - ctx.ui.notify(`Stream rule triggered: ${owner}`, "warning"); - } catch { - return; - } - } - function ensureInitialized(ctx: ExtensionContext): void { if (manager !== null) return; disabled = pi.getFlag("ttsr-disabled") === true; @@ -114,15 +110,18 @@ export default function ttsrExtension(pi: ExtensionAPI): void { repetitiveTurns.configure(new Set(disabledRules)); const settings = { ...DEFAULT_TTSR_SETTINGS, enabled: !disabled, disabledRules }; manager = new TtsrManager(settings, (pattern) => compileRuleCondition(pattern).regex); - const injectedNames = ctx.sessionManager - .getEntries() - .filter((entry) => entry.type === "custom" && entry.customType === TTSR_INJECTION_CUSTOM_TYPE) - .flatMap((entry) => { - const data = entry.type === "custom" ? entry.data : undefined; - if (typeof data !== "object" || data === null || !("rules" in data)) return []; - const rules = (data as { rules?: unknown }).rules; - return Array.isArray(rules) ? rules.filter((rule): rule is string => typeof rule === "string") : []; - }); + const injectedNames = ctx.sessionManager.getEntries().flatMap((entry) => { + if (entry.type !== "custom") return []; + if (entry.customType === RULE_ACTIVATION_ENTRY_TYPE) { + const details = parseRuleActivationDetails(entry.data); + return details?.kind === "ttsr" ? [...details.rules] : []; + } + if (entry.customType !== TTSR_INJECTION_CUSTOM_TYPE) return []; + const data = entry.data; + if (typeof data !== "object" || data === null || !("rules" in data)) return []; + const rules = (data as { rules?: unknown }).rules; + return Array.isArray(rules) ? rules.filter((rule): rule is string => typeof rule === "string") : []; + }); manager.restoreInjected(injectedNames); for (const rule of BUILTIN_TTSR_RULES) { manager.addRule(rule); @@ -166,14 +165,18 @@ export default function ttsrExtension(pi: ExtensionAPI): void { cancelRemediation(); }); + pi.on("agent_end", (event) => { + settlingAgentEnd = event; + if (event.abortSource === "user") { + cancelRemediation(); + return; + } + if (event.willRetry === true) resetGenerationState(); + }); + pi.on("turn_start", (_event, ctx) => { ensureInitialized(ctx); - generation += 1; - genState = createGenerationState(); - pendingRemediation = null; - pendingRuleNudge = null; - repetitiveTurns.resetTurn(); - watcher?.reset(); + resetGenerationState(); }); pi.on("turn_end", () => { @@ -190,8 +193,7 @@ export default function ttsrExtension(pi: ExtensionAPI): void { const outcome = watcher.handleDelta(source, streamKey, deltaEvent.delta, generation); if (outcome.resolution !== null && claimAbort(genState, outcome.resolution)) { pendingRemediation = { resolution: outcome.resolution, streamKind: source }; - notify(ctx, outcome.resolution.owner); - ctx.abort(); + ctx.abort("system"); return; } if (source === "text") { @@ -200,8 +202,7 @@ export default function ttsrExtension(pi: ExtensionAPI): void { genState.abortClaimed = true; genState.abortOwner = "collapse-repetition"; genState.selfAbortAt = Date.now(); - notify(ctx, REPETITIVE_TURNS_RULE_NAME); - ctx.abort(); + ctx.abort("system"); return; } } @@ -212,8 +213,7 @@ export default function ttsrExtension(pi: ExtensionAPI): void { genState.abortOwner = "collapse-repetition"; genState.selfAbortAt = Date.now(); pendingRuleNudge = { rule }; - notify(ctx, rule.name); - ctx.abort(); + ctx.abort("system"); } }); @@ -261,8 +261,9 @@ export default function ttsrExtension(pi: ExtensionAPI): void { }); pi.on("agent_settled", () => { - if (pendingNudge === null || genState.userCancelled) { + if (pendingNudge === null || genState.userCancelled || settlingAgentEnd?.abortSource === "user") { pendingNudge = null; + settlingAgentEnd = null; return; } const nudge = pendingNudge; diff --git a/packages/coding-agent/src/core/extensions/changes.md b/packages/coding-agent/src/core/extensions/changes.md index 730849b98..0edc3f39b 100644 --- a/packages/coding-agent/src/core/extensions/changes.md +++ b/packages/coding-agent/src/core/extensions/changes.md @@ -1,5 +1,29 @@ # Core Extensions Changes +## 2026-08-05 - Extension abort provenance + +### What changed + +- `ExtensionContext.abort(source?)` and its host action now accept `"user" | "system"`. +- The default remains `"user"` for compatibility. A system-owned abort bypasses + the interactive user-abort handler and reaches the agent as + `agent_end.abortSource === "system"`. + +### Why + +- Builtin stream remediation previously called the same user-abort path as Escape, + so the Goal extension persisted `blocked("user interrupted the turn")` even + though TTSR, not the user, stopped the generation. +- Abort provenance belongs at the initiating extension boundary. Consumers such + as Goal can retain their existing source-based policy without detector coupling. + +### Expected merge conflict zones + +- LOW in `types.ts` around `ExtensionContext.abort` and + `ExtensionContextActions.abort`. +- LOW in `runner.ts` and `agent-session.ts` around extension context forwarding + and binding. + ## 2026-08-03 - ExtensionContext exposes the resolved agent dir ### What changed and why diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 7aec17403..4e4799ba5 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -380,7 +380,7 @@ export class ExtensionRunner { private isProjectTrustedFn: () => boolean = () => true; private getSignalFn: () => AbortSignal | undefined = () => undefined; private waitForIdleFn: () => Promise = async () => {}; - private abortFn: () => void = () => {}; + private abortFn: ExtensionContextActions["abort"] = () => {}; private hasPendingMessagesFn: () => boolean = () => false; private isCompactingFn: () => boolean = () => false; private checkReloadVetoFn: ExtensionContextActions["checkReloadVeto"]; @@ -1015,9 +1015,9 @@ export class ExtensionRunner { runner.assertActive(); return runner.getSignalFn(); }, - abort: () => { + abort: (source) => { runner.assertActive(); - runner.abortFn(); + runner.abortFn(source); }, hasPendingMessages: () => { runner.assertActive(); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index cd0863acc..af7d9f4bb 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -398,7 +398,7 @@ export interface ExtensionContext { /** The current abort signal, or undefined when the agent is not streaming. */ signal: AbortSignal | undefined; /** Abort the current agent operation */ - abort(): void; + abort(source?: "user" | "system"): void; /** Whether there are queued messages waiting */ hasPendingMessages(): boolean; /** @@ -2053,7 +2053,7 @@ export interface ExtensionContextActions { isIdle: () => boolean; isProjectTrusted: () => boolean; getSignal: () => AbortSignal | undefined; - abort: () => void; + abort: (source?: "user" | "system") => void; hasPendingMessages: () => boolean; isCompacting: () => boolean; checkReloadVeto?: () => Promise; diff --git a/packages/coding-agent/test/suite/goal-abort-extension.test.ts b/packages/coding-agent/test/suite/goal-abort-extension.test.ts index 894446b63..0f0c2df23 100644 --- a/packages/coding-agent/test/suite/goal-abort-extension.test.ts +++ b/packages/coding-agent/test/suite/goal-abort-extension.test.ts @@ -1,9 +1,10 @@ -import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { fauxAssistantMessage, fauxText, 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, updateGoal } 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 ttsrExtension from "../../src/core/extensions/builtin/ttsr/index.ts"; import { createHarness, type Harness } from "./harness.ts"; type AgentEndSnapshot = { @@ -113,8 +114,49 @@ describe("goal abort lifecycle through the agent session", () => { await harness.session.prompt("run normally"); expect(observed).toEqual([{ aborted: undefined, abortSource: undefined }]); - expect(observed).toEqual([{ aborted: undefined, abortSource: undefined }]); }); + + it("keeps an active monitored Goal live across a TTSR system abort", async () => { + const abortSources: Array = []; + let resolveScheduledContinuation: ((data: unknown) => void) | undefined; + const scheduledContinuation = new Promise((resolve) => { + resolveScheduledContinuation = resolve; + }); + const harness = await createHarness({ + persistSession: true, + extensionFactories: [ + goalExtension, + ttsrExtension, + (pi) => { + pi.on("session_start", () => { + pi.events?.emit("terminal_monitor_state", { activeCount: 1 }); + }); + pi.events?.on("goal_continuation_scheduled", (data) => resolveScheduledContinuation?.(data)); + pi.on("agent_end", (event) => { + abortSources.push(event.abortSource); + }); + }, + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Keep waiting for the live monitor"); + harness.setResponses([ + fauxAssistantMessage([fauxText(' inert imitation')]), + fauxAssistantMessage("recovered after the stream rule"), + ]); + + await harness.session.prompt("continue monitoring"); + + expect(abortSources).toContain("system"); + expect(abortSources).not.toContain("user"); + expect(abortSources).toContain(undefined); + expect(harness.faux.getCallLog()).toHaveLength(2); + expect(await readGoal(ref)).toMatchObject({ status: "active" }); + expect(await scheduledContinuation).toEqual(expect.objectContaining({ delayMs: 240_000 })); + }); + it("keeps a model-authored block blocked on ordinary direct input", async () => { const statusesAtBeforeAgentStart: GoalStatus[] = []; const harness = await createHarness({ diff --git a/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts b/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts index 252b92e7e..8cac27e49 100644 --- a/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts +++ b/packages/coding-agent/test/suite/goal-cache-warm-metrics.test.ts @@ -1,10 +1,6 @@ import type { Api, Model } from "@earendil-works/pi-ai"; import { describe, expect, it } from "vitest"; -import { - buildCacheWarmResumedNotice, - buildCacheWarmScheduledNotice, - estimateCacheWarmMetrics, -} from "../../src/core/extensions/builtin/goal/cache-warm.ts"; +import { estimateCacheWarmMetrics } from "../../src/core/extensions/builtin/goal/cache-warm.ts"; function anthropicModel(costOverrides: Partial["cost"]> = {}): Model { return { @@ -58,42 +54,3 @@ describe("goal cache-warm metrics", () => { expect(inverted?.estimatedSavedUsd).toBe(0); }); }); - -describe("goal cache-warm notices", () => { - it("explains the deferred continuation with cache context", () => { - const notice = buildCacheWarmScheduledNotice(240_000, 1, { - ttlSeconds: 300, - cachedTokens: 120_000, - estimatedSavedUsd: 0.324, - }); - expect(notice).toContain("1 monitor on duty"); - expect(notice).toMatch(/4 minutes/i); - expect(notice).toContain("~120K tokens"); - expect(notice).toContain("5m prompt-cache TTL"); - }); - - it("falls back to a monitor-only explanation without cache metrics", () => { - const notice = buildCacheWarmScheduledNotice(240_000, 2, undefined); - expect(notice).toContain("2 monitors on duty"); - expect(notice).toMatch(/4 minutes/i); - expect(notice).not.toContain("tokens"); - }); - - it("celebrates the cache-warm wake with savings", () => { - const notice = buildCacheWarmResumedNotice(240_000, 1, { - ttlSeconds: 300, - cachedTokens: 120_000, - estimatedSavedUsd: 0.324, - }); - expect(notice).toContain("Cache-warm wake after 4m"); - expect(notice).toContain("~120K tokens stayed warm"); - expect(notice).toContain("est. $0.324 saved"); - }); - - it("stays graceful when the wake has no cache story", () => { - const notice = buildCacheWarmResumedNotice(45_000, 1, undefined); - expect(notice).toContain("Cache-warm wake after 45s"); - expect(notice).toContain("Continuing the goal"); - expect(notice).not.toContain("tokens"); - }); -}); diff --git a/packages/coding-agent/test/suite/goal-cache-warm-ownership.test.ts b/packages/coding-agent/test/suite/goal-cache-warm-ownership.test.ts new file mode 100644 index 000000000..556f6f6ed --- /dev/null +++ b/packages/coding-agent/test/suite/goal-cache-warm-ownership.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + GOAL_CACHE_WARMUP_ENTRY_TYPE, + type GoalCacheWarmupEntryData, +} from "../../src/core/extensions/builtin/goal/cache-warm.ts"; +import { GOAL_MONITOR_CONTINUATION_DELAY_MS } from "../../src/core/extensions/builtin/goal/monitor-continuation.ts"; +import { + type AppendedGoalEntry, + cleanAssistantStop, + cleanupGoalMonitorTempDirs, + createGoalHarness, + makeGoalContext, + runGoalHandlers, + waitForSentCount, +} from "./goal-monitor-test-harness.ts"; + +describe("goal cache-warm rendering ownership", () => { + afterEach(async () => { + vi.useRealTimers(); + await cleanupGoalMonitorTempDirs(); + }); + + it("uses durable entries without duplicate transient notices", async () => { + vi.useFakeTimers(); + const notices: string[] = []; + const harness = createGoalHarness(); + const { tools, handlers, events, entries } = harness; + const ctx = await makeGoalContext(notices, "thread-cache-warm-ownership"); + await tools.get("create_goal")?.execute("create", { objective: "Keep watching" }, undefined, undefined, ctx); + await runGoalHandlers(handlers, "session_start", { type: "session_start", reason: "reload" }, ctx); + events.emit("terminal_monitor_state", { activeCount: 1 }); + await events.flush(); + await runGoalHandlers(handlers, "agent_start", { type: "agent_start" }, ctx); + await runGoalHandlers(handlers, "agent_end", { type: "agent_end", messages: [cleanAssistantStop()] }, ctx); + + expect(notices).toEqual([]); + expect(warmupPhases(entries)).toEqual(["scheduled"]); + + const delivered = waitForSentCount(harness, 1); + await vi.advanceTimersByTimeAsync(GOAL_MONITOR_CONTINUATION_DELAY_MS); + await delivered; + await vi.advanceTimersByTimeAsync(0); + expect(notices).toEqual([]); + expect(warmupPhases(entries)).toEqual(["scheduled", "resumed"]); + }); +}); + +function warmupPhases(entries: readonly AppendedGoalEntry[]): string[] { + return entries + .filter((entry) => entry.customType === GOAL_CACHE_WARMUP_ENTRY_TYPE) + .map((entry) => (entry.data as GoalCacheWarmupEntryData | undefined)?.phase) + .filter((phase): phase is GoalCacheWarmupEntryData["phase"] => phase !== undefined); +} diff --git a/packages/coding-agent/test/suite/goal-cache-warmup.test.ts b/packages/coding-agent/test/suite/goal-cache-warmup.test.ts index d0279eabd..b5b404c0f 100644 --- a/packages/coding-agent/test/suite/goal-cache-warmup.test.ts +++ b/packages/coding-agent/test/suite/goal-cache-warmup.test.ts @@ -70,9 +70,7 @@ describe("goal cache-warm continuation story", () => { vi.useFakeTimers(); const { harness, notices } = await setupWarmHarness("thread-cache-warm-scheduled"); - expect(notices).toHaveLength(1); - expect(notices[0]).toMatch(/4 minutes/i); - expect(notices[0]).toContain("~120K tokens"); + expect(notices).toEqual([]); expect(channelEvents(harness, "goal_continuation_scheduled")).toEqual([ expect.objectContaining({ @@ -133,7 +131,7 @@ describe("goal cache-warm continuation story", () => { }), ); - expect(notices.some((notice) => /cache-warm wake/i.test(notice) && notice.includes("$0.324"))).toBe(true); + expect(notices).toEqual([]); }); it("keeps a plain explanation when no cache context exists", async () => { @@ -155,8 +153,7 @@ describe("goal cache-warm continuation story", () => { ctx, ); - expect(notices[0]).toMatch(/4 minutes/i); - expect(notices[0]).not.toContain("tokens"); + expect(notices).toEqual([]); const scheduled = warmupEntryData(harness); expect(scheduled).toHaveLength(1); diff --git a/packages/coding-agent/test/suite/goal-continuation-verdict.test.ts b/packages/coding-agent/test/suite/goal-continuation-verdict.test.ts index 0e17f0985..2aa864a7c 100644 --- a/packages/coding-agent/test/suite/goal-continuation-verdict.test.ts +++ b/packages/coding-agent/test/suite/goal-continuation-verdict.test.ts @@ -124,6 +124,9 @@ describe("goal continuation verdict", () => { expect(evaluateGoalContinuation(makeInput({ path: "sessionStart", lastStopReason: "error" }))).toMatchObject({ kind: "continue", }); + expect( + evaluateGoalContinuation(makeInput({ path: "systemRecovery", isIdle: false, lastStopReason: "error" })), + ).toMatchObject({ kind: "continue" }); }); it("applies the cap to every remaining automatic continuation path", () => { @@ -132,7 +135,7 @@ describe("goal continuation verdict", () => { lastContinuationSignature: "goal-1:1/2:abc123", }); - for (const path of ["immediate", "monitorDelayed", "userGrace", "sessionStart"] as const) { + for (const path of ["immediate", "monitorDelayed", "userGrace", "sessionStart", "systemRecovery"] as const) { expect(evaluateGoalContinuation({ ...capped, path })).toEqual({ kind: "deny", reason: "cap" }); } expect( @@ -151,6 +154,18 @@ describe("goal continuation verdict", () => { ).toMatchObject({ kind: "continue" }); }); + it.each([ + ["single-flight", { continuationPending: true }], + ["cap", { consecutiveContinuations: GOAL_CONTINUATION_CAP }], + ["repetition", { recentNormalizedOutputHashes: ["same", "same", "same"] }], + ] as const)("keeps the %s guard on system recovery", (reason, overrides) => { + expect( + evaluateGoalContinuation( + makeInput({ path: "systemRecovery", isIdle: false, lastStopReason: "error", ...overrides }), + ), + ).toEqual({ kind: "deny", reason }); + }); + it.each([ [GOAL_STALL_TOOLLESS_THRESHOLD - 1, false], [GOAL_STALL_TOOLLESS_THRESHOLD, true], diff --git a/packages/coding-agent/test/suite/goal-extension.test.ts b/packages/coding-agent/test/suite/goal-extension.test.ts index ac9b32ab7..37514a5b2 100644 --- a/packages/coding-agent/test/suite/goal-extension.test.ts +++ b/packages/coding-agent/test/suite/goal-extension.test.ts @@ -459,7 +459,7 @@ describe("goal extension contract (budget-free)", () => { }); }); - it("blocks a non-user aborted turn after retries are exhausted", async () => { + it("blocks a provenance-free aborted turn after retries are exhausted", async () => { const { tools, handlers } = createGoalHarness(); const ctx = await makeCtx("thread-system-abort-provider-guard"); await tools @@ -474,7 +474,6 @@ describe("goal extension contract (budget-free)", () => { type: "agent_end", messages: [assistantMessageWithStopReason("aborted")], aborted: true, - abortSource: "system", willRetry: false, }, ctx, diff --git a/packages/coding-agent/test/suite/goal-monitor-continuation.test.ts b/packages/coding-agent/test/suite/goal-monitor-continuation.test.ts index bc3e251b1..2569bc019 100644 --- a/packages/coding-agent/test/suite/goal-monitor-continuation.test.ts +++ b/packages/coding-agent/test/suite/goal-monitor-continuation.test.ts @@ -390,7 +390,7 @@ describe("goal continuation while a monitor is active", () => { expect(sent).toHaveLength(1); }); - it("waits four minutes before continuing and announces the schedule", async () => { + it("waits four minutes before continuing and persists the schedule", async () => { vi.useFakeTimers(); const notices: string[] = []; const harness = createGoalHarness(); @@ -405,7 +405,7 @@ describe("goal continuation while a monitor is active", () => { await runGoalHandlers(handlers, "agent_end", { type: "agent_end", messages: [cleanAssistantStop()] }, ctx); expect(sent).toHaveLength(0); - expect(notices).toEqual([expect.stringMatching(/4 minutes/i)]); + expect(notices).toEqual([]); expect(events.emitted).toContainEqual({ channel: "goal_continuation_scheduled", data: expect.objectContaining({ delayMs: 240_000 }), diff --git a/packages/coding-agent/test/suite/goal-monitor-lifecycle.test.ts b/packages/coding-agent/test/suite/goal-monitor-lifecycle.test.ts index 91327d0a2..28180fbd5 100644 --- a/packages/coding-agent/test/suite/goal-monitor-lifecycle.test.ts +++ b/packages/coding-agent/test/suite/goal-monitor-lifecycle.test.ts @@ -57,7 +57,7 @@ describe("goal monitor continuation lifecycle", () => { await endCleanTurn(harness, ctx); expect(harness.sent).toHaveLength(0); - expect(notices).toHaveLength(1); + expect(notices).toHaveLength(0); const delayedDeliveryRecorded = waitForSentCount(harness, 1); await vi.advanceTimersByTimeAsync(240_000); await delayedDeliveryRecorded; diff --git a/packages/coding-agent/test/suite/goal-monitor-rpc-notice.test.ts b/packages/coding-agent/test/suite/goal-monitor-rpc-notice.test.ts index 0548c1e8d..06ef11e92 100644 --- a/packages/coding-agent/test/suite/goal-monitor-rpc-notice.test.ts +++ b/packages/coding-agent/test/suite/goal-monitor-rpc-notice.test.ts @@ -15,6 +15,7 @@ interface RpcRecord { readonly method?: string; readonly message?: string; readonly notifyType?: string; + readonly entry?: { readonly customType?: string; readonly data?: { readonly phase?: string } }; } function createRuntimeHost(session: AgentSession): AgentSessionRuntime { @@ -46,7 +47,7 @@ describe("goal monitor scheduling notice over RPC", () => { while (harnesses.length > 0) harnesses.pop()?.cleanup(); }); - it("emits a pi scheduling event and an RPC notify request", async () => { + it("emits a scheduling event and one durable RPC entry", async () => { vi.useFakeTimers(); const scheduleEvents: unknown[] = []; const harness = await createHarness({ @@ -76,13 +77,16 @@ describe("goal monitor scheduling notice over RPC", () => { await runner.emit({ type: "agent_end", messages: [fauxAssistantMessage("clean stop")] }); expect(scheduleEvents).toEqual([expect.objectContaining({ delayMs: 240_000 })]); - expect(rpcRecords(chunks)).toContainEqual( + const records = rpcRecords(chunks); + expect(records).toContainEqual( expect.objectContaining({ - type: "extension_ui_request", - method: "notify", - message: expect.stringMatching(/4 minutes/i), - notifyType: "info", + type: "entry_appended", + entry: expect.objectContaining({ + customType: "goal-cache-warmup", + data: expect.objectContaining({ phase: "scheduled" }), + }), }), ); + expect(records.some((record) => record.method === "notify")).toBe(false); }); }); diff --git a/packages/coding-agent/test/suite/goal-system-abort-monitor.test.ts b/packages/coding-agent/test/suite/goal-system-abort-monitor.test.ts new file mode 100644 index 000000000..10f7a27bd --- /dev/null +++ b/packages/coding-agent/test/suite/goal-system-abort-monitor.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readGoal, recordContinuationDelivered } from "../../src/core/extensions/builtin/goal/store.ts"; +import { goalStoreRef } from "../../src/core/extensions/builtin/goal/store-ref.ts"; +import { + cleanAssistantStop, + cleanupGoalMonitorTempDirs, + createGoalHarness, + createGoalStatusHarness, + makeGoalContext, + runGoalHandlers, +} from "./goal-monitor-test-harness.ts"; + +describe("goal state after a system-owned abort", () => { + afterEach(async () => { + vi.useRealTimers(); + await cleanupGoalMonitorTempDirs(); + }); + + it.each(["aborted", "error"] as const)( + "keeps the Goal active and monitor wait armed after a terminal %s system abort", + async (stopReason) => { + vi.useFakeTimers(); + const notices: string[] = []; + const harness = createGoalHarness(); + const { tools, handlers, sent, events } = harness; + const ctx = await makeGoalContext(notices, "thread-system-abort-monitor"); + await tools.get("create_goal")?.execute("create", { objective: "Keep watching" }, undefined, undefined, ctx); + await runGoalHandlers(handlers, "session_start", { type: "session_start", reason: "reload" }, ctx); + events.emit("terminal_monitor_state", { activeCount: 1 }); + await events.flush(); + await runGoalHandlers(handlers, "agent_start", { type: "agent_start" }, ctx); + + await runGoalHandlers( + handlers, + "agent_end", + { + type: "agent_end", + aborted: true, + abortSource: "system", + willRetry: false, + messages: [{ ...cleanAssistantStop(), stopReason }], + }, + ctx, + ); + + expect(await readGoal(goalStoreRef(ctx.sessionManager, ctx.cwd))).toMatchObject({ status: "active" }); + expect(sent).toHaveLength(0); + expect(events.emitted).toContainEqual({ + channel: "goal_continuation_scheduled", + data: expect.objectContaining({ activeMonitorCount: 1, delayMs: 240_000 }), + }); + }, + ); + + it("queues Goal-owned recovery for a terminal system error without monitors", async () => { + const notices: string[] = []; + const harness = createGoalHarness(); + const { tools, handlers, sent } = harness; + const ctx = await makeGoalContext(notices, "thread-system-error-no-monitor"); + await tools + .get("create_goal") + ?.execute("create", { objective: "Recover without a monitor" }, undefined, undefined, ctx); + await runGoalHandlers(handlers, "agent_start", { type: "agent_start" }, ctx); + + await runGoalHandlers( + handlers, + "agent_end", + { + type: "agent_end", + aborted: true, + abortSource: "system", + willRetry: false, + messages: [{ ...cleanAssistantStop(), stopReason: "error" as const }], + }, + ctx, + ); + + expect(await readGoal(goalStoreRef(ctx.sessionManager, ctx.cwd))).toMatchObject({ status: "active" }); + expect(sent).toHaveLength(0); + await runGoalHandlers(handlers, "agent_settled", { type: "agent_settled" }, ctx); + expect(sent).toHaveLength(1); + expect(sent[0]?.message.customType).toBe("goal-continuation"); + }); + + it("refreshes blocked Goal status when settlement recovery hits the cap", async () => { + const notices: string[] = []; + const status = createGoalStatusHarness(); + const { tools, handlers } = createGoalHarness(); + const ctx = await makeGoalContext(notices, "thread-system-error-cap", { pendingMessages: false, status }); + await tools + .get("create_goal") + ?.execute("create", { objective: "Stop at the continuation cap" }, undefined, undefined, ctx); + const ref = goalStoreRef(ctx.sessionManager, ctx.cwd); + for (let attempt = 0; attempt < 8; attempt++) { + await recordContinuationDelivered(ref, `signature-${attempt}`); + } + await runGoalHandlers(handlers, "agent_start", { type: "agent_start" }, ctx); + await runGoalHandlers( + handlers, + "agent_end", + { + type: "agent_end", + aborted: true, + abortSource: "system", + willRetry: false, + messages: [{ ...cleanAssistantStop(), stopReason: "error" as const }], + }, + ctx, + ); + await runGoalHandlers(handlers, "agent_settled", { type: "agent_settled" }, ctx); + + expect(await readGoal(ref)).toMatchObject({ status: "blocked" }); + expect(status.updates.at(-1)?.text).toContain("Goal blocked"); + }); +}); diff --git a/packages/coding-agent/test/suite/goal-ttsr-late-user-abort-race.test.ts b/packages/coding-agent/test/suite/goal-ttsr-late-user-abort-race.test.ts new file mode 100644 index 000000000..231a59d15 --- /dev/null +++ b/packages/coding-agent/test/suite/goal-ttsr-late-user-abort-race.test.ts @@ -0,0 +1,163 @@ +import { fauxAssistantMessage, fauxText, fauxThinking } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } 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 ttsrExtension from "../../src/core/extensions/builtin/ttsr/index.ts"; +import type { ExtensionFactory } from "../../src/core/extensions/types.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +describe("late user abort during agent_end dispatch", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); + + it("preserves user intent when a handler blocks before Goal and TTSR", async () => { + await runLateJoinScenario({ + harnesses, + objective: "Honor Escape before Goal handles the end", + beforeGoal: true, + responses: [fauxAssistantMessage([fauxText(' inert imitation')])], + }); + }); + + it("cancels remediation when a handler blocks after TTSR handled agent_end", async () => { + await runLateJoinScenario({ + harnesses, + objective: "Cancel remediation after TTSR observed the end", + beforeGoal: false, + responses: [fauxAssistantMessage([fauxText(' inert imitation')])], + }); + }); + + it("cancels a provider retry admitted before the late user join", async () => { + const leaked = ["<", "|", "sep", "|", ">"].join(""); + await runLateJoinScenario({ + harnesses, + objective: "Cancel provider retry after TTSR observed the end", + beforeGoal: false, + settings: { retry: { enabled: true, maxRetries: 1, baseDelayMs: 1 } }, + responses: [ + fauxAssistantMessage([ + fauxThinking(`Thinking... ${leaked} ${leaked} ${leaked} trailing ${"x".repeat(400)}`), + ]), + fauxAssistantMessage([fauxText("must not run")]), + ], + }); + }); + + it("cancels remediation when Escape arrives from the public agent_end boundary", async () => { + const abortSources: Array = []; + let sessionAbortCount = 0; + let abort: Promise | undefined; + let repeatedAbort: Promise | undefined; + let harness: Harness; + harness = await createHarness({ + persistSession: true, + extensionFactories: [ + goalExtension, + ttsrExtension, + (pi) => { + pi.on("agent_end", (event) => { + abortSources.push(event.abortSource); + }); + pi.on("session_abort", () => { + sessionAbortCount += 1; + repeatedAbort ??= harness.session.abort(); + }); + }, + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Cancel remediation after extension dispatch"); + const originalAbort = harness.agent.abort.bind(harness.agent); + const abortSpy = vi.spyOn(harness.agent, "abort").mockImplementation(() => { + originalAbort(); + }); + harness.session.subscribe((event) => { + if (event.type === "agent_end" && abort === undefined) abort = harness.session.abort(); + }); + harness.setResponses([fauxAssistantMessage([fauxText(' inert imitation')])]); + + await harness.session.prompt("continue monitoring"); + await abort; + await repeatedAbort; + + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(abortSources).toEqual(["system"]); + expect(sessionAbortCount).toBe(1); + expect(await readGoal(ref)).toMatchObject({ status: "blocked", blockedReason: "user interrupted the turn" }); + expect(harness.faux.getCallLog()).toHaveLength(1); + }); +}); + +interface LateJoinScenario { + readonly harnesses: Harness[]; + readonly objective: string; + readonly beforeGoal: boolean; + readonly responses: Parameters[0]; + readonly settings?: { + readonly retry: { readonly enabled: boolean; readonly maxRetries: number; readonly baseDelayMs: number }; + }; +} + +async function runLateJoinScenario(options: LateJoinScenario): Promise { + const abortSources: Array = []; + let sessionAbortCount = 0; + let signalAgentEndStarted: (() => void) | undefined; + let releaseAgentEnd: (() => void) | undefined; + const agentEndStarted = new Promise((resolve) => { + signalAgentEndStarted = resolve; + }); + const agentEndRelease = new Promise((resolve) => { + releaseAgentEnd = resolve; + }); + const blockingExtension: ExtensionFactory = (pi) => { + pi.on("agent_end", async () => { + signalAgentEndStarted?.(); + await agentEndRelease; + }); + }; + const observerExtension: ExtensionFactory = (pi) => { + pi.on("agent_end", (event) => { + abortSources.push(event.abortSource); + }); + pi.on("session_abort", () => { + sessionAbortCount += 1; + }); + }; + const orderedExtensions = options.beforeGoal + ? [blockingExtension, goalExtension, ttsrExtension, observerExtension] + : [goalExtension, ttsrExtension, blockingExtension, observerExtension]; + const harness = await createHarness({ + persistSession: true, + ...(options.settings === undefined ? {} : { settings: options.settings }), + extensionFactories: orderedExtensions, + }); + options.harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, options.objective); + const originalAbort = harness.agent.abort.bind(harness.agent); + const abortSpy = vi.spyOn(harness.agent, "abort").mockImplementation(() => { + originalAbort(); + }); + harness.setResponses(options.responses); + + const prompt = harness.session.prompt("continue monitoring"); + await agentEndStarted; + const abort = harness.session.abort(); + releaseAgentEnd?.(); + await Promise.all([abort, prompt]); + + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(abortSources).toEqual(["user"]); + expect(sessionAbortCount).toBe(1); + expect(await readGoal(ref)).toMatchObject({ status: "blocked", blockedReason: "user interrupted the turn" }); + expect(harness.faux.getCallLog()).toHaveLength(1); +} diff --git a/packages/coding-agent/test/suite/goal-ttsr-settlement-race.test.ts b/packages/coding-agent/test/suite/goal-ttsr-settlement-race.test.ts new file mode 100644 index 000000000..c77c0fa63 --- /dev/null +++ b/packages/coding-agent/test/suite/goal-ttsr-settlement-race.test.ts @@ -0,0 +1,192 @@ +import { fauxAssistantMessage, fauxText, fauxThinking, fauxToolCall } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } 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 ttsrExtension from "../../src/core/extensions/builtin/ttsr/index.ts"; +import type { ExtensionFactory } from "../../src/core/extensions/types.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +describe("user abort racing settlement-owned recovery", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); + + it.each([ + ["before", true], + ["after", false], + ] as const)("cancels TTSR when a blocking agent_settled handler runs %s TTSR", async (_label, beforeTtsr) => { + const gate = createSettlementGate(); + let sessionAbortCount = 0; + const observer: ExtensionFactory = (pi) => { + pi.on("session_abort", () => { + sessionAbortCount += 1; + }); + }; + const extensions = beforeTtsr + ? [goalExtension, gate.extension, ttsrExtension, observer] + : [goalExtension, ttsrExtension, gate.extension, observer]; + const harness = await createHarness({ persistSession: true, extensionFactories: extensions }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, `Cancel settlement recovery ${_label} TTSR`); + harness.setResponses([unavailableToolResponse()]); + + const prompt = harness.session.prompt("continue monitoring"); + await gate.started; + const abort = harness.session.abort(); + gate.release(); + await Promise.all([abort, prompt]); + + expect(sessionAbortCount).toBe(1); + expect(await readGoal(ref)).toMatchObject({ status: "blocked", blockedReason: "user interrupted the turn" }); + expect(harness.agent.hasQueuedMessages()).toBe(false); + expect(harness.faux.getCallLog()).toHaveLength(1); + }); + + it("launches Goal-owned recovery after a terminal system error settles", async () => { + const harness = await createHarness({ + persistSession: true, + settings: { retry: { enabled: false, maxRetries: 0, baseDelayMs: 1 } }, + extensionFactories: [goalExtension, ttsrExtension], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Recover the terminal system error"); + harness.setResponses([ + controlTokenLeakResponse(), + fauxAssistantMessage([fauxToolCall("update_goal", { status: "complete" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("goal recovery completed")]), + ]); + + await harness.session.prompt("continue monitoring"); + + expect(harness.faux.getCallLog().length).toBeGreaterThanOrEqual(2); + expect(JSON.stringify(harness.faux.getCallLog()[1]?.context.messages)).toContain( + "Continue working toward the active thread goal.", + ); + expect(await readGoal(ref)).toMatchObject({ status: "complete" }); + }); + + it("drops Goal-owned recovery when the user aborts at the public agent_end boundary", async () => { + let abort: Promise | undefined; + const harness = await createHarness({ + persistSession: true, + settings: { retry: { enabled: false, maxRetries: 0, baseDelayMs: 1 } }, + extensionFactories: [goalExtension, ttsrExtension], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Drop stale Goal recovery"); + harness.session.subscribe((event) => { + if (event.type === "agent_end" && abort === undefined) abort = harness.session.abort(); + }); + harness.setResponses([controlTokenLeakResponse(), fauxAssistantMessage([fauxText("ordinary user response")])]); + + await harness.session.prompt("continue monitoring"); + await abort; + + expect(harness.agent.hasQueuedMessages()).toBe(false); + expect(await readGoal(ref)).toMatchObject({ status: "blocked", blockedReason: "user interrupted the turn" }); + expect(harness.faux.getCallLog()).toHaveLength(1); + + await harness.session.prompt("ordinary follow-up"); + + expect(harness.faux.getCallLog()).toHaveLength(2); + expect(JSON.stringify(harness.faux.getCallLog()[1]?.context.messages)).not.toContain( + "Continue working toward the active thread goal.", + ); + }); + + it("resumes Goal recovery after a canceled settlement delivery", async () => { + let abort: Promise | undefined; + const harness = await createHarness({ + persistSession: true, + settings: { retry: { enabled: false, maxRetries: 0, baseDelayMs: 1 } }, + extensionFactories: [goalExtension, ttsrExtension], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Resume canceled Goal recovery"); + harness.session.subscribe((event) => { + if (event.type === "agent_end" && abort === undefined) abort = harness.session.abort(); + }); + harness.setResponses([ + controlTokenLeakResponse(), + fauxAssistantMessage([fauxToolCall("update_goal", { status: "complete" })], { stopReason: "toolUse" }), + fauxAssistantMessage([fauxText("resumed recovery completed")]), + ]); + + await harness.session.prompt("continue monitoring"); + await abort; + expect(await readGoal(ref)).toMatchObject({ status: "blocked" }); + + const recoverySettled = waitForAgentSettled(harness.session); + await harness.session.prompt("/goal resume"); + await recoverySettled; + + expect(harness.faux.getCallLog().length).toBeGreaterThanOrEqual(2); + expect(JSON.stringify(harness.faux.getCallLog()[1]?.context.messages)).toContain( + "Continue working toward the active thread goal.", + ); + expect(await readGoal(ref)).toMatchObject({ status: "complete" }); + }); +}); + +interface SettlementGate { + readonly extension: ExtensionFactory; + readonly started: Promise; + readonly release: () => void; +} + +function createSettlementGate(): SettlementGate { + let signalStarted: (() => void) | undefined; + let release: (() => void) | undefined; + const started = new Promise((resolve) => { + signalStarted = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + return { + extension: (pi) => { + pi.on("agent_settled", async () => { + signalStarted?.(); + await released; + }); + }, + started, + release: () => release?.(), + }; +} + +function unavailableToolResponse() { + return fauxAssistantMessage([fauxText(' inert imitation')]); +} + +function controlTokenLeakResponse() { + const leaked = ["<", "|", "sep", "|", ">"].join(""); + return fauxAssistantMessage([fauxThinking(`Thinking... ${leaked} ${leaked} ${leaked} trailing ${"x".repeat(400)}`)]); +} + +function waitForAgentSettled(session: Harness["session"]): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out waiting for resumed Goal recovery to settle")); + }, 5_000); + const unsubscribe = session.subscribe((event) => { + if (event.type !== "agent_settled") return; + clearTimeout(timeout); + unsubscribe(); + resolve(); + }); + }); +} diff --git a/packages/coding-agent/test/suite/goal-ttsr-user-abort-race.test.ts b/packages/coding-agent/test/suite/goal-ttsr-user-abort-race.test.ts new file mode 100644 index 000000000..6776b0dd0 --- /dev/null +++ b/packages/coding-agent/test/suite/goal-ttsr-user-abort-race.test.ts @@ -0,0 +1,159 @@ +import { fauxAssistantMessage, fauxText, fauxThinking } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } 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 ttsrExtension from "../../src/core/extensions/builtin/ttsr/index.ts"; +import type { ExtensionUIContext } from "../../src/core/extensions/types.ts"; +import { theme } from "../../src/modes/interactive/theme/theme.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +describe("user abort racing a TTSR system abort", () => { + const harnesses: Harness[] = []; + + afterEach(() => { + vi.restoreAllMocks(); + while (harnesses.length > 0) harnesses.pop()?.cleanup(); + }); + + it("promotes the joined abort to user and cancels remediation", async () => { + const abortSources: Array = []; + let sessionAbortCount = 0; + let terminalInput: ((data: string) => void) | undefined; + let signalSystemAbort: (() => void) | undefined; + const systemAbortStarted = new Promise((resolve) => { + signalSystemAbort = resolve; + }); + const harness = await createHarness({ + persistSession: true, + extensionFactories: [ + goalExtension, + ttsrExtension, + (pi) => { + pi.on("session_start", () => { + pi.events?.emit("terminal_monitor_state", { activeCount: 1 }); + }); + pi.on("agent_end", (event) => { + abortSources.push(event.abortSource); + }); + pi.on("session_abort", () => { + sessionAbortCount += 1; + }); + }, + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({ + mode: "tui", + uiContext: createUi((handler) => { + terminalInput = handler; + }), + }); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Keep the user in control"); + const originalAbort = harness.agent.abort.bind(harness.agent); + const abortSpy = vi.spyOn(harness.agent, "abort").mockImplementation(() => { + signalSystemAbort?.(); + originalAbort(); + }); + harness.setResponses([fauxAssistantMessage([fauxText(' inert imitation')])]); + + const prompt = harness.session.prompt("continue monitoring"); + await systemAbortStarted; + terminalInput?.("\u001b"); + await harness.session.abort(); + await prompt; + + expect(abortSpy).toHaveBeenCalledTimes(1); + expect(abortSources).toContain("user"); + expect(abortSources).not.toContain("system"); + expect(sessionAbortCount).toBe(0); + expect(await readGoal(ref)).toMatchObject({ status: "blocked", blockedReason: "user interrupted the turn" }); + expect(harness.faux.getCallLog()).toHaveLength(1); + }); + + it("aborts each consecutive TTSR recovery generation with system provenance", async () => { + const abortSources: Array = []; + const scheduledContinuations: unknown[] = []; + const harness = await createHarness({ + persistSession: true, + settings: { retry: { enabled: true, maxRetries: 1, baseDelayMs: 1 } }, + extensionFactories: [ + goalExtension, + ttsrExtension, + (pi) => { + pi.on("session_start", () => { + pi.events?.emit("terminal_monitor_state", { activeCount: 1 }); + }); + pi.on("agent_end", (event) => { + abortSources.push(event.abortSource); + }); + pi.events?.on("goal_continuation_scheduled", (data) => { + scheduledContinuations.push(data); + }); + }, + ], + }); + harnesses.push(harness); + await harness.session.bindExtensions({}); + const ref = goalStoreRef(harness.sessionManager, harness.tempDir); + await createGoal(ref, "Keep the monitor live through repeated remediation"); + const originalAbort = harness.agent.abort.bind(harness.agent); + const abortSpy = vi.spyOn(harness.agent, "abort").mockImplementation(() => { + originalAbort(); + }); + const leaked = ["<", "|", "sep", "|", ">"].join(""); + harness.setResponses([ + fauxAssistantMessage([fauxThinking(`Thinking... ${leaked} ${leaked} ${leaked} trailing ${"x".repeat(400)}`)]), + fauxAssistantMessage([fauxThinking(`Retrying with collapsed output ${"!".repeat(800)}`)]), + fauxAssistantMessage([fauxText("clean recovery")]), + ]); + + await harness.session.prompt("continue monitoring"); + + expect(abortSources).toEqual(["system", "system", undefined]); + expect(abortSpy).toHaveBeenCalledTimes(2); + expect(await readGoal(ref)).toMatchObject({ status: "active" }); + expect(scheduledContinuations).toContainEqual( + expect.objectContaining({ activeMonitorCount: 1, delayMs: 240_000 }), + ); + }); +}); + +function createUi(captureInput: (handler: (data: string) => void) => void): ExtensionUIContext { + return { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: () => {}, + onTerminalInput: (handler) => { + captureInput(handler); + return () => {}; + }, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: async (): Promise => { + throw new Error("Race test does not render custom UI"); + }, + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + addAutocompleteProvider: () => {}, + setEditorComponent: () => {}, + getEditorComponent: () => undefined, + theme, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ success: false, error: "UI not available" }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, + }; +} diff --git a/packages/coding-agent/test/suite/ttsr-extension.test.ts b/packages/coding-agent/test/suite/ttsr-extension.test.ts index 9998a320f..14a4b9097 100644 --- a/packages/coding-agent/test/suite/ttsr-extension.test.ts +++ b/packages/coding-agent/test/suite/ttsr-extension.test.ts @@ -149,7 +149,7 @@ describe("collapse remediation persistence", () => { const lines = readSessionLines(harness); const entries = readSessionEntries(harness); expect(lines.length).toBe(entries.length); - expect(lines.length).toBe(7); + expect(lines.length).toBe(6); expectTtsrActivation(entries, { owner: "collapse-repetition", rules: ["collapse-repetition"], @@ -211,7 +211,7 @@ describe("leakage remediation retry", () => { const lines = readSessionLines(harness); const entries = readSessionEntries(harness); - expect(lines.length).toBe(6); + expect(lines.length).toBe(5); expectTtsrActivation(entries, { owner: "control-token-leak", rules: ["control-token-leak"], @@ -326,8 +326,8 @@ describe("repetitive turns remediation", () => { expect(getMessageText(harness.session.messages.at(-1))).toContain("breaking the loop"); const entries = readSessionEntries(harness); - const injectionEntries = entries.filter((e) => e.type === "custom" && e.customType === "ttsr-injection"); - expect(injectionEntries.length).toBe(nudges.length); + const activationEntries = entries.filter((e) => e.type === "custom" && e.customType === "rule-activation"); + expect(activationEntries.length).toBe(nudges.length); }); it("detects repetition generically, not a baked-in phrase", async () => { diff --git a/packages/coding-agent/test/ttsr/coordinator-races.test.ts b/packages/coding-agent/test/ttsr/coordinator-races.test.ts index cea5eaf27..d6b4bece3 100644 --- a/packages/coding-agent/test/ttsr/coordinator-races.test.ts +++ b/packages/coding-agent/test/ttsr/coordinator-races.test.ts @@ -1,7 +1,6 @@ import { readFileSync } from "node:fs"; import { fauxAssistantMessage, fauxText, fauxThinking } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; - import { claimAbort, createGenerationState, @@ -61,8 +60,8 @@ function readSessionEntries(harness: Harness): PersistedEntry[] { }); } -function injectionRecords(entries: PersistedEntry[]): PersistedEntry[] { - return entries.filter((entry) => entry.type === "custom" && entry.customType === TTSR_INJECTION_CUSTOM_TYPE); +function activationRecords(entries: PersistedEntry[]): PersistedEntry[] { + return entries.filter((entry) => entry.type === "custom" && entry.customType === "rule-activation"); } function nudgeMessages(entries: PersistedEntry[]): PersistedEntry[] { @@ -192,7 +191,7 @@ describe("coordinator races through the session wiring", () => { expect(assistants[0]?.stopReason).toBe("error"); expect(assistants[0]?.errorMessage).toBe(LEAK_ERROR_MESSAGE); expect(Array.isArray(assistants[0]?.content) ? assistants[0].content.length : -1).toBe(0); - const records = injectionRecords(entries); + const records = activationRecords(entries); expect(records.length).toBe(1); expect(records[0]?.data?.owner).toBe("control-token-leak"); expect(Array.isArray(records[0]?.data?.rules) ? records[0].data.rules : []).toEqual([ @@ -224,7 +223,7 @@ describe("coordinator races through the session wiring", () => { await harness.session.waitForIdle(); const entries = readSessionEntries(harness); expect(nudgeMessages(entries).length).toBe(1); - const records = injectionRecords(entries); + const records = activationRecords(entries); expect(records.length).toBe(1); expect(records[0]?.data?.owner).toBe("collapse-repetition"); expect(harness.faux.getCallLog().length).toBe(2); @@ -269,7 +268,7 @@ describe("coordinator races through the session wiring", () => { expect(assistants.length).toBe(1); expect(assistants[0]?.stopReason).toBe("error"); expect(assistants[0]?.errorMessage).toBe(LEAK_ERROR_MESSAGE); - expect(injectionRecords(entries).length).toBe(1); + expect(activationRecords(entries).length).toBe(1); expect(nudgeMessages(entries).length).toBe(0); }); @@ -293,7 +292,7 @@ describe("coordinator races through the session wiring", () => { expect(assistants.filter((m) => m.stopReason === "error" && m.errorMessage === LEAK_ERROR_MESSAGE).length).toBe( 2, ); - expect(injectionRecords(entries).length).toBe(2); + expect(activationRecords(entries).length).toBe(2); expect(nudgeMessages(entries).length).toBe(0); expect(assistants.map((message) => getMessageText(message)).join("\n")).toContain("clean answer"); }); diff --git a/packages/coding-agent/test/ttsr/extension-wiring.test.ts b/packages/coding-agent/test/ttsr/extension-wiring.test.ts index ec20d9290..aaecf846b 100644 --- a/packages/coding-agent/test/ttsr/extension-wiring.test.ts +++ b/packages/coding-agent/test/ttsr/extension-wiring.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import ttsrExtension from "../../src/core/extensions/builtin/ttsr/index.ts"; import { LEAK_ERROR_MESSAGE } from "../../src/core/extensions/builtin/ttsr/prompts.ts"; import { TTSR_INJECTION_CUSTOM_TYPE } from "../../src/core/extensions/builtin/ttsr/types.ts"; +import type { ExtensionUIContext } from "../../src/core/extensions/types.ts"; +import { theme } from "../../src/modes/interactive/theme/theme.ts"; import { createHarness, getMessageText, type Harness } from "../suite/harness.ts"; const RULE_ACTIVATION_ENTRY_TYPE = "rule-activation"; @@ -45,11 +47,49 @@ function thinkingTextOf(message: PersistedMessage | undefined): string { .join(""); } +function createUi(notices: string[]): ExtensionUIContext { + return { + select: async () => undefined, + confirm: async () => false, + input: async () => undefined, + notify: (message) => notices.push(message), + onTerminalInput: () => () => {}, + setStatus: () => {}, + setWorkingMessage: () => {}, + setWorkingVisible: () => {}, + setWorkingIndicator: () => {}, + setHiddenThinkingLabel: () => {}, + setWidget: () => {}, + setFooter: () => {}, + setHeader: () => {}, + setTitle: () => {}, + custom: async (): Promise => { + throw new Error("TTSR wiring tests do not render custom UI"); + }, + pasteToEditor: () => {}, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + addAutocompleteProvider: () => {}, + setEditorComponent: () => {}, + getEditorComponent: () => undefined, + theme, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ success: false, error: "UI not available" }), + getToolsExpanded: () => false, + setToolsExpanded: () => {}, + }; +} + describe("ttsr extension wiring", () => { let harness: Harness; + let notices: string[]; beforeEach(async () => { + notices = []; harness = await createHarness({ extensionFactories: [ttsrExtension], persistSession: true }); + await harness.session.bindExtensions({ mode: "tui", uiContext: createUi(notices) }); }); afterEach(() => { @@ -72,14 +112,16 @@ describe("ttsr extension wiring", () => { expect(thinking.startsWith("analyzing the problem")).toBe(true); expect(thinking.length).toBeLessThan(40); expect("!".repeat(100).length).toBeLessThan(600); + expect(notices).toEqual([]); const injections = entries.filter((e) => e.type === "custom" && e.customType === TTSR_INJECTION_CUSTOM_TYPE); - expect(injections.length).toBeGreaterThan(0); + expect(injections).toHaveLength(0); const nudges = entries.filter((e) => e.type === "custom_message" && e.customType === TTSR_INJECTION_CUSTOM_TYPE); expect(nudges.length).toBeGreaterThan(0); const activations = entries.filter((e) => e.type === "custom" && e.customType === RULE_ACTIVATION_ENTRY_TYPE); + expect(activations).toHaveLength(1); expect(activations).toContainEqual( expect.objectContaining({ data: { @@ -113,9 +155,10 @@ describe("ttsr extension wiring", () => { expect(Array.isArray(shelled?.content) ? shelled.content : [1]).toHaveLength(0); const injections = entries.filter((e) => e.type === "custom" && e.customType === TTSR_INJECTION_CUSTOM_TYPE); - expect(injections.length).toBeGreaterThan(0); + expect(injections).toHaveLength(0); const activations = entries.filter((e) => e.type === "custom" && e.customType === RULE_ACTIVATION_ENTRY_TYPE); + expect(activations).toHaveLength(1); expect(activations).toContainEqual( expect.objectContaining({ data: { diff --git a/packages/coding-agent/test/ttsr/persistence.test.ts b/packages/coding-agent/test/ttsr/persistence.test.ts index f83770e1b..13dbf4afc 100644 --- a/packages/coding-agent/test/ttsr/persistence.test.ts +++ b/packages/coding-agent/test/ttsr/persistence.test.ts @@ -2,6 +2,10 @@ import { readFileSync } from "node:fs"; import { fauxAssistantMessage, fauxText, fauxThinking } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; +import { + parseRuleActivationDetails, + RULE_ACTIVATION_ENTRY_TYPE, +} from "../../src/core/extensions/builtin/rule-activation/types.ts"; import ttsrExtension from "../../src/core/extensions/builtin/ttsr/index.ts"; import { TtsrManager, type TtsrMatchContext } from "../../src/core/extensions/builtin/ttsr/manager.ts"; import { COLLAPSE_RULE_NAME } from "../../src/core/extensions/builtin/ttsr/prompts.ts"; @@ -65,7 +69,13 @@ function readPersistedEntries(file: string): PersistedEntry[] { function injectedNamesFrom(entries: readonly SessionEntry[]): string[] { const names: string[] = []; for (const entry of entries) { - if (entry.type !== "custom" || entry.customType !== TTSR_INJECTION_CUSTOM_TYPE) continue; + if (entry.type !== "custom") continue; + if (entry.customType === RULE_ACTIVATION_ENTRY_TYPE) { + const details = parseRuleActivationDetails(entry.data); + if (details?.kind === "ttsr") names.push(...details.rules); + continue; + } + if (entry.customType !== TTSR_INJECTION_CUSTOM_TYPE) continue; const data: unknown = entry.data; if (typeof data !== "object" || data === null || !("rules" in data)) continue; const rules = data.rules; @@ -118,14 +128,14 @@ describe("ttsr persistence", () => { const file = sessionFileOf(harness); const persisted = readPersistedEntries(file); const persistedRecords = persisted.filter( - (entry) => entry.type === "custom" && entry.customType === TTSR_INJECTION_CUSTOM_TYPE, + (entry) => entry.type === "custom" && entry.customType === RULE_ACTIVATION_ENTRY_TYPE, ); expect(persistedRecords.length).toBeGreaterThan(0); const reopened = SessionManager.open(file); const reopenedRecords = reopened .getEntries() - .filter((entry) => entry.type === "custom" && entry.customType === TTSR_INJECTION_CUSTOM_TYPE); + .filter((entry) => entry.type === "custom" && entry.customType === RULE_ACTIVATION_ENTRY_TYPE); expect(reopenedRecords.length).toBe(persistedRecords.length); const names = injectedNamesFrom(reopened.getEntries()); expect(names).toContain(COLLAPSE_RULE_NAME); @@ -166,7 +176,7 @@ describe("ttsr persistence", () => { const persisted = readPersistedEntries(sessionFileOf(harness)); expect( - persisted.filter((entry) => entry.type === "custom" && entry.customType === TTSR_INJECTION_CUSTOM_TYPE).length, + persisted.filter((entry) => entry.type === "custom" && entry.customType === RULE_ACTIVATION_ENTRY_TYPE).length, ).toBeGreaterThan(0); expect( persisted.filter((entry) => entry.type === "custom_message" && entry.customType === TTSR_INJECTION_CUSTOM_TYPE)