diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3f6c3fe67..b2b1408ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- Fixed active goal continuation so aborted, errored, or aborting tool-execution turns do not queue another hidden follow-up after user interruption, and made retry cancellation catch Esc immediately when retry starts. + ## [2026.6.21] - 2026-06-21 ### Fixed diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 213aabf92..54f41080e 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -3064,6 +3064,8 @@ export class AgentSession { } const delayMs = providerDelayMs ?? settings.baseDelayMs * 2 ** (this._retryAttempt - 1); + // Prepare before auto_retry_start so an immediate Esc can cancel the retry sleep. + this._retryAbortController = new AbortController(); this._emit({ type: "auto_retry_start", @@ -3081,7 +3083,6 @@ export class AgentSession { } // Wait with exponential backoff (abortable) - this._retryAbortController = new AbortController(); try { await sleep(delayMs, this._retryAbortController.signal); } catch { diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/command-registration.ts b/packages/coding-agent/src/core/extensions/builtin/goal/command-registration.ts new file mode 100644 index 000000000..f07633418 --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/command-registration.ts @@ -0,0 +1,111 @@ +import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; +import { parseGoalCommand } from "./command.ts"; +import { formatGoalForTool, goalStatusLabel } from "./format.ts"; +import { clearGoal, createGoal, readGoal, updateGoal } from "./store.ts"; +import type { Goal, GoalAccountingMode, GoalStoreRef, TokenUsageSnapshot } from "./types.ts"; +import { updateGoalUi } from "./ui.ts"; + +const GOAL_USAGE = "Usage: /goal "; +const GOAL_EMPTY_HINT = "No goal is currently set."; +const REPLACE_GOAL_CHOICE = "Replace current goal"; +const CANCEL_REPLACE_GOAL_CHOICE = "Cancel"; +const EMPTY_USAGE: TokenUsageSnapshot = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; + +export type GoalCommandRegistrationDeps = { + readonly goalStoreRef: (ctx: ExtensionContext) => GoalStoreRef; + readonly accountCurrentAgentTurn: ( + ctx: ExtensionContext, + usage: TokenUsageSnapshot, + mode: GoalAccountingMode, + ) => Promise; + readonly beginAgentGoalAccounting: (goal: Goal) => void; + readonly stopAgentGoalAccounting: (goalId: string) => void; + readonly clearAgentGoalAccounting: () => void; + readonly queueGoalContinuation: (pi: ExtensionAPI, ctx: ExtensionContext, goal: Goal) => void; +}; + +export function registerGoalCommand(pi: ExtensionAPI, deps: GoalCommandRegistrationDeps): void { + pi.registerCommand("goal", { + description: "Set, inspect, pause, resume, or clear the persistent goal", + handler: async (rawArgs, ctx) => { + const command = parseGoalCommand(rawArgs); + try { + switch (command.kind) { + case "show": { + const goal = await readGoal(deps.goalStoreRef(ctx)); + updateGoalUi(ctx, goal); + ctx.ui.notify( + goal === null ? `${GOAL_USAGE}\n${GOAL_EMPTY_HINT}` : formatGoalForTool(goal), + goal ? "info" : "warning", + ); + return; + } + case "setObjective": { + await setGoalObjective(pi, ctx, command.objective, deps); + return; + } + case "setStatus": { + if (command.status === "paused") { + await deps.accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); + } + const goal = await updateGoal(deps.goalStoreRef(ctx), { status: command.status }); + if (goal.status === "active") { + deps.beginAgentGoalAccounting(goal); + } else { + deps.stopAgentGoalAccounting(goal.id); + } + updateGoalUi(ctx, goal); + ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info"); + deps.queueGoalContinuation(pi, ctx, goal); + return; + } + case "clear": { + await deps.accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); + const cleared = await clearGoal(deps.goalStoreRef(ctx)); + deps.clearAgentGoalAccounting(); + updateGoalUi(ctx, null); + ctx.ui.notify( + cleared ? "Goal cleared" : "No goal to clear\nThis thread does not currently have a goal.", + cleared ? "info" : "warning", + ); + return; + } + } + } catch (error) { + ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + } + }, + }); +} + +async function setGoalObjective( + pi: ExtensionAPI, + ctx: ExtensionContext, + objective: string, + deps: GoalCommandRegistrationDeps, +): Promise { + const ref = deps.goalStoreRef(ctx); + const current = await readGoal(ref); + if (current !== null) { + const shouldReplace = await confirmReplaceGoal(ctx, objective); + if (!shouldReplace) return; + } + + if (current?.status === "active") { + await deps.accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); + } + const goal = current === null ? await createGoal(ref, objective) : await updateGoal(ref, { objective }); + if (goal.status === "active") deps.beginAgentGoalAccounting(goal); + updateGoalUi(ctx, goal); + ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info"); + deps.queueGoalContinuation(pi, ctx, goal); +} + +async function confirmReplaceGoal(ctx: ExtensionContext, objective: string): Promise { + if (!ctx.hasUI) return true; + const choice = await ctx.ui.select(`Replace goal?\nNew objective: ${objective}`, [ + REPLACE_GOAL_CHOICE, + CANCEL_REPLACE_GOAL_CHOICE, + ]); + return choice === REPLACE_GOAL_CHOICE; +} 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 b6f317903..86a3291b9 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/continuation.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/continuation.ts @@ -1,5 +1,9 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { Goal } from "./types.ts"; +type AssistantAgentMessage = Extract; +type ToolResultAgentMessage = Extract; + export function shouldQueueGoalContinuationWhenIdle( goal: Goal | null, isIdle: boolean, @@ -8,6 +12,43 @@ export function shouldQueueGoalContinuationWhenIdle( return goal?.status === "active" && isIdle && !hasPendingMessages; } -export function shouldQueueGoalContinuationAfterAgentEnd(goal: Goal | null, hasPendingMessages: boolean): goal is Goal { - return goal?.status === "active" && !hasPendingMessages; +export function shouldQueueGoalContinuationAfterAgentEnd( + goal: Goal | null, + hasPendingMessages: boolean, + messages: readonly AgentMessage[], +): goal is Goal { + return goal?.status === "active" && !hasPendingMessages && didAgentEndCleanly(messages); +} + +function didAgentEndCleanly(messages: readonly AgentMessage[]): boolean { + const lastAssistantIndex = findLastAssistantMessageIndex(messages); + if (lastAssistantIndex === undefined) return false; + + const lastAssistant = messages[lastAssistantIndex]; + if (lastAssistant?.role !== "assistant" || !isContinuableStopReason(lastAssistant.stopReason)) return false; + + for (let index = lastAssistantIndex + 1; index < messages.length; index++) { + const message = messages[index]; + if (message?.role === "toolResult" && isAbortedToolResult(message)) return false; + } + return true; +} + +function findLastAssistantMessageIndex(messages: readonly AgentMessage[]): number | undefined { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") { + return index; + } + } + return undefined; +} + +function isContinuableStopReason(stopReason: AssistantAgentMessage["stopReason"]): boolean { + return stopReason === "stop" || stopReason === "toolUse" || stopReason === "length"; +} + +function isAbortedToolResult(message: ToolResultAgentMessage): boolean { + if (!message.isError) return false; + return message.content.some((content) => content.type === "text" && /\babort(?:ed)?\b/i.test(content.text)); } 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 4573c59eb..7932796ee 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/index.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/index.ts @@ -1,28 +1,23 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; -import { Type } from "typebox"; import { getAgentDir } from "../../../../config.ts"; -import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "../../types.ts"; -import { parseGoalCommand } from "./command.ts"; +import type { ExtensionAPI, ExtensionContext } from "../../types.ts"; +import { registerGoalCommand } from "./command-registration.ts"; import { shouldQueueGoalContinuationAfterAgentEnd, shouldQueueGoalContinuationWhenIdle } from "./continuation.ts"; -import { formatGoalForTool, formatGoalToolResponse, goalStatusLabel } from "./format.ts"; +import { formatGoalForTool, goalStatusLabel } from "./format.ts"; import { buildContinuationPrompt } from "./prompt.ts"; -import { accountGoalUsage, clearGoal, createGoal, readGoal, updateGoal } from "./store.ts"; +import { accountGoalUsage, readGoal, updateGoal } from "./store.ts"; +import { registerGoalTools } from "./tool-registration.ts"; import type { Goal, GoalAccountingMode, GoalStoreRef, TokenUsageSnapshot } from "./types.ts"; -import { COMPLETABLE_GOAL_STATUS_VALUES, isRecord } from "./types.ts"; +import { isRecord } from "./types.ts"; import { updateGoalUi } from "./ui.ts"; -const GOAL_USAGE = "Usage: /goal "; -const GOAL_EMPTY_HINT = "No goal is currently set."; const GOAL_CONTINUATION_MESSAGE_TYPE = "goal-continuation"; -const REPLACE_GOAL_CHOICE = "Replace current goal"; -const CANCEL_REPLACE_GOAL_CHOICE = "Cancel"; const RESUME_GOAL_CHOICE = "Resume goal"; const LEAVE_GOAL_PAUSED_CHOICE = "Leave paused"; const EMPTY_USAGE: TokenUsageSnapshot = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; const STALE_EXTENSION_CONTEXT_ERROR_PREFIX = "This extension ctx is stale after session replacement or reload."; -type GoalToolResult = AgentToolResult>; type AssistantUsageMessage = { role: "assistant"; usage: Record; @@ -37,127 +32,19 @@ export default function goalExtension(pi: ExtensionAPI): void { let agentGoalAccounting: AgentGoalAccounting | null = null; let completedThisTurnGoalId: string | null = null; - pi.registerTool({ - name: "create_goal", - label: "Create Goal", - description: - "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.\nFails if a goal already exists; use update_goal only for status.", - parameters: Type.Object( - { - objective: Type.String({ - description: - "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails.", - }), - }, - { additionalProperties: false }, - ), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - const ref = goalStoreRef(ctx); - if ((await readGoal(ref)) !== null) { - throw new Error( - "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete", - ); - } - const goal = await createGoal(ref, params.objective); - beginAgentGoalAccounting(goal); - updateGoalUi(ctx, goal); - return toolText(formatGoalToolResponse(goal)); - }, - }); - - pi.registerTool({ - name: "update_goal", - label: "Update Goal", - description: - "Update the existing goal.\nUse this tool only to mark the goal achieved.\nSet status to `complete` only when the objective has actually been achieved and no required work remains.\nDo not mark a goal complete merely because you are stopping work.\nYou cannot use this tool to pause or resume a goal; those status changes are controlled by the user or system.\nWhen marking the goal achieved with status `complete`, report the final elapsed time and token usage from the tool result to the user.", - parameters: Type.Object( - { - status: Type.Union( - COMPLETABLE_GOAL_STATUS_VALUES.map((status) => Type.Literal(status)), - { - description: - "Required. Set to complete only when the objective is achieved and no required work remains.", - }, - ), - }, - { additionalProperties: false }, - ), - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - if (params.status !== "complete") { - throw new Error( - "update_goal can only mark the existing goal complete; pause and resume are controlled by the user or system", - ); - } - await accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); - const goal = await updateGoal(goalStoreRef(ctx), { status: "complete" }); - markGoalCompletedThisTurn(goal); - updateGoalUi(ctx, goal); - return toolText(formatGoalToolResponse(goal)); - }, - }); - - pi.registerTool({ - name: "get_goal", - label: "Get Goal", - description: "Get the current goal for this thread, including status, token and elapsed-time usage.", - parameters: Type.Object({}, { additionalProperties: false }), - async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { - const goal = await readGoal(goalStoreRef(ctx)); - updateGoalUi(ctx, goal); - return toolText(formatGoalToolResponse(goal)); - }, + registerGoalTools(pi, { + goalStoreRef, + accountCurrentAgentTurn, + beginAgentGoalAccounting, + markGoalCompletedThisTurn, }); - - pi.registerCommand("goal", { - description: "Set, inspect, pause, resume, or clear the persistent goal", - handler: async (rawArgs, ctx) => { - const command = parseGoalCommand(rawArgs); - try { - switch (command.kind) { - case "show": { - const goal = await readGoal(goalStoreRef(ctx)); - updateGoalUi(ctx, goal); - ctx.ui.notify( - goal === null ? `${GOAL_USAGE}\n${GOAL_EMPTY_HINT}` : formatGoalForTool(goal), - goal ? "info" : "warning", - ); - return; - } - case "setObjective": { - await setGoalObjective(pi, ctx, command.objective); - return; - } - case "setStatus": { - if (command.status === "paused") { - await accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); - } - const goal = await updateGoal(goalStoreRef(ctx), { status: command.status }); - if (goal.status === "active") { - beginAgentGoalAccounting(goal); - } else { - stopAgentGoalAccounting(goal.id); - } - updateGoalUi(ctx, goal); - ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info"); - queueGoalContinuation(pi, ctx, goal); - return; - } - case "clear": { - await accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); - const cleared = await clearGoal(goalStoreRef(ctx)); - clearAgentGoalAccounting(); - updateGoalUi(ctx, null); - ctx.ui.notify( - cleared ? "Goal cleared" : "No goal to clear\nThis thread does not currently have a goal.", - cleared ? "info" : "warning", - ); - return; - } - } - } catch (error) { - ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); - } - }, + registerGoalCommand(pi, { + goalStoreRef, + accountCurrentAgentTurn, + beginAgentGoalAccounting, + stopAgentGoalAccounting, + clearAgentGoalAccounting, + queueGoalContinuation, }); pi.on("session_start", async (event, ctx) => { @@ -198,7 +85,10 @@ export default function goalExtension(pi: ExtensionAPI): void { clearAgentGoalAccounting(); } updateGoalUiBestEffort(ctx, goal); - if (goal?.status === "active" && shouldQueueGoalContinuationAfterAgentEnd(goal, ctx.hasPendingMessages())) { + if ( + goal?.status === "active" && + shouldQueueGoalContinuationAfterAgentEnd(goal, ctx.hasPendingMessages(), event.messages) + ) { queueHiddenGoalPrompt(pi, buildContinuationPrompt(goal)); } }); @@ -210,33 +100,6 @@ export default function goalExtension(pi: ExtensionAPI): void { clearAgentGoalAccounting(); }); - async function setGoalObjective(pi: ExtensionAPI, ctx: ExtensionContext, objective: string): Promise { - const ref = goalStoreRef(ctx); - const current = await readGoal(ref); - if (current !== null) { - const shouldReplace = await confirmReplaceGoal(ctx, objective); - if (!shouldReplace) return; - } - - if (current?.status === "active") { - await accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); - } - const goal = current === null ? await createGoal(ref, objective) : await updateGoal(ref, { objective }); - if (goal.status === "active") beginAgentGoalAccounting(goal); - updateGoalUi(ctx, goal); - ctx.ui.notify(`Goal ${goalStatusLabel(goal.status)}\n${formatGoalForTool(goal)}`, "info"); - queueGoalContinuation(pi, ctx, goal); - } - - async function confirmReplaceGoal(ctx: ExtensionContext, objective: string): Promise { - if (!ctx.hasUI) return true; - const choice = await ctx.ui.select(`Replace goal?\nNew objective: ${objective}`, [ - REPLACE_GOAL_CHOICE, - CANCEL_REPLACE_GOAL_CHOICE, - ]); - return choice === REPLACE_GOAL_CHOICE; - } - async function maybePromptResumePausedGoal( pi: ExtensionAPI, ctx: ExtensionContext, @@ -359,10 +222,6 @@ function cwdStoreKey(cwd: string): string { return createHash("sha256").update(cwd).digest("hex").slice(0, 24); } -function toolText(text: string): GoalToolResult { - return { content: [{ type: "text" as const, text }], details: {} }; -} - function collectAssistantUsage(messages: unknown[]): TokenUsageSnapshot { const usage: TokenUsageSnapshot = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; for (const message of messages) { diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/tool-registration.ts b/packages/coding-agent/src/core/extensions/builtin/goal/tool-registration.ts new file mode 100644 index 000000000..58c6310da --- /dev/null +++ b/packages/coding-agent/src/core/extensions/builtin/goal/tool-registration.ts @@ -0,0 +1,99 @@ +import { Type } from "typebox"; +import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "../../types.ts"; +import { formatGoalToolResponse } from "./format.ts"; +import { createGoal, readGoal, updateGoal } from "./store.ts"; +import type { Goal, GoalAccountingMode, GoalStoreRef, TokenUsageSnapshot } from "./types.ts"; +import { COMPLETABLE_GOAL_STATUS_VALUES } from "./types.ts"; +import { updateGoalUi } from "./ui.ts"; + +const EMPTY_USAGE: TokenUsageSnapshot = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 }; + +type GoalToolResult = AgentToolResult>; + +export type GoalToolRegistrationDeps = { + readonly goalStoreRef: (ctx: ExtensionContext) => GoalStoreRef; + readonly accountCurrentAgentTurn: ( + ctx: ExtensionContext, + usage: TokenUsageSnapshot, + mode: GoalAccountingMode, + ) => Promise; + readonly beginAgentGoalAccounting: (goal: Goal) => void; + readonly markGoalCompletedThisTurn: (goal: Goal) => void; +}; + +export function registerGoalTools(pi: ExtensionAPI, deps: GoalToolRegistrationDeps): void { + pi.registerTool({ + name: "create_goal", + label: "Create Goal", + description: + "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.\nFails if a goal already exists; use update_goal only for status.", + parameters: Type.Object( + { + objective: Type.String({ + description: + "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails.", + }), + }, + { additionalProperties: false }, + ), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const ref = deps.goalStoreRef(ctx); + if ((await readGoal(ref)) !== null) { + throw new Error( + "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete", + ); + } + const goal = await createGoal(ref, params.objective); + deps.beginAgentGoalAccounting(goal); + updateGoalUi(ctx, goal); + return toolText(formatGoalToolResponse(goal)); + }, + }); + + pi.registerTool({ + name: "update_goal", + label: "Update Goal", + description: + "Update the existing goal.\nUse this tool only to mark the goal achieved.\nSet status to `complete` only when the objective has actually been achieved and no required work remains.\nDo not mark a goal complete merely because you are stopping work.\nYou cannot use this tool to pause or resume a goal; those status changes are controlled by the user or system.\nWhen marking the goal achieved with status `complete`, report the final elapsed time and token usage from the tool result to the user.", + parameters: Type.Object( + { + status: Type.Union( + COMPLETABLE_GOAL_STATUS_VALUES.map((status) => Type.Literal(status)), + { + description: + "Required. Set to complete only when the objective is achieved and no required work remains.", + }, + ), + }, + { additionalProperties: false }, + ), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + if (params.status !== "complete") { + throw new Error( + "update_goal can only mark the existing goal complete; pause and resume are controlled by the user or system", + ); + } + await deps.accountCurrentAgentTurn(ctx, EMPTY_USAGE, "active"); + const goal = await updateGoal(deps.goalStoreRef(ctx), { status: "complete" }); + deps.markGoalCompletedThisTurn(goal); + updateGoalUi(ctx, goal); + return toolText(formatGoalToolResponse(goal)); + }, + }); + + pi.registerTool({ + name: "get_goal", + label: "Get Goal", + description: "Get the current goal for this thread, including status, token and elapsed-time usage.", + parameters: Type.Object({}, { additionalProperties: false }), + async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { + const goal = await readGoal(deps.goalStoreRef(ctx)); + updateGoalUi(ctx, goal); + return toolText(formatGoalToolResponse(goal)); + }, + }); +} + +function toolText(text: string): GoalToolResult { + return { content: [{ type: "text" as const, text }], details: {} }; +} diff --git a/packages/coding-agent/test/suite/goal-e2e.test.ts b/packages/coding-agent/test/suite/goal-e2e.test.ts index d3c60936f..4e55d20cb 100644 --- a/packages/coding-agent/test/suite/goal-e2e.test.ts +++ b/packages/coding-agent/test/suite/goal-e2e.test.ts @@ -1,4 +1,6 @@ +import type { AgentTool } from "@earendil-works/pi-agent-core"; import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; import { afterEach, describe, expect, it } from "vitest"; import goalExtension from "../../src/core/extensions/builtin/goal/index.ts"; import { createHarness, getMessageText, type Harness } from "./harness.ts"; @@ -23,6 +25,12 @@ function toolResultTexts(harness: Harness, toolName: string): string[] { .map((message) => getMessageText(message)); } +function goalContinuationEntries(harness: Harness) { + return harness.sessionManager.getEntries().filter((entry) => { + return entry.type === "custom_message" && entry.customType === "goal-continuation"; + }); +} + describe("goal extension end-to-end through the real AgentSession", () => { it("registers, creates, and completes a goal through real tool execution (budget-free)", async () => { const harness = await createHarness({ extensionFactories: [goalExtension] }); @@ -52,4 +60,83 @@ describe("goal extension end-to-end through the real AgentSession", () => { expect(updateResults).toHaveLength(1); expect(JSON.parse(updateResults[0] ?? "{}").goal).toMatchObject({ status: "complete" }); }, 20_000); + + it("does not queue another hidden continuation after aborting a retrying goal turn", async () => { + const harness = await createHarness({ + extensionFactories: [goalExtension], + settings: { retry: { enabled: true, maxRetries: 2, baseDelayMs: 100 } }, + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage("", { + stopReason: "error", + errorMessage: "overloaded_error", + }), + ]); + const retryEnded = new Promise((resolve) => { + const unsubscribe = harness.session.subscribe((event) => { + if (event.type === "auto_retry_start") { + void harness.session.abort(); + } + if (event.type === "auto_retry_end") { + unsubscribe(); + resolve(); + } + }); + }); + + await harness.session.prompt("/goal keep working until explicitly stopped"); + await retryEnded; + + expect(goalContinuationEntries(harness)).toHaveLength(1); + expect(harness.eventsOfType("auto_retry_end")).toEqual([ + expect.objectContaining({ success: false, finalError: "Retry cancelled" }), + ]); + expect(harness.session.retryAttempt).toBe(0); + expect(harness.session.isRetrying).toBe(false); + expect(harness.session.isStreaming).toBe(false); + expect(harness.faux.state.callCount).toBe(1); + }, 20_000); + + it("does not queue another hidden continuation after aborting during goal tool execution", async () => { + let toolStarted: () => void = () => {}; + let releaseToolExecution: () => void = () => {}; + const waitForToolStart = new Promise((resolve) => { + toolStarted = resolve; + }); + const toolRelease = new Promise((resolve) => { + releaseToolExecution = resolve; + }); + const waitTool: AgentTool = { + name: "wait", + label: "Wait", + description: "Wait until the test releases the tool", + parameters: Type.Object({}), + execute: async () => { + toolStarted(); + await toolRelease; + return { content: [{ type: "text", text: "waited" }], details: {} }; + }, + }; + const harness = await createHarness({ + extensionFactories: [goalExtension], + tools: [waitTool], + }); + harnesses.push(harness); + harness.setResponses([ + fauxAssistantMessage(fauxToolCall("wait", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("should not run after abort"), + ]); + + const promptPromise = harness.session.prompt("/goal keep working until explicitly stopped"); + await waitForToolStart; + const continuationCountBeforeAbort = goalContinuationEntries(harness).length; + const abortPromise = harness.session.abort(); + releaseToolExecution(); + await Promise.all([promptPromise, abortPromise]); + + expect(goalContinuationEntries(harness)).toHaveLength(continuationCountBeforeAbort); + expect(harness.faux.state.callCount).toBe(1); + expect(harness.session.isStreaming).toBe(false); + }, 20_000); }); diff --git a/packages/coding-agent/test/suite/goal-extension.test.ts b/packages/coding-agent/test/suite/goal-extension.test.ts index 40796484d..139c8ac9e 100644 --- a/packages/coding-agent/test/suite/goal-extension.test.ts +++ b/packages/coding-agent/test/suite/goal-extension.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { afterEach, describe, expect, it } from "vitest"; import goalExtension from "../../src/core/extensions/builtin/goal/index.ts"; import { goalFilePath, readGoal } from "../../src/core/extensions/builtin/goal/store.ts"; @@ -139,13 +140,52 @@ describe("goal extension contract (budget-free)", () => { await tools.get("create_goal")?.execute("c1", { objective: "Keep going" }, undefined, undefined, ctx); await runHandlers(handlers, "agent_start", { type: "agent_start" }, ctx); - await runHandlers(handlers, "agent_end", { type: "agent_end", messages: [] }, ctx); + await runHandlers( + handlers, + "agent_end", + { type: "agent_end", messages: [assistantMessageWithStopReason("stop")] }, + ctx, + ); expect(sent).toHaveLength(1); expect(sent[0]?.message.customType).toBe("goal-continuation"); expect(sent[0]?.message.display).toBe(false); expect(sent[0]?.message.content.toLowerCase()).not.toContain("token budget"); }); + + it("does not queue a hidden continuation prompt after an aborted agent_end", async () => { + const { tools, handlers, sent } = createGoalHarness(); + const ctx = await makeCtx(); + await tools.get("create_goal")?.execute("c1", { objective: "Stop when aborted" }, undefined, undefined, ctx); + + await runHandlers(handlers, "agent_start", { type: "agent_start" }, ctx); + await runHandlers( + handlers, + "agent_end", + { type: "agent_end", messages: [assistantMessageWithStopReason("aborted")] }, + ctx, + ); + + expect(sent).toHaveLength(0); + }); + + it("does not queue a hidden continuation prompt after an error agent_end", async () => { + const { tools, handlers, sent } = createGoalHarness(); + const ctx = await makeCtx(); + await tools + .get("create_goal") + ?.execute("c1", { objective: "Stop when provider errors" }, undefined, undefined, ctx); + + await runHandlers(handlers, "agent_start", { type: "agent_start" }, ctx); + await runHandlers( + handlers, + "agent_end", + { type: "agent_end", messages: [assistantMessageWithStopReason("error")] }, + ctx, + ); + + expect(sent).toHaveLength(0); + }); }); function textOf(result: { content?: Array<{ type: string; text?: string }> } | undefined): string { @@ -162,3 +202,29 @@ async function runHandlers( await handler(payload, ctx); } } + +function assistantMessageWithStopReason(stopReason: "aborted" | "error" | "stop"): AgentMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: "faux", + provider: "faux", + model: "faux", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + errorMessage: + stopReason === "aborted" + ? "Operation aborted" + : stopReason === "error" + ? "429 usage limit reached" + : undefined, + timestamp: Date.now(), + }; +} diff --git a/packages/coding-agent/test/suite/goal-modules.test.ts b/packages/coding-agent/test/suite/goal-modules.test.ts index a54e1af3b..1e8e6eccf 100644 --- a/packages/coding-agent/test/suite/goal-modules.test.ts +++ b/packages/coding-agent/test/suite/goal-modules.test.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it } from "vitest"; import { parseGoalCommand } from "../../src/core/extensions/builtin/goal/command.ts"; import { @@ -28,6 +29,43 @@ function makeGoal(overrides: Partial = {}): Goal { }; } +function assistantMessageWithStopReason(stopReason: "aborted" | "error" | "stop" | "toolUse"): AgentMessage { + return { + role: "assistant", + content: [{ type: "text", text: "" }], + api: "faux", + provider: "faux", + model: "faux", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + errorMessage: + stopReason === "aborted" + ? "Operation aborted" + : stopReason === "error" + ? "429 usage limit reached" + : undefined, + timestamp: Date.now(), + }; +} + +function abortedToolResultMessage(): AgentMessage { + return { + role: "toolResult", + toolCallId: "call-1", + toolName: "wait", + content: [{ type: "text", text: "Operation aborted" }], + isError: true, + timestamp: Date.now(), + }; +} + describe("goal command parsing", () => { it("maps bare input, keywords, and objectives", () => { expect(parseGoalCommand("")).toEqual({ kind: "show" }); @@ -50,9 +88,29 @@ describe("goal continuation gating", () => { }); it("queues after agent end for active goals with no pending messages", () => { - expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false)).toBe(true); - expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), true)).toBe(false); - expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "complete" }), false)).toBe(false); + const cleanMessages = [assistantMessageWithStopReason("stop")]; + expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false, cleanMessages)).toBe(true); + expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), true, cleanMessages)).toBe(false); + expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "complete" }), false, cleanMessages)).toBe( + false, + ); + expect(shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false, [])).toBe(false); + expect( + shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false, [ + assistantMessageWithStopReason("aborted"), + ]), + ).toBe(false); + expect( + shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false, [ + assistantMessageWithStopReason("error"), + ]), + ).toBe(false); + expect( + shouldQueueGoalContinuationAfterAgentEnd(makeGoal({ status: "active" }), false, [ + assistantMessageWithStopReason("toolUse"), + abortedToolResultMessage(), + ]), + ).toBe(false); }); });