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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -3081,7 +3083,6 @@ export class AgentSession {
}

// Wait with exponential backoff (abortable)
this._retryAbortController = new AbortController();
try {
await sleep(delayMs, this._retryAbortController.signal);
} catch {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <objective>";
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<Goal | null>;
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<void> {
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<boolean> {
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;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import type { Goal } from "./types.ts";

type AssistantAgentMessage = Extract<AgentMessage, { role: "assistant" }>;
type ToolResultAgentMessage = Extract<AgentMessage, { role: "toolResult" }>;

export function shouldQueueGoalContinuationWhenIdle(
goal: Goal | null,
isIdle: boolean,
Expand All @@ -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));
}
Loading