Skip to content

Commit a619acb

Browse files
Show per-call subjects in coalesced tool lanes (#941)
* Show per-call subjects in coalesced tool lanes * Align coalesced lane member labels and cap lane memory A lane hydrated without member labels paired later answers to the wrong member; backfilled placeholders keep ids and labels aligned, and both arrays now share the run cap instead of growing unbounded.
1 parent 8f91239 commit a619acb

3 files changed

Lines changed: 342 additions & 18 deletions

File tree

src/tui/stream.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ export interface StreamRow {
163163
* newest call.
164164
*/
165165
readonly memberIds?: readonly string[];
166+
/**
167+
* What each absorbed call was about, aligned with `memberIds` — a lane's
168+
* expanded body lines are "member — outcome" pairs, not bare "answered",
169+
* because four identical answers to four different calls say nothing.
170+
*/
171+
readonly memberLabels?: readonly string[];
166172
/**
167173
* Most recent result's full text on a coalesced lane — the Alt+C copy
168174
* source. Single rows copy `text` as before.

src/tui/tool-rows.test.ts

Lines changed: 191 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ describe("a run of identical calls", () => {
242242
expect(rows[0]?.outstanding).toBe(0);
243243
});
244244

245-
test("a lane keeps every member id while the count climbs", () => {
245+
test("a lane caps member ids and labels together at the run cap", () => {
246246
const rows: StreamRow[] = [];
247247
for (let i = 1; i <= 33; i++) {
248248
pushToolCall(rows, {
@@ -253,7 +253,10 @@ describe("a run of identical calls", () => {
253253
}
254254
expect(rows.length).toBe(1);
255255
expect(rows[0]?.callCount).toBe(33);
256-
expect(rows[0]?.memberIds?.length).toBe(33);
256+
// The lane's memory is bounded alongside the detail cap, oldest-first
257+
// like the answers — and both arrays together, never one alone.
258+
expect(rows[0]?.memberIds?.length).toBe(30);
259+
expect(rows[0]?.memberLabels?.length).toBe(30);
257260
expect(rows[0]?.memberIds?.[0]).toBe("c1");
258261
expect(pendingCallIndex(rows, "grep", "c33")).toBe(0);
259262
expect(pendingCallIndex(rows, "grep", "c1")).toBe(0);
@@ -274,16 +277,24 @@ describe("a run of identical calls", () => {
274277
expect(rows.length).toBe(1);
275278
expect(rows[0]?.pending).toBe(true);
276279
expect(rows[0]?.outstanding).toBe(32);
277-
for (let i = 2; i <= 33; i++) {
280+
// Members past the run cap kept no id on the lane, so their answers
281+
// cannot pair back to it — except the newest, found by the lane's own id.
282+
for (let i = 2; i <= 30; i++) {
278283
pushToolResult(rows, {
279284
name: "grep",
280285
content: `r${i}`,
281286
callId: `c${i}`,
282287
});
283288
}
289+
pushToolResult(rows, { name: "grep", content: "r33", callId: "c33" });
284290
expect(rows.length).toBe(1);
285-
expect(rows[0]?.pending).toBeUndefined();
286-
expect(rows[0]?.outstanding).toBe(0);
291+
expect(rows[0]?.pending).toBe(true);
292+
expect(rows[0]?.outstanding).toBe(2);
293+
pushToolResult(rows, { name: "grep", content: "r31", callId: "c31" });
294+
pushToolResult(rows, { name: "grep", content: "r32", callId: "c32" });
295+
expect(rows.length).toBe(3);
296+
expect(rows[0]?.pending).toBe(true);
297+
expect(rows[0]?.outstanding).toBe(2);
287298
});
288299

289300
test("a different tool breaks the lane", () => {
@@ -333,6 +344,180 @@ describe("a run of identical calls", () => {
333344
expect(rows[0]?.outstanding).toBe(1);
334345
expect(rows[0]?.pending).toBe(true);
335346
});
347+
348+
const runLines = (row: StreamRow | undefined): string[] =>
349+
(row?.detail ?? []).map((line) =>
350+
line.map((segment) => segment.text).join(""),
351+
);
352+
353+
test("each member line names the call's own target, not a bare answer", () => {
354+
const rows: StreamRow[] = [];
355+
pushToolCall(rows, {
356+
name: "mcp__linear__save_comment",
357+
arguments: JSON.stringify({ issueId: "CL-7386", body: "looks good" }),
358+
callId: "m1",
359+
});
360+
pushToolCall(rows, {
361+
name: "mcp__linear__save_comment",
362+
arguments: JSON.stringify({ issueId: "CL-7390", body: "done" }),
363+
callId: "m2",
364+
});
365+
pushToolResult(rows, {
366+
name: "mcp__linear__save_comment",
367+
content: "",
368+
callId: "m1",
369+
});
370+
pushToolResult(rows, {
371+
name: "mcp__linear__save_comment",
372+
content: "",
373+
callId: "m2",
374+
});
375+
// An MCP call's painted summary is empty — the verb is the sentence — so
376+
// the lane reads the identifying argument (the issue, not the body).
377+
expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]);
378+
expect(runLines(rows[0])).toEqual([
379+
"CL-7386 — answered",
380+
"CL-7390 — answered",
381+
]);
382+
});
383+
384+
test("four members each name their own target", () => {
385+
const rows: StreamRow[] = [];
386+
const issues = ["CL-7386", "CL-7390", "CL-7399", "CL-7401"];
387+
issues.forEach((issueId, i) => {
388+
pushToolCall(rows, {
389+
name: "mcp__linear__save_comment",
390+
arguments: JSON.stringify({ issueId, body: `note ${i}` }),
391+
callId: `m${i}`,
392+
});
393+
});
394+
issues.forEach((_, i) => {
395+
pushToolResult(rows, {
396+
name: "mcp__linear__save_comment",
397+
content: "",
398+
callId: `m${i}`,
399+
});
400+
});
401+
expect(rows.length).toBe(1);
402+
expect(rows[0]?.memberLabels).toEqual(issues);
403+
expect(runLines(rows[0])).toEqual(issues.map((id) => `${id} — answered`));
404+
});
405+
406+
test("a pre-PR lane with ids but no labels coalesces with aligned placeholders", () => {
407+
const rows: StreamRow[] = [];
408+
pushToolCall(rows, {
409+
name: "mcp__linear__save_comment",
410+
arguments: JSON.stringify({ issueId: "CL-7386", body: "first" }),
411+
callId: "m1",
412+
});
413+
pushToolCall(rows, {
414+
name: "mcp__linear__save_comment",
415+
arguments: JSON.stringify({ issueId: "CL-7390", body: "second" }),
416+
callId: "m2",
417+
});
418+
// Lanes persisted before per-call labels carry memberIds without
419+
// memberLabels; only the tail's own label is still recoverable.
420+
const { memberLabels: _dropped, ...legacy } = defined(rows[0]);
421+
rows[0] = legacy;
422+
pushToolCall(rows, {
423+
name: "mcp__linear__save_comment",
424+
arguments: JSON.stringify({ issueId: "CL-7399", body: "third" }),
425+
callId: "m3",
426+
});
427+
expect(rows[0]?.memberIds).toEqual(["m1", "m2", "m3"]);
428+
expect(rows[0]?.memberLabels).toEqual(["", "CL-7390", "CL-7399"]);
429+
pushToolResult(rows, {
430+
name: "mcp__linear__save_comment",
431+
content: "",
432+
callId: "m2",
433+
});
434+
// Without the placeholder backfill the second result would read the
435+
// third member's label.
436+
expect(runLines(rows[0])).toEqual(["CL-7390 — answered"]);
437+
pushToolResult(rows, {
438+
name: "mcp__linear__save_comment",
439+
content: "",
440+
callId: "m3",
441+
});
442+
expect(runLines(rows[0])).toEqual([
443+
"CL-7390 — answered",
444+
"CL-7399 — answered",
445+
]);
446+
});
447+
448+
test("a member settled before the lane keeps the call's label, not the payload's", () => {
449+
const rows: StreamRow[] = [];
450+
pushToolCall(rows, {
451+
name: "mcp__linear__save_comment",
452+
arguments: JSON.stringify({ issueId: "CL-7386", body: "first" }),
453+
callId: "m1",
454+
});
455+
pushToolResult(rows, {
456+
name: "mcp__linear__save_comment",
457+
content: '{"id":"comment-9"}',
458+
callId: "m1",
459+
});
460+
// The merge replaced the row's args with the answer payload; the label
461+
// must still name what the call acted on.
462+
pushToolCall(rows, {
463+
name: "mcp__linear__save_comment",
464+
arguments: JSON.stringify({ issueId: "CL-7390", body: "second" }),
465+
callId: "m2",
466+
});
467+
expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]);
468+
expect(runLines(rows[0])).toEqual(['CL-7386 — {"id":"comment-9"}']);
469+
});
470+
471+
test("a failed member's line carries its target and the error", () => {
472+
const rows: StreamRow[] = [];
473+
pushToolCall(rows, {
474+
name: "mcp__linear__save_comment",
475+
arguments: JSON.stringify({ issueId: "CL-1", body: "x" }),
476+
callId: "m1",
477+
});
478+
pushToolCall(rows, {
479+
name: "mcp__linear__save_comment",
480+
arguments: JSON.stringify({ issueId: "CL-2", body: "y" }),
481+
callId: "m2",
482+
});
483+
pushToolResult(rows, {
484+
name: "mcp__linear__save_comment",
485+
content: "HTTP 401 unauthorized",
486+
isError: true,
487+
callId: "m1",
488+
});
489+
pushToolResult(rows, {
490+
name: "mcp__linear__save_comment",
491+
content: "",
492+
callId: "m2",
493+
});
494+
expect(runLines(rows[0])).toEqual([
495+
"CL-1 — HTTP 401 unauthorized",
496+
"CL-2 — answered",
497+
]);
498+
});
499+
500+
test("member labels are one-line and bounded", () => {
501+
const rows: StreamRow[] = [];
502+
pushToolCall(rows, {
503+
name: "mcp__granola__search",
504+
arguments: JSON.stringify({
505+
query: ` multi\n line ${"q".repeat(100)}`,
506+
}),
507+
callId: "m1",
508+
});
509+
pushToolCall(rows, {
510+
name: "mcp__granola__search",
511+
arguments: JSON.stringify({ query: "b" }),
512+
callId: "m2",
513+
});
514+
const labels = rows[0]?.memberLabels ?? [];
515+
expect(labels.length).toBe(2);
516+
expect(labels[0]?.length).toBeLessThanOrEqual(48);
517+
expect(labels[0]).not.toContain("\n");
518+
expect(labels[0]?.endsWith("…")).toBe(true);
519+
expect(labels[1]).toBe("b");
520+
});
336521
});
337522

338523
describe("parallel calls to the same tool", () => {
@@ -969,8 +1154,7 @@ describe("lane paint", () => {
9691154
const answers = (rows[0]?.detail ?? []).map((line) =>
9701155
line.map((segment) => segment.text).join(""),
9711156
);
972-
expect(answers).not.toEqual(["answered", "answered"]);
973-
expect(answers).toContain("a");
1157+
expect(answers).toEqual(["echo a — a", "echo b — b"]);
9741158
expect(rows[0]?.resultText).toBe("b");
9751159
expect(rows[0]?.previewLines).toEqual(["b"]);
9761160
});

0 commit comments

Comments
 (0)