Skip to content

Commit 5fc58cd

Browse files
committed
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 bcc0ba4 commit 5fc58cd

2 files changed

Lines changed: 97 additions & 12 deletions

File tree

src/tui/tool-rows.test.ts

Lines changed: 80 additions & 5 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", () => {
@@ -370,6 +381,70 @@ describe("a run of identical calls", () => {
370381
]);
371382
});
372383

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+
373448
test("a member settled before the lane keeps the call's label, not the payload's", () => {
374449
const rows: StreamRow[] = [];
375450
pushToolCall(rows, {

src/tui/tool-rows.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -343,16 +343,26 @@ function laneMembers(
343343
tail: StreamRow,
344344
next: StreamRow,
345345
): { ids: string[]; labels: string[] } {
346-
const ids = [
347-
...(tail.memberIds ?? (tail.callId !== undefined ? [tail.callId] : [])),
348-
...(next.callId !== undefined ? [next.callId] : []),
349-
];
346+
const tailIds =
347+
tail.memberIds ?? (tail.callId !== undefined ? [tail.callId] : []);
348+
// A lane hydrated from pre-PR history carries memberIds without
349+
// memberLabels. Backfill placeholders so the two arrays stay aligned —
350+
// only the tail's own label is still recoverable; earlier members read as
351+
// bare outcomes until their answers arrive.
352+
const tailLabels =
353+
tail.memberLabels ??
354+
tailIds.map((id) => (id === tail.callId ? laneMemberLabel(tail) : ""));
355+
const ids = [...tailIds, ...(next.callId !== undefined ? [next.callId] : [])];
350356
const labels = [
351-
...(tail.memberLabels ??
352-
(tail.callId !== undefined ? [laneMemberLabel(tail)] : [])),
357+
...tailLabels,
353358
...(next.callId !== undefined ? [laneMemberLabel(next)] : []),
354359
];
355-
return { ids, labels };
360+
// Bound the lane's memory alongside the detail cap, oldest-first like the
361+
// answers. Both arrays are sliced together so they never come unaligned.
362+
return {
363+
ids: ids.slice(0, MAX_RUN_LINES),
364+
labels: labels.slice(0, MAX_RUN_LINES),
365+
};
356366
}
357367

358368
/**

0 commit comments

Comments
 (0)