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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user