diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d1e4b61f5..501f754071f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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--` 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. diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index bc101506cf8..ddb550de22b 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -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(); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 8e66f747148..1ea5d03d7ff 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -111,6 +111,7 @@ export class CodexAppServerEventProjector { private readonly activeItemIds = new Set(); private readonly completedItemIds = new Set(); private readonly activeCompactionItemIds = new Set(); + private readonly toolProgressTexts = new Set(); private readonly toolResultSummaryItemIds = new Set(); private readonly toolResultOutputItemIds = new Set(); private readonly toolResultOutputStreamedItemIds = new Set(); @@ -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; } } diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 569ef572ccf..0d06e87657f 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -546,6 +546,7 @@ describe("buildOpenAIProvider", () => { sanitizeToolCallIds: false, validateGeminiTurns: false, validateAnthropicTurns: false, + allowSyntheticToolResults: true, }); }); diff --git a/extensions/openai/replay-policy.ts b/extensions/openai/replay-policy.ts index 058198767d9..78881a32bb5 100644 --- a/extensions/openai/replay-policy.ts +++ b/extensions/openai/replay-policy.ts @@ -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, diff --git a/src/agents/pi-embedded-runner.guard.test.ts b/src/agents/pi-embedded-runner.guard.test.ts index 5d381d30563..a29ee49fe48 100644 --- a/src/agents/pi-embedded-runner.guard.test.ts +++ b/src/agents/pi-embedded-runner.guard.test.ts @@ -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: { diff --git a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts index c2b2521dde8..9b96e4d5e04 100644 --- a/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts +++ b/src/agents/pi-embedded-runner.guard.waitforidle-before-flush.test.ts @@ -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", + ]); }); }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 9f118a3f312..0b3d85af1e6 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -1371,7 +1371,6 @@ async function compactEmbeddedPiSessionDirectOnce( await flushPendingToolResultsAfterIdle({ agent: session?.agent, sessionManager, - clearPendingOnTimeout: true, }); } catch { /* best-effort */ diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts index ec90e0cee6d..8f2742766c2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -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 } = {}; diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts index 49d4a583f9a..da5e8f4d0b8 100644 --- a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts @@ -58,7 +58,6 @@ export async function cleanupEmbeddedAttemptResources(params: { agent: IdleAwareAgent | null | undefined; sessionManager: ToolResultFlushManager | null | undefined; timeoutMs?: number; - clearPendingOnTimeout?: boolean; }) => Promise; 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 { diff --git a/src/agents/pi-embedded-runner/run/attempt.transcript-policy.test.ts b/src/agents/pi-embedded-runner/run/attempt.transcript-policy.test.ts index 8e428f999b3..4d918073310 100644 --- a/src/agents/pi-embedded-runner/run/attempt.transcript-policy.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.transcript-policy.test.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 8b80f12f1ce..2384c81c9e2 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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, diff --git a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts index 6ccd0869920..5986a03a288 100644 --- a/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts +++ b/src/agents/pi-embedded-runner/wait-for-idle-before-flush.ts @@ -44,19 +44,13 @@ export async function flushPendingToolResultsAfterIdle(opts: { agent: IdleAwareAgent | null | undefined; sessionManager: ToolResultFlushManager | null | undefined; timeoutMs?: number; - clearPendingOnTimeout?: boolean; }): Promise { 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?.(); } diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts index b90c79e4056..f708162352e 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.test.ts @@ -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", () => { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index c4b41044012..7ca10e8f936 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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; diff --git a/src/agents/session-file-repair.test.ts b/src/agents/session-file-repair.test.ts index d4dd7d3272f..bc7df346f2f 100644 --- a/src/agents/session-file-repair.test.ts +++ b/src/agents/session-file-repair.test.ts @@ -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 diff --git a/src/agents/session-file-repair.ts b/src/agents/session-file-repair.ts index 4db76f1e57b..21fdfd69af8 100644 --- a/src/agents/session-file-repair.ts +++ b/src/agents/session-file-repair.ts @@ -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 { + const ids = new Set(); + 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, + ); + 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, }; } diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index fca44501394..529a81105b1 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -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. diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index 67baa7e85ea..51bdf986b6a 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -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([ { diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index d18b423e025..84f7b0cfad3 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -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") { diff --git a/src/agents/tool-display-common.ts b/src/agents/tool-display-common.ts index 8cba8d1e6d3..a785df84bb8 100644 --- a/src/agents/tool-display-common.ts +++ b/src/agents/tool-display-common.ts @@ -17,6 +17,14 @@ export type ToolDisplaySpec = { actions?: Record; }; +export type ToolSearchCodeDisplayTarget = { + toolName: string; + displayToolName?: string; + displayArgs?: Record; + detail?: string; + bridgeVerb?: "call" | "describe" | "search"; +}; + type CoerceDisplayValueOptions = { includeFalse?: boolean; includeZero?: boolean; @@ -350,6 +358,253 @@ function collectWebSearchQueries(record: Record): 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 { + const bindings = new Map(); + 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 | undefined { + const source = extractObjectLiteralSource(raw); + if (!source) { + return undefined; + } + const args: Record = {}; + 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 ?? []; diff --git a/src/agents/tool-display.test.ts b/src/agents/tool-display.test.ts index e0fa3bc167c..c0e676893a7 100644 --- a/src/agents/tool-display.test.ts +++ b/src/agents/tool-display.test.ts @@ -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({ diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index adb265c7c1e..9a0dda17cc9 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -149,7 +149,9 @@ function buildUnownedProviderTransportReplayFallback(params: { ...(isAnthropic || isStrictOpenAiCompatible || isClaudeOpenAiResponses ? { validateAnthropicTurns: true } : {}), - ...(isGoogle || isAnthropic ? { allowSyntheticToolResults: true } : {}), + ...(isGoogle || isAnthropic || isOpenAiResponsesCompatibleApi(params.modelApi) + ? { allowSyntheticToolResults: true } + : {}), }; } diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts index 7a22fee112d..45683343a0b 100644 --- a/src/gateway/server-chat.agent-events.test.ts +++ b/src/gateway/server-chat.agent-events.test.ts @@ -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 }; + }; + 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", diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 0e707f2ba50..21484ae0dc1 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -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(payload: T): T { + const data = payload.data; + if (!data || typeof data !== "object") { + return payload; + } + const record = data as Record; + 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 = { ...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, ); } diff --git a/src/plugins/provider-replay-helpers.test.ts b/src/plugins/provider-replay-helpers.test.ts index 8fe5f5ab29f..f453ba473b1 100644 --- a/src/plugins/provider-replay-helpers.test.ts +++ b/src/plugins/provider-replay-helpers.test.ts @@ -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"); diff --git a/src/plugins/provider-replay-helpers.ts b/src/plugins/provider-replay-helpers.ts index eec976e185d..2180dbd4d24 100644 --- a/src/plugins/provider-replay-helpers.ts +++ b/src/plugins/provider-replay-helpers.ts @@ -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,