From 4e1f26dc18920d8ce9cd01cf000e7908fb5d1209 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 16 Aug 2026 23:44:29 -0700 Subject: [PATCH] refactor(gateway,ui): one bounded display projection; delete marker strip sites (#124997) * refactor(gateway,ui): one bounded display projection; delete marker strip sites Persisted transcripts are marker-free since the write-boundary projection (#124793), the historical migration (#124888), and TTS facts (#124913), so display surfaces stop compensating. sessions.list.lastMessagePreview and its siblings (sessions.preview/describe, TUI picker, sessions_list tool, MCP) now share one bounded role-aware projection (240 chars, tool/system/thinking and suppressed control replies excluded, directive-only rows fall through). The web reply chip reads the typed openclawDelivery fact instead of parsing text; chat.history preserves the field to the UI. Post-hoc display strips are deleted across web/TUI/MCP/sessions-list; live streaming cleaners stay. Stale gateway-protocol preview comments corrected; no schema change. Assertion-safety baseline pruned for shrunk files (sanctioned direction). Production net -173, tests net -137. Fixes the sidebar [[reply_to_current]] preview leak and the empty-code-pill overstrip of quoted markers. * fix(agents): preserve restart recovery transcript reads * refactor(gateway): remove obsolete transcript exports * fix(gateway): normalize injected delivery directives * fix(ci): scope projection and recovery checks * chore(ci): shrink plugin SDK surface budgets * test: deflake loaded side question and worker checks * test: align display projection CI fixtures * style: format display projection fixture --- config/assertion-safety-baseline.txt | 5 +- .../src/app-server/side-question.test.ts | 372 ++++++++++-------- .../gateway-protocol/src/schema/sessions.ts | 8 +- scripts/plugin-sdk-surface-report.mts | 6 +- ...agent-registry-restart-recovery-message.ts | 27 ++ .../subagent-registry-restart-recovery.ts | 25 +- src/agents/tools/sessions-list-tool.test.ts | 4 +- .../chat-display-projection.helpers.ts | 5 +- .../chat-display-projection.message-tool.ts | 3 +- .../chat-display-projection.sanitize.ts | 50 +-- src/gateway/control-reply-text.test.ts | 32 -- src/gateway/dashboard-session-title.test.ts | 4 +- src/gateway/dashboard-session-title.ts | 11 +- .../server-methods/chat-assistant-content.ts | 12 +- .../chat-send-nonagent-finalization.ts | 11 +- .../server-methods/chat-transcript-inject.ts | 19 +- .../chat-transcript-persistence.ts | 14 +- .../chat.directive-tags.test.ts | 49 ++- .../server-methods/server-methods.test.ts | 18 +- .../server.chat.gateway-server-chat-b.test.ts | 71 ++-- .../server.sessions.preview-resolve.test.ts | 7 +- .../session-display-projection.test.ts | 51 +++ src/gateway/session-display-projection.ts | 69 ++++ src/gateway/session-transcript-readers.ts | 32 -- .../session-transcript-title-reader.ts | 13 +- src/gateway/session-utils.fs.test.ts | 42 +- src/gateway/session-utils.fs.ts | 138 +------ ...ovider-provisioning-node-admission.test.ts | 28 +- .../provider-provisioning-node.test.ts | 28 +- src/mcp/channel-server.test.ts | 4 +- src/media/parse.ts | 6 +- src/plugin-sdk/text-chunking.ts | 4 +- src/tui/tui-session-picker.test.ts | 15 + src/utils/directive-tags.test.ts | 129 ------ src/utils/directive-tags.ts | 57 --- src/utils/transcript-tools.test.ts | 14 +- src/utils/transcript-tools.ts | 4 - ...chat-flow.sidebar-presentation.e2e.test.ts | 1 + ui/src/lib/chat/message-normalizer.test.ts | 33 +- ui/src/lib/chat/message-normalizer.ts | 48 ++- .../chat/components/chat-message.test.ts | 3 +- 41 files changed, 638 insertions(+), 834 deletions(-) create mode 100644 src/agents/subagents/registry/subagent-registry-restart-recovery-message.ts create mode 100644 src/gateway/session-display-projection.test.ts create mode 100644 src/gateway/session-display-projection.ts create mode 100644 src/tui/tui-session-picker.test.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 1663467379a5..94ec63b7eec7 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -3128,12 +3128,12 @@ src/gateway/session-sharing.ts 2 src/gateway/session-transcript-derived-readers.ts 2 src/gateway/session-transcript-files.fs.ts 2 src/gateway/session-transcript-message.ts 4 -src/gateway/session-transcript-readers.ts 8 +src/gateway/session-transcript-readers.ts 3 src/gateway/session-transcript-title-reader.ts 1 src/gateway/session-utils-core.ts 1 src/gateway/session-utils-row.ts 1 src/gateway/session-utils-search.ts 1 -src/gateway/session-utils.fs.ts 19 +src/gateway/session-utils.fs.ts 16 src/gateway/sessions-history-http.ts 1 src/gateway/stale-install.ts 1 src/gateway/system-ca-warmup.ts 1 @@ -3960,7 +3960,6 @@ src/tui/tui-task-suggestions.ts 7 src/tui/tui.ts 5 src/utils.ts 1 src/utils/delivery-context.shared.ts 2 -src/utils/directive-tags.ts 2 src/utils/reaction-level.ts 2 src/utils/string-readers.ts 2 src/utils/transcript-tools.ts 2 diff --git a/extensions/codex/src/app-server/side-question.test.ts b/extensions/codex/src/app-server/side-question.test.ts index bf31d69208e7..d5f73fcd5803 100644 --- a/extensions/codex/src/app-server/side-question.test.ts +++ b/extensions/codex/src/app-server/side-question.test.ts @@ -157,6 +157,38 @@ function mockCall(mock: ReturnType, index = 0): unknown[] { return call; } +async function handleClientRequestWhenReady( + client: ReturnType, + request: Parameters["handleRequest"]>[0], + assertHandled: (response: unknown) => void = (response) => expect(response).not.toBeUndefined(), +): Promise { + let response: unknown; + await vi.waitFor(async () => { + response = await client.handleRequest(request); + assertHandled(response); + }); + return response; +} + +async function startClientRequestWhenReady( + client: ReturnType, + request: Parameters["handleRequest"]>[0], + started: Promise, +): Promise { + await vi.waitFor(async () => { + const requestResult = client.handleRequest(request); + void requestResult.catch(() => undefined); + const state = await Promise.race([ + started.then(() => "started" as const), + requestResult.then( + () => "unhandled" as const, + () => "unhandled" as const, + ), + ]); + expect(state).toBe("started"); + }); +} + function flushDiagnosticEvents() { return new Promise((resolve) => { setImmediate(resolve); @@ -439,7 +471,6 @@ async function runSideQuestionWithManagedWebSearchCall( options: { preserveToolFactory?: boolean } = {}, ) { const client = createFakeClient(); - let toolResponse: unknown; if (!options.preserveToolFactory) { createOpenClawCodingToolsMock.mockReturnValue([ { @@ -458,21 +489,6 @@ async function runSideQuestionWithManagedWebSearchCall( return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - toolResponse = await client.handleRequest({ - id: 42, - method: "item/tool/call", - params: { - ...codexTestTurnIds("side-thread"), - callId: "tool-1", - tool: "web_search", - arguments: { query: "service providers" }, - }, - }); - client.emit(turnCompleted("side-thread", "turn-1", "Search answer.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe" || method === "turn/interrupt") { @@ -482,7 +498,19 @@ async function runSideQuestionWithManagedWebSearchCall( }); getSharedCodexAppServerClientMock.mockResolvedValue(client); - const result = await runCodexAppServerSideQuestion(params); + const run = runCodexAppServerSideQuestion(params); + const toolResponse = await handleClientRequestWhenReady(client, { + id: 42, + method: "item/tool/call", + params: { + ...codexTestTurnIds("side-thread"), + callId: "tool-1", + tool: "web_search", + arguments: { query: "service providers" }, + }, + }); + client.emit(turnCompleted("side-thread", "turn-1", "Search answer.")); + const result = await run; const forkCall = client.request.mock.calls.find(([method]) => method === "thread/fork"); const forkConfig = (forkCall?.[1] as { config?: Record } | undefined)?.config; return { forkConfig, result, toolResponse }; @@ -1502,7 +1530,6 @@ describe("runCodexAppServerSideQuestion", () => { it("forwards side-thread command approvals through the active native hook relay", async () => { const client = createFakeClient(); let relayIdDuringFork: string | undefined; - let approvalResponse: unknown; handleCodexAppServerApprovalRequestMock.mockResolvedValueOnce({ decision: "decline" }); client.request.mockImplementation(async (method: string, requestParams: unknown) => { if (method === "thread/fork") { @@ -1514,21 +1541,6 @@ describe("runCodexAppServerSideQuestion", () => { return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - approvalResponse = await client.handleRequest({ - id: 42, - method: "item/commandExecution/requestApproval", - params: { - ...codexTestTurnIds("side-thread"), - itemId: "cmd-side", - command: "/bin/bash -lc 'node -v'", - cwd: "/tmp/workspace", - }, - }); - client.emit(turnCompleted("side-thread", "turn-1", "Side answer.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe" || method === "turn/interrupt") { @@ -1538,17 +1550,27 @@ describe("runCodexAppServerSideQuestion", () => { }); getSharedCodexAppServerClientMock.mockResolvedValue(client); - await expect( - runCodexAppServerSideQuestion( - sideLoopRelayParams({ - sessionKey: "agent:main:session-1", - messageChannel: "discord", - messageProvider: "discord-voice", - opts: { runId: "run-side-approval" }, - }), - { nativeHookRelay: { enabled: true } }, - ), - ).resolves.toEqual({ text: "Side answer." }); + const run = runCodexAppServerSideQuestion( + sideLoopRelayParams({ + sessionKey: "agent:main:session-1", + messageChannel: "discord", + messageProvider: "discord-voice", + opts: { runId: "run-side-approval" }, + }), + { nativeHookRelay: { enabled: true } }, + ); + const approvalResponse = await handleClientRequestWhenReady(client, { + id: 42, + method: "item/commandExecution/requestApproval", + params: { + ...codexTestTurnIds("side-thread"), + itemId: "cmd-side", + command: "/bin/bash -lc 'node -v'", + cwd: "/tmp/workspace", + }, + }); + client.emit(turnCompleted("side-thread", "turn-1", "Side answer.")); + await expect(run).resolves.toEqual({ text: "Side answer." }); expect(approvalResponse).toEqual({ decision: "decline" }); expect(handleCodexAppServerApprovalRequestMock).toHaveBeenCalledTimes(1); @@ -2291,7 +2313,6 @@ describe("runCodexAppServerSideQuestion", () => { : [], ); const client = createFakeClient(); - let toolResponse: unknown; client.request.mockImplementation(async (method: string) => { if (method === "thread/fork") { return threadResult("side-thread"); @@ -2300,22 +2321,6 @@ describe("runCodexAppServerSideQuestion", () => { return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - toolResponse = await client.handleRequest({ - id: 42, - method: "item/tool/call", - params: { - ...codexTestTurnIds("side-thread"), - callId: "tool-1", - tool: "wiki_status", - arguments: { topic: "AGENTS.md" }, - }, - }); - client.emit(agentDelta("side-thread", "turn-1", "Tool answer.")); - client.emit(turnCompleted("side-thread", "turn-1", "Tool answer.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe" || method === "turn/interrupt") { @@ -2325,12 +2330,45 @@ describe("runCodexAppServerSideQuestion", () => { }); getSharedCodexAppServerClientMock.mockResolvedValue(client); - const result = await runCodexAppServerSideQuestion( + const run = runCodexAppServerSideQuestion( sideParams({ cfg: { tools: { profile: "coding" } } as never, preparedModelRuntime, } as never), ); + await vi.waitFor(() => + expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ preparedModelRuntime }), + ), + ); + const toolFactoryOptions = mockCall(createOpenClawCodingToolsMock)[0] as { + preparedModelRuntime?: unknown; + }; + expect(toolFactoryOptions.preparedModelRuntime).toBe(preparedModelRuntime); + const toolResponse = await handleClientRequestWhenReady( + client, + { + id: 42, + method: "item/tool/call", + params: { + ...codexTestTurnIds("side-thread"), + callId: "tool-1", + tool: "wiki_status", + arguments: { topic: "AGENTS.md" }, + }, + }, + (response) => + expect({ response, toolCalls: toolExecuteMock.mock.calls.length }).toEqual({ + response: { + success: true, + contentItems: [{ type: "inputText", text: "tool output" }], + }, + toolCalls: 1, + }), + ); + client.emit(agentDelta("side-thread", "turn-1", "Tool answer.")); + client.emit(turnCompleted("side-thread", "turn-1", "Tool answer.")); + const result = await run; expect(result).toEqual({ text: "Tool answer." }); const [toolCallId, toolArguments, toolSignal, toolOptions] = mockCall(toolExecuteMock); @@ -2404,7 +2442,6 @@ describe("runCodexAppServerSideQuestion", () => { it("omits computer control from side threads without a compaction owner", async () => { const client = createFakeClient(); const computerExecute = vi.fn(); - let toolResponse: unknown; createOpenClawCodingToolsMock.mockReturnValue([ { name: "computer", @@ -2421,22 +2458,6 @@ describe("runCodexAppServerSideQuestion", () => { return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - toolResponse = await client.handleRequest({ - id: 43, - method: "item/tool/call", - params: { - ...codexTestTurnIds("side-thread"), - callId: "computer-1", - tool: "computer", - arguments: { action: "screenshot" }, - }, - }); - client.emit(agentDelta("side-thread", "turn-1", "Side answer.")); - client.emit(turnCompleted("side-thread", "turn-1", "Side answer.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe" || method === "turn/interrupt") { @@ -2446,9 +2467,20 @@ describe("runCodexAppServerSideQuestion", () => { }); getSharedCodexAppServerClientMock.mockResolvedValue(client); - await expect(runCodexAppServerSideQuestion(sideParams())).resolves.toEqual({ - text: "Side answer.", + const run = runCodexAppServerSideQuestion(sideParams()); + const toolResponse = await handleClientRequestWhenReady(client, { + id: 43, + method: "item/tool/call", + params: { + ...codexTestTurnIds("side-thread"), + callId: "computer-1", + tool: "computer", + arguments: { action: "screenshot" }, + }, }); + client.emit(agentDelta("side-thread", "turn-1", "Side answer.")); + client.emit(turnCompleted("side-thread", "turn-1", "Side answer.")); + await expect(run).resolves.toEqual({ text: "Side answer." }); expect(computerExecute).not.toHaveBeenCalled(); expect(toolResponse).toEqual({ success: false, @@ -2489,22 +2521,6 @@ describe("runCodexAppServerSideQuestion", () => { return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - void client.handleRequest({ - id: 42, - method: "item/tool/call", - params: { - ...codexTestTurnIds("side-thread"), - callId: "tool-1", - tool: "wiki_status", - arguments: {}, - }, - }); - await toolStarted; - client.emit(turnCompleted("side-thread", "turn-1", "Finished answer.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe") { @@ -2516,6 +2532,21 @@ describe("runCodexAppServerSideQuestion", () => { getSharedCodexAppServerClientMock.mockResolvedValue(client); const run = runCodexAppServerSideQuestion(sideParams()); + await startClientRequestWhenReady( + client, + { + id: 42, + method: "item/tool/call", + params: { + ...codexTestTurnIds("side-thread"), + callId: "tool-1", + tool: "wiki_status", + arguments: {}, + }, + }, + toolStarted, + ); + client.emit(turnCompleted("side-thread", "turn-1", "Finished answer.")); await vi.waitFor(() => expect(client.request.mock.calls.some(([method]) => method === "thread/unsubscribe")).toBe( true, @@ -2540,22 +2571,6 @@ describe("runCodexAppServerSideQuestion", () => { return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - await client.handleRequest({ - id: 42, - method: "item/tool/call", - params: { - ...codexTestTurnIds("side-thread"), - callId: "tool-1", - tool: "wiki_status", - arguments: { topic: "AGENTS.md" }, - }, - }); - client.emit(agentDelta("side-thread", "turn-1", "Tool answer.")); - client.emit(turnCompleted("side-thread", "turn-1", "Tool answer.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe" || method === "turn/interrupt") { @@ -2565,11 +2580,28 @@ describe("runCodexAppServerSideQuestion", () => { }); getSharedCodexAppServerClientMock.mockResolvedValue(client); - await runCodexAppServerSideQuestion( + const run = runCodexAppServerSideQuestion( sideParams({ opts: { runId: "run-side-diagnostics" }, }), ); + await handleClientRequestWhenReady( + client, + { + id: 42, + method: "item/tool/call", + params: { + ...codexTestTurnIds("side-thread"), + callId: "tool-1", + tool: "wiki_status", + arguments: { topic: "AGENTS.md" }, + }, + }, + () => expect(toolExecuteMock).toHaveBeenCalledTimes(1), + ); + client.emit(agentDelta("side-thread", "turn-1", "Tool answer.")); + client.emit(turnCompleted("side-thread", "turn-1", "Tool answer.")); + await run; await flushDiagnosticEvents(); unsubscribeDiagnostics(); @@ -3019,22 +3051,6 @@ describe("runCodexAppServerSideQuestion", () => { return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - await client.handleRequest({ - id: 42, - method: "item/tool/call", - params: { - ...codexTestTurnIds("side-thread"), - callId: "tool-1", - tool: "wiki_status", - arguments: { topic: "AGENTS.md" }, - }, - }); - client.emit(agentDelta("side-thread", "turn-1", "Tool answer.")); - client.emit(turnCompleted("side-thread", "turn-1", "Tool answer.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe" || method === "turn/interrupt") { @@ -3044,15 +3060,30 @@ describe("runCodexAppServerSideQuestion", () => { }); getSharedCodexAppServerClientMock.mockResolvedValue(client); - await expect( - runCodexAppServerSideQuestion( - sideParams({ - messageChannel: "discord", - messageProvider: "discord-voice", - currentChannelId: "discord:voice-room", - }), - ), - ).resolves.toEqual({ text: "Tool answer." }); + const run = runCodexAppServerSideQuestion( + sideParams({ + messageChannel: "discord", + messageProvider: "discord-voice", + currentChannelId: "discord:voice-room", + }), + ); + await handleClientRequestWhenReady( + client, + { + id: 42, + method: "item/tool/call", + params: { + ...codexTestTurnIds("side-thread"), + callId: "tool-1", + tool: "wiki_status", + arguments: { topic: "AGENTS.md" }, + }, + }, + () => expect(toolExecuteMock).toHaveBeenCalledTimes(1), + ); + client.emit(agentDelta("side-thread", "turn-1", "Tool answer.")); + client.emit(turnCompleted("side-thread", "turn-1", "Tool answer.")); + await expect(run).resolves.toEqual({ text: "Tool answer." }); expect(beforeToolCall).toHaveBeenCalledTimes(1); expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith( @@ -3063,8 +3094,6 @@ describe("runCodexAppServerSideQuestion", () => { it("returns an empty response for side-thread user input requests", async () => { const client = createFakeClient(); - let unrelatedUserInputResponse: unknown; - let userInputResponse: unknown; client.request.mockImplementation(async (method: string) => { if (method === "thread/fork") { return threadResult("side-thread"); @@ -3073,37 +3102,6 @@ describe("runCodexAppServerSideQuestion", () => { return {}; } if (method === "turn/start") { - setTimeout(() => { - void (async () => { - unrelatedUserInputResponse = await client.handleRequest({ - id: 42, - method: "item/tool/requestUserInput", - params: { - threadId: "parent-thread", - turnId: "parent-turn", - itemId: "input-parent", - questions: [], - }, - }); - userInputResponse = await client.handleRequest({ - id: 43, - method: "item/tool/requestUserInput", - params: { - ...codexTestTurnIds("side-thread"), - itemId: "input-1", - questions: [ - { - id: "choice", - header: "Choice", - question: "Pick one", - options: [{ label: "A", description: "" }], - }, - ], - }, - }); - client.emit(turnCompleted("side-thread", "turn-1", "No input needed.")); - })(); - }, 0); return turnStartResult("turn-1"); } if (method === "thread/unsubscribe" || method === "turn/interrupt") { @@ -3113,7 +3111,35 @@ describe("runCodexAppServerSideQuestion", () => { }); getSharedCodexAppServerClientMock.mockResolvedValue(client); - const result = await runCodexAppServerSideQuestion(sideParams()); + const run = runCodexAppServerSideQuestion(sideParams()); + const userInputResponse = await handleClientRequestWhenReady(client, { + id: 43, + method: "item/tool/requestUserInput", + params: { + ...codexTestTurnIds("side-thread"), + itemId: "input-1", + questions: [ + { + id: "choice", + header: "Choice", + question: "Pick one", + options: [{ label: "A", description: "" }], + }, + ], + }, + }); + const unrelatedUserInputResponse = await client.handleRequest({ + id: 42, + method: "item/tool/requestUserInput", + params: { + threadId: "parent-thread", + turnId: "parent-turn", + itemId: "input-parent", + questions: [], + }, + }); + client.emit(turnCompleted("side-thread", "turn-1", "No input needed.")); + const result = await run; expect(result).toEqual({ text: "No input needed." }); expect(unrelatedUserInputResponse).toBeUndefined(); diff --git a/packages/gateway-protocol/src/schema/sessions.ts b/packages/gateway-protocol/src/schema/sessions.ts index 37ee48709218..a7f00533d7bb 100644 --- a/packages/gateway-protocol/src/schema/sessions.ts +++ b/packages/gateway-protocol/src/schema/sessions.ts @@ -387,13 +387,13 @@ export const SessionsListParamsSchema = closedObject({ /** Limit agent-scoped rows to agents currently present in config. */ configuredAgentsOnly: Type.Optional(Type.Boolean()), /** - * Read first 8KB of each session transcript to derive title from first user message. - * Performs a file read per session - use `limit` to bound result set on large stores. + * Read a bounded transcript head projection to derive a title from the first user message. + * Use `limit` to bound projection work on large stores. */ includeDerivedTitles: Type.Optional(Type.Boolean()), /** - * Read last 16KB of each session transcript to extract most recent message preview. - * Performs a file read per session - use `limit` to bound result set on large stores. + * Read a bounded transcript tail projection for the latest visible user or assistant text. + * The returned short preview excludes tool, system, reasoning, and silent rows. */ includeLastMessage: Type.Optional(Type.Boolean()), label: Type.Optional(SessionLabelString), diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 0e897faa8e32..c3affdda8af2 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -292,7 +292,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: strict explicit agent-id normalization without default-agent fallback. // +5: session-catalog paging capability, family/node-host composers, and option contracts. // +3: two focused primitives and the closed read-only SecretRef result contract. - 4327, + // -2: remove obsolete transcript display helper exports. + 4325, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -370,7 +371,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: strict explicit agent-id normalization without default-agent fallback. // +2: session-catalog family and node-host binding composers. // +2: bounded provider stream and read-only SecretRef resolver. - 2570, + // -1: remove the obsolete transcript tool-call predicate. + 2569, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/agents/subagents/registry/subagent-registry-restart-recovery-message.ts b/src/agents/subagents/registry/subagent-registry-restart-recovery-message.ts new file mode 100644 index 000000000000..6023442af2b9 --- /dev/null +++ b/src/agents/subagents/registry/subagent-registry-restart-recovery-message.ts @@ -0,0 +1,27 @@ +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; + +export function readSubagentRecoveryTranscriptMessage( + message: unknown, +): { role?: string; text: string | null } | null { + const record = asOptionalRecord(message); + if (!record) { + return null; + } + const role = readStringValue(record.role); + if (typeof record.content === "string") { + return { role, text: record.content.trim() || null }; + } + if (Array.isArray(record.content)) { + const text = record.content + .flatMap((block) => { + const blockText = readStringValue(asOptionalRecord(block)?.text)?.trim(); + return blockText ? [blockText] : []; + }) + .join("\n") + .trim(); + return { role, text: text || null }; + } + const text = readStringValue(record.text)?.trim(); + return { role, text: text || null }; +} diff --git a/src/agents/subagents/registry/subagent-registry-restart-recovery.ts b/src/agents/subagents/registry/subagent-registry-restart-recovery.ts index 4485807348be..b8f6b1a36d61 100644 --- a/src/agents/subagents/registry/subagent-registry-restart-recovery.ts +++ b/src/agents/subagents/registry/subagent-registry-restart-recovery.ts @@ -8,11 +8,7 @@ import { patchSessionEntryCore, } from "../../../config/sessions/session-accessor.js"; import type { GatewayRecoveryRuntime } from "../../../gateway/server-instance-runtime.types.js"; -import { - extractMessageRole, - extractSessionTranscriptText, - readSessionMessagesAsync, -} from "../../../gateway/session-transcript-readers.js"; +import { readSessionMessagesAsync } from "../../../gateway/session-transcript-readers.js"; import * as agentEvents from "../../../infra/agent-events.js"; import { formatErrorMessage } from "../../../infra/errors.js"; import { @@ -31,6 +27,7 @@ import { getRestartRecoveryReplayError, isRestartRecoveryLifecycleCurrent, } from "./subagent-registry-restart-recovery-helpers.js"; +import { readSubagentRecoveryTranscriptMessage } from "./subagent-registry-restart-recovery-message.js"; import { settleAcceptedRecoverySession } from "./subagent-registry-restart-recovery-session.js"; import type { createSubagentRunManager } from "./subagent-registry-run-manager.js"; import type { @@ -479,15 +476,17 @@ export async function recoverInterruptedSubagentRow( if (!isRecoverySourceCurrent()) { return { status: "handled" }; } - const lastHumanMessage = extractSessionTranscriptText( - [...messages].toReversed().find((message) => extractMessageRole(message) === "user"), - ); - const configChanged = messages.some( + const recoveryMessages = messages.flatMap((message) => { + const projected = readSubagentRecoveryTranscriptMessage(message); + return projected ? [projected] : []; + }); + const lastHumanMessage = recoveryMessages + .toReversed() + .find((message) => message.role === "user")?.text; + const configChanged = recoveryMessages.some( (message) => - extractMessageRole(message) === "assistant" && - /openclaw\.json|openclaw gateway restart|config\.patch/i.test( - extractSessionTranscriptText(message) ?? "", - ), + message.role === "assistant" && + /openclaw\.json|openclaw gateway restart|config\.patch/i.test(message.text ?? ""), ); const sessionId = sessionEntry.sessionId; const updatedAt = sessionEntry.updatedAt; diff --git a/src/agents/tools/sessions-list-tool.test.ts b/src/agents/tools/sessions-list-tool.test.ts index 6c37f5b813a9..869b8afecaa2 100644 --- a/src/agents/tools/sessions-list-tool.test.ts +++ b/src/agents/tools/sessions-list-tool.test.ts @@ -361,7 +361,7 @@ describe("sessions-list-tool", () => { label: "worker", displayName: "Worker", derivedTitle: "Investigate queue", - lastMessagePreview: "done", + lastMessagePreview: "Use `[[reply_to_current]]` literally.", spawnedBy: "agent:main:main", updatedAt: 100, archived: false, @@ -400,7 +400,7 @@ describe("sessions-list-tool", () => { label: "worker", displayName: "Worker", derivedTitle: "Investigate queue", - lastMessagePreview: "done", + lastMessagePreview: "Use `[[reply_to_current]]` literally.", parentSessionKey: "agent:main:main", updatedAt: 100, stateVersion: 4, diff --git a/src/gateway/chat-display-projection.helpers.ts b/src/gateway/chat-display-projection.helpers.ts index 693ff28c2631..5885e3cb2188 100644 --- a/src/gateway/chat-display-projection.helpers.ts +++ b/src/gateway/chat-display-projection.helpers.ts @@ -1,7 +1,6 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { isMeaningfulMediaFact, readPersistedMediaFacts } from "../media/media-facts.js"; import { normalizeInputProvenance } from "../sessions/input-provenance.js"; -import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { isSuppressedControlReplyText } from "./control-reply-text.js"; export type RoleContentMessage = { @@ -129,9 +128,7 @@ export function shouldPreserveAssistantControlReplyText(message: Record 0 && - texts.every((text) => - isSuppressedControlReplyText(stripInlineDirectiveTagsForDisplay(text).text), - ) && + texts.every((text) => isSuppressedControlReplyText(text)) && hasAssistantDisplayableNonTextContent(message) ); } diff --git a/src/gateway/chat-display-projection.message-tool.ts b/src/gateway/chat-display-projection.message-tool.ts index 0f8145ea75a4..1372279242a5 100644 --- a/src/gateway/chat-display-projection.message-tool.ts +++ b/src/gateway/chat-display-projection.message-tool.ts @@ -3,7 +3,6 @@ import { asPositiveSafeInteger } from "@openclaw/normalization-core/number-coerc import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { isOpenClawDeliveryMirrorAssistantMessage } from "../shared/transcript-only-openclaw-assistant.js"; -import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { extractAssistantTextForSilentCheck, hasAssistantDisplayableNonTextContent, @@ -118,7 +117,7 @@ function readMessageToolVisibleText(args: Record): string | und for (const field of ["message", "text", "content", "body", "caption"] as const) { const value = args[field]; if (typeof value === "string" && value.trim()) { - return stripInlineDirectiveTagsForDisplay(value).text; + return value; } } return undefined; diff --git a/src/gateway/chat-display-projection.sanitize.ts b/src/gateway/chat-display-projection.sanitize.ts index 8f14adf0f9b5..db7fa5b4efde 100644 --- a/src/gateway/chat-display-projection.sanitize.ts +++ b/src/gateway/chat-display-projection.sanitize.ts @@ -6,7 +6,6 @@ import { parseAssistantTextSignature, resolveAssistantMessagePhase, } from "../shared/chat-message-content.js"; -import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { isToolHistoryBlockType, isToolResultHistoryBlockType, @@ -162,25 +161,17 @@ export function sanitizeChatHistoryContentBlock( changed = true; } if (typeof entry.text === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.text); - if (preserveExactToolPayload) { - entry.text = stripped.text; - changed ||= stripped.changed; - } else { - const res = truncateChatHistoryText(stripped.text, maxChars); + if (!preserveExactToolPayload) { + const res = truncateChatHistoryText(entry.text, maxChars); entry.text = res.text; - changed ||= stripped.changed || res.truncated; + changed ||= res.truncated; } } if (typeof entry.content === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.content); - if (preserveExactToolPayload) { - entry.content = stripped.text; - changed ||= stripped.changed; - } else { - const res = truncateChatHistoryText(stripped.text, maxChars); + if (!preserveExactToolPayload) { + const res = truncateChatHistoryText(entry.content, maxChars); entry.content = res.text; - changed ||= stripped.changed || res.truncated; + changed ||= res.truncated; } } if (typeof entry.partialJson === "string" && !preserveExactToolPayload) { @@ -269,8 +260,7 @@ function projectAssistantMixedToolContent( if (typeof entry.text !== "string" || !entry.text.trim()) { continue; } - const stripped = stripInlineDirectiveTagsForDisplay(entry.text); - const truncated = truncateChatHistoryText(stripped.text, maxChars); + const truncated = truncateChatHistoryText(entry.text, maxChars); if (truncated.text.trim()) { projectedContent.push({ type: "text", text: truncated.text }); hasVisibleText = true; @@ -464,18 +454,16 @@ export function sanitizeChatHistoryMessage( role === "assistant" && !shouldPreserveAssistantControlReplyText(entry); if (typeof entry.content === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.content); const controlStripped = stripAssistantControlTokens - ? stripSuppressedControlReplyToken(stripped.text) - : stripped.text; - changed ||= controlStripped !== stripped.text; + ? stripSuppressedControlReplyToken(entry.content) + : entry.content; + changed ||= controlStripped !== entry.content; if (preserveExactToolPayload) { entry.content = controlStripped; - changed ||= stripped.changed; } else { const res = truncateChatHistoryText(controlStripped, maxChars); entry.content = res.text; - changed ||= stripped.changed || res.truncated; + changed ||= res.truncated; } } else if (Array.isArray(entry.content)) { const updated = entry.content.map((block) => { @@ -523,18 +511,16 @@ export function sanitizeChatHistoryMessage( } if (typeof entry.text === "string") { - const stripped = stripInlineDirectiveTagsForDisplay(entry.text); const controlStripped = stripAssistantControlTokens - ? stripSuppressedControlReplyToken(stripped.text) - : stripped.text; - changed ||= controlStripped !== stripped.text; + ? stripSuppressedControlReplyToken(entry.text) + : entry.text; + changed ||= controlStripped !== entry.text; if (preserveExactToolPayload) { entry.text = controlStripped; - changed ||= stripped.changed; } else { const res = truncateChatHistoryText(controlStripped, maxChars); entry.text = res.text; - changed ||= stripped.changed || res.truncated; + changed ||= res.truncated; } } @@ -585,11 +571,7 @@ export function shouldDropAssistantHistoryMessage(message: unknown): boolean { return !hasAssistantMixedToolVisibleText(message); } const text = extractAssistantTextForSilentCheck(message); - // Classify after removing UI-only directives, before sanitization can erase - // the control token and leave a blank assistant row behind. - const displayText = - text === undefined ? undefined : stripInlineDirectiveTagsForDisplay(text).text; - if (displayText === undefined || !isSuppressedControlReplyText(displayText)) { + if (text === undefined || !isSuppressedControlReplyText(text)) { return false; } return !hasAssistantDisplayableNonTextContent(message); diff --git a/src/gateway/control-reply-text.test.ts b/src/gateway/control-reply-text.test.ts index a5cd4475e2fc..be69d46b6a34 100644 --- a/src/gateway/control-reply-text.test.ts +++ b/src/gateway/control-reply-text.test.ts @@ -94,38 +94,6 @@ describe("control reply display projection", () => { ]); }); - it("strips a trailing control token after removing inline directives", () => { - expect( - projectChatDisplayMessages([ - { - role: "assistant", - content: [ - { - type: "text", - text: "The handoff is complete.\n\nREPLY_SKIP [[audio_as_voice]]", - }, - ], - }, - ]), - ).toEqual([ - { - role: "assistant", - content: [{ type: "text", text: "The handoff is complete." }], - }, - ]); - }); - - it("hides a control-only reply with an inline directive", () => { - expect( - projectChatDisplayMessages([ - { - role: "assistant", - content: [{ type: "text", text: "NO_REPLY [[audio_as_voice]]" }], - }, - ]), - ).toEqual([]); - }); - it("hides a control-only reply that also contains model thinking", () => { expect( projectChatDisplayMessages([ diff --git a/src/gateway/dashboard-session-title.test.ts b/src/gateway/dashboard-session-title.test.ts index d96e237cbb34..20897226e079 100644 --- a/src/gateway/dashboard-session-title.test.ts +++ b/src/gateway/dashboard-session-title.test.ts @@ -341,7 +341,9 @@ describe("buildDashboardSessionTitleSource", () => { message: "Review this rollout [[reply_to_current]]", attachments: [textAttachment("Deployment context"), textAttachment(pastedText)], }); - expect(source).toBe(`Review this rollout\nDeployment context\n${pastedText}`.slice(0, 1_000)); + expect(source).toBe( + `Review this rollout [[reply_to_current]]\nDeployment context\n${pastedText}`.slice(0, 1_000), + ); }); it.each([ diff --git a/src/gateway/dashboard-session-title.ts b/src/gateway/dashboard-session-title.ts index 8de1b43bd7fe..456abe998f22 100644 --- a/src/gateway/dashboard-session-title.ts +++ b/src/gateway/dashboard-session-title.ts @@ -12,7 +12,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withTimeout } from "../infra/fs-safe.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; import { getOrCreatePromise } from "../shared/lazy-promise.js"; -import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { isValidAttachmentBase64, type ChatAttachment } from "./chat-attachments.js"; import { readSessionTitleFieldsFromTranscript } from "./session-transcript-title-reader.js"; @@ -72,7 +71,7 @@ export function buildDashboardSessionTitleSource(params: { message: string; attachments?: readonly ChatAttachment[]; }): string { - const visibleMessage = stripInlineDirectiveTagsForDisplay(params.message).text.trim(); + const visibleMessage = params.message.trim(); const slashCommand = visibleMessage.startsWith("/"); let source = slashCommand ? "" : visibleMessage; for (const attachment of params.attachments ?? []) { @@ -323,12 +322,8 @@ export async function maybeGenerateSessionTitle(params: { sessionKey: params.sessionKey, storePath: params.storePath, }).firstUserMessage; - const transcriptText = transcriptSource - ? stripInlineDirectiveTagsForDisplay(stripInboundMetadata(transcriptSource)).text.trim() - : ""; - const currentText = params.currentUserMessage - ? stripInlineDirectiveTagsForDisplay(params.currentUserMessage).text.trim() - : ""; + const transcriptText = transcriptSource ? stripInboundMetadata(transcriptSource).trim() : ""; + const currentText = params.currentUserMessage?.trim() ?? ""; // A first-turn transcript may win the persistence race before title work starts. // When it is the current turn, retain the supplied attachment-enriched source. const sourceText = diff --git a/src/gateway/server-methods/chat-assistant-content.ts b/src/gateway/server-methods/chat-assistant-content.ts index 1db3d680c83c..086fd7ab31ee 100644 --- a/src/gateway/server-methods/chat-assistant-content.ts +++ b/src/gateway/server-methods/chat-assistant-content.ts @@ -7,7 +7,7 @@ import { import { createOutboundPayloadPlan } from "../../infra/outbound/payloads.js"; import { renderQrPngDataUrl } from "../../media/qr-image.js"; import { renderQrTerminal } from "../../media/qr-terminal.js"; -import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js"; +import { stripInlineDirectiveTagsForDelivery } from "../../utils/directive-tags.js"; import { stripEnvelopeFromMessage } from "../chat-sanitize.js"; import { cleanupManagedOutgoingMediaRecords, @@ -187,9 +187,13 @@ export function sanitizeAssistantDisplayText( } const withoutEnvelope = stripEnvelopeFromMessage(value); const normalized = typeof withoutEnvelope === "string" ? withoutEnvelope : value; - const stripped = stripInlineDirectiveTagsForDisplay(normalized).text; - const visible = stripped.trim(); - return visible ? (options?.preserveBoundaries ? stripped : visible) : undefined; + const stripped = stripInlineDirectiveTagsForDelivery(normalized); + const visible = stripped.text.trim(); + return visible + ? options?.preserveBoundaries && !stripped.changed + ? normalized + : visible + : undefined; } export function extractAssistantDisplayTextFromContent( diff --git a/src/gateway/server-methods/chat-send-nonagent-finalization.ts b/src/gateway/server-methods/chat-send-nonagent-finalization.ts index d08630cfc1a9..83f241ce1805 100644 --- a/src/gateway/server-methods/chat-send-nonagent-finalization.ts +++ b/src/gateway/server-methods/chat-send-nonagent-finalization.ts @@ -1,10 +1,10 @@ import { expectDefined } from "@openclaw/normalization-core"; import { getReplyPayloadMetadata, type ReplyPayload } from "../../auto-reply/reply-payload.js"; +import { applyAssistantDeliveryDirectives } from "../../config/sessions/transcript-assistant-delivery.js"; import { appendLocalMediaParentRoots, getAgentScopedMediaLocalRoots, } from "../../media/local-roots.js"; -import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js"; import { attachManagedOutgoingMediaToMessage } from "../managed-image-attachments.js"; import { loadSessionEntry } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; @@ -295,9 +295,7 @@ export async function finalizeChatSendNonAgentReplies(params: { const displayReply = extractAssistantDisplayTextFromContent(assistantContent) ?? buildTranscriptReplyText(finalPayloads); - const transcriptDisplayReply = displayReply - ? stripInlineDirectiveTagsForDisplay(displayReply).text.trim() - : ""; + const transcriptDisplayReply = displayReply?.trim() ?? ""; const transcriptReply = mediaMessage?.transcriptText || (managedMediaPrepareFailed @@ -330,7 +328,10 @@ export async function finalizeChatSendNonAgentReplies(params: { }); } message = broadcastAssistantContent?.length - ? { ...appended.message, content: broadcastAssistantContent } + ? applyAssistantDeliveryDirectives({ + ...appended.message, + content: broadcastAssistantContent.map((block) => ({ ...block })), + }) : appended.message; } else { context.logGateway.warn( diff --git a/src/gateway/server-methods/chat-transcript-inject.ts b/src/gateway/server-methods/chat-transcript-inject.ts index 684e857723ae..7ebc0b6407cb 100644 --- a/src/gateway/server-methods/chat-transcript-inject.ts +++ b/src/gateway/server-methods/chat-transcript-inject.ts @@ -2,6 +2,7 @@ // preserving agent-session parent links and transcript update notifications. import type { SessionManager } from "../../agents/sessions/session-manager.js"; import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js"; +import { applyAssistantDeliveryDirectives } from "../../config/sessions/transcript-assistant-delivery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; @@ -90,10 +91,19 @@ export async function appendInjectedAssistantMessageToTranscript(params: { label: params.label, content: params.content, }); - const messageBody: AppendMessageArg & Record = { + const rawDeliveryMessage: { + role: "assistant"; + content: Array>; + openclawDelivery?: unknown; + } = { + role: "assistant", + content: [{ type: "text", text: params.message }], + }; + const rawDeliveryFacts = applyAssistantDeliveryDirectives(rawDeliveryMessage).openclawDelivery; + const messageBody: AppendMessageArg & Record = applyAssistantDeliveryDirectives({ role: "assistant", // Gateway-injected assistant messages can include non-model content blocks (e.g. embedded TTS audio). - content: resolvedContent as unknown as Extract< + content: resolvedContent.map((block) => Object.assign({}, block)) as unknown as Extract< AppendMessageArg, { role: "assistant" } >["content"], @@ -117,7 +127,10 @@ export async function appendInjectedAssistantMessageToTranscript(params: { }, } : {}), - }; + }); + if (rawDeliveryFacts && messageBody.openclawDelivery === undefined) { + messageBody.openclawDelivery = rawDeliveryFacts; + } try { if (!params.transcriptPath && (!params.storePath || !params.sessionId || !params.sessionKey)) { diff --git a/src/gateway/server-methods/chat-transcript-persistence.ts b/src/gateway/server-methods/chat-transcript-persistence.ts index b53c84f70e4d..b03b406f547b 100644 --- a/src/gateway/server-methods/chat-transcript-persistence.ts +++ b/src/gateway/server-methods/chat-transcript-persistence.ts @@ -12,11 +12,11 @@ import { type SessionTranscriptWriteScope, type TranscriptEvent, } from "../../config/sessions/session-accessor.js"; +import { applyAssistantDeliveryDirectives } from "../../config/sessions/transcript-assistant-delivery.js"; import { resolveMirroredTranscriptText } from "../../config/sessions/transcript-mirror.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeMediaReferenceForComparison } from "../../media/media-reference-comparison.js"; import { splitMediaFromOutput } from "../../media/parse.js"; -import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js"; import { sanitizeAssistantDisplayText, type AssistantDisplayContentBlock, @@ -155,9 +155,8 @@ function mergeManagedMediaIntoAssistantContent(params: { continue; } const split = splitMediaFromOutput(block.text); - const directiveTagsChanged = stripInlineDirectiveTagsForDisplay(split.text).changed; const visibleText = sanitizeAssistantDisplayText(split.text, { - preserveBoundaries: !directiveTagsChanged, + preserveBoundaries: true, }); if (visibleText) { const { textSignature: _textSignature, ...rest } = block; @@ -481,11 +480,12 @@ export async function rewriteAssistantTranscriptMessageByTurnIndexAndMedia(param if (!mergedContent) { return null; } + const rewrittenMessage = applyAssistantDeliveryDirectives({ + ...target.message, + content: mergedContent, + }); const rewrittenEvent = Object.assign({}, targetRow.event as Record, { - message: { - ...target.message, - content: mergedContent, - }, + message: rewrittenMessage, }); const rewritten = await rewriteTranscriptEventRowsExact(params.scope, { allowInitialGenerationMaterialization: initialGenerationMaterialized, diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 86e11443358a..293f5d92acde 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -4665,9 +4665,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => typeof update.message === "object" && update.message !== null && (update.message as { role?: unknown }).role === "assistant" && - JSON.stringify(update.message).includes("[[reply_to_current]]"), + (update.message as { openclawDelivery?: { replyToCurrent?: boolean } }).openclawDelivery + ?.replyToCurrent === true, ); expect(transcriptUpdate).toBeTruthy(); + expect(JSON.stringify(transcriptUpdate)).not.toContain("[[reply_to_current]]"); }); it("broadcasts sensitive pairing QR display without persisting QR content", async () => { @@ -4776,7 +4778,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant", ); - expect(JSON.stringify(transcriptUpdate?.message)).toContain("[[reply_to_current]]"); + expect(transcriptUpdate?.message).toMatchObject({ + openclawDelivery: { replyToCurrent: true }, + }); + expect(JSON.stringify(transcriptUpdate?.message)).not.toContain("[[reply_to_current]]"); }); it("keeps slash-command block text when the final payload only carries a reply directive", async () => { @@ -4805,7 +4810,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant", ); - expect(JSON.stringify(transcriptUpdate?.message)).toContain("[[reply_to_current]]"); + expect(transcriptUpdate?.message).toMatchObject({ + openclawDelivery: { replyToCurrent: true }, + }); + expect(JSON.stringify(transcriptUpdate?.message)).not.toContain("[[reply_to_current]]"); expect(JSON.stringify(transcriptUpdate?.message)).toContain( "Trajectory exports can include prompts.", ); @@ -4894,7 +4902,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant", ); - expect(JSON.stringify(transcriptUpdate?.message)).toContain("[[reply_to_current]]"); + expect(transcriptUpdate?.message).toMatchObject({ + openclawDelivery: { replyToCurrent: true }, + }); + expect(JSON.stringify(transcriptUpdate?.message)).not.toContain("[[reply_to_current]]"); expect(JSON.stringify(transcriptUpdate?.message)).toContain("done"); }, }, @@ -5200,7 +5211,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => expect(extractFirstTextBlock(payload)).toBe(""); }); - it("preserves inline reply directives in transcript text while stripping them from display", async () => { + it("persists inline reply directives as typed facts while stripping them from text", async () => { await createTranscriptFixture("openclaw-chat-send-inline-reply-transcript-"); mockState.finalText = "see[[reply_to_current]]now with spacing"; const { send } = createChatRequestFixture(); @@ -5216,8 +5227,11 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant", ); - expect(JSON.stringify(transcriptUpdate?.message)).toContain("[[reply_to_current]]"); - expect(JSON.stringify(transcriptUpdate?.message)).toContain("see now with spacing"); + expect(transcriptUpdate?.message).toMatchObject({ + openclawDelivery: { replyToCurrent: true }, + }); + expect(JSON.stringify(transcriptUpdate?.message)).not.toContain("[[reply_to_current]]"); + expect(JSON.stringify(transcriptUpdate?.message)).toContain("see now with spacing"); }); it("rejects oversized chat.send session keys before dispatch", async () => { @@ -6320,7 +6334,7 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); }); - it("preserves reply tags in transcript updates for media replies while stripping them from the broadcast", async () => { + it("persists typed reply facts for media replies without leaking tags", async () => { await expectImageOnlyFinal({ transcriptPrefix: "openclaw-chat-send-media-reply-tags-", idempotencyKey: "idem-media-reply-tags", @@ -6332,17 +6346,16 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant" && Array.isArray((update.message as { content?: unknown }).content) && - ((update.message as { content: Array<{ type?: string; text?: string }> }).content.some( - (block) => block?.type === "text" && block?.text?.includes("[[reply_to_current]]"), - ) ?? - false), + (update.message as { openclawDelivery?: { replyToCurrent?: boolean } }).openclawDelivery + ?.replyToCurrent === true, ); const transcriptMessage = transcriptUpdate?.message as Record | undefined; expect(transcriptMessage?.role).toBe("assistant"); expect(transcriptMessage?.content?.[0]).toEqual({ type: "text", - text: "[[reply_to_current]]Image reply", + text: "Image reply", }); + expect(JSON.stringify(transcriptUpdate)).not.toContain("[[reply_to_current]]"); expect(JSON.stringify(transcriptUpdate)).not.toContain("data:image/png;base64,cG5n"); }); @@ -6402,7 +6415,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant", ); - expect(JSON.stringify(transcriptUpdate)).toContain("[[reply_to:abcaudio_as_voice]]"); + expect(transcriptUpdate?.message).toMatchObject({ + openclawDelivery: { replyToId: "abcaudio_as_voice" }, + }); + expect(JSON.stringify(transcriptUpdate)).not.toContain("[[reply_to:"); expect(JSON.stringify(transcriptUpdate)).not.toContain("[[audio_as_voice]]"); }); @@ -6425,7 +6441,10 @@ describe("chat directive tag stripping for non-streaming final payloads", () => update.message !== null && (update.message as { role?: unknown }).role === "assistant", ); - expect(JSON.stringify(transcriptUpdate)).toContain("[[reply_to:inline-id]]"); + expect(transcriptUpdate?.message).toMatchObject({ + openclawDelivery: { replyToId: "inline-id" }, + }); + expect(JSON.stringify(transcriptUpdate)).not.toContain("[[reply_to:"); }); it("routes text-only image offloads into media-understanding fields", async () => { diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index c8b35d8d5fa2..b9794965ed70 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -1318,7 +1318,7 @@ describe("projectRecentChatDisplayMessages", () => { ]); }); - it.each(["[[reply_to_current]]", "NO_REPLY", STREAM_ERROR_FALLBACK_TEXT])( + it.each(["NO_REPLY", STREAM_ERROR_FALLBACK_TEXT])( "projects display-hidden assistant error text %j as a generic safe failure", (text) => { const result = projectRecentChatDisplayMessages([ @@ -2058,27 +2058,13 @@ describe("projectRecentChatDisplayMessages", () => { ]); }); - it("merges delayed TTS supplements when directive tags are stripped for display", () => { - const rawVisibleText = "[[reply_to_current]]Visible answer."; - const projectedVisibleText = "Visible answer."; - const textSha256 = createHash("sha256").update(projectedVisibleText).digest("hex"); - - const result = projectRecentChatDisplayMessages([ - assistantHistoryMessage(rawVisibleText, { timestamp: 1 }), - ttsSupplementHistoryMessage({ textSha256 }, 2), - ]); - - expect(result).toEqual([assistantAudioAttachmentHistoryMessage(projectedVisibleText, 1)]); - }); - it("merges delayed TTS supplements before display truncation", () => { const projectedVisibleText = "Visible answer ".repeat(8).trim(); - const rawVisibleText = `[[reply_to_current]]${projectedVisibleText}`; const textSha256 = createHash("sha256").update(projectedVisibleText).digest("hex"); const result = projectRecentChatDisplayMessages( [ - assistantHistoryMessage(rawVisibleText, { timestamp: 1 }), + assistantHistoryMessage(projectedVisibleText, { timestamp: 1 }), ttsSupplementHistoryMessage({ textSha256 }, 2), ], { maxChars: 24 }, diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 92bfff163d4d..67fa1032fead 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -4754,7 +4754,7 @@ describe("gateway server chat", () => { }); }); - test("chat.history deduplicates a directive-tagged local Claude delivery with managed audio", async () => { + test("chat.history deduplicates a structured local Claude delivery with managed audio", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); const sessionDir = await createSessionDir(); @@ -4786,22 +4786,19 @@ describe("gateway server chat", () => { ); await writeMainSessionTranscript( [ - createTextTranscriptEvent( - "assistant", - "[[reply_to:delivery-run-1]]CLAUDE DELIVERY READY", - { - timestamp: deliveryTimestamp, - message: { - content: [ - { - type: "text", - text: "[[reply_to:delivery-run-1]]CLAUDE DELIVERY READY", - }, - { type: "audio", url: managedAudioUrl, openUrl: managedAudioUrl }, - ], - }, + createTextTranscriptEvent("assistant", "CLAUDE DELIVERY READY", { + timestamp: deliveryTimestamp, + message: { + content: [ + { + type: "text", + text: "CLAUDE DELIVERY READY", + }, + { type: "audio", url: managedAudioUrl, openUrl: managedAudioUrl }, + ], + openclawDelivery: { replyToId: "delivery-run-1" }, }, - ), + }), ], sessionId, ); @@ -5985,50 +5982,26 @@ describe("gateway server chat", () => { }); }); - test("chat.history strips inline directives from displayed message text", async () => { + test("chat.history preserves quoted inline directives verbatim", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await connectOk(ws); await createSessionDir(); await writeMainSessionStore(); + const quoted = "Use `[[reply_to_current]]` and `[[tts]]` literally."; const lines = [ - makeTranscriptTextEvent("Hello [[reply_to_current]] world [[audio_as_voice]]", { - message: { timestamp: Date.now() }, + makeTranscriptTextEvent(quoted, { + message: { openclawDelivery: { replyToCurrent: true }, timestamp: Date.now() }, }), - JSON.stringify({ - message: { - role: "assistant", - content: "A [[reply_to:abc-123]] B", - timestamp: Date.now() + 1, - }, - }), - JSON.stringify({ - message: { - role: "assistant", - text: "[[ reply_to : 456 ]] C", - timestamp: Date.now() + 2, - }, - }), - createTextTranscriptEvent("assistant", " keep padded ", { timestamp: Date.now() + 3 }), ]; await writeMainSessionTranscript(lines); const messages = await fetchHistoryMessages(ws); - expect(messages.length).toBe(4); - - const serialized = JSON.stringify(messages); - expect(serialized.includes("[[reply_to")).toBe(false); - expect(serialized.includes("[[audio_as_voice]]")).toBe(false); - - const first = messages[0] as { content?: Array<{ text?: string }> }; - const second = messages[1] as { content?: string }; - const third = messages[2] as { text?: string }; - const fourth = messages[3] as { content?: Array<{ text?: string }> }; - - expect(first.content?.[0]?.text?.replace(/\s+/g, " ").trim()).toBe("Hello world"); - expect(second.content?.replace(/\s+/g, " ").trim()).toBe("A B"); - expect(third.text?.replace(/\s+/g, " ").trim()).toBe("C"); - expect(fourth.content?.[0]?.text).toBe(" keep padded "); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + content: [{ text: quoted }], + openclawDelivery: { replyToCurrent: true }, + }); }); }); diff --git a/src/gateway/server.sessions.preview-resolve.test.ts b/src/gateway/server.sessions.preview-resolve.test.ts index dcdc00a123f5..5a375c7962ad 100644 --- a/src/gateway/server.sessions.preview-resolve.test.ts +++ b/src/gateway/server.sessions.preview-resolve.test.ts @@ -70,8 +70,11 @@ test("sessions.preview returns transcript previews", async () => { const entry = preview.payload?.previews[0]; expect(entry?.key).toBe("main"); expect(entry?.status).toBe("ok"); - expect(entry?.items.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]); - expect(entry?.items[1]?.text).toContain("call weather"); + expect(entry?.items).toEqual([ + { role: "user", text: "Hello" }, + { role: "assistant", text: "Hi" }, + { role: "assistant", text: "Forecast ready" }, + ]); }); test("sessions.resolve by sessionId ignores fuzzy-search list limits and returns the exact match", async () => { diff --git a/src/gateway/session-display-projection.test.ts b/src/gateway/session-display-projection.test.ts new file mode 100644 index 000000000000..4bfd148e7be9 --- /dev/null +++ b/src/gateway/session-display-projection.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "vitest"; +import { projectSessionDisplayMessage } from "./session-display-projection.js"; + +const SESSION_LAST_MESSAGE_PREVIEW_MAX_CHARS = 240; + +describe("projectSessionDisplayMessage", () => { + test("keeps visible user and assistant text while excluding non-display rows", () => { + const messages = [ + { role: "user", content: "Initial request" }, + { role: "assistant", content: "Visible final answer" }, + { role: "toolResult", content: [{ type: "text", text: "tool output" }] }, + { role: "system", content: "system event" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "private thought" }, + { type: "reasoning", text: "reasoning summary" }, + ], + }, + { role: "assistant", content: "NO_REPLY" }, + { + role: "assistant", + content: [{ type: "text", text: "" }], + openclawDelivery: { replyToCurrent: true }, + }, + ]; + + expect(messages.map(projectSessionDisplayMessage)).toEqual([ + { role: "user", text: "Initial request" }, + { role: "assistant", text: "Visible final answer" }, + null, + null, + null, + null, + null, + ]); + }); + + test("bounds previews without splitting surrogate pairs", () => { + const longReply = `${"a".repeat(SESSION_LAST_MESSAGE_PREVIEW_MAX_CHARS - 2)}😊tail`; + const preview = projectSessionDisplayMessage({ role: "assistant", content: longReply }); + + expect(preview?.text).toHaveLength(SESSION_LAST_MESSAGE_PREVIEW_MAX_CHARS); + expect(preview?.text).toBe(`${"a".repeat(SESSION_LAST_MESSAGE_PREVIEW_MAX_CHARS - 3)}...`); + }); + + test("preserves quoted directive examples", () => { + const quoted = "Use `[[reply_to_current]]` literally."; + expect(projectSessionDisplayMessage({ role: "assistant", content: quoted })?.text).toBe(quoted); + }); +}); diff --git a/src/gateway/session-display-projection.ts b/src/gateway/session-display-projection.ts new file mode 100644 index 000000000000..855b4a23ada3 --- /dev/null +++ b/src/gateway/session-display-projection.ts @@ -0,0 +1,69 @@ +import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { extractAssistantPhaseText } from "../shared/chat-message-content.js"; +import { stripEnvelope } from "./chat-sanitize.js"; +import { isSuppressedControlReplyText } from "./control-reply-text.js"; + +const SESSION_LAST_MESSAGE_PREVIEW_MAX_CHARS = 240; + +type SessionDisplayProjection = { + role: "user" | "assistant"; + text: string; +}; + +function extractUserText(message: Record): string | undefined { + if (typeof message.content === "string") { + return message.content; + } + if (Array.isArray(message.content)) { + const parts = message.content.flatMap((block) => { + const entry = readRecord(block); + if (!entry) { + return []; + } + return (entry.type === "text" || entry.type === "input_text") && + typeof entry.text === "string" + ? [entry.text] + : []; + }); + if (parts.length > 0) { + return parts.join("\n"); + } + } + return typeof message.text === "string" ? message.text : undefined; +} + +/** Projects one transcript row onto the bounded text shared by session-list consumers. */ +export function projectSessionDisplayMessage( + message: unknown, + maxChars = SESSION_LAST_MESSAGE_PREVIEW_MAX_CHARS, +): SessionDisplayProjection | null { + const entry = readRecord(message); + if (!entry) { + return null; + } + const role = typeof entry.role === "string" ? entry.role.toLowerCase() : ""; + if (role !== "user" && role !== "assistant") { + return null; + } + const extracted = + role === "assistant" ? extractAssistantPhaseText(entry) : extractUserText(entry); + let text = extracted?.trim(); + if (!text || (role === "assistant" && isSuppressedControlReplyText(text))) { + return null; + } + if (role === "user") { + text = stripEnvelope(text).trim(); + } + if (!text) { + return null; + } + const limit = Math.min( + SESSION_LAST_MESSAGE_PREVIEW_MAX_CHARS, + Math.max(20, Math.floor(maxChars)), + ); + return { + role, + text: text.length <= limit ? text : `${truncateUtf16Safe(text, limit - 3)}...`, + }; +} diff --git a/src/gateway/session-transcript-readers.ts b/src/gateway/session-transcript-readers.ts index 7a904e14f7cd..b872db14aa83 100644 --- a/src/gateway/session-transcript-readers.ts +++ b/src/gateway/session-transcript-readers.ts @@ -246,38 +246,6 @@ export function sqliteMessageEventWithSeq(entry: SessionTranscriptMessageEvent): return projectTranscriptEntryMessage(entry.event, entry.seq); } -export function extractMessageRole(message: unknown): string | undefined { - return message && typeof message === "object" && !Array.isArray(message) - ? ((message as { role?: unknown }).role as string | undefined) - : undefined; -} - -export function extractSessionTranscriptText(message: unknown): string | null { - if (!message || typeof message !== "object" || Array.isArray(message)) { - return null; - } - const record = message as { content?: unknown; text?: unknown }; - if (typeof record.content === "string") { - return record.content.trim() || null; - } - if (Array.isArray(record.content)) { - const text = record.content - .map((entry) => - entry && typeof entry === "object" && typeof (entry as { text?: unknown }).text === "string" - ? (entry as { text: string }).text - : "", - ) - .filter((part) => part.trim()) - .join("\n") - .trim(); - return text || null; - } - if (typeof record.text === "string") { - return record.text.trim() || null; - } - return null; -} - function readSqliteAggregateUsageSnapshot( target: ResolvedTranscriptReadTarget, ): SessionTranscriptUsageSnapshot | null { diff --git a/src/gateway/session-transcript-title-reader.ts b/src/gateway/session-transcript-title-reader.ts index 4e56bfc28e52..3d8184c2ad8b 100644 --- a/src/gateway/session-transcript-title-reader.ts +++ b/src/gateway/session-transcript-title-reader.ts @@ -11,9 +11,8 @@ import { } from "../config/sessions/session-accessor.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; import { hasInterSessionUserProvenance } from "../sessions/input-provenance.js"; +import { projectSessionDisplayMessage } from "./session-display-projection.js"; import { - extractMessageRole, - extractSessionTranscriptText, resolveTranscriptReadTarget, sqliteMessageEventWithSeq, toTranscriptReadScope, @@ -84,7 +83,7 @@ function findFirstTitleUserMessage( includeInterSession: boolean, ): unknown { return entries.map(sqliteMessageEventWithSeq).find((message) => { - if (extractMessageRole(message) !== "user") { + if (projectSessionDisplayMessage(message)?.role !== "user") { return false; } return ( @@ -99,8 +98,8 @@ function findLastMessageText(entries: readonly SessionTranscriptMessageEvent[]): entries .toReversed() .map(sqliteMessageEventWithSeq) - .map(extractSessionTranscriptText) - .find(Boolean) ?? null + .map((message) => projectSessionDisplayMessage(message)) + .find(Boolean)?.text ?? null ); } @@ -161,7 +160,7 @@ function readSqliteTitleFields( ); } fields = { - firstUserMessage: firstUser ? extractSessionTranscriptText(firstUser) : null, + firstUserMessage: firstUser ? (projectSessionDisplayMessage(firstUser)?.text ?? null) : null, lastMessagePreview: lastText, }; } catch (error) { @@ -284,7 +283,7 @@ function readSessionTitleFieldsFromTranscriptBatchCurrent( continue; } const fields = { - firstUserMessage: firstUser ? extractSessionTranscriptText(firstUser) : null, + firstUserMessage: firstUser ? (projectSessionDisplayMessage(firstUser)?.text ?? null) : null, lastMessagePreview: lastText, }; const fieldsByVariant = diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 7a6945b556c7..1383725c9959 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -1414,40 +1414,16 @@ describe("buildSessionPreviewItems", () => { return buildSessionPreviewItems(messages, maxItems, maxChars); } - test("returns recent preview items with tool summary", () => { + test("returns only recent user and assistant display text", () => { const sessionId = "preview-session"; const lines = createToolSummaryPreviewTranscriptLines(sessionId); const result = readPreview(lines); - expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]); - expect(result[1]?.text).toContain("call weather"); - }); - - test("detects tool calls from tool_use/tool_call blocks and toolName field", () => { - const sessionId = "preview-session-tools"; - const lines = [ - JSON.stringify({ type: "session", version: 1, id: sessionId }), - JSON.stringify({ message: { role: "assistant", content: "Hi" } }), - JSON.stringify({ - message: { - role: "assistant", - toolName: "camera", - content: [ - { type: "tool_use", name: "read" }, - { type: "tool_call", name: "write" }, - ], - }, - }), - JSON.stringify({ message: { role: "assistant", content: "Done" } }), - ]; - const result = readPreview(lines); - - expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]); - expect(result[1]?.text).toContain("call"); - expect(result[1]?.text).toContain("camera"); - expect(result[1]?.text).toContain("read"); - // Preview text may not list every tool name; it should at least hint there were multiple calls. - expect(result[1]?.text).toMatch(/\+\d+/); + expect(result).toEqual([ + { role: "user", text: "Hello" }, + { role: "assistant", text: "Hi" }, + { role: "assistant", text: "Forecast ready" }, + ]); }); const commentaryText = { @@ -1470,10 +1446,10 @@ describe("buildSessionPreviewItems", () => { expected: [{ role: "assistant", text: `${"t".repeat(196)}...` }], }, { - name: "strips inline directives from preview items", - content: "A [[reply_to:abc-123]] B [[audio_as_voice]]", + name: "preserves quoted inline directives in preview items", + content: "Use `[[reply_to_current]]` literally", maxChars: 120, - expected: [{ role: "assistant", text: "A B" }], + expected: [{ role: "assistant", text: "Use `[[reply_to_current]]` literally" }], }, { name: "prefers final_answer text for assistant preview items", diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index de706fbdf1fd..ee9e6c9214be 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -13,7 +13,6 @@ import { resolveIntegerOption, resolveNonNegativeIntegerOption, } from "@openclaw/normalization-core/number-coercion"; -import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import { deriveSessionTotalTokens, hasNonzeroUsage, @@ -28,11 +27,7 @@ import { selectSessionTranscriptActiveEntries } from "../config/sessions/transcr import { readFileWindowFully } from "../infra/file-read.js"; import { jsonUtf8Bytes } from "../infra/json-utf8-bytes.js"; import { pruneMapToMaxSize } from "../infra/map-size.js"; -import { extractAssistantPhaseText } from "../shared/chat-message-content.js"; -import { truncateUtf16Safe } from "../utils.js"; -import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; -import { extractToolCallNames, hasToolCall } from "../utils/transcript-tools.js"; -import { stripEnvelope } from "./chat-sanitize.js"; +import { projectSessionDisplayMessage } from "./session-display-projection.js"; import { resolveSessionTranscriptCandidates, resolveSessionTranscriptResetArchiveCandidatesAsync, @@ -912,7 +907,7 @@ function extractTranscriptUsageCost(raw: unknown): number | undefined { function extractTranscriptContentEstimatedChars(content: unknown): number { if (typeof content === "string") { - const normalized = stripInlineDirectiveTagsForDisplay(content).text.trim(); + const normalized = content.trim(); return normalized ? estimateStringChars(normalized) : 0; } if (!Array.isArray(content)) { @@ -931,7 +926,7 @@ function extractTranscriptContentEstimatedChars(content: unknown): number { if (type !== "text" && type !== "output_text" && type !== "input_text") { continue; } - const normalized = stripInlineDirectiveTagsForDisplay(record.text).text.trim(); + const normalized = record.text.trim(); if (normalized) { chars += estimateStringChars(normalized); } @@ -1219,99 +1214,6 @@ export async function readLatestSessionUsageFromTranscriptFileAsync( } } -type TranscriptContentEntry = { - type?: string; - text?: string; - name?: string; -}; - -type TranscriptPreviewMessage = { - role?: string; - content?: string | TranscriptContentEntry[]; - text?: string; - toolName?: string; - tool_name?: string; -}; - -function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] { - if (isTool) { - return "tool"; - } - switch (normalizeLowercaseStringOrEmpty(role)) { - case "user": - return "user"; - case "assistant": - return "assistant"; - case "system": - return "system"; - case "tool": - return "tool"; - default: - return "other"; - } -} - -function truncatePreviewText(text: string, maxChars: number): string { - if (text.length <= maxChars) { - return text; - } - // The preview entry point clamps maxChars to at least 20, so the suffix budget stays positive. - return `${truncateUtf16Safe(text, maxChars - 3)}...`; -} - -function extractPreviewText(message: TranscriptPreviewMessage): string | null { - const role = normalizeLowercaseStringOrEmpty(message.role); - if (role === "assistant") { - const assistantText = extractAssistantPhaseText(message); - if (assistantText) { - const normalized = stripInlineDirectiveTagsForDisplay(assistantText).text.trim(); - return normalized ? normalized : null; - } - return null; - } - if (typeof message.content === "string") { - const normalized = stripInlineDirectiveTagsForDisplay(message.content).text.trim(); - return normalized ? normalized : null; - } - if (Array.isArray(message.content)) { - const parts = message.content - .map((entry) => - typeof entry?.text === "string" ? stripInlineDirectiveTagsForDisplay(entry.text).text : "", - ) - .filter((text) => text.trim().length > 0); - if (parts.length > 0) { - return parts.join("\n").trim(); - } - } - if (typeof message.text === "string") { - const normalized = stripInlineDirectiveTagsForDisplay(message.text).text.trim(); - return normalized ? normalized : null; - } - return null; -} - -function isToolCall(message: TranscriptPreviewMessage): boolean { - return hasToolCall(message as Record); -} - -function extractToolNames(message: TranscriptPreviewMessage): string[] { - return extractToolCallNames(message as Record); -} - -function extractMediaSummary(message: TranscriptPreviewMessage): string | null { - if (!Array.isArray(message.content)) { - return null; - } - for (const entry of message.content) { - const raw = normalizeLowercaseStringOrEmpty(entry?.type); - if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") { - continue; - } - return `[${raw}]`; - } - return null; -} - export function buildSessionPreviewItems( messages: readonly unknown[], maxItems: number, @@ -1319,39 +1221,11 @@ export function buildSessionPreviewItems( ): SessionPreviewItem[] { const items: SessionPreviewItem[] = []; for (const message of messages) { - if (!message || typeof message !== "object" || Array.isArray(message)) { + const projected = projectSessionDisplayMessage(message, maxChars); + if (!projected) { continue; } - const previewMessage = message as TranscriptPreviewMessage; - const toolCall = isToolCall(previewMessage); - const role = normalizeRole(previewMessage.role, toolCall); - let text = extractPreviewText(previewMessage); - if (!text) { - const toolNames = extractToolNames(previewMessage); - if (toolNames.length > 0) { - const shown = toolNames.slice(0, 2); - const overflow = toolNames.length - shown.length; - text = `call ${shown.join(", ")}`; - if (overflow > 0) { - text += ` +${overflow}`; - } - } - } - if (!text) { - text = extractMediaSummary(previewMessage); - } - if (!text) { - continue; - } - let trimmed = text.trim(); - if (!trimmed) { - continue; - } - if (role === "user") { - trimmed = stripEnvelope(trimmed); - } - trimmed = truncatePreviewText(trimmed, maxChars); - items.push({ role, text: trimmed }); + items.push(projected); } if (items.length <= maxItems) { diff --git a/src/gateway/worker-environments/provider-provisioning-node-admission.test.ts b/src/gateway/worker-environments/provider-provisioning-node-admission.test.ts index b86c5047c130..827d735a1da9 100644 --- a/src/gateway/worker-environments/provider-provisioning-node-admission.test.ts +++ b/src/gateway/worker-environments/provider-provisioning-node-admission.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { admitWorkerConnection } from "./admission.js"; +import { hashWorkerCredential } from "./credential.js"; import { REQUEST, seedActivePlacement } from "./placement-dispatch-test-fixtures.js"; import { createWorkerSessionPlacementStore } from "./placement-store.js"; import { createWorkerSessionPlacementGate } from "./placement-worker-gate.js"; @@ -48,15 +49,32 @@ describe("worker environment node provisioning", () => { credential: support.CREDENTIAL, bundleHash: support.BUNDLE_HASH, }); - await workerService.attachSession({ + const attachedCredential = await workerService.attachSession({ environmentId: result.environmentId, ownerEpoch: result.ownerEpoch, sessionId: REQUEST.sessionId, }); - const attached = support.testState.store.get(result.environmentId)!; + await support.waitForFast(() => { + expect({ + environment: support.testState.store.get(result.environmentId), + credential: support.testState.store.getCredential(result.environmentId), + }).toMatchObject({ + environment: { + state: "attached", + ownerEpoch: attachedCredential.ownerEpoch, + attachedSessionIds: [REQUEST.sessionId], + }, + credential: { + credentialHash: hashWorkerCredential(attachedCredential.credential), + bundleHash: workerBuild.bundleHash, + sessionId: REQUEST.sessionId, + ownerEpoch: attachedCredential.ownerEpoch, + }, + }); + }); seedActivePlacement(placements, { environmentId: result.environmentId, - ownerEpoch: attached.ownerEpoch, + ownerEpoch: attachedCredential.ownerEpoch, }); const turnClaim = placements.claimTurn({ sessionId: REQUEST.sessionId, @@ -67,14 +85,14 @@ describe("worker environment node provisioning", () => { owner: { kind: "worker", environmentId: result.environmentId, - ownerEpoch: attached.ownerEpoch, + ownerEpoch: attachedCredential.ownerEpoch, }, }); const turnCredential = await workerService.acquireTurnCredential(turnClaim); const admission = { environmentId: result.environmentId, credential: turnCredential.credential, - ownerEpoch: attached.ownerEpoch, + ownerEpoch: attachedCredential.ownerEpoch, rpcSetVersion: 1, sessionId: REQUEST.sessionId, runId: turnClaim.runId, diff --git a/src/gateway/worker-environments/provider-provisioning-node.test.ts b/src/gateway/worker-environments/provider-provisioning-node.test.ts index b96886fade09..42872ef21631 100644 --- a/src/gateway/worker-environments/provider-provisioning-node.test.ts +++ b/src/gateway/worker-environments/provider-provisioning-node.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { admitWorkerConnection } from "./admission.js"; +import { hashWorkerCredential } from "./credential.js"; import { REQUEST, seedActivePlacement } from "./placement-dispatch-test-fixtures.js"; import { createWorkerSessionPlacementStore } from "./placement-store.js"; import { createWorkerSessionPlacementGate } from "./placement-worker-gate.js"; @@ -48,15 +49,32 @@ describe("node worker provider provisioning", () => { credential: support.CREDENTIAL, bundleHash: support.BUNDLE_HASH, }); - await workerService.attachSession({ + const attachedCredential = await workerService.attachSession({ environmentId: result.environmentId, ownerEpoch: result.ownerEpoch, sessionId: REQUEST.sessionId, }); - const attached = support.testState.store.get(result.environmentId)!; + await support.waitForFast(() => { + expect({ + environment: support.testState.store.get(result.environmentId), + credential: support.testState.store.getCredential(result.environmentId), + }).toMatchObject({ + environment: { + state: "attached", + ownerEpoch: attachedCredential.ownerEpoch, + attachedSessionIds: [REQUEST.sessionId], + }, + credential: { + credentialHash: hashWorkerCredential(attachedCredential.credential), + bundleHash: workerBuild.bundleHash, + sessionId: REQUEST.sessionId, + ownerEpoch: attachedCredential.ownerEpoch, + }, + }); + }); seedActivePlacement(placements, { environmentId: result.environmentId, - ownerEpoch: attached.ownerEpoch, + ownerEpoch: attachedCredential.ownerEpoch, }); const turnClaim = placements.claimTurn({ sessionId: REQUEST.sessionId, @@ -67,14 +85,14 @@ describe("node worker provider provisioning", () => { owner: { kind: "worker", environmentId: result.environmentId, - ownerEpoch: attached.ownerEpoch, + ownerEpoch: attachedCredential.ownerEpoch, }, }); const turnCredential = await workerService.acquireTurnCredential(turnClaim); const admission = { environmentId: result.environmentId, credential: turnCredential.credential, - ownerEpoch: attached.ownerEpoch, + ownerEpoch: attachedCredential.ownerEpoch, rpcSetVersion: 1, sessionId: REQUEST.sessionId, runId: turnClaim.runId, diff --git a/src/mcp/channel-server.test.ts b/src/mcp/channel-server.test.ts index 71ece73134ea..068bdca959d8 100644 --- a/src/mcp/channel-server.test.ts +++ b/src/mcp/channel-server.test.ts @@ -438,7 +438,7 @@ describe("openclaw channel mcp server", () => { to: "-100123", accountId: "acct-1", }, - lastMessagePreview: "latest message", + lastMessagePreview: "Use `[[reply_to_current]]` literally.", }, }; } @@ -452,7 +452,7 @@ describe("openclaw channel mcp server", () => { expect(conversation?.channel).toBe("telegram"); expect(conversation?.to).toBe("-100123"); expect(conversation?.accountId).toBe("acct-1"); - expect(conversation?.lastMessagePreview).toBe("latest message"); + expect(conversation?.lastMessagePreview).toBe("Use `[[reply_to_current]]` literally."); expect(gatewayRequest).toHaveBeenCalledWith("sessions.describe", { key: "agent:main:main", includeDerivedTitles: true, diff --git a/src/media/parse.ts b/src/media/parse.ts index 73375e4a2e1b..0c0fc3083abe 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -30,6 +30,7 @@ type ParsedMediaOutputSegment = /** Controls which non-MEDIA syntaxes may be lifted into media attachments. */ type SplitMediaFromOutputOptions = { + extractAudioDirectives?: boolean; extractMarkdownImages?: boolean; extractMediaDirectives?: boolean; }; @@ -715,7 +716,10 @@ export function splitMediaFromOutput( } const visibleText = keptLines.join("\n").replace(/^(?:[ \t]*\n)+/, ""); - const audioTagResult = parseAudioTag(visibleText); + const audioTagResult = + options.extractAudioDirectives === false + ? { text: visibleText, audioAsVoice: false } + : parseAudioTag(visibleText); const cleanedText = audioTagResult.text.trimEnd(); const hasAudioAsVoice = audioTagResult.audioAsVoice; diff --git a/src/plugin-sdk/text-chunking.ts b/src/plugin-sdk/text-chunking.ts index 0e87ffb85c52..2705d33a17a2 100644 --- a/src/plugin-sdk/text-chunking.ts +++ b/src/plugin-sdk/text-chunking.ts @@ -94,12 +94,10 @@ export { stripMarkdown } from "../shared/text/strip-markdown.js"; export { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; /** System-message marker helpers for preserving generated status lines. */ export { SYSTEM_MARK, hasSystemMark, prefixSystemMessage } from "../infra/system-message.ts"; -/** Inline directive stripping helpers for display and delivery boundaries. */ +/** Inline directive stripping helpers for streaming display and delivery boundaries. */ export { stripInlineDirectiveTagsForDelivery, stripInlineDirectiveTagsForDisplay, - stripInlineDirectiveTagsFromMessageForDisplay, - type DisplayMessageWithContent, type InlineDirectiveParseResult, } from "../utils/directive-tags.js"; /** Generic item chunker for plugin payload planning. */ diff --git a/src/tui/tui-session-picker.test.ts b/src/tui/tui-session-picker.test.ts new file mode 100644 index 000000000000..28c97b8c7462 --- /dev/null +++ b/src/tui/tui-session-picker.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { buildSessionChoices } from "./tui-session-picker.js"; + +describe("buildSessionChoices", () => { + it("renders the Gateway display projection without reinterpreting quoted directives", () => { + const [choice] = buildSessionChoices([ + { + key: "agent:main:quoted-directive", + lastMessagePreview: "Use `[[reply_to_current]]` literally.", + }, + ]); + + expect(choice?.description).toBe("Use `[[reply_to_current]]` literally."); + }); +}); diff --git a/src/utils/directive-tags.test.ts b/src/utils/directive-tags.test.ts index 6e06aa5960cc..87052ba8ed62 100644 --- a/src/utils/directive-tags.test.ts +++ b/src/utils/directive-tags.test.ts @@ -5,7 +5,6 @@ import { sanitizeReplyDirectiveId, stripInlineDirectiveTagsForDelivery, stripInlineDirectiveTagsForDisplay, - stripInlineDirectiveTagsFromMessageForDisplay, } from "./directive-tags.js"; function hasUnpairedSurrogate(value: string): boolean { @@ -316,131 +315,3 @@ describe("sanitizeReplyDirectiveId", () => { expect(hasUnpairedSurrogate("a😊b")).toBe(false); }); }); - -describe("stripInlineDirectiveTagsFromMessageForDisplay", () => { - test("strips inline directives from text content blocks", () => { - const input = { - role: "assistant", - content: [{ type: "text", text: "hello [[reply_to_current]] world [[audio_as_voice]]" }], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - if (!result) { - throw new Error("expected stripped message"); - } - expect(result.content).toEqual([{ type: "text", text: "hello world " }]); - }); - - test("preserves empty-string text when directives are entire content", () => { - const input = { - role: "assistant", - content: [{ type: "text", text: "[[reply_to_current]]" }], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - if (!result) { - throw new Error("expected stripped message"); - } - expect(result.content).toEqual([{ type: "text", text: "" }]); - }); - - test("returns original message when content is not an array", () => { - const input = { - role: "assistant", - content: "plain text", - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - expect(result).toBe(input); - }); - - test("returns original message reference when no directives are present", () => { - const input = { - role: "assistant", - content: [{ type: "text", text: "plain text without directives" }], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - expect(result).toBe(input); - }); - - test("returns original message reference when content has only non-text parts", () => { - const input = { - role: "assistant", - content: [{ type: "image", url: "https://example.test/x.png" }], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - expect(result).toBe(input); - }); - - test("preserves unchanged text-part references when only some parts change", () => { - const unchangedPart = { type: "text" as const, text: "plain text" }; - const changedPart = { type: "text" as const, text: "with [[reply_to_current]] tag" }; - const nonTextPart = { type: "image" as const, url: "https://example.test/x.png" }; - const input = { - role: "assistant", - content: [unchangedPart, changedPart, nonTextPart], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - expect(result).not.toBe(input); - const content = result?.content as Array>; - expect(content[0]).toBe(unchangedPart); - expect(content[1]).not.toBe(changedPart); - expect(content[1]).toEqual({ type: "text", text: "with tag" }); - expect(content[2]).toBe(nonTextPart); - }); - - test("preserves trailing references when only the first part changes", () => { - const changedPart = { type: "text" as const, text: "first [[reply_to_current]]" }; - const unchangedText = { type: "text" as const, text: "second" }; - const unchangedImage = { type: "image" as const, url: "https://example.test/x.png" }; - const input = { - role: "assistant", - content: [changedPart, unchangedText, unchangedImage], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - expect(result).not.toBe(input); - const content = result?.content as Array>; - expect(content).toHaveLength(3); - expect(content[0]).not.toBe(changedPart); - expect(content[0]).toEqual({ type: "text", text: "first " }); - expect(content[1]).toBe(unchangedText); - expect(content[2]).toBe(unchangedImage); - }); - - test("preserves leading references when only the last part changes", () => { - const unchangedText = { type: "text" as const, text: "first" }; - const unchangedImage = { type: "image" as const, url: "https://example.test/x.png" }; - const changedPart = { type: "text" as const, text: "last [[reply_to_current]]" }; - const input = { - role: "assistant", - content: [unchangedText, unchangedImage, changedPart], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - expect(result).not.toBe(input); - const content = result?.content as Array>; - expect(content).toHaveLength(3); - expect(content[0]).toBe(unchangedText); - expect(content[1]).toBe(unchangedImage); - expect(content[2]).not.toBe(changedPart); - expect(content[2]).toEqual({ type: "text", text: "last " }); - }); - - test("preserves arbitrary extra fields on rebuilt text parts", () => { - const input = { - role: "assistant", - content: [ - { - type: "text", - text: "with [[reply_to_current]] tag", - id: "part-1", - cache_control: { type: "ephemeral" }, - }, - ], - }; - const result = stripInlineDirectiveTagsFromMessageForDisplay(input); - const content = result?.content as Array>; - expect(content[0]).toEqual({ - type: "text", - text: "with tag", - id: "part-1", - cache_control: { type: "ephemeral" }, - }); - }); -}); diff --git a/src/utils/directive-tags.ts b/src/utils/directive-tags.ts index 66e7d708dff0..5e439cb88203 100644 --- a/src/utils/directive-tags.ts +++ b/src/utils/directive-tags.ts @@ -102,17 +102,6 @@ type StripInlineDirectiveTagsResult = { changed: boolean; }; -type MessageTextPart = { - type: "text"; - text: string; -} & Record; - -type MessagePart = Record | null | undefined; - -export type DisplayMessageWithContent = { - content?: unknown; -} & Record; - export function stripInlineDirectiveTagsForDisplay(text: string): StripInlineDirectiveTagsResult { if (!text) { return { text, changed: false }; @@ -171,52 +160,6 @@ export function stripInlineDirectiveTagsForDelivery(text: string): StripInlineDi }; } -function isMessageTextPart(part: MessagePart): part is MessageTextPart { - return Boolean(part) && part?.type === "text" && typeof part.text === "string"; -} - -/** - * Strips inline directive tags from text content while preserving message shape. - * Empty post-strip text stays empty-string to preserve caller semantics. - * Returns the input message reference (including the original content array) when - * no text part changed, and reuses unchanged text-part references in mixed content, - * so identity-equality consumers avoid spurious churn. - */ -export function stripInlineDirectiveTagsFromMessageForDisplay( - message: DisplayMessageWithContent | undefined, -): DisplayMessageWithContent | undefined { - if (!message) { - return message; - } - if (!Array.isArray(message.content)) { - return message; - } - let cleaned: unknown[] | undefined; - for (let i = 0; i < message.content.length; i++) { - const part = message.content[i]; - let next: unknown = part; - if (part && typeof part === "object" && isMessageTextPart(part as MessagePart)) { - const record = part as MessageTextPart; - const stripped = stripInlineDirectiveTagsForDisplay(record.text); - if (stripped.changed) { - next = { ...record, text: stripped.text }; - } - } - if (next === part) { - cleaned?.push(part); - continue; - } - if (!cleaned) { - cleaned = message.content.slice(0, i); - } - cleaned.push(next); - } - if (!cleaned) { - return message; - } - return { ...message, content: cleaned }; -} - export function parseInlineDirectives( text?: string, options: InlineDirectiveParseOptions = {}, diff --git a/src/utils/transcript-tools.test.ts b/src/utils/transcript-tools.test.ts index 7a78b51fd4f1..e23bbe601f64 100644 --- a/src/utils/transcript-tools.test.ts +++ b/src/utils/transcript-tools.test.ts @@ -1,6 +1,6 @@ // Transcript tool tests cover transcript utility parsing and formatting. import { describe, expect, it } from "vitest"; -import { countToolResults, extractToolCallNames, hasToolCall } from "./transcript-tools.js"; +import { countToolResults, extractToolCallNames } from "./transcript-tools.js"; describe("transcript-tools", () => { describe("extractToolCallNames", () => { @@ -34,18 +34,6 @@ describe("transcript-tools", () => { }); }); - describe("hasToolCall", () => { - it("returns true when tool call names exist", () => { - expect(hasToolCall({ toolName: "weather" })).toBe(true); - expect(hasToolCall({ content: [{ type: "tool_use", name: "read" }] })).toBe(true); - }); - - it("returns false when no tool calls exist", () => { - expect(hasToolCall({})).toBe(false); - expect(hasToolCall({ content: [{ type: "text", text: "hi" }] })).toBe(false); - }); - }); - describe("countToolResults", () => { it("counts tool_result blocks and tool_result_error blocks; tracks errors via is_error", () => { expect( diff --git a/src/utils/transcript-tools.ts b/src/utils/transcript-tools.ts index f9ec7242f54c..d588fd0efa6d 100644 --- a/src/utils/transcript-tools.ts +++ b/src/utils/transcript-tools.ts @@ -54,10 +54,6 @@ export const extractToolCallNames = (message: Record): string[] return Array.from(names); }; -/** Returns whether a transcript message contains any recognized tool-call marker. */ -export const hasToolCall = (message: Record): boolean => - extractToolCallNames(message).length > 0; - /** Counts recognized tool-result blocks and the subset explicitly marked as errors. */ export const countToolResults = (message: Record): ToolResultCounts => { const content = message.content; diff --git a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts index 567f0c0cb4af..42a2ea300c0d 100644 --- a/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.sidebar-presentation.e2e.test.ts @@ -113,6 +113,7 @@ suite.define(() => { .poll(async () => (await gateway.getRequests("sessions.list")).length) .toBeGreaterThan(listCount); await row.getByText("The repaired sidebar now shows the final reply.").waitFor(); + expect(await row.textContent()).not.toContain("[["); if (captureUiProofEnabled) { await page.screenshot({ fullPage: true, diff --git a/ui/src/lib/chat/message-normalizer.test.ts b/ui/src/lib/chat/message-normalizer.test.ts index e9ae2481b0be..8a71714b6fe4 100644 --- a/ui/src/lib/chat/message-normalizer.test.ts +++ b/ui/src/lib/chat/message-normalizer.test.ts @@ -420,11 +420,12 @@ describe("message-normalizer", () => { ]); }); - it("extracts MEDIA attachments and reply metadata from assistant text", () => { + it("extracts MEDIA attachments and reads persisted delivery facts", () => { const result = normalizeMessage({ role: "assistant", content: - "[[reply_to:thread-123]]Intro\nMEDIA:https://example.com/image.png\nOutro\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]", + "Intro\nMEDIA:https://example.com/image.png\nOutro\nMEDIA:https://example.com/voice.ogg", + openclawDelivery: { audioAsVoice: true, replyToId: "thread-123" }, }); expect(result.replyTarget).toEqual({ kind: "id", id: "thread-123" }); @@ -510,7 +511,7 @@ describe("message-normalizer", () => { }, ); - it("preserves canonical code fences after removing reply and audio directives", () => { + it("preserves canonical code fences with structured delivery facts", () => { const code = ["```python", "value = 'a b'", "``` not a close", "other = 'c d'", "```"].join( "\n", ); @@ -518,7 +519,8 @@ describe("message-normalizer", () => { expect( normalizeMessage({ role: "assistant", - content: `[[reply_to_current]]\n[[audio_as_voice]]\n${code}\nMEDIA:https://example.com/image.png`, + content: `${code}\nMEDIA:https://example.com/image.png`, + openclawDelivery: { audioAsVoice: true, replyToCurrent: true }, }).content, ).toEqual([ { type: "text", text: code }, @@ -534,10 +536,11 @@ describe("message-normalizer", () => { ]); }); - it("marks media-only audio attachments as voice notes when audio_as_voice is present", () => { + it("marks media-only audio attachments as voice notes from delivery facts", () => { const result = normalizeMessage({ role: "assistant", - content: "MEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]", + content: "MEDIA:https://example.com/voice.ogg", + openclawDelivery: { audioAsVoice: true }, }); expect(result.audioAsVoice).toBe(true); @@ -761,26 +764,36 @@ describe("message-normalizer", () => { ]); }); - it("strips reply_to_current without rendering a quoted preview", () => { + it("uses persisted delivery facts for the current-message reply target", () => { const result = normalizeMessage({ role: "assistant", - content: "[[reply_to_current]]\nReply body", + content: "Reply body", + openclawDelivery: { replyToCurrent: true }, }); expect(result.replyTarget).toEqual({ kind: "current" }); expect(result.content).toEqual([{ type: "text", text: "Reply body" }]); }); - it("does not restore stripped reply tags when no visible text remains", () => { + it("keeps a fact-only current-message reply target", () => { const result = normalizeMessage({ role: "assistant", - content: "[[reply_to_current]]", + content: "", + openclawDelivery: { replyToCurrent: true }, }); expect(result.replyTarget).toEqual({ kind: "current" }); expect(result.content).toStrictEqual([]); }); + it("renders quoted delivery and TTS markers verbatim", () => { + const text = "Use `[[reply_to_current]]` and `[[tts]]` literally."; + const result = normalizeMessage({ role: "assistant", content: text }); + + expect(result.replyTarget).toBeUndefined(); + expect(result.content).toEqual([{ type: "text", text }]); + }); + it("preserves structured attachment content items", () => { const result = normalizeMessage({ role: "assistant", diff --git a/ui/src/lib/chat/message-normalizer.ts b/ui/src/lib/chat/message-normalizer.ts index 01df0e5c39b6..38ccca76fcc1 100644 --- a/ui/src/lib/chat/message-normalizer.ts +++ b/ui/src/lib/chat/message-normalizer.ts @@ -15,7 +15,6 @@ import { resolveToolBlockArgs, } from "../../../../src/chat/tool-content.js"; import { splitMediaFromOutput } from "../../../../src/media/parse.js"; -import { parseInlineDirectives } from "../../../../src/utils/directive-tags.js"; import { getMediaFileExtension } from "../media-file-extension.ts"; import type { NormalizedMessage, MessageContentItem } from "./chat-types.ts"; import { formatSenderLabel, normalizeSenderIdentity } from "./sender-label.ts"; @@ -107,6 +106,14 @@ const rawOpenClawMetadataSchema = z }) .optional() .catch(undefined); +const rawOpenClawDeliverySchema = z + .object({ + audioAsVoice: z.literal(true).optional(), + replyToCurrent: z.literal(true).optional(), + replyToId: optionalMessageStringSchema, + }) + .optional() + .catch(undefined); const rawMessageSchema = z .looseObject({ role: optionalMessageStringSchema, @@ -122,6 +129,7 @@ const rawMessageSchema = z toolName: optionalMessageStringSchema, tool_name: optionalMessageStringSchema, __openclaw: rawOpenClawMetadataSchema, + openclawDelivery: rawOpenClawDeliverySchema, }) .catch({}); @@ -448,16 +456,24 @@ function stripMessageDisplayMetadata(items: MessageContentItem[]): MessageConten .filter((item) => item.type !== "text" || Boolean(item.text?.trim())); } -function expandTextContent(text: string): { +function expandTextContent( + text: string, + delivery: z.infer, +): { content: MessageContentItem[]; audioAsVoice: boolean; replyTarget: NormalizedMessage["replyTarget"]; } { const extracted = extractCanvasShortcodes(text); - const parsed = splitMediaFromOutput(extracted.text); + const parsed = splitMediaFromOutput(extracted.text, { extractAudioDirectives: false }); const parts: MessageContentItem[] = []; - let audioAsVoice = parsed.audioAsVoice === true; - let replyTarget: NormalizedMessage["replyTarget"] = null; + const audioAsVoice = delivery?.audioAsVoice === true; + const replyToId = delivery?.replyToId?.trim(); + const replyTarget: NormalizedMessage["replyTarget"] = replyToId + ? { kind: "id", id: replyToId } + : delivery?.replyToCurrent === true + ? { kind: "current" } + : null; const segments = parsed.segments ?? [{ type: "text" as const, text: parsed.text }]; for (const segment of segments) { @@ -481,19 +497,8 @@ function expandTextContent(text: string): { continue; } - const directives = parseInlineDirectives(segment.text, { - stripAudioTag: true, - stripReplyTags: true, - }); - audioAsVoice = audioAsVoice || directives.audioAsVoice; - if (directives.replyToExplicitId) { - replyTarget = { kind: "id", id: directives.replyToExplicitId }; - } else if (directives.replyToCurrent && replyTarget === null) { - replyTarget = { kind: "current" }; - } - if (directives.text) { - const normalizedText = directives.text + (segment.text.endsWith("\n") ? "\n" : ""); - parts.push({ type: "text", text: normalizedText }); + if (segment.text) { + parts.push({ type: "text", text: segment.text }); } } for (const preview of extracted.previews) { @@ -553,6 +558,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { role = "toolResult"; } const isAssistantMessage = role === "assistant"; + const delivery = isAssistantMessage ? m.openclawDelivery : undefined; // Extract content let content: MessageContentItem[] = []; @@ -561,7 +567,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { if (typeof m.content === "string") { if (isAssistantMessage) { - const expanded = expandTextContent(m.content); + const expanded = expandTextContent(m.content, delivery); content = expanded.content; audioAsVoice = expanded.audioAsVoice; replyTarget = expanded.replyTarget; @@ -638,7 +644,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { } if (isTextContentBlock(item, role)) { if (isAssistantMessage) { - const expanded = expandTextContent(item.text); + const expanded = expandTextContent(item.text, delivery); audioAsVoice = audioAsVoice || expanded.audioAsVoice; if (expanded.replyTarget?.kind === "id") { replyTarget = expanded.replyTarget; @@ -671,7 +677,7 @@ export function normalizeMessage(message: unknown): NormalizedMessage { }); } else if (m.text !== undefined) { if (isAssistantMessage) { - const expanded = expandTextContent(m.text); + const expanded = expandTextContent(m.text, delivery); content = expanded.content; audioAsVoice = expanded.audioAsVoice; replyTarget = expanded.replyTarget; diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts index 1b69c68bc70c..27f2619578e7 100644 --- a/ui/src/pages/chat/components/chat-message.test.ts +++ b/ui/src/pages/chat/components/chat-message.test.ts @@ -2974,9 +2974,10 @@ describe("grouped chat rendering", () => { renderAssistantMessage( container, createAssistantMessage( - "[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]", + "Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg", { id: "assistant-media-inline", + openclawDelivery: { audioAsVoice: true, replyToCurrent: true }, }, ), { showToolCalls: false, onOpenImage },