Archived
Avoid duplicate live skill read bubbles
This commit is contained in:
@@ -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", () => {
|
it("does not merge different finalized user messages", () => {
|
||||||
const messages = [textMessage("user", "first queued prompt")];
|
const messages = [textMessage("user", "first queued prompt")];
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ 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;
|
const skillReadIndex = findMatchingSkillRead(messages, ended);
|
||||||
if (skillReadIndex >= 0) return [...messages.slice(0, skillReadIndex), ended, ...messages.slice(skillReadIndex + 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];
|
||||||
@@ -30,20 +30,35 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number {
|
function findMatchingSkillRead(messages: ChatLine[], ended: ChatLine): number {
|
||||||
const endedReads = skillReadPaths(ended);
|
const endedReads = skillReads(ended);
|
||||||
if (endedReads.length === 0) return -1;
|
if (endedReads.length === 0) return -1;
|
||||||
for (let index = messages.length - 1; index >= 0; index--) {
|
for (let index = messages.length - 1; index >= 0; index--) {
|
||||||
const paths = skillReadPaths(messages[index]);
|
const message = messages[index];
|
||||||
if (paths.length === endedReads.length && paths.every((path, pathIndex) => path === endedReads[pathIndex])) return index;
|
if (message?.role !== "skill") continue;
|
||||||
|
const reads = skillReads(message);
|
||||||
|
if (sameSkillReads(reads, endedReads)) return index;
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function skillReadPaths(message: ChatLine | undefined): string[] {
|
function skillReads(message: ChatLine | undefined): SkillRead[] {
|
||||||
if (message === undefined || message.role !== "skill") return [];
|
if (message === undefined) return [];
|
||||||
return message.parts
|
return message.parts.filter((part): part is SkillRead => part.type === "skillRead");
|
||||||
.filter((part): part is Extract<ChatLine["parts"][number], { type: "skillRead" }> => part.type === "skillRead")
|
}
|
||||||
.map((part) => part.path);
|
|
||||||
|
type SkillRead = Extract<ChatLine["parts"][number], { type: "skillRead" }>;
|
||||||
|
|
||||||
|
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 {
|
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[] {
|
function appendLine(messages: ChatLine[], line: ChatLine): ChatLine[] {
|
||||||
const last = messages.at(-1);
|
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];
|
return [...messages, line];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user