Archived
Render skill reads as chat bubbles
This commit is contained in:
@@ -30,6 +30,17 @@ describe("groupChatMessages", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps skill reads out of event groups", () => {
|
||||||
|
const messages: ChatLine[] = [
|
||||||
|
{ role: "assistant", parts: [{ type: "thinking", text: "plan" }, { type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(groupChatMessages(messages)).toEqual([
|
||||||
|
{ kind: "group", startIndex: 0, messages: [{ role: "assistant", parts: [{ type: "thinking", text: "plan" }] }] },
|
||||||
|
{ kind: "message", index: 0, message: { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves message metadata when grouping", () => {
|
it("preserves message metadata when grouping", () => {
|
||||||
const message: ChatLine = { role: "assistant", parts: [{ type: "thinking", text: "hidden" }, { type: "text", text: "shown" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "test", id: "model" } } };
|
const message: ChatLine = { role: "assistant", parts: [{ type: "thinking", text: "hidden" }, { type: "text", text: "shown" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "test", id: "model" } } };
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export function groupChatMessages(messages: ChatLine[], indexOffset = 0): ChatGr
|
|||||||
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts, ...metadata }, absoluteIndex);
|
if (technicalParts.length) pushEvent({ role: message.role, parts: technicalParts, ...metadata }, absoluteIndex);
|
||||||
if (readableParts.length) {
|
if (readableParts.length) {
|
||||||
flushEvents();
|
flushEvents();
|
||||||
groups.push({ kind: "message", message: { role: message.role, parts: readableParts, ...metadata }, index: absoluteIndex });
|
const role = readableParts.every((part) => part.type === "skillRead") ? "skill" : message.role;
|
||||||
|
groups.push({ kind: "message", message: { role, parts: readableParts, ...metadata }, index: absoluteIndex });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
flushEvents();
|
flushEvents();
|
||||||
@@ -48,6 +49,6 @@ export function summarizeChatGroup(messages: ChatLine[]): string {
|
|||||||
|
|
||||||
function isReadablePart(message: ChatLine, part: ChatPart): boolean {
|
function isReadablePart(message: ChatLine, part: ChatPart): boolean {
|
||||||
if (message.source === "compaction" || message.source === "branch_summary") return false;
|
if (message.source === "compaction" || message.source === "branch_summary") return false;
|
||||||
if (part.type === "skillInvocation") return true;
|
if (part.type === "skillInvocation" || part.type === "skillRead") return true;
|
||||||
return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash");
|
return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ describe("chat message normalization", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes skill reads into skill chat lines", () => {
|
||||||
|
expect(normalizeMessage({ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "/home/user/.agents/skills/playwright/SKILL.md" } }] })).toEqual([
|
||||||
|
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/home/user/.agents/skills/playwright/SKILL.md" }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("formats bash execution records as bash chat lines", () => {
|
it("formats bash execution records as bash chat lines", () => {
|
||||||
expect(normalizeMessage({
|
expect(normalizeMessage({
|
||||||
role: "bashExecution",
|
role: "bashExecution",
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ export function normalizeMessage(message: unknown): ChatLine[] {
|
|||||||
if (role === "tool") return [withMessageMeta({ role, parts, ...(source === undefined ? {} : { source }) }, message)];
|
if (role === "tool") return [withMessageMeta({ role, parts, ...(source === undefined ? {} : { source }) }, message)];
|
||||||
|
|
||||||
const visible = parts.filter((part) => part.type !== "empty");
|
const visible = parts.filter((part) => part.type !== "empty");
|
||||||
return visible.length > 0 ? [withMessageMeta({ role, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : [];
|
const displayRole = role === "assistant" && visible.length > 0 && visible.every((part) => part.type === "skillRead") ? "skill" : role;
|
||||||
|
return visible.length > 0 ? [withMessageMeta({ role: displayRole, parts: visible, ...(source === undefined ? {} : { source }) }, message)] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSkillInvocation(parts: ChatPart[]): ChatLine[] | undefined {
|
function normalizeSkillInvocation(parts: ChatPart[]): ChatLine[] | undefined {
|
||||||
@@ -142,7 +143,13 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
|
|||||||
const thinking = getString(part, "thinking") ?? text;
|
const thinking = getString(part, "thinking") ?? text;
|
||||||
return thinking !== undefined && thinking !== "" ? [{ type: "thinking", text: thinking }] : [];
|
return thinking !== undefined && thinking !== "" ? [{ type: "thinking", text: thinking }] : [];
|
||||||
}
|
}
|
||||||
if (type === "toolCall") return [{ type: "toolCall", toolName: getString(part, "name") ?? "tool", summary: summarizeArgs(getProperty(part, "arguments")) }];
|
if (type === "toolCall") {
|
||||||
|
const toolName = getString(part, "name") ?? "tool";
|
||||||
|
const args = getProperty(part, "arguments");
|
||||||
|
const skillRead = toolName === "read" ? parseSkillReadPath(getString(args, "path")) : undefined;
|
||||||
|
if (skillRead !== undefined) return [{ type: "skillRead", ...skillRead }];
|
||||||
|
return [{ type: "toolCall", toolName, summary: summarizeArgs(args) }];
|
||||||
|
}
|
||||||
if (type === "image") return [{ type: "text", text: "[image]" }];
|
if (type === "image") return [{ type: "text", text: "[image]" }];
|
||||||
return objectFallback(part);
|
return objectFallback(part);
|
||||||
}).map((part) => part.type === "text" && getString(message, "role") === "toolResult"
|
}).map((part) => part.type === "text" && getString(message, "role") === "toolResult"
|
||||||
@@ -150,6 +157,15 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
|
|||||||
: part);
|
: part);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseSkillReadPath(path: string | undefined): { name: string; path: string } | undefined {
|
||||||
|
if (path === undefined || path === "") return undefined;
|
||||||
|
const normalized = path.replace(/\\/g, "/");
|
||||||
|
if (!normalized.endsWith("/SKILL.md") && normalized !== "SKILL.md") return undefined;
|
||||||
|
const name = normalized.split("/").at(-2);
|
||||||
|
if (name === undefined || name === "") return undefined;
|
||||||
|
return { name, path };
|
||||||
|
}
|
||||||
|
|
||||||
function objectFallback(value: unknown): ChatPart[] {
|
function objectFallback(value: unknown): ChatPart[] {
|
||||||
if (value == null) return [];
|
if (value == null) return [];
|
||||||
if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }];
|
if (typeof value === "object") return [{ type: "text", text: summarizeArgs(value) }];
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ describe("applyTranscriptEvent", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("replaces streamed skill reads when the finalized assistant tool call arrives after the tool result", () => {
|
||||||
|
const streamed: ChatLine[] = [
|
||||||
|
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] },
|
||||||
|
{ role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "skill content", isError: false }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(applyTranscriptEvent(streamed, {
|
||||||
|
type: "message.end",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "toolCall", name: "read", arguments: { path: "/skills/playwright/SKILL.md" } }],
|
||||||
|
timestamp: "2026-05-09T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
})).toEqual([
|
||||||
|
{ role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }], meta: { timestamp: "2026-05-09T12:00:00.000Z" } },
|
||||||
|
{ role: "tool", parts: [{ type: "toolResult", toolName: "read", text: "skill content", isError: false }] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not merge different finalized user messages", () => {
|
it("does not merge different finalized user messages", () => {
|
||||||
const messages = [textMessage("user", "first queued prompt")];
|
const messages = [textMessage("user", "first queued prompt")];
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,31 @@ export function applyTranscriptEvent(messages: ChatLine[], event: SessionUiEvent
|
|||||||
function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined {
|
function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[] | undefined {
|
||||||
const ended = normalizeMessage(rawMessage)[0];
|
const ended = normalizeMessage(rawMessage)[0];
|
||||||
if (ended === undefined) return undefined;
|
if (ended === undefined) return undefined;
|
||||||
|
const skillReadIndex = ended.role === "skill" ? findMatchingSkillRead(messages, ended) : -1;
|
||||||
|
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), ended, ...messages.slice(skillReadIndex + 1)];
|
||||||
const last = messages.at(-1);
|
const last = messages.at(-1);
|
||||||
if (last?.role !== ended.role) return [...messages, ended];
|
if (last?.role !== ended.role) return [...messages, ended];
|
||||||
if (ended.role === "assistant" || sameMessageText(last, ended)) return [...messages.slice(0, -1), ended];
|
if (ended.role === "assistant" || sameMessageText(last, ended)) return [...messages.slice(0, -1), ended];
|
||||||
return [...messages, ended];
|
return [...messages, ended];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number {
|
||||||
|
const endedReads = skillReadPaths(ended);
|
||||||
|
if (endedReads.length === 0) return -1;
|
||||||
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
|
const paths = skillReadPaths(messages[index]);
|
||||||
|
if (paths.length === endedReads.length && paths.every((path, pathIndex) => path === endedReads[pathIndex])) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skillReadPaths(message: ChatLine | undefined): string[] {
|
||||||
|
if (message === undefined || message.role !== "skill") return [];
|
||||||
|
return message.parts
|
||||||
|
.filter((part): part is Extract<ChatLine["parts"][number], { type: "skillRead" }> => part.type === "skillRead")
|
||||||
|
.map((part) => part.path);
|
||||||
|
}
|
||||||
|
|
||||||
function sameMessageText(left: ChatLine, right: ChatLine): boolean {
|
function sameMessageText(left: ChatLine, right: ChatLine): boolean {
|
||||||
return messageText(left) === messageText(right);
|
return messageText(left) === messageText(right);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,12 @@ export class ChatView extends LitElement {
|
|||||||
<formatted-text .text=${part.content}></formatted-text>
|
<formatted-text .text=${part.content}></formatted-text>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`;
|
||||||
|
if (part.type === "skillRead") return html`
|
||||||
|
<div class="part skill-read">
|
||||||
|
<strong>Loaded ${part.name}</strong>
|
||||||
|
<small>read ${part.path}</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
|
if (part.type === "toolCall") return html`<div class="part tool-line">▶ ${part.toolName}<span class="summary">${part.summary}</span></div>`;
|
||||||
if (part.type === "toolResult") return html`
|
if (part.type === "toolResult") return html`
|
||||||
<details class="part" ?open=${part.isError}>
|
<details class="part" ?open=${part.isError}>
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ export type ChatPart =
|
|||||||
| { type: "text"; text: string }
|
| { type: "text"; text: string }
|
||||||
| { type: "thinking"; text: string }
|
| { type: "thinking"; text: string }
|
||||||
| { type: "skillInvocation"; name: string; location: string; content: string }
|
| { type: "skillInvocation"; name: string; location: string; content: string }
|
||||||
|
| { type: "skillRead"; name: string; path: string }
|
||||||
| { type: "toolCall"; toolName: string; summary: string }
|
| { type: "toolCall"; toolName: string; summary: string }
|
||||||
| { type: "toolResult"; toolName: string; text: string; isError: boolean }
|
| { type: "toolResult"; toolName: string; text: string; isError: boolean }
|
||||||
| { type: "empty" };
|
| { type: "empty" };
|
||||||
|
|
||||||
export interface ChatLine {
|
export interface ChatLine {
|
||||||
role: "user" | "assistant" | "tool" | "system" | "bash";
|
role: "user" | "assistant" | "tool" | "system" | "bash" | "skill";
|
||||||
parts: ChatPart[];
|
parts: ChatPart[];
|
||||||
source?: "compaction" | "branch_summary";
|
source?: "compaction" | "branch_summary";
|
||||||
meta?: {
|
meta?: {
|
||||||
@@ -156,6 +157,7 @@ export const chatStyles = css`
|
|||||||
.msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
|
.msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
|
||||||
.msg.system { color: #ff7b72; }
|
.msg.system { color: #ff7b72; }
|
||||||
.msg.bash { border-color: #3fb950; background: #0f1b12; }
|
.msg.bash { border-color: #3fb950; background: #0f1b12; }
|
||||||
|
.msg.skill { border-color: #a371f7; background: #21132f; }
|
||||||
.msg.event-group { padding: 0; border-color: #30363d; background: #0d1117; color: #8b949e; }
|
.msg.event-group { padding: 0; border-color: #30363d; background: #0d1117; color: #8b949e; }
|
||||||
.msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; }
|
.msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; }
|
||||||
.msg.event-group > summary .label { margin: 0; }
|
.msg.event-group > summary .label { margin: 0; }
|
||||||
@@ -206,9 +208,9 @@ export const chatStyles = css`
|
|||||||
.summary { color: #8b949e; margin-left: 6px; }
|
.summary { color: #8b949e; margin-left: 6px; }
|
||||||
.part:is(details) { border-top: 1px solid #30363d; padding-top: 8px; }
|
.part:is(details) { border-top: 1px solid #30363d; padding-top: 8px; }
|
||||||
.part > formatted-text { display: block; max-width: 100%; min-width: 0; overflow: visible; }
|
.part > formatted-text { display: block; max-width: 100%; min-width: 0; overflow: visible; }
|
||||||
.skill-invocation { border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 8px 10px; }
|
.skill-invocation, .skill-read { border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 8px 10px; }
|
||||||
.skill-invocation > summary { color: #d2a8ff; }
|
.skill-invocation > summary, .skill-read > strong { color: #d2a8ff; }
|
||||||
.skill-invocation > small { display: block; margin: 6px 0 8px; color: #8b949e; }
|
.skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: #8b949e; }
|
||||||
summary { cursor: pointer; color: #8b949e; }
|
summary { cursor: pointer; color: #8b949e; }
|
||||||
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
|
pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
|
||||||
.shell-output { color: #e6edf3; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
|
.shell-output { color: #e6edf3; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
|
||||||
|
|||||||
Reference in New Issue
Block a user