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", () => {
|
||||
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 (readableParts.length) {
|
||||
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();
|
||||
@@ -48,6 +49,6 @@ export function summarizeChatGroup(messages: ChatLine[]): string {
|
||||
|
||||
function isReadablePart(message: ChatLine, part: ChatPart): boolean {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
expect(normalizeMessage({
|
||||
role: "bashExecution",
|
||||
|
||||
@@ -51,7 +51,8 @@ export function normalizeMessage(message: unknown): ChatLine[] {
|
||||
if (role === "tool") return [withMessageMeta({ role, parts, ...(source === undefined ? {} : { source }) }, message)];
|
||||
|
||||
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 {
|
||||
@@ -142,7 +143,13 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
|
||||
const thinking = getString(part, "thinking") ?? text;
|
||||
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]" }];
|
||||
return objectFallback(part);
|
||||
}).map((part) => part.type === "text" && getString(message, "role") === "toolResult"
|
||||
@@ -150,6 +157,15 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
|
||||
: 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[] {
|
||||
if (value == null) return [];
|
||||
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", () => {
|
||||
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 {
|
||||
const ended = normalizeMessage(rawMessage)[0];
|
||||
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);
|
||||
if (last?.role !== ended.role) return [...messages, ended];
|
||||
if (ended.role === "assistant" || sameMessageText(last, ended)) return [...messages.slice(0, -1), 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 {
|
||||
return messageText(left) === messageText(right);
|
||||
}
|
||||
|
||||
@@ -332,6 +332,12 @@ export class ChatView extends LitElement {
|
||||
<formatted-text .text=${part.content}></formatted-text>
|
||||
</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 === "toolResult") return html`
|
||||
<details class="part" ?open=${part.isError}>
|
||||
|
||||
@@ -4,12 +4,13 @@ export type ChatPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "thinking"; text: string }
|
||||
| { type: "skillInvocation"; name: string; location: string; content: string }
|
||||
| { type: "skillRead"; name: string; path: string }
|
||||
| { type: "toolCall"; toolName: string; summary: string }
|
||||
| { type: "toolResult"; toolName: string; text: string; isError: boolean }
|
||||
| { type: "empty" };
|
||||
|
||||
export interface ChatLine {
|
||||
role: "user" | "assistant" | "tool" | "system" | "bash";
|
||||
role: "user" | "assistant" | "tool" | "system" | "bash" | "skill";
|
||||
parts: ChatPart[];
|
||||
source?: "compaction" | "branch_summary";
|
||||
meta?: {
|
||||
@@ -156,6 +157,7 @@ export const chatStyles = css`
|
||||
.msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; }
|
||||
.msg.system { color: #ff7b72; }
|
||||
.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 > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; }
|
||||
.msg.event-group > summary .label { margin: 0; }
|
||||
@@ -206,9 +208,9 @@ export const chatStyles = css`
|
||||
.summary { color: #8b949e; margin-left: 6px; }
|
||||
.part:is(details) { border-top: 1px solid #30363d; padding-top: 8px; }
|
||||
.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 > summary { color: #d2a8ff; }
|
||||
.skill-invocation > small { display: block; margin: 6px 0 8px; color: #8b949e; }
|
||||
.skill-invocation, .skill-read { border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 8px 10px; }
|
||||
.skill-invocation > summary, .skill-read > strong { color: #d2a8ff; }
|
||||
.skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: #8b949e; }
|
||||
summary { cursor: pointer; color: #8b949e; }
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user