diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts index e26eff5..dcecc0c 100644 --- a/src/client/src/chatTranscript.test.ts +++ b/src/client/src/chatTranscript.test.ts @@ -61,6 +61,68 @@ describe("applyTranscriptEvent", () => { ]); }); + it("replaces streamed skill reads when the finalized assistant message includes thinking", () => { + 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: "thinking", thinking: "load skill" }, + { type: "toolCall", name: "read", arguments: { path: "/skills/playwright/SKILL.md" } }, + ], + timestamp: "2026-05-09T12:00:00.000Z", + }, + })).toEqual([ + { role: "assistant", parts: [{ type: "thinking", text: "load skill" }, { 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("replaces streamed skill reads when finalized paths differ but the skill name matches", () => { + 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: "/home/user/.agents/skills/playwright/SKILL.md" } }], + timestamp: "2026-05-09T12:00:00.000Z", + }, + })).toEqual([ + { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/home/user/.agents/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 consecutive streamed skill reads", () => { + let messages: ChatLine[] = []; + messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages; + messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "2", summary: "", args: { path: "/skills/sentry-cli/SKILL.md" } }) ?? messages; + + expect(messages).toEqual([ + { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] }, + { role: "skill", parts: [{ type: "skillRead", name: "sentry-cli", path: "/skills/sentry-cli/SKILL.md" }] }, + ]); + }); + + it("ignores duplicate streamed skill read starts", () => { + let messages: ChatLine[] = []; + messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages; + messages = applyTranscriptEvent(messages, { type: "tool.start", toolName: "read", toolCallId: "1", summary: "", args: { path: "/skills/playwright/SKILL.md" } }) ?? messages; + + expect(messages).toEqual([ + { role: "skill", parts: [{ type: "skillRead", name: "playwright", path: "/skills/playwright/SKILL.md" }] }, + ]); + }); + it("does not merge different finalized user messages", () => { const messages = [textMessage("user", "first queued prompt")]; diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts index 6933c4c..799c9a3 100644 --- a/src/client/src/chatTranscript.ts +++ b/src/client/src/chatTranscript.ts @@ -21,7 +21,7 @@ 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; + const skillReadIndex = findMatchingSkillRead(messages, ended); 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]; @@ -30,20 +30,35 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[ } function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number { - const endedReads = skillReadPaths(ended); + const endedReads = skillReads(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; + const message = messages[index]; + if (message?.role !== "skill") continue; + const reads = skillReads(message); + if (sameSkillReads(reads, endedReads)) 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 => part.type === "skillRead") - .map((part) => part.path); +function skillReads(message: ChatLine | undefined): SkillRead[] { + if (message === undefined) return []; + return message.parts.filter((part): part is SkillRead => part.type === "skillRead"); +} + +type SkillRead = Extract; + +function sameSkillReads(left: SkillRead[], right: SkillRead[]): boolean { + return left.length === right.length && left.every((read, index) => sameSkillRead(read, right[index])); +} + +function sameSkillRead(left: SkillRead, right: SkillRead | undefined): boolean { + if (right === undefined) return false; + return normalizeSkillPath(left.path) === normalizeSkillPath(right.path) || left.name === right.name; +} + +function normalizeSkillPath(path: string): string { + return path.replace(/\\/g, "/"); } function sameMessageText(left: ChatLine, right: ChatLine): boolean { @@ -63,6 +78,7 @@ function appendNormalized(messages: ChatLine[], rawMessage: unknown): ChatLine[] function appendLine(messages: ChatLine[], line: ChatLine): ChatLine[] { const last = messages.at(-1); - if (last?.role === line.role) return [...messages.slice(0, -1), { ...last, parts: [...last.parts, ...line.parts] }]; + if (line.role === "skill" && sameSkillReads(skillReads(last), skillReads(line))) return messages; + if (last?.role === line.role && line.role !== "skill") return [...messages.slice(0, -1), { ...last, parts: [...last.parts, ...line.parts] }]; return [...messages, line]; }