Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/tui/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export interface StreamRow {
* newest call.
*/
readonly memberIds?: readonly string[];
/**
* What each absorbed call was about, aligned with `memberIds` — a lane's
* expanded body lines are "member — outcome" pairs, not bare "answered",
* because four identical answers to four different calls say nothing.
*/
readonly memberLabels?: readonly string[];
/**
* Most recent result's full text on a coalesced lane — the Alt+C copy
* source. Single rows copy `text` as before.
Expand Down
198 changes: 191 additions & 7 deletions src/tui/tool-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ describe("a run of identical calls", () => {
expect(rows[0]?.outstanding).toBe(0);
});

test("a lane keeps every member id while the count climbs", () => {
test("a lane caps member ids and labels together at the run cap", () => {
const rows: StreamRow[] = [];
for (let i = 1; i <= 33; i++) {
pushToolCall(rows, {
Expand All @@ -253,7 +253,10 @@ describe("a run of identical calls", () => {
}
expect(rows.length).toBe(1);
expect(rows[0]?.callCount).toBe(33);
expect(rows[0]?.memberIds?.length).toBe(33);
// The lane's memory is bounded alongside the detail cap, oldest-first
// like the answers — and both arrays together, never one alone.
expect(rows[0]?.memberIds?.length).toBe(30);
expect(rows[0]?.memberLabels?.length).toBe(30);
expect(rows[0]?.memberIds?.[0]).toBe("c1");
expect(pendingCallIndex(rows, "grep", "c33")).toBe(0);
expect(pendingCallIndex(rows, "grep", "c1")).toBe(0);
Expand All @@ -274,16 +277,24 @@ describe("a run of identical calls", () => {
expect(rows.length).toBe(1);
expect(rows[0]?.pending).toBe(true);
expect(rows[0]?.outstanding).toBe(32);
for (let i = 2; i <= 33; i++) {
// Members past the run cap kept no id on the lane, so their answers
// cannot pair back to it — except the newest, found by the lane's own id.
for (let i = 2; i <= 30; i++) {
pushToolResult(rows, {
name: "grep",
content: `r${i}`,
callId: `c${i}`,
});
}
pushToolResult(rows, { name: "grep", content: "r33", callId: "c33" });
expect(rows.length).toBe(1);
expect(rows[0]?.pending).toBeUndefined();
expect(rows[0]?.outstanding).toBe(0);
expect(rows[0]?.pending).toBe(true);
expect(rows[0]?.outstanding).toBe(2);
pushToolResult(rows, { name: "grep", content: "r31", callId: "c31" });
pushToolResult(rows, { name: "grep", content: "r32", callId: "c32" });
expect(rows.length).toBe(3);
expect(rows[0]?.pending).toBe(true);
expect(rows[0]?.outstanding).toBe(2);
});

test("a different tool breaks the lane", () => {
Expand Down Expand Up @@ -333,6 +344,180 @@ describe("a run of identical calls", () => {
expect(rows[0]?.outstanding).toBe(1);
expect(rows[0]?.pending).toBe(true);
});

const runLines = (row: StreamRow | undefined): string[] =>
(row?.detail ?? []).map((line) =>
line.map((segment) => segment.text).join(""),
);

test("each member line names the call's own target, not a bare answer", () => {
const rows: StreamRow[] = [];
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-7386", body: "looks good" }),
callId: "m1",
});
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-7390", body: "done" }),
callId: "m2",
});
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: "",
callId: "m1",
});
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: "",
callId: "m2",
});
// An MCP call's painted summary is empty — the verb is the sentence — so
// the lane reads the identifying argument (the issue, not the body).
expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]);
expect(runLines(rows[0])).toEqual([
"CL-7386 — answered",
"CL-7390 — answered",
]);
});

test("four members each name their own target", () => {
const rows: StreamRow[] = [];
const issues = ["CL-7386", "CL-7390", "CL-7399", "CL-7401"];
issues.forEach((issueId, i) => {
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId, body: `note ${i}` }),
callId: `m${i}`,
});
});
issues.forEach((_, i) => {
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: "",
callId: `m${i}`,
});
});
expect(rows.length).toBe(1);
expect(rows[0]?.memberLabels).toEqual(issues);
expect(runLines(rows[0])).toEqual(issues.map((id) => `${id} — answered`));
});

test("a pre-PR lane with ids but no labels coalesces with aligned placeholders", () => {
const rows: StreamRow[] = [];
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-7386", body: "first" }),
callId: "m1",
});
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-7390", body: "second" }),
callId: "m2",
});
// Lanes persisted before per-call labels carry memberIds without
// memberLabels; only the tail's own label is still recoverable.
const { memberLabels: _dropped, ...legacy } = defined(rows[0]);
rows[0] = legacy;
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-7399", body: "third" }),
callId: "m3",
});
expect(rows[0]?.memberIds).toEqual(["m1", "m2", "m3"]);
expect(rows[0]?.memberLabels).toEqual(["", "CL-7390", "CL-7399"]);
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: "",
callId: "m2",
});
// Without the placeholder backfill the second result would read the
// third member's label.
expect(runLines(rows[0])).toEqual(["CL-7390 — answered"]);
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: "",
callId: "m3",
});
expect(runLines(rows[0])).toEqual([
"CL-7390 — answered",
"CL-7399 — answered",
]);
});

test("a member settled before the lane keeps the call's label, not the payload's", () => {
const rows: StreamRow[] = [];
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-7386", body: "first" }),
callId: "m1",
});
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: '{"id":"comment-9"}',
callId: "m1",
});
// The merge replaced the row's args with the answer payload; the label
// must still name what the call acted on.
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-7390", body: "second" }),
callId: "m2",
});
expect(rows[0]?.memberLabels).toEqual(["CL-7386", "CL-7390"]);
expect(runLines(rows[0])).toEqual(['CL-7386 — {"id":"comment-9"}']);
});

test("a failed member's line carries its target and the error", () => {
const rows: StreamRow[] = [];
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-1", body: "x" }),
callId: "m1",
});
pushToolCall(rows, {
name: "mcp__linear__save_comment",
arguments: JSON.stringify({ issueId: "CL-2", body: "y" }),
callId: "m2",
});
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: "HTTP 401 unauthorized",
isError: true,
callId: "m1",
});
pushToolResult(rows, {
name: "mcp__linear__save_comment",
content: "",
callId: "m2",
});
expect(runLines(rows[0])).toEqual([
"CL-1 — HTTP 401 unauthorized",
"CL-2 — answered",
]);
});

test("member labels are one-line and bounded", () => {
const rows: StreamRow[] = [];
pushToolCall(rows, {
name: "mcp__granola__search",
arguments: JSON.stringify({
query: ` multi\n line ${"q".repeat(100)}`,
}),
callId: "m1",
});
pushToolCall(rows, {
name: "mcp__granola__search",
arguments: JSON.stringify({ query: "b" }),
callId: "m2",
});
const labels = rows[0]?.memberLabels ?? [];
expect(labels.length).toBe(2);
expect(labels[0]?.length).toBeLessThanOrEqual(48);
expect(labels[0]).not.toContain("\n");
expect(labels[0]?.endsWith("…")).toBe(true);
expect(labels[1]).toBe("b");
});
});

describe("parallel calls to the same tool", () => {
Expand Down Expand Up @@ -969,8 +1154,7 @@ describe("lane paint", () => {
const answers = (rows[0]?.detail ?? []).map((line) =>
line.map((segment) => segment.text).join(""),
);
expect(answers).not.toEqual(["answered", "answered"]);
expect(answers).toContain("a");
expect(answers).toEqual(["echo a — a", "echo b — b"]);
expect(rows[0]?.resultText).toBe("b");
expect(rows[0]?.previewLines).toEqual(["b"]);
});
Expand Down
Loading
Loading