From 48e6f4d30672b47ca6e00f8947681eecb8ab0e15 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 15 Jun 2026 02:50:07 +0000 Subject: [PATCH 1/2] fix(session): recorded silent exit diagnostics Recorded durable tool-start and session-exit markers so resumes can warn when a prior branch ended with pending tool calls. Added regression coverage for pending bash tool calls and shutdown diagnostics. Fixes #2606 --- packages/coding-agent/CHANGELOG.md | 2 + packages/coding-agent/src/main.ts | 16 ++ .../coding-agent/src/session/agent-session.ts | 69 ++++++++ .../src/session/exit-diagnostics.ts | 164 ++++++++++++++++++ .../test/session-exit-diagnostics.test.ts | 154 ++++++++++++++++ 5 files changed, 405 insertions(+) create mode 100644 packages/coding-agent/src/session/exit-diagnostics.ts create mode 100644 packages/coding-agent/test/session-exit-diagnostics.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 06d11d2e8..885c45055 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -165,6 +165,7 @@ ### Fixed +- 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 `### ` 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)) @@ -174,6 +175,7 @@ - Fixed a collapsed, still-streaming tool preview (an `eval`/`bash`/`ssh` box with output streaming in) reading as "weirdly truncated" — top border and head rows missing — once its box outgrew the viewport, snapping back to whole only while expanded with `ctrl+o` and breaking again when collapsed. A streaming preview was classified commit-unstable whenever collapsed, so the transcript offered none of its rows to native scrollback; once the box outgrew the window its head fell into the gap between the commit boundary and the window top, committed nowhere and repainted nowhere. The `provisionalPendingPreview` flag now applies only to the pending call preview (before any result) — once a streaming result exists the result renderer is the live, top-anchored shape and the block is commit-stable in both collapsed and expanded states, so its durable head always reaches scrollback. - Fixed a crash in subagent task execution and extensions when a string (instead of a string array) was returned or set for the system prompt. Gracefully wrap string values in arrays. + ## [15.13.0] - 2026-06-14 ### Breaking Changes diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f92530f27..47e735cca 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -64,6 +64,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"; @@ -1120,6 +1121,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({ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b2f448360..d7dc7f7b3 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -107,6 +107,7 @@ import { isEnoent, isUnexpectedSocketCloseMessage, logger, + postmortem, prompt, relativePathWithinRoot, Snowflake, @@ -250,6 +251,13 @@ import { shouldEvaluateCodexAutoRedeem, shouldPromptCodexAutoRedeem, } from "./codex-auto-reset"; +import { + collectPendingToolCalls, + SESSION_EXIT_CUSTOM_TYPE, + type SessionExitData, + TOOL_EXECUTION_START_CUSTOM_TYPE, + type ToolExecutionStartData, +} from "./exit-diagnostics"; import { type BashExecutionMessage, type CustomMessage, @@ -925,6 +933,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 }; @@ -1355,6 +1365,9 @@ export class AgentSession { ); }, }); + this.#cancelExitRecorder = postmortem.register(`agent-session:${this.sessionManager.getSessionId()}`, reason => { + this.#recordSessionExit(reason); + }); // Always subscribe to agent events for internal handling // (session persistence, hooks, auto-compaction, retry logic) @@ -1574,6 +1587,55 @@ export class AgentSession { this.#emit({ type: "notice", level, message, source }); } + #recordToolExecutionStart(event: Extract): 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 = Promise.resolve(); #queueExtensionEvent(event: AgentSessionEvent): Promise { @@ -1659,6 +1721,10 @@ export class AgentSession { }); } + if (event.type === "tool_execution_start") { + this.#recordToolExecutionStart(event); + } + await this.#emitSessionEvent(displayEvent); if (event.type === "turn_start") { @@ -3160,6 +3226,9 @@ export class AgentSession { */ async dispose(): Promise { this.beginDispose(); + this.#recordSessionExit("dispose"); + this.#cancelExitRecorder?.(); + this.#cancelExitRecorder = undefined; try { if (this.#extensionRunner?.hasHandlers("session_shutdown")) { await this.#extensionRunner.emit({ type: "session_shutdown" }); diff --git a/packages/coding-agent/src/session/exit-diagnostics.ts b/packages/coding-agent/src/session/exit-diagnostics.ts new file mode 100644 index 000000000..7e7bbb122 --- /dev/null +++ b/packages/coding-agent/src/session/exit-diagnostics.ts @@ -0,0 +1,164 @@ +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 { + 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, message: AgentMessage): void { + if (message.role !== "assistant") return; + if (message.stopReason !== "toolUse") { + pending.clear(); + 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, 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, 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(); + 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).`; +} diff --git a/packages/coding-agent/test/session-exit-diagnostics.test.ts b/packages/coding-agent/test/session-exit-diagnostics.test.ts new file mode 100644 index 000000000..606d24bcb --- /dev/null +++ b/packages/coding-agent/test/session-exit-diagnostics.test.ts @@ -0,0 +1,154 @@ +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("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(); + }); +}); From d1f91f0c709ac6ca6cfc1da6babd7b98919412a7 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 15 Jun 2026 02:58:18 +0000 Subject: [PATCH 2/2] fix(session): detected pending tool calls without toolUse Collected assistant tool calls regardless of stopReason so malformed or legacy restored tails still surface pending-tool diagnostics. Added regression coverage for a toolCall-bearing assistant stop message. Fixes #2606 --- .../coding-agent/src/session/exit-diagnostics.ts | 4 ---- .../test/session-exit-diagnostics.test.ts | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/session/exit-diagnostics.ts b/packages/coding-agent/src/session/exit-diagnostics.ts index 7e7bbb122..51063c5ed 100644 --- a/packages/coding-agent/src/session/exit-diagnostics.ts +++ b/packages/coding-agent/src/session/exit-diagnostics.ts @@ -70,10 +70,6 @@ function readToolExecutionStart(entry: SessionEntry): ToolExecutionStartData | u function appendAssistantToolCalls(pending: Map, message: AgentMessage): void { if (message.role !== "assistant") return; - if (message.stopReason !== "toolUse") { - pending.clear(); - return; - } const content = Array.isArray(message.content) ? message.content : []; const toolCalls: PendingToolCallRecord[] = []; for (let index = 0; index < content.length; index++) { diff --git a/packages/coding-agent/test/session-exit-diagnostics.test.ts b/packages/coding-agent/test/session-exit-diagnostics.test.ts index 606d24bcb..ec424d491 100644 --- a/packages/coding-agent/test/session-exit-diagnostics.test.ts +++ b/packages/coding-agent/test/session-exit-diagnostics.test.ts @@ -130,6 +130,20 @@ describe("session exit diagnostics", () => { }); }); + 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);