diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f1eb3f388..0555e827d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 `### ` 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::`), 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:`) 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 diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 9ac0931d5..67a13a3d7 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -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({ diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 9ac3d7083..c20c4e042 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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): 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 { @@ -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 { 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..51063c5ed --- /dev/null +++ b/packages/coding-agent/src/session/exit-diagnostics.ts @@ -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 { + 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; + 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..ec424d491 --- /dev/null +++ b/packages/coding-agent/test/session-exit-diagnostics.test.ts @@ -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(); + }); +});