mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
59c661bf66
commit
4e1f26dc18
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}...`,
|
||||
};
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user