Skip to content

Commit 2edf756

Browse files
committed
fix command progress ids without changing public line ids
1 parent 1b435ab commit 2edf756

2 files changed

Lines changed: 113 additions & 41 deletions

File tree

src/channels/streaming.ts

Lines changed: 62 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ export type ChannelProgressDraftLine = {
301301
prefix?: boolean;
302302
};
303303

304+
const progressDraftLineCorrelationKeys = new WeakMap<ChannelProgressDraftLine, string>();
305+
304306
function compactStrings(values: readonly (string | undefined | null)[]): string[] {
305307
return values.map((value) => value?.replace(/\s+/g, " ").trim()).filter(Boolean) as string[];
306308
}
@@ -322,6 +324,7 @@ function buildNamedProgressLine(
322324
metas: readonly (string | undefined | null)[] | undefined,
323325
options?: ChannelProgressLineOptions,
324326
fields?: {
327+
correlationKey?: string;
325328
id?: string;
326329
status?: string;
327330
},
@@ -340,7 +343,7 @@ function buildNamedProgressLine(
340343
const detail = text.startsWith(`${prefix}: `)
341344
? text.slice(prefix.length + 2).trim()
342345
: compactCommandPrefix;
343-
return {
346+
const line = {
344347
...(fields?.id ? { id: fields.id } : {}),
345348
kind,
346349
text,
@@ -350,6 +353,18 @@ function buildNamedProgressLine(
350353
...(fields?.status ? { status: fields.status } : {}),
351354
toolName: display.name,
352355
};
356+
setProgressDraftLineCorrelationKey(line, fields?.correlationKey);
357+
return line;
358+
}
359+
360+
function setProgressDraftLineCorrelationKey(
361+
line: ChannelProgressDraftLine,
362+
correlationKey: string | undefined,
363+
): void {
364+
const normalized = correlationKey?.trim();
365+
if (normalized) {
366+
progressDraftLineCorrelationKeys.set(line, normalized);
367+
}
353368
}
354369

355370
function itemKindToToolName(kind: string | undefined): string | undefined {
@@ -381,34 +396,24 @@ function resolveProgressDraftLineId(
381396
input: {
382397
itemId?: string;
383398
toolCallId?: string;
384-
itemKind?: string;
385-
name?: string;
386399
},
387400
params?: {
388-
commandLike?: boolean;
389401
useToolCallIdFallback?: boolean;
390402
},
391403
): string | undefined {
392404
const itemId = input.itemId?.trim();
393405
const toolCallId = input.toolCallId?.trim();
394-
const commandLike =
395-
params?.commandLike ??
396-
(normalizeOptionalLowercaseString(input.itemKind) === "command" ||
397-
isCommandToolName(input.name));
398-
if (commandLike) {
399-
if (itemId && /^command(?::|-)/i.test(itemId)) {
400-
return itemId;
401-
}
402-
if (toolCallId) {
403-
return `command:${toolCallId}`;
404-
}
405-
}
406406
if (itemId) {
407407
return itemId;
408408
}
409409
return params?.useToolCallIdFallback === true ? toolCallId : undefined;
410410
}
411411

412+
function resolveCommandProgressCorrelationKey(input: { toolCallId?: string }): string | undefined {
413+
const toolCallId = input.toolCallId?.trim();
414+
return toolCallId ? `command:${toolCallId}` : undefined;
415+
}
416+
412417
function isEmptyReasoningProgressItem(
413418
input: Extract<ChannelProgressDraftLineInput, { event: "item" }>,
414419
meta: string | undefined,
@@ -493,7 +498,12 @@ export function buildChannelProgressDraftLine(
493498
input.phase && !input.name ? input.phase : undefined,
494499
],
495500
options,
496-
{ id: resolveProgressDraftLineId(input, { useToolCallIdFallback: true }) },
501+
{
502+
correlationKey: isCommandToolName(input.name)
503+
? resolveCommandProgressCorrelationKey(input)
504+
: undefined,
505+
id: resolveProgressDraftLineId(input, { useToolCallIdFallback: true }),
506+
},
497507
);
498508
}
499509
case "item": {
@@ -509,23 +519,30 @@ export function buildChannelProgressDraftLine(
509519
}
510520
if (name) {
511521
return buildNamedProgressLine(input.event, name, [meta], options, {
512-
id: resolveProgressDraftLineId(input, {
513-
commandLike: isCommandProgressItem(input),
514-
}),
522+
correlationKey: isCommandProgressItem(input)
523+
? resolveCommandProgressCorrelationKey(input)
524+
: undefined,
525+
id: resolveProgressDraftLineId(input),
515526
status: input.status,
516527
});
517528
}
518529
const text = compactStrings([meta, input.title]).at(0);
519-
const id = resolveProgressDraftLineId(input, { commandLike: isCommandProgressItem(input) });
520-
return text
521-
? {
522-
...(id ? { id } : {}),
523-
kind: input.event,
524-
text,
525-
label: input.title?.trim() || input.itemKind?.trim() || "Update",
526-
...(input.status ? { status: input.status } : {}),
527-
}
530+
const id = resolveProgressDraftLineId(input);
531+
const correlationKey = isCommandProgressItem(input)
532+
? resolveCommandProgressCorrelationKey(input)
528533
: undefined;
534+
if (!text) {
535+
return undefined;
536+
}
537+
const line = {
538+
...(id ? { id } : {}),
539+
kind: input.event,
540+
text,
541+
label: input.title?.trim() || input.itemKind?.trim() || "Update",
542+
...(input.status ? { status: input.status } : {}),
543+
};
544+
setProgressDraftLineCorrelationKey(line, correlationKey);
545+
return line;
529546
}
530547
case "plan": {
531548
if (input.phase !== undefined && input.phase !== "update") {
@@ -566,10 +583,8 @@ export function buildChannelProgressDraftLine(
566583
[status, input.title],
567584
options,
568585
{
569-
id: resolveProgressDraftLineId(input, {
570-
commandLike: true,
571-
useToolCallIdFallback: true,
572-
}),
586+
correlationKey: resolveCommandProgressCorrelationKey(input),
587+
id: resolveProgressDraftLineId(input, { useToolCallIdFallback: true }),
573588
status,
574589
},
575590
);
@@ -1067,10 +1082,10 @@ export function mergeChannelProgressDraftLine<TLine extends string | ChannelProg
10671082
return lines;
10681083
}
10691084
const maxLines = Math.max(1, params.maxLines);
1070-
const lineId = typeof line === "object" ? line.id?.trim() : undefined;
1071-
if (lineId) {
1072-
const existingIndex = lines.findIndex(
1073-
(entry) => typeof entry === "object" && entry.id?.trim() === lineId,
1085+
const lineKeys = resolveProgressDraftLineMergeKeys(line);
1086+
if (lineKeys.length > 0) {
1087+
const existingIndex = lines.findIndex((entry) =>
1088+
resolveProgressDraftLineMergeKeys(entry).some((entryKey) => lineKeys.includes(entryKey)),
10741089
);
10751090
if (existingIndex >= 0) {
10761091
if (normalizeChannelProgressDraftLineIdentity(lines[existingIndex]) === normalized) {
@@ -1088,6 +1103,16 @@ export function mergeChannelProgressDraftLine<TLine extends string | ChannelProg
10881103
return [...lines, line].slice(-maxLines);
10891104
}
10901105

1106+
function resolveProgressDraftLineMergeKeys(line: string | ChannelProgressDraftLine): string[] {
1107+
if (typeof line !== "object") {
1108+
return [];
1109+
}
1110+
const keys = [progressDraftLineCorrelationKeys.get(line), line.id]
1111+
.map((key) => key?.trim())
1112+
.filter((key): key is string => Boolean(key));
1113+
return [...new Set(keys)];
1114+
}
1115+
10911116
export function formatChannelProgressDraftText(params: {
10921117
/** Channel streaming config source for progress label and bounds. */
10931118
entry?: StreamingCompatEntry | null;

src/plugin-sdk/channel-streaming.test.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -554,7 +554,7 @@ describe("channel-streaming", () => {
554554
).toBe("🛠️ Exec\n• Checking the app-server stream");
555555
});
556556

557-
it("normalizes command progress ids across tool and output lines", () => {
557+
it("keeps public command progress ids while replacing by command correlation", () => {
558558
const toolLine = buildChannelProgressDraftLine({
559559
event: "tool",
560560
itemId: "tool:call-1",
@@ -580,14 +580,61 @@ describe("channel-streaming", () => {
580580
progressText: "install dependencies",
581581
});
582582

583-
expect(toolLine).toMatchObject({ id: "command:call-1", kind: "tool", toolName: "bash" });
584-
expect(itemLine).toMatchObject({ id: "command:call-1", kind: "item", toolName: "bash" });
583+
expect(toolLine).toMatchObject({ id: "tool:call-1", kind: "tool", toolName: "bash" });
584+
expect(itemLine).toMatchObject({ id: "tool:call-1", kind: "item", toolName: "bash" });
585585
expect(commandLine).toMatchObject({
586-
id: "command:call-1",
586+
id: "tool:call-1-output",
587587
kind: "command-output",
588588
status: "completed",
589589
toolName: "bash",
590590
});
591+
592+
if (!toolLine || !itemLine || !commandLine) {
593+
throw new Error("expected command progress lines");
594+
}
595+
const updated = [itemLine, commandLine].reduce(
596+
(lines, line) => mergeChannelProgressDraftLine(lines, line, { maxLines: 4 }),
597+
mergeChannelProgressDraftLine([], toolLine, { maxLines: 4 }),
598+
);
599+
600+
expect(updated).toHaveLength(1);
601+
expect(updated[0]).toMatchObject({
602+
id: "tool:call-1-output",
603+
kind: "command-output",
604+
status: "completed",
605+
});
606+
607+
const recoveredItemLine = buildChannelProgressDraftLine({
608+
event: "item",
609+
itemId: "command-2",
610+
itemKind: "command",
611+
name: "bash",
612+
phase: "end",
613+
status: "failed",
614+
progressText: "install dependencies failed",
615+
});
616+
const recoveredCommandLine = buildChannelProgressDraftLine({
617+
event: "command-output",
618+
itemId: "command-2",
619+
toolCallId: "call-2",
620+
name: "bash",
621+
phase: "end",
622+
exitCode: 0,
623+
});
624+
if (!recoveredItemLine || !recoveredCommandLine) {
625+
throw new Error("expected recovered command progress lines");
626+
}
627+
expect(
628+
mergeChannelProgressDraftLine([recoveredItemLine], recoveredCommandLine, {
629+
maxLines: 4,
630+
}),
631+
).toMatchObject([
632+
{
633+
id: "command-2",
634+
kind: "command-output",
635+
status: "completed",
636+
},
637+
]);
591638
});
592639

593640
it("starts progress drafts after five seconds or a second work event", async () => {

0 commit comments

Comments
 (0)