Merge PR #2607: fix(session): detected pending tool calls without toolUse (@roboomp)

# Conflicts:
#	packages/coding-agent/src/session/agent-session.ts
This commit is contained in:
can1357
2026-07-01 21:55:01 +02:00
5 changed files with 415 additions and 0 deletions
+2
View File
@@ -1394,6 +1394,7 @@
- Fixed a bug where Gemini MALFORMED_FUNCTION_CALL tool-generation errors (which are transient) surfaced as terminal error blocks. Added "malformed function call" to the transient transport error classifier so the session automatically retries the turn.
- Fixed Ctrl+C teardown waiting up to 30s when an extension's `session_shutdown` handler hung — observed on Windows with `omp-discord-presence` 0.1.2 stuck on a stale Discord IPC pipe. `ExtensionRunner.emit` previously shared the generic 30s `EXTENSION_HANDLER_TIMEOUT_MS` budget for every event, including the fire-and-forget teardown event extensions cannot observe. `session_shutdown` now uses a dedicated 2s `SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS` cap routed through a per-event `handlerTimeoutForEvent()` lookup, so a hung third-party handler can no longer hold dispose hostage. As a defence-in-depth ladder, a Ctrl+C arriving while interactive shutdown is already running now hard-exits with code 130 (the session JSONL has already been sync-flushed by the first press), surfaced via a new read-only `InteractiveModeContext.isShuttingDown` ([#2600](https://github.com/can1357/oh-my-pi/issues/2600)).
- Fixed the external editor flow (Ctrl+G, plan editor, `/todo edit`) warning `No editor configured` on Windows even when the user expected the system's native editor. `getEditorCommand()` now falls back to `notepad` on `win32` after consulting `$VISUAL`/`$EDITOR` (and trims those values so accidental whitespace is ignored), so Windows users get a working editor out of the box while POSIX still warns to nudge configuration ([#2604](https://github.com/can1357/oh-my-pi/issues/2604)).
- Fixed session resumes after a silent exit by recording pre-tool start markers, shutdown diagnostics, and warnings for pending tool calls ([#2606](https://github.com/can1357/oh-my-pi/issues/2606)).
- Fixed Kokoro TTS setup loading the workspace/global `@huggingface/transformers` runtime before the side-installed Kokoro runtime, which could leave `onnxruntime-node@1.26.0` bound to an older `libonnxruntime.so.1` and fail with `VERS_1.26.0` missing ([#2591](https://github.com/can1357/oh-my-pi/issues/2591)).
- Fixed `scripts/ci-release-notes.ts` stranding curated changelog entries from intervening *silent* tags (a `vX.Y.Z` tag pushed without a GitHub Release, e.g. the `v15.12.5`/`v15.12.6` casualties of the pre-#2564 release-cancellation bug). The generator now walks `(latest-published-release, target]` — resolved via `gh release list` from the `release_github` CI job — and merges every in-range `## [X.Y.Z]` section per package, grouped by `### <category>` with bullet-level dedupe so post-release changelog flattening cannot duplicate entries. Falls back to the legacy single-version extraction when no prior published release resolves, and `OMP_RELEASE_NOTES_FLOOR=v15.12.4` overrides the lookup for manual rebuilds ([#2596](https://github.com/can1357/oh-my-pi/issues/2596)).
- Fixed `Test & smoke (TS)` CI timeouts caused by parallel test files racing on the process-global Settings singleton. `CustomEditor` now accepts a `magicKeywordsEnabledOverride` injection point so the shimmer-gate test can assert behaviour without calling `resetSettingsForTest()` / `Settings.init()`; the "streaming tool call preview height" describe drops its gratuitous Settings reset+init. Production wiring is unchanged ([#2582](https://github.com/can1357/oh-my-pi/issues/2582))
@@ -1405,6 +1406,7 @@
- Fixed `/mcp unauth` deleting another profile's MCP OAuth credential row under broker-backed auth storage: when a shared `mcp.json` pins an explicit `auth.credentialId` scoped to a different profile (`mcp_oauth:profile:<other>:<url>`), removal now skips ids scoped to a non-active profile, mirroring the read path that already refuses to *use* a foreign profile's id. Legacy url-keyed (`mcp_oauth:<url>`) and active-profile ids are still cleared.
- Fixed auto-learn managed skills to use the active profile's agent directory, so authored profile skills keep priority over managed fallbacks.
## [15.13.0] - 2026-06-14
### Breaking Changes
+16
View File
@@ -66,6 +66,7 @@ import {
} from "./sdk";
import type { AgentSession } from "./session/agent-session";
import type { AuthStorage } from "./session/auth-storage";
import { describePendingToolCalls } from "./session/exit-diagnostics";
import { resolveResumableSession, type SessionInfo } from "./session/session-listing";
import { SessionManager } from "./session/session-manager";
import { executeBuiltinSlashCommand } from "./slash-commands/builtin-registry";
@@ -1208,6 +1209,21 @@ export async function runRootCommand(
sessionManager = await SessionManager.open(selected.path);
}
if (sessionManager && (parsedArgs.continue || parsedArgs.resume || parsedArgs.fork)) {
const pendingToolWarning = describePendingToolCalls(sessionManager.getBranch());
if (pendingToolWarning) {
logger.warn("Resumed session has pending tool calls", {
sessionId: sessionManager.getSessionId(),
sessionFile: sessionManager.getSessionFile(),
});
if (isInteractive) {
notifs.push({ kind: "warn", message: pendingToolWarning });
} else {
process.stderr.write(`${chalk.yellow(`${pendingToolWarning}\n`)}`);
}
}
}
await pluginPreloadPromise;
scheduleMarketplaceAutoUpdate({
@@ -128,6 +128,7 @@ import {
isBunTestRuntime,
isEnoent,
logger,
postmortem,
prompt,
relativePathWithinRoot,
Snowflake,
@@ -321,6 +322,13 @@ import {
shouldPromptCodexAutoRedeem,
} from "./codex-auto-reset";
import { findCompactMode } from "./compact-modes";
import {
collectPendingToolCalls,
SESSION_EXIT_CUSTOM_TYPE,
type SessionExitData,
TOOL_EXECUTION_START_CUSTOM_TYPE,
type ToolExecutionStartData,
} from "./exit-diagnostics";
import {
type BashExecutionMessage,
type CustomMessage,
@@ -1407,6 +1415,8 @@ export class AgentSession {
// Event subscription state
#unsubscribeAgent?: () => void;
#cancelExitRecorder?: () => void;
#exitRecorded = false;
#unsubscribeAppendOnly?: () => void;
/** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
#lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
@@ -2067,6 +2077,9 @@ export class AgentSession {
);
},
});
this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => {
this.#recordSessionExit(reason);
});
this.#advisorEnabled = this.settings.get("advisor.enabled") as boolean;
if (this.#advisorEnabled) this.#buildAdvisorRuntime();
@@ -2871,6 +2884,55 @@ export class AgentSession {
this.#emit({ type: "notice", level, message, source });
}
#recordToolExecutionStart(event: Extract<AgentEvent, { type: "tool_execution_start" }>): void {
const data: ToolExecutionStartData = {
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
startedAt: new Date().toISOString(),
};
if (event.intent) data.intent = event.intent;
this.sessionManager.appendCustomEntry(TOOL_EXECUTION_START_CUSTOM_TYPE, data);
}
#recordSessionExit(reason: postmortem.Reason | "dispose"): void {
if (this.#exitRecorded) return;
this.#exitRecorded = true;
const pendingToolCalls = collectPendingToolCalls(this.sessionManager.getBranch());
const kind: SessionExitData["kind"] =
reason === "dispose" || reason === postmortem.Reason.MANUAL
? "normal"
: reason === postmortem.Reason.UNCAUGHT_EXCEPTION || reason === postmortem.Reason.UNHANDLED_REJECTION
? "fatal"
: reason === postmortem.Reason.EXIT
? "process_exit"
: "signal";
const data: SessionExitData = {
reason,
kind,
recordedAt: new Date().toISOString(),
};
if (pendingToolCalls.length > 0) data.pendingToolCalls = pendingToolCalls;
try {
this.sessionManager.appendCustomEntry(SESSION_EXIT_CUSTOM_TYPE, data);
this.sessionManager.flushSync();
logger.warn("Session exit recorded", {
sessionId: this.sessionManager.getSessionId(),
sessionFile: this.sessionManager.getSessionFile(),
reason,
kind,
pendingToolCalls: pendingToolCalls.length,
});
} catch (error) {
logger.error("Failed to record session exit", {
sessionId: this.sessionManager.getSessionId(),
sessionFile: this.sessionManager.getSessionFile(),
reason,
error: error instanceof Error ? error.message : String(error),
});
}
}
#queuedExtensionEvents: Promise<void> = Promise.resolve();
#queueExtensionEvent(event: AgentSessionEvent): Promise<void> {
@@ -3209,6 +3271,10 @@ export class AgentSession {
});
}
if (event.type === "tool_execution_start") {
this.#recordToolExecutionStart(event);
}
try {
await this.#emitSessionEvent(displayEvent);
} catch (error) {
@@ -5250,6 +5316,9 @@ export class AgentSession {
async #doDispose(): Promise<void> {
this.beginDispose();
this.#recordSessionExit("dispose");
this.#cancelExitRecorder?.();
this.#cancelExitRecorder = undefined;
try {
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -0,0 +1,160 @@
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import type { SessionEntry } from "./session-entries";
export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
export const SESSION_EXIT_CUSTOM_TYPE = "session_exit";
/** Persisted marker written before a tool implementation starts running. */
export interface ToolExecutionStartData {
toolCallId: string;
toolName: string;
args?: unknown;
intent?: string;
startedAt: string;
}
/** Tool call left without a matching toolResult at the end of a branch. */
export interface PendingToolCallDiagnostic {
toolCallId?: string;
toolName: string;
args?: unknown;
intent?: string;
assistantTimestamp?: number;
startedAt?: string;
}
/** Session shutdown marker written during normal and fatal process teardown. */
export interface SessionExitData {
reason: string;
kind: "normal" | "signal" | "fatal" | "process_exit";
recordedAt: string;
pendingToolCalls?: PendingToolCallDiagnostic[];
}
interface PendingToolCallRecord extends PendingToolCallDiagnostic {
key: string;
}
interface ToolCallContent {
type: "toolCall";
id?: string;
name?: string;
arguments?: unknown;
}
function isObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object") return false;
return value !== null;
}
function isToolCallContent(value: unknown): value is ToolCallContent {
if (!isObject(value)) return false;
return value.type === "toolCall" && (typeof value.name === "string" || typeof value.id === "string");
}
function readToolExecutionStart(entry: SessionEntry): ToolExecutionStartData | undefined {
if (entry.type !== "custom" || entry.customType !== TOOL_EXECUTION_START_CUSTOM_TYPE) return undefined;
const data = entry.data;
if (!isObject(data)) return undefined;
if (typeof data.toolCallId !== "string" || typeof data.toolName !== "string") return undefined;
const startedAt = typeof data.startedAt === "string" ? data.startedAt : entry.timestamp;
const result: ToolExecutionStartData = {
toolCallId: data.toolCallId,
toolName: data.toolName,
startedAt,
};
if ("args" in data) result.args = data.args;
if (typeof data.intent === "string") result.intent = data.intent;
return result;
}
function appendAssistantToolCalls(pending: Map<string, PendingToolCallRecord>, message: AgentMessage): void {
if (message.role !== "assistant") return;
const content = Array.isArray(message.content) ? message.content : [];
const toolCalls: PendingToolCallRecord[] = [];
for (let index = 0; index < content.length; index++) {
const part = content[index];
if (!isToolCallContent(part)) continue;
const toolName = part.name ?? "unknown";
const key = part.id ?? `assistant:${message.timestamp ?? "unknown"}:${index}:${toolName}`;
const record: PendingToolCallRecord = {
key,
toolName,
};
if (typeof message.timestamp === "number") record.assistantTimestamp = message.timestamp;
if (part.id) record.toolCallId = part.id;
if ("arguments" in part) record.args = part.arguments;
toolCalls.push(record);
}
pending.clear();
for (const toolCall of toolCalls) pending.set(toolCall.key, toolCall);
}
function applyToolExecutionStart(pending: Map<string, PendingToolCallRecord>, marker: ToolExecutionStartData): void {
const existing = pending.get(marker.toolCallId);
if (existing) {
existing.startedAt = marker.startedAt;
existing.args = marker.args;
if (marker.intent) existing.intent = marker.intent;
return;
}
const record: PendingToolCallRecord = {
key: marker.toolCallId,
toolCallId: marker.toolCallId,
toolName: marker.toolName,
args: marker.args,
startedAt: marker.startedAt,
};
if (marker.intent) record.intent = marker.intent;
pending.set(marker.toolCallId, record);
}
function applyMessageEntry(pending: Map<string, PendingToolCallRecord>, message: AgentMessage): void {
if (message.role === "toolResult") {
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
if (toolCallId) pending.delete(toolCallId);
return;
}
appendAssistantToolCalls(pending, message);
}
/** Finds tool calls left pending at the end of a session branch. */
export function collectPendingToolCalls(entries: readonly SessionEntry[]): PendingToolCallDiagnostic[] {
const pending = new Map<string, PendingToolCallRecord>();
for (const entry of entries) {
if (entry.type === "message") {
applyMessageEntry(pending, entry.message);
continue;
}
const marker = readToolExecutionStart(entry);
if (marker) applyToolExecutionStart(pending, marker);
}
return [...pending.values()].map(({ key: _key, ...toolCall }) => toolCall);
}
function appendArgumentSummary(parts: string[], args: unknown): void {
if (!isObject(args)) return;
const command = args.command;
if (typeof command === "string" && command.length > 0) {
parts.push(`command \`${command}\``);
return;
}
const path = args.path;
if (typeof path === "string" && path.length > 0) parts.push(`path \`${path}\``);
}
function formatPendingToolCall(call: PendingToolCallDiagnostic): string {
const parts = [call.toolName];
if (call.toolCallId) parts.push(call.toolCallId);
appendArgumentSummary(parts, call.args);
return parts.join(" ");
}
/** Builds the resume warning shown when a prior branch ended mid-tool-call. */
export function describePendingToolCalls(entries: readonly SessionEntry[]): string | undefined {
const pending = collectPendingToolCalls(entries);
if (pending.length === 0) return undefined;
const formatted = pending.map(formatPendingToolCall).join(", ");
const noun = pending.length === 1 ? "tool call" : "tool calls";
return `Previous session ended while ${pending.length} ${noun} remained pending: ${formatted}. The prior OMP process exited before recording tool result(s).`;
}
@@ -0,0 +1,168 @@
import { afterEach, describe, expect, it } from "bun:test";
import * as path from "node:path";
import { Agent } from "@oh-my-pi/pi-agent-core";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import {
collectPendingToolCalls,
describePendingToolCalls,
SESSION_EXIT_CUSTOM_TYPE,
TOOL_EXECUTION_START_CUSTOM_TYPE,
type ToolExecutionStartData,
} from "@oh-my-pi/pi-coding-agent/session/exit-diagnostics";
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
const pendingAssistant: AssistantMessage = {
role: "assistant",
content: [
{
type: "toolCall",
id: "toolu_repro",
name: "bash",
arguments: { command: "bun run check:ts" },
},
],
api: "anthropic-messages",
provider: "anthropic",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
};
describe("session exit diagnostics", () => {
let session: AgentSession | undefined;
let authStorage: AuthStorage | undefined;
let tempDir: TempDir | undefined;
afterEach(async () => {
await session?.dispose();
session = undefined;
authStorage?.close();
authStorage = undefined;
tempDir?.removeSync();
tempDir = undefined;
});
it("records a durable tool start marker and shutdown diagnostic before a pending result exists", async () => {
tempDir = TempDir.createSync("@pi-session-exit-");
authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) throw new Error("Expected built-in anthropic model to exist");
const sessionManager = SessionManager.inMemory(tempDir.path());
const agent = new Agent({
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
convertToLlm,
});
session = new AgentSession({
agent,
sessionManager,
settings: Settings.isolated({ "compaction.enabled": false }),
modelRegistry,
});
agent.emitExternalEvent({ type: "message_end", message: pendingAssistant });
await Promise.resolve();
agent.emitExternalEvent({
type: "tool_execution_start",
toolCallId: "toolu_repro",
toolName: "bash",
args: { command: "bun run check:ts" },
});
await Promise.resolve();
const marker = sessionManager
.getEntries()
.find(entry => entry.type === "custom" && entry.customType === TOOL_EXECUTION_START_CUSTOM_TYPE);
if (marker?.type !== "custom") throw new Error("Expected tool execution start marker");
expect(marker.data).toMatchObject({
toolCallId: "toolu_repro",
toolName: "bash",
args: { command: "bun run check:ts" },
});
const pending = collectPendingToolCalls(sessionManager.getBranch());
expect(pending).toMatchObject([
{
toolCallId: "toolu_repro",
toolName: "bash",
args: { command: "bun run check:ts" },
},
]);
expect(describePendingToolCalls(sessionManager.getBranch())).toContain("bun run check:ts");
await session.dispose();
session = undefined;
const exitEntry = sessionManager
.getEntries()
.find(entry => entry.type === "custom" && entry.customType === SESSION_EXIT_CUSTOM_TYPE);
if (exitEntry?.type !== "custom") throw new Error("Expected session exit marker");
expect(exitEntry.data).toMatchObject({
reason: "dispose",
kind: "normal",
pendingToolCalls: [
{
toolCallId: "toolu_repro",
toolName: "bash",
args: { command: "bun run check:ts" },
},
],
});
});
it("treats assistant tool calls as pending even when stopReason is not toolUse", () => {
const sessionManager = SessionManager.inMemory();
sessionManager.appendMessage({ ...pendingAssistant, stopReason: "stop" });
expect(collectPendingToolCalls(sessionManager.getBranch())).toMatchObject([
{
toolCallId: "toolu_repro",
toolName: "bash",
args: { command: "bun run check:ts" },
},
]);
expect(describePendingToolCalls(sessionManager.getBranch())).toContain("bun run check:ts");
});
it("clears the pending warning once the matching tool result is recorded", () => {
const sessionManager = SessionManager.inMemory();
sessionManager.appendMessage(pendingAssistant);
sessionManager.appendCustomEntry(TOOL_EXECUTION_START_CUSTOM_TYPE, {
toolCallId: "toolu_repro",
toolName: "bash",
args: { command: "bun run check:ts" },
startedAt: new Date().toISOString(),
} satisfies ToolExecutionStartData);
sessionManager.appendMessage({
role: "toolResult",
toolCallId: "toolu_repro",
toolName: "bash",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now(),
});
expect(collectPendingToolCalls(sessionManager.getBranch())).toEqual([]);
expect(describePendingToolCalls(sessionManager.getBranch())).toBeUndefined();
});
});