diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index fa5401ff0..16dec65eb 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -202,6 +202,10 @@ - Fixed `PI_DIALECT=minimax` being ignored by the owned tool-calling env selector. ([#2759](https://github.com/can1357/oh-my-pi/issues/2759)) + +### Added + +- Added per-tool abort metadata so stream-wide aborts can label matching tool-call placeholders separately from unaffected sibling calls ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)). ## [16.0.1] - 2026-06-15 ### Fixed diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 4c8f7b64f..cdfa20a73 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -112,6 +112,26 @@ function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: stri * tool's own window elapses. A cheap synchronous queue check; latency-bounded * at one tick. */ +/** + * Abort reason for a turn-wide interruption where only some tool calls caused + * the abort and sibling placeholders need neutral messages. + */ +export interface ToolScopedAbortReason { + readonly kind: "tool-scoped-abort"; + readonly message: string; + readonly toolCallMessages: Record; + readonly defaultToolCallMessage: string; +} + +/** Creates an abort reason that labels matching tool calls separately from siblings. */ +export function createToolScopedAbortReason( + message: string, + toolCallMessages: Record, + defaultToolCallMessage: string, +): ToolScopedAbortReason { + return { kind: "tool-scoped-abort", message, toolCallMessages, defaultToolCallMessage }; +} + const STEERING_INTERRUPT_POLL_MS = 250; class HarmonyLeakInterruption extends Error { @@ -173,6 +193,7 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage { cost: { ...message.usage.cost }, }, disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined, + toolCallAbortMessages: message.toolCallAbortMessages ? { ...message.toolCallAbortMessages } : undefined, }; } @@ -920,7 +941,8 @@ async function runLoopBody( ); const toolResults: ToolResultMessage[] = []; for (const toolCall of toolCalls) { - const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage); + const errorMessage = message.toolCallAbortMessages?.[toolCall.id] ?? message.errorMessage; + const result = createAbortedToolResult(toolCall, stream, message.stopReason, errorMessage); currentContext.messages.push(result); newMessages.push(result); toolResults.push(result); @@ -1610,6 +1632,34 @@ function emitDiscardedHarmonyPartial( }); } +function isStringRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value).every(child => typeof child === "string"); +} + +function toolScopedAbortReason(signal: AbortSignal | undefined): ToolScopedAbortReason | undefined { + const reason = signal?.reason; + if (!reason || typeof reason !== "object") return undefined; + if (Reflect.get(reason, "kind") !== "tool-scoped-abort") return undefined; + if (typeof Reflect.get(reason, "message") !== "string") return undefined; + if (typeof Reflect.get(reason, "defaultToolCallMessage") !== "string") return undefined; + return isStringRecord(Reflect.get(reason, "toolCallMessages")) ? reason : undefined; +} + +function buildToolCallAbortMessages( + message: AssistantMessage, + reason: ToolScopedAbortReason, +): Record | undefined { + let hasToolCall = false; + const messages: Record = {}; + for (const block of message.content) { + if (block.type !== "toolCall") continue; + hasToolCall = true; + messages[block.id] = reason.toolCallMessages[block.id] ?? reason.defaultToolCallMessage; + } + return hasToolCall ? messages : undefined; +} + /** Resolve the human-readable reason an abort carried. A caller that aborts via * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error` * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the @@ -1617,6 +1667,8 @@ function emitDiscardedHarmonyPartial( * `signal.reason` is the default `AbortError` `DOMException`) falls back to the * generic sentinel that downstream renderers treat as "no specific reason". */ export function abortReasonText(signal: AbortSignal | undefined): string { + const scopedReason = toolScopedAbortReason(signal); + if (scopedReason) return scopedReason.message; const reason = signal?.reason; if (typeof reason === "string" && reason.trim().length > 0) return reason; if (reason instanceof Error && reason.name !== "AbortError" && reason.message.trim().length > 0) { @@ -1625,6 +1677,7 @@ export function abortReasonText(signal: AbortSignal | undefined): string { return "Request was aborted"; } + function emitAbortedAssistantMessage( partialMessage: AssistantMessage | null, addedPartial: boolean, @@ -1665,6 +1718,11 @@ function emitAbortedAssistantMessage( // labeled user interrupt still surfaces through `errorMessage`, but partial // tool arguments are unsafe to keep and can carry incomplete provider IDs. const retained = retainCompletedToolCalls(base, completedToolCallIds); + const scopedAbort = toolScopedAbortReason(requestSignal); + const toolCallAbortMessages = scopedAbort ? buildToolCallAbortMessages(retained, scopedAbort) : undefined; + if (toolCallAbortMessages) { + retained.toolCallAbortMessages = toolCallAbortMessages; + } const abortedMessage = snapshotAssistantMessage(retained); if (addedPartial) { context.messages[context.messages.length - 1] = abortedMessage; diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index d423eaeb5..757488a84 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -695,6 +695,10 @@ - Fixed OpenAI-compatible Ollama completions that return empty `finish_reason:length` after filling `num_ctx` so they surface an actionable context-window error instead of an empty length stop. ([#2774](https://github.com/can1357/oh-my-pi/issues/2774)) - Fixed Codex browser login issuing credentials for the `opencode` OAuth originator while OMP requests identify as `pi`, which could make the first authenticated Codex request return 401 ([#2696](https://github.com/can1357/oh-my-pi/issues/2696)). + +### Added + +- Added `AssistantMessage.toolCallAbortMessages` for per-tool placeholder labels on aborted assistant turns ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)). ## [16.0.1] - 2026-06-15 ### Added diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index a34df07c8..995fd454f 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -715,6 +715,8 @@ export interface AssistantMessage { stopReason: StopReason; stopDetails?: StopDetails | null; errorMessage?: string; + /** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */ + toolCallAbortMessages?: Record; /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */ errorStatus?: number; /** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */ diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5310cdbf..7591620f8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1450,6 +1450,10 @@ - Fixed task subagents to install their configured ordered model candidates as child-session retry fallback chains, so retryable provider failures can advance to the next subagent model instead of failing the worker ([#2750](https://github.com/can1357/oh-my-pi/issues/2750)). - Fixed empty reasonless aborted assistant turns to auto-retry without switching model fallback, so transient provider-side aborts after tool results do not end headless sessions ([#2685](https://github.com/can1357/oh-my-pi/issues/2685)). + +### Fixed + +- Fixed TTSR stream interrupts so only the tool call whose stream matched a rule receives the rule-named abort result; sibling tool-call placeholders now use a neutral abort reason ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)). ## [16.0.1] - 2026-06-15 ### Breaking Changes diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 56d1b73b1..63d03baec 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -35,6 +35,7 @@ import { type AsideMessage, type CompactionSummaryMessage, countTokens, + createToolScopedAbortReason, resolveTelemetry, type StreamFn, ThinkingLevel, @@ -4704,7 +4705,8 @@ export class AgentSession { // Decide first: a non-interrupting tool-source match attaches to the // specific tool call's result instead of driving a loop-wide follow-up. const shouldInterrupt = this.#shouldInterruptForTtsrMatch(matches, matchContext); - const perToolId = shouldInterrupt ? undefined : this.#extractTtsrToolCallId(matchContext); + const matchedToolId = this.#extractTtsrToolCallId(matchContext); + const perToolId = shouldInterrupt ? undefined : matchedToolId; if (perToolId) { this.#addPerToolTtsrInjections(perToolId, matches); this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); @@ -4720,7 +4722,16 @@ export class AgentSession { // Abort the stream immediately — do not gate on extension callbacks this.#ttsrAbortPending = true; this.#ensureTtsrResumePromise(); - this.agent.abort(this.#formatTtsrAbortReason(matches)); + const abortReason = this.#formatTtsrAbortReason(matches); + this.agent.abort( + matchedToolId + ? createToolScopedAbortReason( + abortReason, + { [matchedToolId]: abortReason }, + "TTSR interrupt on another tool call", + ) + : abortReason, + ); // Notify extensions (fire-and-forget, does not block abort) this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {}); // Schedule retry after a short delay diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 2660f7d84..889fe8880 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -1124,7 +1124,7 @@ describe("AgentSession TTSR resume gate", () => { expect(session.isStreaming).toBe(false); }); - it("labels aborted tool placeholders with the TTSR rule reason", async () => { + it("labels only the matching aborted tool placeholder with the TTSR rule reason", async () => { collapseSchedulerSettleDelays(); const model = getBundledModel("anthropic", "claude-sonnet-4-5")!; let streamCallCount = 0; @@ -1138,7 +1138,13 @@ describe("AgentSession TTSR resume gate", () => { }); ttsrManager.addRule(testRule); - const toolCallContent: ToolCall = { + const readToolCallContent: ToolCall = { + type: "toolCall", + id: "call_innocent_read", + name: "read", + arguments: { path: "history://Eval1WithSkill" }, + }; + const matchedToolCallContent: ToolCall = { type: "toolCall", id: "call_ttsr_abort_reason", name: "mock_edit", @@ -1147,7 +1153,7 @@ describe("AgentSession TTSR resume gate", () => { const makeToolCallMsg = (stopReason: "toolUse" | "aborted" = "toolUse"): AssistantMessage => ({ role: "assistant", - content: [toolCallContent], + content: [readToolCallContent, matchedToolCallContent], api: "anthropic-messages", provider: "anthropic", model: "mock", @@ -1187,10 +1193,10 @@ describe("AgentSession TTSR resume gate", () => { ); } stream.push({ type: "start", partial }); - stream.push({ type: "toolcall_start", contentIndex: 0, partial }); + stream.push({ type: "toolcall_start", contentIndex: 1, partial }); stream.push({ type: "toolcall_delta", - contentIndex: 0, + contentIndex: 1, delta: 'let val = result.unwrap("oops")', partial, }); @@ -1217,22 +1223,22 @@ describe("AgentSession TTSR resume gate", () => { await session.prompt("Write some Rust code"); - const toolResult = sessionManager + const toolResults = sessionManager .getEntries() - .find( - entry => - entry.type === "message" && - entry.message.role === "toolResult" && - entry.message.toolCallId === toolCallContent.id, - ); - expect(toolResult?.type).toBe("message"); - const text = - toolResult?.type === "message" && toolResult.message.role === "toolResult" - ? (toolResult.message.content.find((part): part is { type: "text"; text: string } => part.type === "text") - ?.text ?? "") - : ""; - expect(text).toContain("Tool execution was aborted: TTSR matched rule: no-unwrap"); - expect(text).not.toContain("Request was aborted"); + .filter(entry => entry.type === "message" && entry.message.role === "toolResult") + .map(entry => (entry.type === "message" && entry.message.role === "toolResult" ? entry.message : undefined)) + .filter(message => message !== undefined); + const toolResultText = (toolCallId: string): string => + toolResults + .find(message => message.toolCallId === toolCallId) + ?.content.find((part): part is { type: "text"; text: string } => part.type === "text")?.text ?? ""; + + const readText = toolResultText(readToolCallContent.id); + const matchedText = toolResultText(matchedToolCallContent.id); + expect(readText).toContain("Tool execution was aborted: TTSR interrupt on another tool call"); + expect(readText).not.toContain("TTSR matched rule: no-unwrap"); + expect(matchedText).toContain("Tool execution was aborted: TTSR matched rule: no-unwrap"); + expect(matchedText).not.toContain("Request was aborted"); }); it("relativizes the rule file path in the TTSR interrupt injection (no absolute leak)", async () => {