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
This commit is contained in:
Peter Steinberger
2026-08-16 23:44:29 -07:00
committed by GitHub
parent 59c661bf66
commit 4e1f26dc18
41 changed files with 638 additions and 834 deletions
+2 -3
View File
@@ -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
@@ -157,6 +157,38 @@ function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
return call;
}
async function handleClientRequestWhenReady(
client: ReturnType<typeof createFakeClient>,
request: Parameters<ReturnType<typeof createFakeClient>["handleRequest"]>[0],
assertHandled: (response: unknown) => void = (response) => expect(response).not.toBeUndefined(),
): Promise<unknown> {
let response: unknown;
await vi.waitFor(async () => {
response = await client.handleRequest(request);
assertHandled(response);
});
return response;
}
async function startClientRequestWhenReady(
client: ReturnType<typeof createFakeClient>,
request: Parameters<ReturnType<typeof createFakeClient>["handleRequest"]>[0],
started: Promise<void>,
): Promise<void> {
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<void>((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<string, unknown> } | 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();
@@ -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),
+4 -2
View File
@@ -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(
@@ -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 };
}
@@ -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;
+2 -2
View File
@@ -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,
@@ -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<string,
: [];
return (
texts.length > 0 &&
texts.every((text) =>
isSuppressedControlReplyText(stripInlineDirectiveTagsForDisplay(text).text),
) &&
texts.every((text) => isSuppressedControlReplyText(text)) &&
hasAssistantDisplayableNonTextContent(message)
);
}
@@ -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, unknown>): 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;
+16 -34
View File
@@ -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);
-32
View File
@@ -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([
+3 -1
View File
@@ -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([
+3 -8
View File
@@ -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 =
@@ -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(
@@ -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(
@@ -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<string, unknown> = {
const rawDeliveryMessage: {
role: "assistant";
content: Array<Record<string, unknown>>;
openclawDelivery?: unknown;
} = {
role: "assistant",
content: [{ type: "text", text: params.message }],
};
const rawDeliveryFacts = applyAssistantDeliveryDirectives(rawDeliveryMessage).openclawDelivery;
const messageBody: AppendMessageArg & Record<string, unknown> = 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)) {
@@ -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<string, unknown>, {
message: {
...target.message,
content: mergedContent,
},
message: rewrittenMessage,
});
const rewritten = await rewriteTranscriptEventRowsExact(params.scope, {
allowInitialGenerationMaterialization: initialGenerationMaterialized,
@@ -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<string, any> | 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 () => {
@@ -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 },
@@ -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 },
});
});
});
@@ -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 () => {
@@ -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);
});
});
+69
View File
@@ -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, unknown>): 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)}...`,
};
}
-32
View File
@@ -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 {
@@ -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 =
+9 -33
View File
@@ -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",
+6 -132
View File
@@ -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<string, unknown>);
}
function extractToolNames(message: TranscriptPreviewMessage): string[] {
return extractToolCallNames(message as Record<string, unknown>);
}
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) {
@@ -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,
@@ -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,
+2 -2
View File
@@ -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,
+5 -1
View File
@@ -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;
+1 -3
View File
@@ -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. */
+15
View File
@@ -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.");
});
});
-129
View File
@@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
expect(content[0]).toEqual({
type: "text",
text: "with tag",
id: "part-1",
cache_control: { type: "ephemeral" },
});
});
});
-57
View File
@@ -102,17 +102,6 @@ type StripInlineDirectiveTagsResult = {
changed: boolean;
};
type MessageTextPart = {
type: "text";
text: string;
} & Record<string, unknown>;
type MessagePart = Record<string, unknown> | null | undefined;
export type DisplayMessageWithContent = {
content?: unknown;
} & Record<string, unknown>;
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 = {},
+1 -13
View File
@@ -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(
-4
View File
@@ -54,10 +54,6 @@ export const extractToolCallNames = (message: Record<string, unknown>): string[]
return Array.from(names);
};
/** Returns whether a transcript message contains any recognized tool-call marker. */
export const hasToolCall = (message: Record<string, unknown>): boolean =>
extractToolCallNames(message).length > 0;
/** Counts recognized tool-result blocks and the subset explicitly marked as errors. */
export const countToolResults = (message: Record<string, unknown>): ToolResultCounts => {
const content = message.content;
@@ -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,
+23 -10
View File
@@ -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",
+27 -21
View File
@@ -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<typeof rawOpenClawDeliverySchema>,
): {
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;
@@ -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 },