merge PR #4542: fix(coding-agent): scoped TTSR abort reason to matching tool call

This commit is contained in:
can1357
2026-07-08 15:27:25 +02:00
7 changed files with 112 additions and 23 deletions
+4
View File
@@ -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
+59 -1
View File
@@ -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<string, string>;
readonly defaultToolCallMessage: string;
}
/** Creates an abort reason that labels matching tool calls separately from siblings. */
export function createToolScopedAbortReason(
message: string,
toolCallMessages: Record<string, string>,
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<string, string> {
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<string, string> | undefined {
let hasToolCall = false;
const messages: Record<string, string> = {};
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;
+4
View File
@@ -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
+2
View File
@@ -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<string, string>;
/** 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. */
+4
View File
@@ -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
@@ -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
@@ -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 () => {