fix: stabilize code-mode follow-up tool display and replay (#80663)

* fix: project tool-search bridge event display

* fix: keep codex tool progress out of final replies

* fix: preserve tool result pairs on cleanup

* fix: restore tool search display target helper

* fix: keep tool search controls independent

* fix: render bridged tool calls like native tools

* fix: abort timed out tool search bridge calls

* fix: preserve code-mode tool results across display turns

* fix: repair missing code-mode tool results on disk

* fix: expose tool search controls in embedded runs

* docs: add code-mode followups changelog

* fix: update session repair agent-core import

* fix: harden code-mode follow-up repair

* fix: use stable session repair ids

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Josh Lehman
2026-05-11 15:31:35 -07:00
committed by GitHub
parent 1786d60cf8
commit 4bfd7416f0
27 changed files with 909 additions and 35 deletions

View File

@@ -79,6 +79,7 @@ Docs: https://docs.openclaw.ai
- Bonjour/Gateway: treat active ciao probing and fresh name-conflict renames as in-progress so the mDNS watchdog waits for probe settlement before retrying, preventing rapid re-advertise loops on Windows, WSL, and other multicast-hostile hosts. (#74778) Refs #74242. Thanks @fuller-stack-dev.
- Providers/MiniMax: send a minimal Anthropic-compatible user fallback when message conversion filters a turn to an empty payload, so MiniMax M2.7 no longer returns `chat content is empty` after tool-heavy sessions. Fixes #74589. Thanks @neeravmakwana and @DerekEXS.
- Tools/media: preserve implicit allow-all semantics from `tools.alsoAllow`-only policies when preconstructing built-in media generation and PDF tools, so configured media tools become live without forcing `tools.allow: ["*", ...]`. Fixes #77841. Thanks @trialanderrorstudios.
- Codex/Telegram: separate code-mode tool progress from final replies, render bridged tool calls with native tool labels, and repair persisted missing tool results for safer follow-up turns. (#80663) Thanks @jalehman.
- Memory/search: load the platform-specific `sqlite-vec-<platform>-<arch>` variant directly when the meta `sqlite-vec` package is missing from a global install, so vector recall keeps working on `npm install -g openclaw@latest` upgrades where optionalDependencies left only the platform variant on disk. Fixes #77838. Thanks @corevibe555 and @Simon2256928.
- Cron: keep long manual cron runs active in the task registry until completion, preventing transient `lost` markers before durable recovery reconciles. Fixes #78233. (#78243) Thanks @Feelw00.
- Doctor/GitHub CLI: surface a `GH_CONFIG_DIR` hint when the GitHub skill is usable but `gh` auth lives under a different operator HOME than the agent process, without warning for disabled or filtered skills. Fixes #78063. (#78095) Thanks @tmimmanuel.

View File

@@ -406,6 +406,53 @@ describe("CodexAppServerEventProjector", () => {
expect(result.toolMediaUrls).toStrictEqual([]);
});
it("does not promote repeated tool progress text to the final assistant reply", async () => {
const onToolResult = vi.fn();
const projector = await createProjector({
...(await createParams()),
verboseLevel: "on",
onToolResult,
});
await projector.handleNotification(
forCurrentTurn("item/started", {
item: {
type: "commandExecution",
id: "cmd-1",
command: "pnpm test extensions/codex",
cwd: "/workspace",
processId: null,
source: "agent",
status: "inProgress",
commandActions: [],
aggregatedOutput: null,
exitCode: null,
durationMs: null,
},
}),
);
const toolProgressText = onToolResult.mock.calls[0]?.[0]?.text;
expect(toolProgressText).toBe("🛠️ `run tests (workspace)`");
await projector.handleNotification(
forCurrentTurn("rawResponseItem/completed", {
item: {
type: "message",
id: "raw-tool-progress",
role: "assistant",
content: [{ type: "output_text", text: toolProgressText }],
},
}),
);
await projector.handleNotification(turnCompleted());
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual([]);
expect(result.lastAssistant).toBeUndefined();
});
it("does not fail a completed reply after a retryable app-server error notification", async () => {
const projector = await createProjector();

View File

@@ -111,6 +111,7 @@ export class CodexAppServerEventProjector {
private readonly activeItemIds = new Set<string>();
private readonly completedItemIds = new Set<string>();
private readonly activeCompactionItemIds = new Set<string>();
private readonly toolProgressTexts = new Set<string>();
private readonly toolResultSummaryItemIds = new Set<string>();
private readonly toolResultOutputItemIds = new Set<string>();
private readonly toolResultOutputStreamedItemIds = new Set<string>();
@@ -962,11 +963,16 @@ export class CodexAppServerEventProjector {
text: string;
finalOutput?: boolean;
}): void {
const text = params.text.trim();
if (!text) {
return;
}
this.toolProgressTexts.add(text);
if (params.finalOutput) {
this.toolResultOutputItemIds.add(params.itemId);
}
try {
void Promise.resolve(this.params.onToolResult?.({ text: params.text })).catch(() => {
void Promise.resolve(this.params.onToolResult?.({ text })).catch(() => {
// Tool progress delivery is best-effort and should not affect the turn.
});
} catch {
@@ -1109,7 +1115,7 @@ export class CodexAppServerEventProjector {
continue;
}
const text = this.assistantTextByItem.get(itemId)?.trim();
if (text) {
if (text && !this.toolProgressTexts.has(text)) {
return text;
}
}

View File

@@ -546,6 +546,7 @@ describe("buildOpenAIProvider", () => {
sanitizeToolCallIds: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
allowSyntheticToolResults: true,
});
});

View File

@@ -3,15 +3,23 @@ import type {
ProviderReplayPolicyContext,
} from "openclaw/plugin-sdk/plugin-entry";
const RESPONSES_FAMILY_APIS = new Set([
"openai-responses",
"openai-codex-responses",
"azure-openai-responses",
]);
/**
* Returns the provider-owned replay policy for OpenAI-family transports.
*/
export function buildOpenAIReplayPolicy(ctx: ProviderReplayPolicyContext): ProviderReplayPolicy {
const isResponsesFamily = RESPONSES_FAMILY_APIS.has(ctx.modelApi ?? "");
return {
sanitizeMode: "images-only",
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
...(isResponsesFamily ? { allowSyntheticToolResults: true } : {}),
...(ctx.modelApi === "openai-completions"
? {
sanitizeToolCallIds: true,

View File

@@ -37,6 +37,65 @@ describe("guardSessionManager integration", () => {
]);
});
it("keeps real toolResult pending across delivery-mirror assistant messages", () => {
const sm = guardSessionManager(SessionManager.inMemory());
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
appendMessage(assistantToolCall("call_1"));
appendMessage({
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
content: [{ type: "text", text: "display copy" }],
} as AgentMessage);
appendMessage({
role: "toolResult",
toolCallId: "call_1",
toolName: "n",
content: [{ type: "text", text: "real output" }],
isError: false,
} as AgentMessage);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
expect(messages.map((m) => m.role)).toEqual(["assistant", "assistant", "toolResult"]);
expect((messages[1] as { model?: string }).model).toBe("delivery-mirror");
expect((messages[2] as { isError?: boolean }).isError).toBe(false);
expect((messages[2] as { content?: Array<{ text?: string }> }).content?.[0]?.text).toBe(
"real output",
);
expect(JSON.stringify(messages)).not.toContain("missing tool result");
});
it("uses Codex-style aborted synthetic results for interrupted Responses tool calls", () => {
const sm = guardSessionManager(SessionManager.inMemory(), {
allowSyntheticToolResults: true,
missingToolResultText: "aborted",
});
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
appendMessage(assistantToolCall("call_responses_1"));
appendMessage({
role: "user",
content: [{ type: "text", text: "interrupting prompt" }],
timestamp: Date.now(),
} as AgentMessage);
const messages = sm
.getEntries()
.filter((e) => e.type === "message")
.map((e) => (e as { message: AgentMessage }).message);
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult", "user"]);
expect((messages[1] as { toolCallId?: string }).toolCallId).toBe("call_responses_1");
expect((messages[1] as { content?: Array<{ text?: string }> }).content?.[0]?.text).toBe(
"aborted",
);
});
it("redacts configured text patterns before persisting transcript messages", () => {
const cfg = {
logging: {

View File

@@ -100,7 +100,7 @@ describe("flushPendingToolResultsAfterIdle", () => {
);
});
it("clears pending without synthetic flush when timeout cleanup is requested", async () => {
it("flushes pending on cleanup timeout instead of leaving orphaned tool calls", async () => {
const sm = guardSessionManager(SessionManager.inMemory());
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
vi.useFakeTimers();
@@ -112,19 +112,21 @@ describe("flushPendingToolResultsAfterIdle", () => {
agent,
sessionManager: sm,
timeoutMs: 30,
clearPendingOnTimeout: true,
});
await vi.advanceTimersByTimeAsync(30);
await flushPromise;
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant"]);
const messages = getMessages(sm);
expect(messages.map((m) => m.role)).toEqual(["assistant", "toolResult"]);
expect((messages[1] as { toolCallId?: string }).toolCallId).toBe("call_orphan_2");
expect((messages[1] as { isError?: boolean }).isError).toBe(true);
appendMessage({
role: "user",
content: "still there?",
timestamp: Date.now(),
} as AgentMessage);
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "user"]);
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "toolResult", "user"]);
});
it("clears timeout handle when waitForIdle resolves first", async () => {
@@ -142,7 +144,7 @@ describe("flushPendingToolResultsAfterIdle", () => {
expect(vi.getTimerCount()).toBe(0);
});
it("immediately clears pending tool results without waiting when timeoutMs is 0 or less", async () => {
it("immediately flushes pending tool results without waiting when timeoutMs is 0 or less", async () => {
const sm = guardSessionManager(SessionManager.inMemory());
const appendMessage = sm.appendMessage.bind(sm) as unknown as (message: AgentMessage) => void;
@@ -158,14 +160,13 @@ describe("flushPendingToolResultsAfterIdle", () => {
agent,
sessionManager: sm,
timeoutMs: 0,
clearPendingOnTimeout: true,
});
// Verify waitForIdle was completely bypassed
expect(waitForIdleSpy).not.toHaveBeenCalled();
// The pending tool result should be cleared immediately.
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant"]);
// The pending tool result should be flushed immediately.
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "toolResult"]);
// Test negative timeout as well
appendMessage(assistantToolCall("call_orphan_negative"));
@@ -173,11 +174,15 @@ describe("flushPendingToolResultsAfterIdle", () => {
agent,
sessionManager: sm,
timeoutMs: -100,
clearPendingOnTimeout: true,
});
// Verify waitForIdle was still bypassed
expect(waitForIdleSpy).not.toHaveBeenCalled();
expect(getMessages(sm).map((m) => m.role)).toEqual(["assistant", "assistant"]);
expect(getMessages(sm).map((m) => m.role)).toEqual([
"assistant",
"toolResult",
"assistant",
"toolResult",
]);
});
});

View File

@@ -1371,7 +1371,6 @@ async function compactEmbeddedPiSessionDirectOnce(
await flushPendingToolResultsAfterIdle({
agent: session?.agent,
sessionManager,
clearPendingOnTimeout: true,
});
} catch {
/* best-effort */

View File

@@ -188,6 +188,33 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
vi.restoreAllMocks();
});
it("enables Tool Search controls for embedded PI runs when configured", async () => {
await createContextEngineAttemptRunner({
contextEngine: {
assemble: async ({ messages }) => ({ messages, estimatedTokens: 1 }),
},
sessionKey,
tempPaths,
attemptOverrides: {
disableTools: false,
config: {
tools: {
toolSearch: true,
},
} as OpenClawConfig,
},
});
expect(hoisted.createOpenClawCodingToolsMock).toHaveBeenCalled();
const options = mockParams(
hoisted.createOpenClawCodingToolsMock,
0,
"createOpenClawCodingTools options",
);
expect(options.includeToolSearchControls).toBe(true);
expect(options.toolSearchCatalogRef).toBeTruthy();
});
it("sends transcriptPrompt visibly and queues runtime context as hidden custom context", async () => {
const seen: { prompt?: string; messages?: unknown[]; systemPrompt?: string } = {};

View File

@@ -58,7 +58,6 @@ export async function cleanupEmbeddedAttemptResources(params: {
agent: IdleAwareAgent | null | undefined;
sessionManager: ToolResultFlushManager | null | undefined;
timeoutMs?: number;
clearPendingOnTimeout?: boolean;
}) => Promise<void>;
session?: { agent?: unknown; dispose(): void };
sessionManager: unknown;
@@ -83,11 +82,13 @@ export async function cleanupEmbeddedAttemptResources(params: {
sessionId: params.sessionId ?? "unknown",
});
}
// PERF: When the run was aborted (user stop / timeout), skip the expensive
// waitForIdle (up to 30 s) and flush pending tool results synchronously so
// the session write-lock is released without leaving orphaned tool calls.
try {
await params.flushPendingToolResultsAfterIdle({
agent: params.session?.agent as IdleAwareAgent | null | undefined,
sessionManager: params.sessionManager as ToolResultFlushManager | null | undefined,
clearPendingOnTimeout: true,
...(params.aborted ? { timeoutMs: 0 } : {}),
});
} catch {

View File

@@ -81,7 +81,7 @@ describe("resolveAttemptTranscriptPolicy", () => {
expect(policy.toolCallIdMode).toBe("strict");
expect(policy.repairToolUseResultPairing).toBe(true);
expect(policy.validateAnthropicTurns).toBe(false);
expect(policy.allowSyntheticToolResults).toBe(false);
expect(policy.allowSyntheticToolResults).toBe(true);
expect(resolveProviderRuntimePluginMock).toHaveBeenCalledWith({
provider: "custom-openai-compatible",
config: undefined,

View File

@@ -1037,7 +1037,7 @@ export async function runEmbeddedAttempt(
currentThreadTs: params.currentThreadTs,
currentMessageId: params.currentMessageId,
includeCoreTools: toolConstructionPlan.includeCoreTools,
includeToolSearchControls: true,
includeToolSearchControls: toolSearchControlsEnabledForRun,
toolSearchCatalogExecutor: (toolParams) => {
if (!toolSearchCatalogExecutor) {
throw new Error("Tool Search catalog executor is unavailable for this run.");
@@ -2578,10 +2578,9 @@ export async function runEmbeddedAttempt(
await flushPendingToolResultsAfterIdle({
agent: activeSession?.agent,
sessionManager,
clearPendingOnTimeout: true,
// PERF: If the run was aborted during the setup,
// skip the idle wait and clear pending results synchronously so we can
// immediately dispose the session and throw the error without blocking.
// skip the idle wait and flush pending results synchronously so we can
// immediately dispose the session without orphaning tool calls.
...(params.abortSignal?.aborted ? { timeoutMs: 0 } : {}),
});
activeSession.dispose();
@@ -4160,6 +4159,8 @@ export async function runEmbeddedAttempt(
bundleMcpRuntime,
bundleLspRuntime,
sessionLock,
// PERF: If the run was aborted (user stop, timeout, etc.), skip the idle wait
// and flush pending results synchronously so we can release the session lock ASAP.
aborted: cleanupAborted,
abortSettlePromise: cleanupAborted ? buildAbortSettlePromise() : null,
runId: params.runId,

View File

@@ -44,19 +44,13 @@ export async function flushPendingToolResultsAfterIdle(opts: {
agent: IdleAwareAgent | null | undefined;
sessionManager: ToolResultFlushManager | null | undefined;
timeoutMs?: number;
clearPendingOnTimeout?: boolean;
}): Promise<void> {
const isImmediateTimeout = opts.timeoutMs !== undefined && opts.timeoutMs <= 0;
const timedOut =
isImmediateTimeout ||
(await waitForAgentIdleBestEffort(
if (!isImmediateTimeout) {
await waitForAgentIdleBestEffort(
opts.agent,
opts.timeoutMs ?? DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS,
));
if (timedOut && opts.clearPendingOnTimeout && opts.sessionManager?.clearPendingToolResults) {
opts.sessionManager.clearPendingToolResults();
return;
);
}
opts.sessionManager?.flushPendingToolResults?.();
}

View File

@@ -252,6 +252,9 @@ describe("createOpenClawCodingTools", () => {
});
it("keeps PI Tool Search controls when core OpenClaw tools are not materialized", () => {
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
createOpenClawToolsMock.mockClear();
const tools = createOpenClawCodingTools({
includeCoreTools: false,
includeToolSearchControls: true,
@@ -270,11 +273,13 @@ describe("createOpenClawCodingTools", () => {
});
const names = new Set(tools.map((tool) => tool.name));
expect(createOpenClawToolsMock).not.toHaveBeenCalled();
expect(names.has("tool_search_code")).toBe(true);
expect(names.has("tool_search")).toBe(true);
expect(names.has("tool_describe")).toBe(true);
expect(names.has("tool_call")).toBe(true);
expect(names.has("message")).toBe(false);
expect(names.has("exec")).toBe(false);
});
it("exposes only an explicitly authorized owner-only tool to non-owner sessions", () => {

View File

@@ -416,7 +416,7 @@ export function createOpenClawCodingTools(options?: {
forceHeartbeatTool?: boolean;
/** If false, build plugin tools only while preserving the shared policy pipeline. */
includeCoreTools?: boolean;
/** PI-only: expose OpenClaw Tool Search controls for catalog compaction. */
/** Include Tool Search control tools when enabled for this run. */
includeToolSearchControls?: boolean;
/** Executes cataloged tools through the active PI run lifecycle. */
toolSearchCatalogExecutor?: ToolSearchCatalogToolExecutor;

View File

@@ -474,6 +474,142 @@ describe("repairSessionFileIfNeeded", () => {
expect(after).toBe(original);
});
it("inserts missing code-mode tool results before replay repair has to synthesize them", async () => {
const { file } = await createTempSessionPath();
const { header, message } = buildSessionHeaderAndMessage();
const toolCallAssistant = {
type: "message",
id: "msg-asst-process",
parentId: "msg-1",
timestamp: new Date().toISOString(),
message: {
role: "assistant",
provider: "openai-codex",
model: "gpt-5.5",
api: "openai-codex-responses",
content: [
{ type: "text", text: "Process List" },
{
type: "toolCall",
id: "call_process|fc_1",
name: "process",
arguments: { action: "poll", sessionId: "wild-wharf", timeout: 30_000 },
},
],
stopReason: "toolUse",
},
};
const deliveryMirror = {
type: "message",
id: "msg-delivery",
parentId: "msg-asst-process",
timestamp: new Date().toISOString(),
message: {
role: "assistant",
provider: "openclaw",
model: "delivery-mirror",
api: "openai-responses",
content: [{ type: "text", text: "Process: `wild-wharf`" }],
stopReason: "stop",
},
};
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(deliveryMirror)}\n`;
await fs.writeFile(file, original, "utf-8");
const result = await repairSessionFileIfNeeded({ sessionFile: file });
expect(result.repaired).toBe(true);
expect(result.insertedToolResults).toBe(1);
const backup = await fs.readFile(requireBackupPath(result), "utf-8");
expect(backup).toBe(original);
const lines = (await fs.readFile(file, "utf-8")).trimEnd().split("\n");
expect(lines).toHaveLength(5);
const inserted = JSON.parse(lines[3]);
expect(inserted.type).toBe("message");
expect(inserted.parentId).toBe("msg-asst-process");
expect(inserted.message.role).toBe("toolResult");
expect(inserted.message.toolCallId).toBe("call_process|fc_1");
expect(inserted.message.toolName).toBe("process");
expect(inserted.message.isError).toBe(true);
expect(inserted.message.content[0].text).toBe("aborted");
expect(JSON.parse(lines[4])).toEqual(deliveryMirror);
});
it("does not duplicate code-mode tool results that are already persisted", async () => {
const { file } = await createTempSessionPath();
const { header, message } = buildSessionHeaderAndMessage();
const toolCallAssistant = {
type: "message",
id: "msg-asst-exec",
parentId: "msg-1",
timestamp: new Date().toISOString(),
message: {
role: "assistant",
provider: "openai-codex",
model: "gpt-5.5",
api: "openai-codex-responses",
content: [{ type: "toolCall", id: "call_exec|fc_1", name: "exec", arguments: {} }],
stopReason: "toolUse",
},
};
const toolResult = {
type: "message",
id: "msg-tool-result",
parentId: "msg-asst-exec",
timestamp: new Date().toISOString(),
message: {
role: "toolResult",
toolCallId: "call_exec|fc_1",
toolName: "exec",
content: [{ type: "text", text: "ok" }],
isError: false,
},
};
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(toolCallAssistant)}\n${JSON.stringify(toolResult)}\n`;
await fs.writeFile(file, original, "utf-8");
const result = await repairSessionFileIfNeeded({ sessionFile: file });
expect(result.repaired).toBe(false);
expect(result.insertedToolResults ?? 0).toBe(0);
const after = await fs.readFile(file, "utf-8");
expect(after).toBe(original);
});
it.each(["error", "aborted"] as const)(
"does not insert missing code-mode tool results for %s assistant turns",
async (stopReason) => {
const { file } = await createTempSessionPath();
const { header, message } = buildSessionHeaderAndMessage();
const incompleteAssistant = {
type: "message",
id: `msg-asst-${stopReason}`,
parentId: "msg-1",
timestamp: new Date().toISOString(),
message: {
role: "assistant",
provider: "openai-codex",
model: "gpt-5.5",
api: "openai-codex-responses",
content: [
{ type: "toolCall", id: `call_${stopReason}|fc_1`, name: "exec", arguments: {} },
],
stopReason,
},
};
const original = `${JSON.stringify(header)}\n${JSON.stringify(message)}\n${JSON.stringify(incompleteAssistant)}\n`;
await fs.writeFile(file, original, "utf-8");
const result = await repairSessionFileIfNeeded({ sessionFile: file });
expect(result.repaired).toBe(false);
expect(result.insertedToolResults ?? 0).toBe(0);
const after = await fs.readFile(file, "utf-8");
expect(after).toBe(original);
},
);
it("preserves final text assistant turn that follows a tool-call/tool-result pair", async () => {
// Regression: a trailing assistant message with stopReason "stop" that follows a
// tool-call turn and its matching tool-result must never be trimmed by the repair

View File

@@ -1,7 +1,11 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentMessage } from "@earendil-works/pi-agent-core";
import { replaceFileAtomic } from "../infra/replace-file.js";
import { makeMissingToolResult } from "./session-transcript-repair.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "./stream-message-shared.js";
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
/** Placeholder for blank user messages — preserves the user turn so strict
* providers that require at least one user message don't reject the transcript. */
@@ -13,6 +17,7 @@ type RepairReport = {
rewrittenAssistantMessages?: number;
droppedBlankUserMessages?: number;
rewrittenUserMessages?: number;
insertedToolResults?: number;
backupPath?: string;
reason?: string;
};
@@ -166,6 +171,7 @@ function buildRepairSummaryParts(params: {
rewrittenAssistantMessages: number;
droppedBlankUserMessages: number;
rewrittenUserMessages: number;
insertedToolResults: number;
}): string {
const parts: string[] = [];
if (params.droppedLines > 0) {
@@ -180,9 +186,111 @@ function buildRepairSummaryParts(params: {
if (params.rewrittenUserMessages > 0) {
parts.push(`rewrote ${params.rewrittenUserMessages} user message(s)`);
}
if (params.insertedToolResults > 0) {
parts.push(`inserted ${params.insertedToolResults} missing tool result(s)`);
}
return parts.length > 0 ? parts.join(", ") : "no changes";
}
function isCodeModeToolCallRepairCandidate(entry: unknown): entry is SessionMessageEntry {
if (!entry || typeof entry !== "object") {
return false;
}
const record = entry as { type?: unknown; message?: unknown };
if (record.type !== "message" || !record.message || typeof record.message !== "object") {
return false;
}
const message = record.message as {
role?: unknown;
api?: unknown;
provider?: unknown;
stopReason?: unknown;
};
return (
message.role === "assistant" &&
message.api === "openai-codex-responses" &&
message.provider === "openai-codex" &&
message.stopReason !== "error" &&
message.stopReason !== "aborted"
);
}
function collectPersistedToolResultIds(entries: unknown[]): Set<string> {
const ids = new Set<string>();
for (const entry of entries) {
if (!entry || typeof entry !== "object") {
continue;
}
const record = entry as { type?: unknown; message?: unknown };
if (record.type !== "message" || !record.message || typeof record.message !== "object") {
continue;
}
const message = record.message as AgentMessage;
if (message.role !== "toolResult") {
continue;
}
const id = extractToolResultId(message);
if (id) {
ids.add(id);
}
}
return ids;
}
function makeSyntheticToolResultEntry(params: {
parent: SessionMessageEntry;
toolCallId: string;
toolName?: string;
}): SessionMessageEntry {
const message = makeMissingToolResult({
toolCallId: params.toolCallId,
toolName: params.toolName,
text: "aborted",
});
return {
type: "message",
id: `repair-${randomUUID()}`,
parentId: typeof params.parent.id === "string" ? params.parent.id : undefined,
timestamp: new Date().toISOString(),
message: message as unknown as SessionMessageEntry["message"],
};
}
function insertMissingCodeModeToolResults(entries: unknown[]): {
entries: unknown[];
insertedToolResults: number;
} {
const resultIds = collectPersistedToolResultIds(entries);
let insertedToolResults = 0;
const out: unknown[] = [];
for (const entry of entries) {
out.push(entry);
if (!isCodeModeToolCallRepairCandidate(entry)) {
continue;
}
const toolCalls = extractToolCallsFromAssistant(
entry.message as unknown as Extract<AgentMessage, { role: "assistant" }>,
);
for (const toolCall of toolCalls) {
if (resultIds.has(toolCall.id)) {
continue;
}
out.push(
makeSyntheticToolResultEntry({
parent: entry,
toolCallId: toolCall.id,
toolName: toolCall.name,
}),
);
resultIds.add(toolCall.id);
insertedToolResults += 1;
}
}
return { entries: insertedToolResults > 0 ? out : entries, insertedToolResults };
}
export async function repairSessionFileIfNeeded(params: {
sessionFile: string;
debug?: (message: string) => void;
@@ -212,6 +320,7 @@ export async function repairSessionFileIfNeeded(params: {
let rewrittenAssistantMessages = 0;
let droppedBlankUserMessages = 0;
let rewrittenUserMessages = 0;
let insertedToolResults = 0;
for (const line of lines) {
if (!line.trim()) {
@@ -274,7 +383,18 @@ export async function repairSessionFileIfNeeded(params: {
droppedBlankUserMessages === 0 &&
rewrittenUserMessages === 0
) {
return { repaired: false, droppedLines: 0 };
const repairedToolResults = insertMissingCodeModeToolResults(entries);
insertedToolResults = repairedToolResults.insertedToolResults;
if (insertedToolResults === 0) {
return { repaired: false, droppedLines: 0 };
}
entries.splice(0, entries.length, ...repairedToolResults.entries);
} else {
const repairedToolResults = insertMissingCodeModeToolResults(entries);
insertedToolResults = repairedToolResults.insertedToolResults;
if (insertedToolResults > 0) {
entries.splice(0, entries.length, ...repairedToolResults.entries);
}
}
const cleaned = `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
@@ -308,6 +428,7 @@ export async function repairSessionFileIfNeeded(params: {
rewrittenAssistantMessages,
droppedBlankUserMessages,
rewrittenUserMessages,
insertedToolResults,
})} (${path.basename(sessionFile)})`,
);
return {
@@ -316,6 +437,7 @@ export async function repairSessionFileIfNeeded(params: {
rewrittenAssistantMessages,
droppedBlankUserMessages,
rewrittenUserMessages,
insertedToolResults,
backupPath,
};
}

View File

@@ -269,6 +269,15 @@ function normalizePersistedToolResultName(
return toolResult;
}
function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage): boolean {
if (!message || message.role !== "assistant") {
return false;
}
const provider = normalizeOptionalString((message as { provider?: unknown }).provider) ?? "";
const model = normalizeOptionalString((message as { model?: unknown }).model) ?? "";
return provider === "openclaw" && (model === "delivery-mirror" || model === "gateway-injected");
}
export { getRawSessionAppendMessage };
export function installSessionToolResultGuard(
@@ -449,7 +458,14 @@ export function installSessionToolResultGuard(
// synthetic results (e.g. OpenAI) accumulate stale pending state when a user message
// interrupts in-flight tool calls, leaving orphaned tool_use blocks in the transcript
// that cause API 400 errors on subsequent requests.
if (pendingState.shouldFlushBeforeNonToolResult(nextRole, toolCalls.length)) {
const transcriptOnlyAssistant =
nextRole === "assistant" &&
toolCalls.length === 0 &&
isTranscriptOnlyOpenClawAssistantMessage(nextMessage);
if (
!transcriptOnlyAssistant &&
pendingState.shouldFlushBeforeNonToolResult(nextRole, toolCalls.length)
) {
flushPendingToolResults();
}
// If new tool calls arrive while older ones are pending, flush the old ones first.

View File

@@ -147,6 +147,52 @@ describe("sanitizeToolUseResultPairing", () => {
expect(JSON.stringify(result.added)).not.toContain("missing tool result");
});
it("keeps parallel tool results when code-mode display turns arrive first", () => {
const input = castAgentMessages([
{
role: "assistant",
content: [
{ type: "toolCall", id: "call_search", name: "lcm_expand_query", arguments: {} },
{ type: "toolCall", id: "call_status", name: "session_status", arguments: {} },
],
},
{
role: "assistant",
content: [{ type: "text", text: "Lcm Expand Query: missing tool result" }],
stopReason: "stop",
},
{
role: "toolResult",
toolCallId: "call_status",
toolName: "session_status",
content: [{ type: "text", text: "ok" }],
isError: false,
},
{
role: "toolResult",
toolCallId: "call_search",
toolName: "lcm_expand_query",
content: [{ type: "text", text: "expanded" }],
isError: false,
},
{ role: "user", content: "next turn" },
]);
const result = repairToolUseResultPairing(input);
expect(result.added).toHaveLength(0);
expect(result.messages.map((message) => message.role)).toEqual([
"assistant",
"toolResult",
"toolResult",
"assistant",
"user",
]);
expect((result.messages[1] as { toolCallId?: string }).toolCallId).toBe("call_search");
expect((result.messages[2] as { toolCallId?: string }).toolCallId).toBe("call_status");
expect(result.moved).toBe(true);
});
it("repairs blank tool result names from matching tool calls", () => {
const input = castAgentMessages([
{

View File

@@ -456,6 +456,13 @@ function shouldDropErroredAssistantResults(options?: ToolUseResultPairingOptions
return options?.erroredAssistantResultPolicy === "drop";
}
function assistantHasToolCalls(message: AgentMessage): boolean {
if (!message || typeof message !== "object" || message.role !== "assistant") {
return false;
}
return extractToolCallsFromAssistant(message).length > 0;
}
export function repairToolUseResultPairing(
messages: AgentMessage[],
options?: ToolUseResultPairingOptions,
@@ -538,7 +545,11 @@ export function repairToolUseResultPairing(
const nextRole = (next as { role?: unknown }).role;
if (nextRole === "assistant") {
break;
if (assistantHasToolCalls(next)) {
break;
}
remainder.push(next);
continue;
}
if (nextRole === "toolResult") {

View File

@@ -17,6 +17,14 @@ export type ToolDisplaySpec = {
actions?: Record<string, ToolDisplayActionSpec>;
};
export type ToolSearchCodeDisplayTarget = {
toolName: string;
displayToolName?: string;
displayArgs?: Record<string, unknown>;
detail?: string;
bridgeVerb?: "call" | "describe" | "search";
};
type CoerceDisplayValueOptions = {
includeFalse?: boolean;
includeZero?: boolean;
@@ -350,6 +358,253 @@ function collectWebSearchQueries(record: Record<string, unknown>): string[] {
return queries;
}
function parseToolSearchCall(code: string): { target: string; args?: string } | undefined {
const prefixMatch = code.match(/openclaw\.tools\.call\s*\(\s*/s);
if (!prefixMatch || prefixMatch.index === undefined) {
return undefined;
}
const rest = code.slice(prefixMatch.index + prefixMatch[0].length);
const targetMatch = rest.match(/^("[^"]{1,240}"|'[^']{1,240}'|[^,)\s]{1,240})/s);
if (!targetMatch?.[1]) {
return undefined;
}
const afterTarget = rest.slice(targetMatch[0].length);
const commaIndex = afterTarget.indexOf(",");
if (commaIndex < 0) {
return { target: targetMatch[1] };
}
const args = afterTarget.slice(commaIndex + 1);
return { target: targetMatch[1], args };
}
function normalizeToolSearchDisplayToolName(toolName: string | undefined): string | undefined {
const value = normalizeOptionalString(toolName);
if (!value) {
return undefined;
}
const catalogIdMatch = value.match(/^(?:openclaw|mcp|client):[^:]+:(.+)$/s);
return normalizeOptionalString(catalogIdMatch?.[1]) ?? value;
}
function collectToolSearchDescribeBindings(code: string): Map<string, string> {
const bindings = new Map<string, string>();
const bindingPattern =
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?openclaw\.tools\.describe\s*\(\s*("[^"]{1,240}"|'[^']{1,240}')\s*(?:,|\))/gs;
for (const match of code.matchAll(bindingPattern)) {
const variableName = match[1];
const target = summarizeToolSearchTarget(match[2]);
if (variableName && target) {
bindings.set(variableName, target);
}
}
return bindings;
}
function resolveToolSearchCallTarget(
code: string,
rawTarget: string | undefined,
): string | undefined {
const target = normalizeOptionalString(rawTarget);
if (!target) {
return undefined;
}
const idReference = target.match(/^([A-Za-z_$][\w$]*)\.id\b/s);
if (idReference?.[1]) {
const describedTarget = collectToolSearchDescribeBindings(code).get(idReference[1]);
if (describedTarget) {
return describedTarget;
}
}
return summarizeToolSearchTarget(target);
}
function summarizeToolSearchTarget(raw: string | undefined): string | undefined {
const value = normalizeOptionalString(raw);
if (!value) {
return undefined;
}
const literalMatch = value.match(/^[\s]*["']([^"']{1,160})["'][\s]*$/s);
if (literalMatch?.[1]) {
return normalizeOptionalString(literalMatch[1]);
}
const idPropertyMatch = value.match(/\.id\b/);
if (idPropertyMatch) {
return normalizeOptionalString(value.replace(/\.id\b.*/s, ""));
}
const namePropertyMatch = value.match(/name\s*:\s*["']([^"']{1,120})["']/s);
if (namePropertyMatch?.[1]) {
return normalizeOptionalString(namePropertyMatch[1]);
}
const compact = value.replace(/\s+/g, " ").trim();
return compact.length <= 80 ? compact : undefined;
}
function parseToolSearchCallArgs(raw: string | undefined): Record<string, unknown> | undefined {
const source = extractObjectLiteralSource(raw);
if (!source) {
return undefined;
}
const args: Record<string, unknown> = {};
const propertyPattern =
/(?:^|[,{\s])([A-Za-z_$][\w$]*)\s*:\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|true|false|null|-?\d+(?:\.\d+)?)/g;
for (const match of source.matchAll(propertyPattern)) {
const key = match[1];
const value = match[2];
if (!key || value === undefined) {
continue;
}
args[key] = parseSimpleToolSearchArgValue(value);
}
return Object.keys(args).length > 0 ? args : undefined;
}
function extractObjectLiteralSource(raw: string | undefined): string | undefined {
const value = normalizeOptionalString(raw);
if (!value) {
return undefined;
}
const start = value.indexOf("{");
if (start < 0) {
return undefined;
}
let depth = 0;
let quote: "'" | '"' | undefined;
let escaped = false;
for (let i = start; i < value.length; i += 1) {
const char = value[i];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (quote) {
if (char === quote) {
quote = undefined;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return value.slice(start, i + 1);
}
}
}
return undefined;
}
function parseSimpleToolSearchArgValue(raw: string): unknown {
if (raw === "true") {
return true;
}
if (raw === "false") {
return false;
}
if (raw === "null") {
return null;
}
if (/^-?\d+(?:\.\d+)?$/.test(raw)) {
return Number(raw);
}
const quote = raw[0];
const inner = raw.slice(1, -1);
if (quote === '"') {
try {
return JSON.parse(raw) as unknown;
} catch {
return inner;
}
}
return inner.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
}
function summarizeToolSearchCallInput(raw: string | undefined): string | undefined {
const value = normalizeOptionalString(raw)
?.replace(/[);\s]+$/g, "")
.trim();
if (!value) {
return undefined;
}
const queryMatch = value.match(/query\s*:\s*["']([^"']{1,80})["']/s);
if (queryMatch?.[1]) {
return "query " + queryMatch[1].trim();
}
const actionMatch = value.match(/action\s*:\s*["']([^"']{1,80})["']/s);
if (actionMatch?.[1]) {
return normalizeOptionalString(actionMatch[1]);
}
const commandMatch = value.match(/command\s*:\s*["']([^"'\n]{1,120})["']/s);
if (commandMatch?.[1]) {
return normalizeOptionalString(commandMatch[1]);
}
const sessionMatch = value.match(/sessionId\s*:\s*["']([^"']{1,80})["']/s);
if (sessionMatch?.[1]) {
return "session " + sessionMatch[1].trim();
}
const idMatch = value.match(/id\s*:\s*["']([^"']{1,80})["']/s);
if (idMatch?.[1]) {
return idMatch[1].trim();
}
return undefined;
}
export function resolveToolSearchCodeDisplayTarget(
args: unknown,
): ToolSearchCodeDisplayTarget | undefined {
const record = asRecord(args);
if (!record || typeof record.code !== "string") {
return undefined;
}
const code = record.code;
const call = parseToolSearchCall(code);
if (call) {
const toolName = resolveToolSearchCallTarget(code, call.target);
if (!toolName) {
return { toolName: "tool_search_code", detail: "call selected tool", bridgeVerb: "call" };
}
return {
toolName,
displayToolName: normalizeToolSearchDisplayToolName(toolName),
displayArgs: parseToolSearchCallArgs(call.args),
detail: summarizeToolSearchCallInput(call.args),
bridgeVerb: "call",
};
}
const describeMatch = code.match(/openclaw\.tools\.describe\s*\(\s*([^)]+?)\s*(?:,|\))/s);
if (describeMatch) {
const toolName = summarizeToolSearchTarget(describeMatch[1]);
return toolName
? { toolName, detail: "describe via tool search", bridgeVerb: "describe" }
: { toolName: "tool_search_code", detail: "describe selected tool", bridgeVerb: "describe" };
}
const searchMatch = code.match(/openclaw\.tools\.search\s*\(\s*([^)]+?)\s*(?:,|\))/s);
if (searchMatch) {
const query = summarizeToolSearchTarget(searchMatch[1]);
return {
toolName: "tool_search_code",
detail: query ? "search " + query : "search tools",
bridgeVerb: "search",
};
}
return { toolName: "tool_search_code", detail: "run bridge code" };
}
function resolveToolSearchCodeDetail(args: unknown): string | undefined {
return resolveToolSearchCodeDisplayTarget(args)?.detail;
}
function resolveWebFetchDetail(args: unknown): string | undefined {
const record = asRecord(args);
if (!record) {
@@ -491,6 +746,9 @@ function resolveToolVerbAndDetail(params: {
if (!detail && params.toolKey === "web_fetch") {
detail = resolveWebFetchDetail(params.args);
}
if (!detail && params.toolKey === "tool_search_code") {
detail = resolveToolSearchCodeDetail(params.args);
}
const detailKeys =
actionSpec?.detailKeys ?? params.spec?.detailKeys ?? params.fallbackDetailKeys ?? [];

View File

@@ -1,7 +1,36 @@
import { describe, expect, it } from "vitest";
import { resolveToolSearchCodeDisplayTarget } from "./tool-display-common.js";
import { formatToolDetail, formatToolSummary, resolveToolDisplay } from "./tool-display.js";
describe("tool display details", () => {
it("summarizes tool-search code targets from described tool ids", () => {
expect(
resolveToolSearchCodeDisplayTarget({
code: "const tool = await openclaw.tools.describe('openclaw:core:exec'); return await openclaw.tools.call(tool.id, { command: 'echo hi' });",
}),
).toEqual({
toolName: "openclaw:core:exec",
displayToolName: "exec",
displayArgs: { command: "echo hi" },
detail: "echo hi",
bridgeVerb: "call",
});
});
it("normalizes direct tool-search catalog ids to native display names and args", () => {
expect(
resolveToolSearchCodeDisplayTarget({
code: 'return await openclaw.tools.call("openclaw:core:exec", { command: "echo hi" });',
}),
).toEqual({
toolName: "openclaw:core:exec",
displayToolName: "exec",
displayArgs: { command: "echo hi" },
detail: "echo hi",
bridgeVerb: "call",
});
});
it("skips zero/false values for optional detail fields", () => {
const detail = formatToolDetail(
resolveToolDisplay({

View File

@@ -149,7 +149,9 @@ function buildUnownedProviderTransportReplayFallback(params: {
...(isAnthropic || isStrictOpenAiCompatible || isClaudeOpenAiResponses
? { validateAnthropicTurns: true }
: {}),
...(isGoogle || isAnthropic ? { allowSyntheticToolResults: true } : {}),
...(isGoogle || isAnthropic || isOpenAiResponsesCompatibleApi(params.modelApi)
? { allowSyntheticToolResults: true }
: {}),
};
}

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
import { formatChannelProgressDraftLine } from "../plugin-sdk/channel-streaming.js";
const persistGatewaySessionLifecycleEventMock = vi.fn();
@@ -1021,6 +1022,60 @@ describe("agent event handler", () => {
resetAgentRunContextForTest();
});
it("projects tool-search bridge calls like native channel verbose tool events", () => {
const { nodeSendToSession, handler } = createHarness({
resolveSessionKeyForRun: () => "session-1",
});
registerAgentRunContext("run-tool-search-node", {
sessionKey: "session-1",
verboseLevel: "on",
});
handler({
runId: "run-tool-search-node",
seq: 1,
stream: "tool",
ts: 1_234,
data: {
phase: "start",
name: "tool_search_code",
toolCallId: "tool-search-node-1",
args: {
code: 'return await openclaw.tools.call("openclaw:core:exec", { command: "echo hi" });',
},
},
});
const payload = nodeSendToSession.mock.calls[0]?.[2] as {
stream?: string;
data?: { name?: string; args?: Record<string, unknown> };
};
expect(payload.stream).toBe("tool");
expect(payload.data).toMatchObject({
phase: "start",
name: "exec",
bridgeToolName: "tool_search_code",
bridgeTargetToolName: "openclaw:core:exec",
bridgeVerb: "call",
args: { command: "echo hi" },
});
expect(
formatChannelProgressDraftLine({
event: "tool",
name: payload.data?.name,
args: payload.data?.args,
}),
).toBe(
formatChannelProgressDraftLine({
event: "tool",
name: "exec",
args: { command: "echo hi" },
}),
);
resetAgentRunContextForTest();
});
it("hydrates node session tool events with session ownership metadata", () => {
const { nodeSendToSession, handler } = createHarness({
resolveSessionKeyForRun: () => "session-1",

View File

@@ -1,3 +1,4 @@
import { resolveToolSearchCodeDisplayTarget } from "../agents/tool-display-common.js";
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-reply/heartbeat.js";
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { getRuntimeConfig } from "../config/io.js";
@@ -39,6 +40,41 @@ export type {
ToolEventRecipientRegistry,
} from "./server-chat-state.js";
function projectToolSearchCodeEventForChannelPayload<T extends { data?: unknown }>(payload: T): T {
const data = payload.data;
if (!data || typeof data !== "object") {
return payload;
}
const record = data as Record<string, unknown>;
if (record.name !== "tool_search_code") {
return payload;
}
const target = resolveToolSearchCodeDisplayTarget(record.args);
if (!target) {
return payload;
}
const projectedName = target.displayToolName ?? target.toolName;
if (!projectedName || projectedName === "tool_search_code") {
return payload;
}
// Channel/node subscribers render from event data, not the richer display
// helper used by Control UI. Project obvious bridge calls so verbose
// surfaces name the concrete tool while keeping the bridge identity available.
const projectedData: Record<string, unknown> = { ...record, name: projectedName };
if (target.displayArgs) {
projectedData.args = target.displayArgs;
} else if (target.detail) {
projectedData.args = { detail: target.detail };
}
if (target.bridgeVerb) {
projectedData.bridgeToolName = "tool_search_code";
projectedData.bridgeTargetToolName = target.toolName;
projectedData.bridgeVerb = target.bridgeVerb;
}
return { ...payload, data: projectedData };
}
function resolveHeartbeatAckMaxChars(): number {
try {
const cfg = getRuntimeConfig();
@@ -694,7 +730,10 @@ export function createAgentEventHandler({
sessionKey,
"agent",
isToolEvent
? { ...channelToolPayload, ...buildSessionEventSnapshot(sessionKey) }
? projectToolSearchCodeEventForChannelPayload({
...channelToolPayload,
...buildSessionEventSnapshot(sessionKey),
})
: agentPayload,
);
}

View File

@@ -78,6 +78,7 @@ describe("provider replay helpers", () => {
applyAssistantFirstOrderingFix: false,
validateGeminiTurns: false,
validateAnthropicTurns: false,
allowSyntheticToolResults: true,
});
expect(policy).not.toHaveProperty("sanitizeToolCallIds");
expect(policy).not.toHaveProperty("toolCallIdMode");

View File

@@ -30,11 +30,16 @@ export function buildOpenAICompatibleReplayPolicy(
const sanitizeToolCallIds = options.sanitizeToolCallIds ?? true;
const dropReasoningFromHistory = options.dropReasoningFromHistory ?? true;
const isResponsesFamily =
modelApi === "openai-responses" ||
modelApi === "openai-codex-responses" ||
modelApi === "azure-openai-responses";
return {
...(sanitizeToolCallIds
? { sanitizeToolCallIds: true, toolCallIdMode: "strict" as const }
: {}),
...(isResponsesFamily ? { allowSyntheticToolResults: true } : {}),
...(modelApi === "openai-completions"
? {
applyAssistantFirstOrderingFix: true,