mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +546,7 @@ describe("buildOpenAIProvider", () => {
|
||||
sanitizeToolCallIds: false,
|
||||
validateGeminiTurns: false,
|
||||
validateAnthropicTurns: false,
|
||||
allowSyntheticToolResults: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1371,7 +1371,6 @@ async function compactEmbeddedPiSessionDirectOnce(
|
||||
await flushPendingToolResultsAfterIdle({
|
||||
agent: session?.agent,
|
||||
sessionManager,
|
||||
clearPendingOnTimeout: true,
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
|
||||
@@ -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 } = {};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -149,7 +149,9 @@ function buildUnownedProviderTransportReplayFallback(params: {
|
||||
...(isAnthropic || isStrictOpenAiCompatible || isClaudeOpenAiResponses
|
||||
? { validateAnthropicTurns: true }
|
||||
: {}),
|
||||
...(isGoogle || isAnthropic ? { allowSyntheticToolResults: true } : {}),
|
||||
...(isGoogle || isAnthropic || isOpenAiResponsesCompatibleApi(params.modelApi)
|
||||
? { allowSyntheticToolResults: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user