mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(webchat): fetch full sidebar content for truncated history
Add a bounded `chat.message.get` gateway method so Control UI can fetch one display-normalized transcript message by id when an assistant history preview was truncated. Keep `chat.history` lightweight, reject oversized/hidden/missing rows with explicit unavailable reasons, and wire the WebChat side reader to request full content only for visible truncated assistant messages. Also refresh the generated Swift gateway protocol models and document the new assistant-message side-reader behavior. Closes #84651. Related #53242. Co-authored-by: NianJiuZst <3235467914@qq.com>
This commit is contained in:
@@ -6880,6 +6880,54 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessageGetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
public let messageid: String
|
||||
public let maxchars: Int?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
agentid: String? = nil,
|
||||
messageid: String,
|
||||
maxchars: Int?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
self.messageid = messageid
|
||||
self.maxchars = maxchars
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
case messageid = "messageId"
|
||||
case maxchars = "maxChars"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessageGetResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let message: AnyCodable?
|
||||
public let unavailablereason: AnyCodable?
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
message: AnyCodable?,
|
||||
unavailablereason: AnyCodable?)
|
||||
{
|
||||
self.ok = ok
|
||||
self.message = message
|
||||
self.unavailablereason = unavailablereason
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case message
|
||||
case unavailablereason = "unavailableReason"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatSendParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
@@ -442,6 +442,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
- `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
|
||||
- `sessions.get` returns the full stored session row.
|
||||
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows such as exact `NO_REPLY` / `no_reply` are omitted, and oversized rows can be replaced with placeholders.
|
||||
- `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Clients pass `sessionKey`, optional `agentId` when the session selection is agent-scoped, plus a transcript `messageId` previously surfaced through `chat.history`, and the Gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
|
||||
- Chat uploads accept images plus non-video files. Images keep the native image path; other files are stored as managed media and shown in history as attachment links.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
- When a visible assistant message was truncated in `chat.history`, the side reader can fetch the full display-normalized transcript entry on demand through `chat.message.get` by `sessionKey`, active `agentId` when needed, and transcript `messageId`. If the Gateway still cannot return more, the reader shows an explicit unavailable state instead of silently repeating the truncated preview.
|
||||
- Assistant/generated images are persisted as managed media references and served back through authenticated Gateway media URLs, so reloads do not depend on raw base64 image payloads staying in the chat history response.
|
||||
- When rendering `chat.history`, the Control UI strips display-only inline directive tags from visible assistant text (for example `[[reply_to_*]]` and `[[audio_as_voice]]`), plain-text tool-call XML payloads (including `<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks), and leaked ASCII/full-width model control tokens, and omits assistant entries whose whole visible text is only the exact silent token `NO_REPLY` / `no_reply` or the heartbeat acknowledgement token `HEARTBEAT_OK`.
|
||||
- During an active send and the final history refresh, the chat view keeps local optimistic user/assistant messages visible if `chat.history` briefly returns an older snapshot; the canonical transcript replaces those local messages once the Gateway history catches up.
|
||||
|
||||
@@ -24,6 +24,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||
- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
|
||||
- When a visible assistant message was truncated in `chat.history`, Control UI can open a side reader and fetch the full display-normalized entry on demand through `chat.message.get` without increasing the default history payload.
|
||||
- `chat.history` follows the active transcript branch for modern append-only session files, so abandoned rewrite branches and superseded prompt copies are not rendered in WebChat.
|
||||
- Compaction entries render as an explicit compacted-history divider. The divider explains that the compacted transcript is preserved as a checkpoint and links to the Sessions checkpoint controls, where operators can branch or restore from that compacted view when their permissions allow it.
|
||||
- Control UI remembers the backing Gateway `sessionId` returned by `chat.history` and includes it on follow-up `chat.send` calls, so reconnects and page refreshes continue the same stored conversation unless the user starts or resets a session.
|
||||
@@ -54,6 +55,7 @@ WebChat has two separate data paths:
|
||||
- Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`.
|
||||
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal embedded agent turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
|
||||
- `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
|
||||
- `chat.message.get` uses the same transcript branch and display projection rules as `chat.history`, including active-agent scoping, but targets one transcript entry by `messageId` and returns an honest unavailable reason when the full content can no longer be returned.
|
||||
|
||||
Normal agent-run final answers should be durable because the embedded runtime writes the assistant `message_end`. Any fallback that mirrors a delivered final payload into the transcript must first avoid duplicating an assistant turn that the embedded runtime already wrote.
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ import {
|
||||
type ChatEvent,
|
||||
ChatEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatMessageGetResultSchema,
|
||||
ChatMessageGetParamsSchema,
|
||||
type ChatInjectParams,
|
||||
ChatInjectParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
@@ -844,10 +846,12 @@ export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeS
|
||||
);
|
||||
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
|
||||
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
|
||||
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
|
||||
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
|
||||
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
|
||||
export const validateChatInjectParams = lazyCompile<ChatInjectParams>(ChatInjectParamsSchema);
|
||||
export const validateChatEvent = lazyCompile(ChatEventSchema);
|
||||
export const validateChatMessageGetResult = lazyCompile(ChatMessageGetResultSchema);
|
||||
export const validateUpdateStatusParams = lazyCompile<UpdateStatusParams>(UpdateStatusParamsSchema);
|
||||
export const validateUpdateRunParams = lazyCompile<UpdateRunParams>(UpdateRunParamsSchema);
|
||||
export const validateWebLoginStartParams =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { ChatSendSessionKeyString, InputProvenanceSchema, NonEmptyString } from "./primitives.js";
|
||||
|
||||
@@ -33,6 +34,32 @@ export const ChatHistoryParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatMessageGetParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
messageId: NonEmptyString,
|
||||
maxChars: Type.Optional(Type.Integer({ minimum: 1, maximum: 2_000_000 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatMessageGetResultSchema = Type.Object(
|
||||
{
|
||||
ok: Type.Boolean(),
|
||||
message: Type.Optional(Type.Unknown()),
|
||||
unavailableReason: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("not_found"),
|
||||
Type.Literal("oversized"),
|
||||
Type.Literal("not_visible"),
|
||||
]),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
export type ChatMessageGetResult = Static<typeof ChatMessageGetResultSchema>;
|
||||
|
||||
export const ChatSendParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: ChatSendSessionKeyString,
|
||||
|
||||
@@ -191,6 +191,8 @@ import {
|
||||
ChatEventSchema,
|
||||
ChatFinalEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatMessageGetParamsSchema,
|
||||
ChatMessageGetResultSchema,
|
||||
ChatInjectParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
LogsTailParamsSchema,
|
||||
@@ -532,6 +534,8 @@ export const ProtocolSchemas = {
|
||||
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatMessageGetParams: ChatMessageGetParamsSchema,
|
||||
ChatMessageGetResult: ChatMessageGetResultSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
ChatAbortParams: ChatAbortParamsSchema,
|
||||
ChatInjectParams: ChatInjectParamsSchema,
|
||||
|
||||
@@ -198,6 +198,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "agent.identity.get", scope: "operator.read" },
|
||||
{ name: "agent.wait", scope: "operator.write", startup: true },
|
||||
{ name: "chat.history", scope: "operator.read", startup: true },
|
||||
{ name: "chat.message.get", scope: "operator.read", startup: true },
|
||||
{ name: "chat.abort", scope: "operator.write" },
|
||||
{ name: "chat.send", scope: "operator.write" },
|
||||
{ name: "assistant.media.get", scope: "operator.read", advertise: false },
|
||||
|
||||
@@ -271,7 +271,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
loadHandlers: loadChannelsHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["chat.history", "chat.abort", "chat.send", "chat.inject"],
|
||||
methods: ["chat.history", "chat.message.get", "chat.abort", "chat.send", "chat.inject"],
|
||||
loadHandlers: loadChatHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
validateChatAbortParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatInjectParams,
|
||||
validateChatMessageGetParams,
|
||||
validateChatSendParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js";
|
||||
@@ -127,11 +128,13 @@ import {
|
||||
createManagedOutgoingImageBlocks,
|
||||
} from "../managed-image-attachments.js";
|
||||
import { ADMIN_SCOPE } from "../method-scopes.js";
|
||||
import { getMaxChatHistoryMessagesBytes } from "../server-constants.js";
|
||||
import { getMaxChatHistoryMessagesBytes, MAX_PAYLOAD_BYTES } from "../server-constants.js";
|
||||
import { readSessionTranscriptIndex } from "../session-transcript-index.fs.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
loadSessionEntry,
|
||||
readSessionMessageByIdAsync,
|
||||
readSessionMessagesAsync,
|
||||
resolveGatewayModelSupportsImages,
|
||||
resolveGatewaySessionThinkingDefault,
|
||||
resolveDeletedAgentIdFromSessionKey,
|
||||
@@ -1387,11 +1390,26 @@ export function buildOversizedHistoryPlaceholder(message?: unknown): Record<stri
|
||||
typeof (message as { timestamp?: unknown }).timestamp === "number"
|
||||
? (message as { timestamp: number }).timestamp
|
||||
: Date.now();
|
||||
const rawMetadata =
|
||||
message && typeof message === "object"
|
||||
? (message as Record<string, unknown>)["__openclaw"]
|
||||
: undefined;
|
||||
const metadata =
|
||||
rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)
|
||||
? (rawMetadata as Record<string, unknown>)
|
||||
: {};
|
||||
const metadataId = typeof metadata.id === "string" ? metadata.id : undefined;
|
||||
const metadataSeq = typeof metadata.seq === "number" ? metadata.seq : undefined;
|
||||
return {
|
||||
role,
|
||||
timestamp,
|
||||
content: [{ type: "text", text: CHAT_HISTORY_OVERSIZED_PLACEHOLDER }],
|
||||
__openclaw: { truncated: true, reason: "oversized" },
|
||||
__openclaw: {
|
||||
...(metadataId ? { id: metadataId } : {}),
|
||||
...(metadataSeq !== undefined ? { seq: metadataSeq } : {}),
|
||||
truncated: true,
|
||||
reason: "oversized",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2330,6 +2348,35 @@ export function dropPreSessionStartAnnouncePairs(
|
||||
return changed ? kept : messages;
|
||||
}
|
||||
|
||||
function readChatHistoryMessageId(message: unknown): string | undefined {
|
||||
const metadata = asOptionalRecord(asOptionalRecord(message)?.["__openclaw"]);
|
||||
return typeof metadata?.id === "string" ? metadata.id : undefined;
|
||||
}
|
||||
|
||||
async function isChatMessageIdVisibleAfterHistoryFilters(params: {
|
||||
sessionId: string;
|
||||
storePath: string | undefined;
|
||||
sessionFile: string | undefined;
|
||||
messageId: string;
|
||||
sessionStartedAt?: number;
|
||||
}): Promise<boolean> {
|
||||
if (params.sessionStartedAt === undefined) {
|
||||
return true;
|
||||
}
|
||||
const messages = await readSessionMessagesAsync(
|
||||
params.sessionId,
|
||||
params.storePath,
|
||||
params.sessionFile,
|
||||
{
|
||||
mode: "full",
|
||||
reason: "chat.message.get visibility",
|
||||
},
|
||||
);
|
||||
return dropPreSessionStartAnnouncePairs(messages, params.sessionStartedAt).some(
|
||||
(message) => readChatHistoryMessageId(message) === params.messageId,
|
||||
);
|
||||
}
|
||||
|
||||
function dropLocalHistoryOverreadContextMessage(
|
||||
messages: unknown[],
|
||||
contextMessage: unknown,
|
||||
@@ -2474,6 +2521,94 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
verboseLevel,
|
||||
});
|
||||
},
|
||||
"chat.message.get": async ({ params, respond, context }) => {
|
||||
if (!validateChatMessageGetParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid chat.message.get params: ${formatValidationErrors(validateChatMessageGetParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { sessionKey, messageId, maxChars } = params as {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
messageId: string;
|
||||
maxChars?: number;
|
||||
};
|
||||
const agentIdOverride = normalizeOptionalText((params as { agentId?: string }).agentId);
|
||||
const requestedAgentId = resolveRequestedChatAgentId({
|
||||
cfg: (context as { getRuntimeConfig?: () => OpenClawConfig }).getRuntimeConfig?.(),
|
||||
requestedSessionKey: sessionKey,
|
||||
agentId: agentIdOverride,
|
||||
});
|
||||
const sessionLoadOptions = requestedAgentId ? { agentId: requestedAgentId } : undefined;
|
||||
const { cfg, storePath, entry } = loadSessionEntry(sessionKey, sessionLoadOptions);
|
||||
const selectedAgent = validateChatSelectedAgent({
|
||||
cfg,
|
||||
requestedSessionKey: sessionKey,
|
||||
agentId: requestedAgentId,
|
||||
});
|
||||
if (!selectedAgent.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, selectedAgent.error));
|
||||
return;
|
||||
}
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId) {
|
||||
respond(true, { ok: false, unavailableReason: "not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await readSessionMessageByIdAsync(
|
||||
sessionId,
|
||||
storePath,
|
||||
entry?.sessionFile,
|
||||
messageId,
|
||||
);
|
||||
if (!resolved.found) {
|
||||
respond(true, { ok: false, unavailableReason: "not_found" });
|
||||
return;
|
||||
}
|
||||
const visible = await isChatMessageIdVisibleAfterHistoryFilters({
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
messageId,
|
||||
sessionStartedAt:
|
||||
typeof entry?.sessionStartedAt === "number" ? entry.sessionStartedAt : undefined,
|
||||
});
|
||||
if (!visible) {
|
||||
respond(true, { ok: false, unavailableReason: "not_found" });
|
||||
return;
|
||||
}
|
||||
if (resolved.oversized) {
|
||||
respond(true, { ok: false, unavailableReason: "oversized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveMaxChars =
|
||||
typeof maxChars === "number" ? maxChars : Math.min(MAX_PAYLOAD_BYTES, 1_000_000);
|
||||
const projectedMessage = resolved.message
|
||||
? projectChatDisplayMessage(resolved.message, {
|
||||
maxChars: effectiveMaxChars,
|
||||
})
|
||||
: undefined;
|
||||
const projected = projectedMessage
|
||||
? augmentChatHistoryWithCanvasBlocks([projectedMessage])[0]
|
||||
: undefined;
|
||||
if (!projected) {
|
||||
respond(true, { ok: false, unavailableReason: "not_visible" });
|
||||
return;
|
||||
}
|
||||
|
||||
respond(true, {
|
||||
ok: true,
|
||||
message: projected,
|
||||
});
|
||||
},
|
||||
"chat.abort": async ({ params, respond, context, client }) => {
|
||||
if (!validateChatAbortParams(params)) {
|
||||
respond(
|
||||
|
||||
@@ -80,6 +80,9 @@ async function withGatewayChatHarness(
|
||||
await run({ ws, createSessionDir });
|
||||
} finally {
|
||||
setMaxChatHistoryMessagesBytesForTest();
|
||||
if (process.env.OPENCLAW_CONFIG_PATH) {
|
||||
await fs.rm(process.env.OPENCLAW_CONFIG_PATH, { force: true });
|
||||
}
|
||||
clearConfigCache();
|
||||
testState.sessionStorePath = undefined;
|
||||
ws.close();
|
||||
@@ -129,6 +132,35 @@ async function fetchHistoryMessages(
|
||||
return historyRes.payload?.messages ?? [];
|
||||
}
|
||||
|
||||
async function fetchChatMessage(
|
||||
ws: GatewaySocket,
|
||||
params: {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
messageId: string;
|
||||
maxChars?: number;
|
||||
},
|
||||
): Promise<{
|
||||
ok?: boolean;
|
||||
message?: unknown;
|
||||
unavailableReason?: "not_found" | "oversized" | "not_visible";
|
||||
}> {
|
||||
const res = await rpcReq<{
|
||||
ok?: boolean;
|
||||
message?: unknown;
|
||||
unavailableReason?: "not_found" | "oversized" | "not_visible";
|
||||
}>(ws, "chat.message.get", {
|
||||
sessionKey: params.sessionKey,
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
messageId: params.messageId,
|
||||
...(typeof params.maxChars === "number" ? { maxChars: params.maxChars } : {}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`chat.message.get rpc failed: ${JSON.stringify(res.error ?? null)}`);
|
||||
}
|
||||
return res.payload ?? {};
|
||||
}
|
||||
|
||||
type ConfiguredImageModelCase = {
|
||||
id: string;
|
||||
imageModel: AgentModelConfig;
|
||||
@@ -1052,6 +1084,7 @@ describe("gateway server chat", () => {
|
||||
|
||||
const hugeNestedText = "n".repeat(120_000);
|
||||
const oversizedLine = JSON.stringify({
|
||||
id: "msg-huge",
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
@@ -1076,6 +1109,9 @@ describe("gateway server chat", () => {
|
||||
const bytes = Buffer.byteLength(serialized, "utf8");
|
||||
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
|
||||
expect(serialized).toContain("[chat.history omitted: message too large]");
|
||||
expect(messages[0]).toMatchObject({
|
||||
__openclaw: { id: "msg-huge", truncated: true, reason: "oversized" },
|
||||
});
|
||||
expect(serialized.includes(hugeNestedText.slice(0, 256))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1368,6 +1404,198 @@ describe("gateway server chat", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get returns the full projected message for a truncated history row", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
id: "msg-full-assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "abcdefghij" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const historyMessages = await fetchHistoryMessages(ws, { maxChars: 5 });
|
||||
expect(JSON.stringify(historyMessages)).toContain("abcde\\n...(truncated)...");
|
||||
|
||||
const full = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-full-assistant",
|
||||
});
|
||||
expect(full.ok).toBe(true);
|
||||
expect(full.unavailableReason).toBeUndefined();
|
||||
expect(JSON.stringify(full.message)).toContain("abcdefghij");
|
||||
expect(JSON.stringify(full.message)).not.toContain("...(truncated)...");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get accepts the selected agent for global sessions", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
await writeGatewayConfig({
|
||||
session: { scope: "global" },
|
||||
agents: {
|
||||
list: [{ id: "main", default: true }, { id: "work" }],
|
||||
},
|
||||
});
|
||||
await connectOk(ws);
|
||||
const sessionDir = await createSessionDir();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
global: { sessionId: "sess-global", updatedAt: Date.now() },
|
||||
},
|
||||
});
|
||||
await fs.writeFile(
|
||||
path.join(sessionDir, "sess-global.jsonl"),
|
||||
`${JSON.stringify({
|
||||
id: "msg-global-agent",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "global agent content" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const full = await fetchChatMessage(ws, {
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
messageId: "msg-global-agent",
|
||||
});
|
||||
expect(full.ok).toBe(true);
|
||||
expect(JSON.stringify(full.message)).toContain("global agent content");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get reports oversized transcript entries as unavailable", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
const oversizedLine = JSON.stringify({
|
||||
id: "msg-oversized",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "x".repeat(300 * 1024) }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
await writeMainSessionTranscript(sessionDir, [oversizedLine]);
|
||||
|
||||
const full = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-oversized",
|
||||
});
|
||||
expect(full.ok).toBe(false);
|
||||
expect(full.unavailableReason).toBe("oversized");
|
||||
expect(full.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get does not return inactive branch entries", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
id: "msg-root",
|
||||
parentId: null,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "question" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-stale",
|
||||
parentId: "msg-root",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "stale branch" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-active",
|
||||
parentId: "msg-root",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "active branch" }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const stale = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-stale",
|
||||
});
|
||||
expect(stale.ok).toBe(false);
|
||||
expect(stale.unavailableReason).toBe("not_found");
|
||||
|
||||
const active = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-active",
|
||||
});
|
||||
expect(active.ok).toBe(true);
|
||||
expect(JSON.stringify(active.message)).toContain("active branch");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.message.get does not return pre-session announce pairs hidden by history", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
await connectOk(ws);
|
||||
const sessionDir = await createSessionDir();
|
||||
const sessionStartedAt = Date.now();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: { sessionId: "sess-main", updatedAt: Date.now(), sessionStartedAt },
|
||||
},
|
||||
});
|
||||
await writeMainSessionTranscript(sessionDir, [
|
||||
JSON.stringify({
|
||||
id: "msg-announce",
|
||||
message: {
|
||||
role: "user",
|
||||
provenance: { kind: "inter_session", sourceTool: "subagent_announce" },
|
||||
content: [{ type: "text", text: "announce" }],
|
||||
timestamp: sessionStartedAt - 2_000,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-hidden-assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hidden pre-session reply" }],
|
||||
timestamp: sessionStartedAt - 1_000,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
id: "msg-visible-assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "visible reply" }],
|
||||
timestamp: sessionStartedAt + 1_000,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const hidden = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-hidden-assistant",
|
||||
});
|
||||
expect(hidden.ok).toBe(false);
|
||||
expect(hidden.unavailableReason).toBe("not_found");
|
||||
|
||||
const visible = await fetchChatMessage(ws, {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-visible-assistant",
|
||||
});
|
||||
expect(visible.ok).toBe(true);
|
||||
expect(JSON.stringify(visible.message)).toContain("visible reply");
|
||||
});
|
||||
});
|
||||
|
||||
test("chat.history still drops assistant NO_REPLY entries before truncation", async () => {
|
||||
await withGatewayChatHarness(async ({ ws, createSessionDir }) => {
|
||||
const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir });
|
||||
|
||||
@@ -3,6 +3,9 @@ import { StringDecoder } from "node:string_decoder";
|
||||
|
||||
const TRANSCRIPT_INDEX_READ_CHUNK_BYTES = 64 * 1024;
|
||||
const MAX_TRANSCRIPT_INDEX_CACHE_ENTRIES = 256;
|
||||
const MAX_TRANSCRIPT_INDEX_PARSE_LINE_BYTES = 256 * 1024;
|
||||
const OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS = 64 * 1024;
|
||||
const TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER = "[chat.history omitted: message too large]";
|
||||
|
||||
type ParsedTranscriptRecord = Record<string, unknown>;
|
||||
|
||||
@@ -55,6 +58,44 @@ function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function extractJsonStringFieldPrefix(prefix: string, field: string): string | undefined {
|
||||
const match = new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(prefix);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(`"${match[1]}"`) as unknown;
|
||||
return normalizeOptionalString(decoded);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonNullableStringFieldPrefix(
|
||||
prefix: string,
|
||||
field: string,
|
||||
): string | null | undefined {
|
||||
if (new RegExp(`"${escapeRegExp(field)}"\\s*:\\s*null`).test(prefix)) {
|
||||
return null;
|
||||
}
|
||||
return extractJsonStringFieldPrefix(prefix, field);
|
||||
}
|
||||
|
||||
function extractJsonNumberFieldPrefix(prefix: string, field: string): number | undefined {
|
||||
const match = new RegExp(
|
||||
`"${escapeRegExp(field)}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)`,
|
||||
).exec(prefix);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const decoded = Number(match[1]);
|
||||
return Number.isFinite(decoded) ? decoded : undefined;
|
||||
}
|
||||
|
||||
async function yieldTranscriptIndexScan(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
@@ -93,6 +134,41 @@ function isTreeTranscriptRecord(record: ParsedTranscriptRecord): boolean {
|
||||
return record.type !== "session" && typeof record.id === "string" && "parentId" in record;
|
||||
}
|
||||
|
||||
function buildOversizedIndexedRawEntry(params: {
|
||||
line: string;
|
||||
offset: number;
|
||||
byteLength: number;
|
||||
}): IndexedRawEntry | null {
|
||||
const prefix = params.line.slice(0, OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS);
|
||||
const messageMatch = /"message"\s*:/.exec(prefix);
|
||||
const recordPrefix = messageMatch ? prefix.slice(0, messageMatch.index) : prefix;
|
||||
const id = extractJsonStringFieldPrefix(prefix, "id");
|
||||
const parentId = extractJsonNullableStringFieldPrefix(prefix, "parentId");
|
||||
const type = extractJsonStringFieldPrefix(prefix, "type");
|
||||
const timestamp =
|
||||
extractJsonStringFieldPrefix(recordPrefix, "timestamp") ??
|
||||
extractJsonNumberFieldPrefix(recordPrefix, "timestamp");
|
||||
const role = extractJsonStringFieldPrefix(prefix, "role") ?? "assistant";
|
||||
const record: ParsedTranscriptRecord = {
|
||||
...(type ? { type } : {}),
|
||||
...(id ? { id } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
...(timestamp !== undefined ? { timestamp } : {}),
|
||||
message: {
|
||||
role,
|
||||
content: [{ type: "text", text: TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER }],
|
||||
__openclaw: { truncated: true, reason: "oversized" },
|
||||
},
|
||||
};
|
||||
return {
|
||||
...(id ? { id } : {}),
|
||||
...(parentId !== undefined ? { parentId } : {}),
|
||||
offset: params.offset,
|
||||
byteLength: params.byteLength,
|
||||
record,
|
||||
};
|
||||
}
|
||||
|
||||
async function visitTranscriptJsonLines(
|
||||
filePath: string,
|
||||
visit: (line: string, offset: number, byteLength: number) => void,
|
||||
@@ -190,6 +266,21 @@ async function buildSessionTranscriptIndex(
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
if (byteLength > MAX_TRANSCRIPT_INDEX_PARSE_LINE_BYTES) {
|
||||
const rawEntry = buildOversizedIndexedRawEntry({ line, offset, byteLength });
|
||||
if (!rawEntry) {
|
||||
return;
|
||||
}
|
||||
rawEntries.push(rawEntry);
|
||||
if (rawEntry.id) {
|
||||
byId.set(rawEntry.id, rawEntry);
|
||||
if (isTreeTranscriptRecord(rawEntry.record)) {
|
||||
hasTreeEntries = true;
|
||||
leafId = rawEntry.id;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
|
||||
@@ -2107,6 +2107,29 @@ describe("oversized transcript line guards", () => {
|
||||
expect(serialized).not.toContain(oversizedContent);
|
||||
});
|
||||
|
||||
test("readSessionMessagesAsync keeps id-less oversized message placeholders", async () => {
|
||||
const sessionId = "test-oversized-idless-async";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const oversizedContent = "w".repeat(300 * 1024);
|
||||
fs.writeFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
message: { role: "assistant", content: oversizedContent },
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const out = await readSessionMessagesAsync(sessionId, storePath, undefined, {
|
||||
mode: "full",
|
||||
reason: "test",
|
||||
});
|
||||
|
||||
expect(out).toHaveLength(1);
|
||||
const serialized = JSON.stringify(out);
|
||||
expect(serialized).toContain("[chat.history omitted: message too large]");
|
||||
expect(serialized).not.toContain(oversizedContent);
|
||||
});
|
||||
|
||||
test("readSessionTitleFieldsFromTranscriptAsync delegates to bounded sync reader", async () => {
|
||||
const sessionId = "test-async-title-bounded";
|
||||
writeTranscript(
|
||||
|
||||
@@ -588,6 +588,31 @@ export async function readSessionMessagesAsync(
|
||||
return index?.entries.flatMap((entry) => indexedTranscriptEntryToMessages(entry)) ?? [];
|
||||
}
|
||||
|
||||
export async function readSessionMessageByIdAsync(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
sessionFile: string | undefined,
|
||||
messageId: string,
|
||||
): Promise<{ message?: unknown; seq?: number; oversized: boolean; found: boolean }> {
|
||||
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
|
||||
if (!filePath) {
|
||||
return { oversized: false, found: false };
|
||||
}
|
||||
const index = await readSessionTranscriptIndex(filePath);
|
||||
if (!index) {
|
||||
return { oversized: false, found: false };
|
||||
}
|
||||
const entry = index.entries.find((candidate) => candidate.id === messageId);
|
||||
if (!entry) {
|
||||
return { oversized: false, found: false };
|
||||
}
|
||||
if (entry.byteLength > MAX_TRANSCRIPT_PARSE_LINE_BYTES) {
|
||||
return { oversized: true, found: true, seq: entry.seq };
|
||||
}
|
||||
const message = indexedTranscriptEntryToMessage(entry);
|
||||
return { message, seq: entry.seq, oversized: false, found: true };
|
||||
}
|
||||
|
||||
export async function visitSessionMessagesAsync(
|
||||
sessionId: string,
|
||||
storePath: string | undefined,
|
||||
|
||||
@@ -116,6 +116,7 @@ export {
|
||||
readRecentSessionMessagesWithStatsAsync,
|
||||
readRecentSessionTranscriptLines,
|
||||
readRecentSessionUsageFromTranscript,
|
||||
readSessionMessageByIdAsync,
|
||||
readSessionMessageCountAsync,
|
||||
readSessionTitleFieldsFromTranscript,
|
||||
readSessionTitleFieldsFromTranscriptAsync,
|
||||
|
||||
@@ -3008,6 +3008,7 @@ export function renderApp(state: AppViewState) {
|
||||
},
|
||||
agentsList: state.agentsList,
|
||||
currentAgentId: resolvedAgentId ?? "main",
|
||||
fullMessageAgentId: scopedAgentParamsForSession(state, state.sessionKey).agentId,
|
||||
onAgentChange: (agentId: string) => {
|
||||
switchChatSession(state, buildAgentMainSessionKey({ agentId }));
|
||||
},
|
||||
|
||||
81
ui/src/ui/app-sidebar-full-message.test.ts
Normal file
81
ui/src/ui/app-sidebar-full-message.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SidebarContent } from "./sidebar-content.ts";
|
||||
|
||||
describe("OpenClawApp full-message sidebar upgrade", () => {
|
||||
it("uses string content returned by chat.message.get", async () => {
|
||||
const { OpenClawApp } = await import("./app.ts");
|
||||
const content: SidebarContent = {
|
||||
kind: "markdown",
|
||||
content: "short\n...(truncated)...",
|
||||
fullMessageRequest: {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-1",
|
||||
kind: "assistant_message",
|
||||
},
|
||||
};
|
||||
const request = vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: { role: "assistant", content: "full assistant text" },
|
||||
}));
|
||||
const app = new OpenClawApp();
|
||||
app.client = { request } as never;
|
||||
|
||||
app.handleOpenSidebar(content);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(request).toHaveBeenCalledWith("chat.message.get", {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-1",
|
||||
maxChars: 500_000,
|
||||
});
|
||||
expect(app.sidebarContent).toMatchObject({
|
||||
kind: "markdown",
|
||||
content: "full assistant text",
|
||||
rawText: "full assistant text",
|
||||
unavailableReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("updates canvas raw text from chat.message.get", async () => {
|
||||
const { OpenClawApp } = await import("./app.ts");
|
||||
const content: SidebarContent = {
|
||||
kind: "canvas",
|
||||
docId: "preview-1",
|
||||
entryUrl: "https://example.test/preview",
|
||||
rawText: "short\n...(truncated)...",
|
||||
fullMessageRequest: {
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
messageId: "msg-2",
|
||||
kind: "tool_output",
|
||||
},
|
||||
};
|
||||
const request = vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: { role: "assistant", text: "full canvas raw text" },
|
||||
}));
|
||||
const app = new OpenClawApp();
|
||||
app.client = { request } as never;
|
||||
|
||||
app.handleOpenSidebar(content);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(request).toHaveBeenCalledWith("chat.message.get", {
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
messageId: "msg-2",
|
||||
maxChars: 500_000,
|
||||
});
|
||||
expect(app.sidebarContent).toMatchObject({
|
||||
kind: "canvas",
|
||||
docId: "preview-1",
|
||||
entryUrl: "https://example.test/preview",
|
||||
rawText: "full canvas raw text",
|
||||
unavailableReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
105
ui/src/ui/app.ts
105
ui/src/ui/app.ts
@@ -152,6 +152,25 @@ declare global {
|
||||
|
||||
const bootAssistantIdentity = normalizeAssistantIdentity({});
|
||||
const bootLocalUserIdentity = loadLocalUserIdentity();
|
||||
const FULL_MESSAGE_SIDEBAR_MAX_CHARS = 500_000;
|
||||
|
||||
function isSidebarMarkdownLike(content: SidebarContent | null): content is SidebarContent {
|
||||
return Boolean(content && (content.kind === "markdown" || content.kind === "canvas"));
|
||||
}
|
||||
|
||||
function resolveSidebarUnavailableReason(
|
||||
reason: "not_found" | "oversized" | "not_visible" | null | undefined,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case "oversized":
|
||||
return "Full content is unavailable because the stored transcript entry is too large to return safely.";
|
||||
case "not_visible":
|
||||
return "Full content is unavailable because this transcript entry does not have a visible WebChat projection.";
|
||||
case "not_found":
|
||||
default:
|
||||
return "Full content is no longer available for this transcript entry.";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOnboardingMode(): boolean {
|
||||
if (!window.location.search) {
|
||||
@@ -1289,6 +1308,89 @@ export class OpenClawApp extends LitElement {
|
||||
this.pendingGatewayToken = null;
|
||||
}
|
||||
|
||||
private async maybeUpgradeSidebarToFullMessage(content: SidebarContent) {
|
||||
const request = content.fullMessageRequest;
|
||||
if (!request || !this.client) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = (await this.client.request("chat.message.get", {
|
||||
sessionKey: request.sessionKey,
|
||||
...(request.agentId ? { agentId: request.agentId } : {}),
|
||||
messageId: request.messageId,
|
||||
maxChars: FULL_MESSAGE_SIDEBAR_MAX_CHARS,
|
||||
})) as
|
||||
| {
|
||||
ok?: boolean;
|
||||
message?: unknown;
|
||||
unavailableReason?: "not_found" | "oversized" | "not_visible";
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (this.sidebarContent !== content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result?.ok || !result.message || typeof result.message !== "object") {
|
||||
this.sidebarContent = {
|
||||
...content,
|
||||
unavailableReason: result?.unavailableReason ?? "not_found",
|
||||
};
|
||||
this.sidebarError = resolveSidebarUnavailableReason(
|
||||
result?.unavailableReason ?? "not_found",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = result.message as Record<string, unknown>;
|
||||
const fetchedMessageText =
|
||||
typeof message.text === "string"
|
||||
? message.text
|
||||
: typeof message.content === "string"
|
||||
? message.content
|
||||
: Array.isArray(message.content)
|
||||
? message.content
|
||||
.map((block) =>
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
typeof (block as { text?: unknown }).text === "string"
|
||||
? (block as { text: string }).text
|
||||
: null,
|
||||
)
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.join("\n")
|
||||
: null;
|
||||
const nextRawText =
|
||||
fetchedMessageText ??
|
||||
(typeof content.rawText === "string"
|
||||
? content.rawText
|
||||
: content.kind === "markdown"
|
||||
? content.content
|
||||
: null);
|
||||
|
||||
if (content.kind === "markdown") {
|
||||
this.sidebarContent = {
|
||||
...content,
|
||||
content: nextRawText || content.content,
|
||||
rawText: nextRawText || content.rawText || content.content,
|
||||
unavailableReason: null,
|
||||
};
|
||||
} else {
|
||||
this.sidebarContent = {
|
||||
...content,
|
||||
rawText: nextRawText || content.rawText || null,
|
||||
unavailableReason: null,
|
||||
};
|
||||
}
|
||||
this.sidebarError = null;
|
||||
} catch (err) {
|
||||
if (this.sidebarContent !== content) {
|
||||
return;
|
||||
}
|
||||
this.sidebarError = `Failed to load full content: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar handlers for tool output viewing
|
||||
handleOpenSidebar(content: SidebarContent) {
|
||||
if (this.sidebarCloseTimer != null) {
|
||||
@@ -1298,6 +1400,9 @@ export class OpenClawApp extends LitElement {
|
||||
this.sidebarContent = content;
|
||||
this.sidebarError = null;
|
||||
this.sidebarOpen = true;
|
||||
if (isSidebarMarkdownLike(content) && content.fullMessageRequest) {
|
||||
void this.maybeUpgradeSidebarToFullMessage(content);
|
||||
}
|
||||
}
|
||||
|
||||
handleCloseSidebar() {
|
||||
|
||||
@@ -17,12 +17,15 @@ export function buildRawSidebarContent(
|
||||
kind: "markdown",
|
||||
content: toPlainTextCodeFence(rawText),
|
||||
rawText,
|
||||
...(content.unavailableReason ? { unavailableReason: content.unavailableReason } : {}),
|
||||
};
|
||||
}
|
||||
if (content.rawText?.trim()) {
|
||||
return {
|
||||
kind: "markdown",
|
||||
content: toPlainTextCodeFence(content.rawText, "json"),
|
||||
rawText: content.rawText,
|
||||
...(content.unavailableReason ? { unavailableReason: content.unavailableReason } : {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -2210,4 +2210,88 @@ describe("grouped chat rendering", () => {
|
||||
expect(onOpenSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(requireFirstMockArg(onOpenSidebar, "sidebar open").kind).toBe("markdown");
|
||||
});
|
||||
|
||||
it("adds a full-message request when opening a truncated assistant message", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "abcde\n...(truncated)..." }],
|
||||
__openclaw: { id: "msg-truncated-1", seq: 1 },
|
||||
},
|
||||
{
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
onOpenSidebar,
|
||||
},
|
||||
);
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(".chat-expand-btn");
|
||||
expect(expandButton).toBeInstanceOf(HTMLButtonElement);
|
||||
expandButton!.click();
|
||||
|
||||
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
|
||||
expect(sidebar.kind).toBe("markdown");
|
||||
expect(sidebar.fullMessageRequest).toEqual({
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
messageId: "msg-truncated-1",
|
||||
kind: "assistant_message",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add a full-message request for non-truncated assistant messages", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "full visible message" }],
|
||||
__openclaw: { id: "msg-visible-1", seq: 1 },
|
||||
},
|
||||
{
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
onOpenSidebar,
|
||||
},
|
||||
);
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(".chat-expand-btn");
|
||||
expect(expandButton).toBeInstanceOf(HTMLButtonElement);
|
||||
expandButton!.click();
|
||||
|
||||
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
|
||||
expect(sidebar.kind).toBe("markdown");
|
||||
expect(sidebar.fullMessageRequest).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not add a full-message request for mirrored message-tool replies", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "mirrored text\n...(truncated)..." }],
|
||||
openclawMessageToolMirror: { toolName: "message", toolCallId: "call-1" },
|
||||
__openclaw: { id: "msg-tool-result", seq: 2, truncated: true },
|
||||
},
|
||||
{
|
||||
sessionKey: "global",
|
||||
agentId: "work",
|
||||
onOpenSidebar,
|
||||
},
|
||||
);
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(".chat-expand-btn");
|
||||
expect(expandButton).toBeInstanceOf(HTMLButtonElement);
|
||||
expandButton!.click();
|
||||
|
||||
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
|
||||
expect(sidebar.kind).toBe("markdown");
|
||||
expect(sidebar.fullMessageRequest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,6 +382,8 @@ export function renderMessageGroup(
|
||||
group: MessageGroup,
|
||||
opts: {
|
||||
onOpenSidebar?: (content: SidebarContent) => void;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
showReasoning: boolean;
|
||||
showToolCalls?: boolean;
|
||||
autoExpandToolCalls?: boolean;
|
||||
@@ -453,6 +455,8 @@ export function renderMessageGroup(
|
||||
item.key,
|
||||
{
|
||||
isStreaming: group.isStreaming && index === group.messages.length - 1,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
duplicateCount: item.duplicateCount ?? 1,
|
||||
showReasoning: opts.showReasoning,
|
||||
showToolCalls: opts.showToolCalls ?? true,
|
||||
@@ -1349,6 +1353,8 @@ function renderInlineToolCards(
|
||||
toolCards: ToolCard[],
|
||||
opts: {
|
||||
messageKey: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
onOpenSidebar?: (content: SidebarContent) => void;
|
||||
isToolExpanded?: (toolCardId: string) => boolean;
|
||||
onToggleToolExpanded?: (toolCardId: string) => void;
|
||||
@@ -1365,6 +1371,8 @@ function renderInlineToolCards(
|
||||
onToggleExpanded: opts.onToggleToolExpanded
|
||||
? () => opts.onToggleToolExpanded?.(`${opts.messageKey}:toolcard:${index}`)
|
||||
: () => undefined,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
onOpenSidebar: opts.onOpenSidebar,
|
||||
canvasPluginSurfaceUrl: opts.canvasPluginSurfaceUrl,
|
||||
embedSandboxMode: opts.embedSandboxMode ?? "scripts",
|
||||
@@ -1420,14 +1428,36 @@ function jsonSummaryLabel(parsed: unknown): string {
|
||||
return "JSON";
|
||||
}
|
||||
|
||||
function renderExpandButton(markdown: string, onOpenSidebar: (content: SidebarContent) => void) {
|
||||
function renderExpandButton(
|
||||
markdown: string,
|
||||
onOpenSidebar: (content: SidebarContent) => void,
|
||||
options?: {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
},
|
||||
) {
|
||||
return html`
|
||||
<button
|
||||
class="btn btn--xs chat-expand-btn"
|
||||
type="button"
|
||||
title="Open in canvas"
|
||||
aria-label="Open in canvas"
|
||||
@click=${() => onOpenSidebar({ kind: "markdown", content: markdown })}
|
||||
@click=${() =>
|
||||
onOpenSidebar({
|
||||
kind: "markdown",
|
||||
content: markdown,
|
||||
...(options?.sessionKey && options?.messageId
|
||||
? {
|
||||
fullMessageRequest: {
|
||||
sessionKey: options.sessionKey,
|
||||
...(options.agentId ? { agentId: options.agentId } : {}),
|
||||
messageId: options.messageId,
|
||||
kind: "assistant_message" as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})}
|
||||
>
|
||||
<span class="chat-expand-btn__icon" aria-hidden="true">${icons.panelRightOpen}</span>
|
||||
</button>
|
||||
@@ -1439,6 +1469,8 @@ function renderGroupedMessage(
|
||||
messageKey: string,
|
||||
opts: {
|
||||
isStreaming: boolean;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
duplicateCount?: number;
|
||||
showReasoning: boolean;
|
||||
showToolCalls?: boolean;
|
||||
@@ -1504,6 +1536,21 @@ function renderGroupedMessage(
|
||||
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||
const canExpand = role === "assistant" && Boolean(onOpenSidebar && markdown?.trim());
|
||||
const hasActions = canCopyMarkdown || canExpand;
|
||||
const transcriptMeta =
|
||||
m["__openclaw"] && typeof m["__openclaw"] === "object" && !Array.isArray(m["__openclaw"])
|
||||
? (m["__openclaw"] as Record<string, unknown>)
|
||||
: null;
|
||||
const sidebarMessageId =
|
||||
typeof transcriptMeta?.id === "string"
|
||||
? transcriptMeta.id
|
||||
: typeof m.messageId === "string"
|
||||
? m.messageId
|
||||
: undefined;
|
||||
const shouldFetchFullMessage = Boolean(
|
||||
sidebarMessageId &&
|
||||
!m.openclawMessageToolMirror &&
|
||||
(transcriptMeta?.truncated === true || markdown?.includes("\n...(truncated)...")),
|
||||
);
|
||||
|
||||
// Detect pure-JSON messages and render as collapsible block
|
||||
const jsonResult = markdown && !opts.isStreaming ? detectJson(markdown) : null;
|
||||
@@ -1582,7 +1629,13 @@ function renderGroupedMessage(
|
||||
${renderReplyPill(normalizedMessage.replyTarget)}
|
||||
${hasActions
|
||||
? html`<div class="chat-bubble-actions">
|
||||
${canExpand ? renderExpandButton(markdown!, onOpenSidebar!) : nothing}
|
||||
${canExpand
|
||||
? renderExpandButton(markdown!, onOpenSidebar!, {
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
messageId: shouldFetchFullMessage ? sidebarMessageId : undefined,
|
||||
})
|
||||
: nothing}
|
||||
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||
</div>`
|
||||
: nothing}
|
||||
@@ -1656,6 +1709,7 @@ function renderGroupedMessage(
|
||||
? singleToolCard && !markdown && !hasImages
|
||||
? renderExpandedToolCardContent(
|
||||
singleToolCard,
|
||||
opts.sessionKey,
|
||||
onOpenSidebar,
|
||||
opts.canvasPluginSurfaceUrl,
|
||||
opts.embedSandboxMode ?? "scripts",
|
||||
@@ -1663,6 +1717,8 @@ function renderGroupedMessage(
|
||||
)
|
||||
: renderInlineToolCards(toolCards, {
|
||||
messageKey,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
onOpenSidebar,
|
||||
isToolExpanded: opts.isToolExpanded,
|
||||
onToggleToolExpanded: opts.onToggleToolExpanded,
|
||||
@@ -1717,6 +1773,8 @@ function renderGroupedMessage(
|
||||
${hasToolCards
|
||||
? renderInlineToolCards(toolCards, {
|
||||
messageKey,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
onOpenSidebar,
|
||||
isToolExpanded: opts.isToolExpanded,
|
||||
onToggleToolExpanded: opts.onToggleToolExpanded,
|
||||
|
||||
@@ -294,6 +294,21 @@ with Example Deck
|
||||
expect(card?.preview?.preferredHeight).toBe(420);
|
||||
});
|
||||
|
||||
it("uses transcript metadata ids for history-backed tool messages", () => {
|
||||
const [card] = extractToolCards(
|
||||
{
|
||||
role: "tool",
|
||||
toolName: "browser.open",
|
||||
content: [{ type: "text", text: "Opened page" }],
|
||||
__openclaw: { id: "msg-tool-history-1", seq: 7 },
|
||||
},
|
||||
"msg:history",
|
||||
);
|
||||
|
||||
expect(card?.messageId).toBe("msg-tool-history-1");
|
||||
expect(card?.outputText).toBe("Opened page");
|
||||
});
|
||||
|
||||
it("does not create previews for non-assistant canvas or generic outputs", () => {
|
||||
const cases = [
|
||||
{
|
||||
|
||||
@@ -313,7 +313,6 @@ describe("tool-cards", () => {
|
||||
expect(sidebar.docId).toBe("cv_sidebar");
|
||||
expect(sidebar.entryUrl).toBe("/__openclaw__/canvas/documents/cv_sidebar/index.html");
|
||||
});
|
||||
|
||||
describe("isToolErrorOutput", () => {
|
||||
it("flags JSON payloads that carry a top-level error string", () => {
|
||||
expect(
|
||||
@@ -577,4 +576,34 @@ describe("tool-cards", () => {
|
||||
expect(container.querySelector(".chat-tool-msg-summary--error")).toBeNull();
|
||||
expect(container.querySelector(".chat-tool-card__status-badge")).toBeNull();
|
||||
});
|
||||
it("does not add a full-message request for ambiguous tool details", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:tool:full",
|
||||
name: "browser.open",
|
||||
outputText: "Opened page",
|
||||
messageId: "msg-tool-full",
|
||||
},
|
||||
{
|
||||
expanded: true,
|
||||
sessionKey: "main",
|
||||
agentId: "work",
|
||||
onToggleExpanded: vi.fn(),
|
||||
onOpenSidebar,
|
||||
},
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const sidebarButton = container.querySelector<HTMLButtonElement>(".chat-tool-card__action-btn");
|
||||
expect(sidebarButton).toBeInstanceOf(HTMLButtonElement);
|
||||
sidebarButton!.click();
|
||||
|
||||
const sidebar = requireFirstMockArg(onOpenSidebar, "sidebar open");
|
||||
expect(sidebar.kind).toBe("markdown");
|
||||
expect(sidebar.fullMessageRequest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,10 +12,26 @@ import { formatToolOutputForSidebar, getTruncatedPreview } from "./tool-helpers.
|
||||
|
||||
export type ToolPreview = NonNullable<ToolCard["preview"]>;
|
||||
|
||||
type FullMessageRequest = NonNullable<SidebarContent["fullMessageRequest"]>;
|
||||
|
||||
function resolveCanvasPreviewSandbox(preview: ToolPreview): string {
|
||||
return resolveEmbedSandbox(preview.kind === "canvas" ? "scripts" : "scripts");
|
||||
}
|
||||
|
||||
function resolveTranscriptMessageId(message: Record<string, unknown>): string | undefined {
|
||||
if (typeof message.messageId === "string" && message.messageId.trim()) {
|
||||
return message.messageId;
|
||||
}
|
||||
const openClawMeta = message["__openclaw"];
|
||||
const transcriptMeta =
|
||||
openClawMeta && typeof openClawMeta === "object" && !Array.isArray(openClawMeta)
|
||||
? (openClawMeta as Record<string, unknown>)
|
||||
: null;
|
||||
return typeof transcriptMeta?.id === "string" && transcriptMeta.id.trim()
|
||||
? transcriptMeta.id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeContent(content: unknown): Array<Record<string, unknown>> {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
@@ -239,6 +255,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
const content = normalizeContent(m.content);
|
||||
const messageIsError = readToolErrorFlag(m);
|
||||
const cards: ToolCard[] = [];
|
||||
const transcriptMessageId = resolveTranscriptMessageId(m);
|
||||
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
const item = content[index] ?? {};
|
||||
@@ -254,6 +271,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
name: typeof item.name === "string" ? item.name : "tool",
|
||||
args,
|
||||
inputText: serializeToolInput(args),
|
||||
messageId: transcriptMessageId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -277,6 +295,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
id: cardId,
|
||||
name,
|
||||
outputText: text,
|
||||
messageId: transcriptMessageId,
|
||||
...(isError !== undefined ? { isError } : {}),
|
||||
preview,
|
||||
});
|
||||
@@ -301,6 +320,7 @@ export function extractToolCards(message: unknown, prefix = "tool"): ToolCard[]
|
||||
id: resolveToolCardId({}, m, 0, prefix),
|
||||
name,
|
||||
outputText: text,
|
||||
messageId: transcriptMessageId,
|
||||
...(messageIsError !== undefined ? { isError: messageIsError } : {}),
|
||||
preview: extractToolPreview(text, name),
|
||||
});
|
||||
@@ -416,18 +436,23 @@ export function renderToolPreview(
|
||||
|
||||
export function buildSidebarContent(
|
||||
value: string,
|
||||
options?: { rawText?: string | null },
|
||||
options?: {
|
||||
rawText?: string | null;
|
||||
fullMessageRequest?: FullMessageRequest;
|
||||
},
|
||||
): SidebarContent {
|
||||
return {
|
||||
kind: "markdown",
|
||||
content: value,
|
||||
...(options?.rawText ? { rawText: options.rawText } : {}),
|
||||
...(options?.fullMessageRequest ? { fullMessageRequest: options.fullMessageRequest } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPreviewSidebarContent(
|
||||
preview: ToolPreview,
|
||||
rawText?: string | null,
|
||||
options?: { fullMessageRequest?: FullMessageRequest },
|
||||
): SidebarContent | null {
|
||||
if (preview.kind !== "canvas" || preview.render !== "url" || !preview.viewId || !preview.url) {
|
||||
return null;
|
||||
@@ -439,9 +464,22 @@ export function buildPreviewSidebarContent(
|
||||
...(preview.title ? { title: preview.title } : {}),
|
||||
...(preview.preferredHeight ? { preferredHeight: preview.preferredHeight } : {}),
|
||||
...(rawText ? { rawText } : {}),
|
||||
...(options?.fullMessageRequest ? { fullMessageRequest: options.fullMessageRequest } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildToolSidebarFullMessageRequest(
|
||||
card: ToolCard,
|
||||
sessionKey: string | undefined,
|
||||
): FullMessageRequest | undefined {
|
||||
if (!sessionKey || !card.messageId) {
|
||||
return undefined;
|
||||
}
|
||||
// A transcript entry can contain multiple tool blocks. Until the request can
|
||||
// identify a specific block, upgrading by message id can show the wrong tool.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function renderRawOutputToggle(text: string) {
|
||||
return html`
|
||||
<div class="chat-tool-card__raw">
|
||||
@@ -538,6 +576,8 @@ export function renderToolCard(
|
||||
opts: {
|
||||
expanded: boolean;
|
||||
onToggleExpanded: (id: string) => void;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
onOpenSidebar?: (content: SidebarContent) => void;
|
||||
canvasPluginSurfaceUrl?: string | null;
|
||||
embedSandboxMode?: EmbedSandboxMode;
|
||||
@@ -570,6 +610,7 @@ export function renderToolCard(
|
||||
<div class="chat-tool-msg-body">
|
||||
${renderExpandedToolCardContent(
|
||||
card,
|
||||
opts.sessionKey,
|
||||
opts.onOpenSidebar,
|
||||
opts.canvasPluginSurfaceUrl,
|
||||
opts.embedSandboxMode ?? "scripts",
|
||||
@@ -584,6 +625,7 @@ export function renderToolCard(
|
||||
|
||||
export function renderExpandedToolCardContent(
|
||||
card: ToolCard,
|
||||
sessionKey?: string,
|
||||
onOpenSidebar?: (content: SidebarContent) => void,
|
||||
canvasPluginSurfaceUrl?: string | null,
|
||||
embedSandboxMode: EmbedSandboxMode = "scripts",
|
||||
@@ -595,12 +637,17 @@ export function renderExpandedToolCardContent(
|
||||
const hasInput = Boolean(card.inputText?.trim());
|
||||
const isError = isToolCardError(card);
|
||||
const canOpenSidebar = Boolean(onOpenSidebar);
|
||||
const fullMessageRequest = buildToolSidebarFullMessageRequest(card, sessionKey);
|
||||
const previewSidebarContent =
|
||||
card.preview?.kind === "canvas"
|
||||
? buildPreviewSidebarContent(card.preview, card.outputText)
|
||||
? buildPreviewSidebarContent(card.preview, card.outputText, { fullMessageRequest })
|
||||
: null;
|
||||
const sidebarActionContent =
|
||||
previewSidebarContent ?? buildSidebarContent(buildToolCardSidebarContent(card));
|
||||
previewSidebarContent ??
|
||||
buildSidebarContent(buildToolCardSidebarContent(card), {
|
||||
fullMessageRequest,
|
||||
rawText: card.outputText ?? null,
|
||||
});
|
||||
const visiblePreview = card.preview
|
||||
? renderToolPreview(card.preview, "chat_tool", {
|
||||
onOpenSidebar,
|
||||
@@ -665,6 +712,7 @@ export function renderToolCardSidebar(
|
||||
onOpenSidebar?: (content: SidebarContent) => void,
|
||||
canvasPluginSurfaceUrl?: string | null,
|
||||
embedSandboxMode: EmbedSandboxMode = "scripts",
|
||||
options?: { sessionKey?: string; agentId?: string },
|
||||
) {
|
||||
const display = resolveToolDisplay({ name: card.name, args: card.args });
|
||||
const detail = formatToolDetail(display);
|
||||
@@ -672,11 +720,20 @@ export function renderToolCardSidebar(
|
||||
const hasText = Boolean(card.outputText?.trim());
|
||||
const hasPreview = Boolean(preview);
|
||||
const isError = isToolCardError(card);
|
||||
const fullMessageRequest = buildToolSidebarFullMessageRequest(card, options?.sessionKey);
|
||||
const sidebarContent =
|
||||
preview?.kind === "canvas"
|
||||
? buildPreviewSidebarContent(preview, card.outputText)
|
||||
: buildSidebarContent(buildToolCardSidebarContent(card));
|
||||
const actionContent = sidebarContent ?? buildSidebarContent(buildToolCardSidebarContent(card));
|
||||
? buildPreviewSidebarContent(preview, card.outputText, { fullMessageRequest })
|
||||
: buildSidebarContent(buildToolCardSidebarContent(card), {
|
||||
fullMessageRequest,
|
||||
rawText: card.outputText ?? null,
|
||||
});
|
||||
const actionContent =
|
||||
sidebarContent ??
|
||||
buildSidebarContent(buildToolCardSidebarContent(card), {
|
||||
fullMessageRequest,
|
||||
rawText: card.outputText ?? null,
|
||||
});
|
||||
const canClick = Boolean(onOpenSidebar);
|
||||
const handleClick = canClick ? () => onOpenSidebar?.(actionContent) : undefined;
|
||||
const isShort = hasText && !hasPreview && (card.outputText?.length ?? 0) <= 240;
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
export type SidebarFullMessageRequest = {
|
||||
sessionKey: string;
|
||||
agentId?: string;
|
||||
messageId: string;
|
||||
kind: "assistant_message" | "tool_output";
|
||||
};
|
||||
|
||||
export type MarkdownSidebarContent = {
|
||||
kind: "markdown";
|
||||
content: string;
|
||||
rawText?: string | null;
|
||||
fullMessageRequest?: SidebarFullMessageRequest;
|
||||
unavailableReason?: "not_found" | "oversized" | "not_visible" | null;
|
||||
};
|
||||
|
||||
export type CanvasSidebarContent = {
|
||||
@@ -11,6 +20,8 @@ export type CanvasSidebarContent = {
|
||||
entryUrl: string;
|
||||
preferredHeight?: number;
|
||||
rawText?: string | null;
|
||||
fullMessageRequest?: SidebarFullMessageRequest;
|
||||
unavailableReason?: "not_found" | "oversized" | "not_visible" | null;
|
||||
};
|
||||
|
||||
export type SidebarContent = MarkdownSidebarContent | CanvasSidebarContent;
|
||||
|
||||
@@ -78,6 +78,7 @@ export type ToolCard = {
|
||||
inputText?: string;
|
||||
outputText?: string;
|
||||
isError?: boolean;
|
||||
messageId?: string;
|
||||
preview?: {
|
||||
kind: "canvas";
|
||||
surface: "assistant_message";
|
||||
|
||||
@@ -1248,6 +1248,25 @@ describe("chat sidebar raw content", () => {
|
||||
rawText: rawMarkdown,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not carry full-message requests into raw views", () => {
|
||||
const raw = buildRawSidebarContent({
|
||||
kind: "markdown",
|
||||
content: "Rendered",
|
||||
rawText: "Raw",
|
||||
fullMessageRequest: {
|
||||
sessionKey: "main",
|
||||
messageId: "msg-raw",
|
||||
kind: "assistant_message",
|
||||
},
|
||||
});
|
||||
|
||||
expect(raw).toEqual({
|
||||
kind: "markdown",
|
||||
content: "```\nRaw\n```",
|
||||
rawText: "Raw",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat welcome", () => {
|
||||
|
||||
@@ -165,6 +165,7 @@ export type ChatProps = {
|
||||
defaultId?: string;
|
||||
} | null;
|
||||
currentAgentId: string;
|
||||
fullMessageAgentId?: string;
|
||||
onAgentChange: (agentId: string) => void;
|
||||
onNavigateToAgent?: () => void;
|
||||
onSessionSelect?: (sessionKey: string) => void;
|
||||
@@ -1262,6 +1263,8 @@ export function renderChat(props: ChatProps) {
|
||||
}
|
||||
return renderMessageGroup(item, {
|
||||
onOpenSidebar: props.onOpenSidebar,
|
||||
sessionKey: props.sessionKey,
|
||||
agentId: props.fullMessageAgentId,
|
||||
showReasoning,
|
||||
showToolCalls: props.showToolCalls,
|
||||
autoExpandToolCalls: Boolean(props.autoExpandToolCalls),
|
||||
|
||||
@@ -49,14 +49,18 @@ export function renderMarkdownSidebar(props: MarkdownSidebarProps) {
|
||||
${props.error
|
||||
? html`
|
||||
<div class="callout danger">${props.error}</div>
|
||||
<button
|
||||
@click=${props.onViewRawText}
|
||||
class="btn"
|
||||
type="button"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
View Raw Text
|
||||
</button>
|
||||
${content?.rawText?.trim()
|
||||
? html`
|
||||
<button
|
||||
@click=${props.onViewRawText}
|
||||
class="btn"
|
||||
type="button"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
View Raw Text
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: content
|
||||
? content.kind === "canvas"
|
||||
|
||||
Reference in New Issue
Block a user