feat(maestro): keep agent text visible, collapse only tool calls
Agent text and thought rows stay outside the collapsed tool group as standalone transcript items instead of being absorbed into it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cd2184b128
commit
cb25320ea0
@@ -124,7 +124,7 @@ describe("activity grouping", () => {
|
||||
expect(activityGroupsByTurn(rows, null, false).size).toBe(0);
|
||||
});
|
||||
|
||||
it("absorbs intermediate narration but keeps the final answer outside", () => {
|
||||
it("keeps intermediate text outside the group as standalone items", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
@@ -133,11 +133,11 @@ describe("activity grouping", () => {
|
||||
text("agent:2", "agent_text", "done"),
|
||||
];
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
||||
expect(group.toolCount).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the newest thought outside while the turn is still live", () => {
|
||||
it("keeps thoughts outside the group as standalone items", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
@@ -145,26 +145,8 @@ describe("activity grouping", () => {
|
||||
text("thought:1", "agent_thought", "hmm"),
|
||||
text("thought:2", "agent_thought", "aha"),
|
||||
];
|
||||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1"]);
|
||||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2", "thought:1", "thought:2"]);
|
||||
});
|
||||
|
||||
it("keeps the newest agent_text outside while the turn is still live", () => {
|
||||
const rows = [
|
||||
text("user:1", "user", "go"),
|
||||
tool("tool:1"),
|
||||
text("agent:1", "agent_text", "working on it"),
|
||||
tool("tool:2"),
|
||||
text("agent:2", "agent_text", "almost done"),
|
||||
];
|
||||
const live = activityGroupsByTurn(rows, null, true).get("turn:user:1")!;
|
||||
// "almost done" stays outside as visible narration while the turn is live
|
||||
expect(live.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
const ended = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
// Once ended, the final agent_text stays outside as the answer
|
||||
expect(ended.rows.map((r) => r.id)).toEqual(["tool:1", "agent:1", "tool:2"]);
|
||||
const group = activityGroupsByTurn(rows, null, false).get("turn:user:1")!;
|
||||
expect(group.rows.map((r) => r.id)).toEqual(["tool:1", "tool:2"]);
|
||||
});
|
||||
|
||||
it("tracks the last tool's label and argument for the collapsed header", () => {
|
||||
|
||||
@@ -144,9 +144,10 @@ export function turnIsActive(state: ReducedState): boolean {
|
||||
}
|
||||
|
||||
export interface ActivityGroup {
|
||||
/** Header label count — tool rows only, never absorbed text. */
|
||||
/** Header label count — tool rows only. */
|
||||
toolCount: number;
|
||||
/** Tools plus absorbed intermediate text/approvals, in transcript order. */
|
||||
/** Tool rows and resolved approvals, in transcript order. Text/thought
|
||||
* rows stay outside the group as standalone transcript items. */
|
||||
rows: TranscriptRow[];
|
||||
/** `splitToolTitle` of the last tool row — the collapsed header shows it. */
|
||||
lastToolLabel: string;
|
||||
@@ -154,23 +155,16 @@ export interface ActivityGroup {
|
||||
}
|
||||
|
||||
/**
|
||||
* Group tool rows (and intermediate agent text / resolved approvals between
|
||||
* them) by turn. An agent_text/agent_thought row is "intermediate" when a tool
|
||||
* row follows it in the same turn — it gets absorbed into the group so the
|
||||
* collapsed block preserves chronological order. Resolved approval rows (not
|
||||
* the currently pending one) are absorbed the same way. The *last* agent text
|
||||
* with no subsequent tool stays standalone (the prominent final answer).
|
||||
*
|
||||
* While a turn is still live, the most recent text or thought row trailing the
|
||||
* group also stays outside as visible narration — the operator always sees the
|
||||
* agent's latest reasoning without opening the group.
|
||||
* Group tool rows (and resolved approvals between them) by turn. Text and
|
||||
* thought rows stay outside the group as standalone transcript items — the
|
||||
* operator sees all reasoning without expanding anything.
|
||||
*
|
||||
* Returns groups keyed by `turn:<userId>`.
|
||||
*/
|
||||
export function activityGroupsByTurn(
|
||||
rows: TranscriptRow[],
|
||||
pendingApprovalId: string | null,
|
||||
turnActive: boolean,
|
||||
_turnActive: boolean,
|
||||
): Map<string, ActivityGroup> {
|
||||
// First: count tools per turn to know which turns qualify for grouping.
|
||||
const toolCounts = new Map<string, number>();
|
||||
@@ -181,11 +175,9 @@ export function activityGroupsByTurn(
|
||||
const gid = `turn:${turnId}`;
|
||||
toolCounts.set(gid, (toolCounts.get(gid) ?? 0) + 1);
|
||||
}
|
||||
// Second: build groups with interleaved text for turns with 2+ tools.
|
||||
// Second: build groups for turns with 2+ tools.
|
||||
const groups = new Map<string, ActivityGroup>();
|
||||
turnId = "before-first-message";
|
||||
// Pending rows that might be intermediate (not yet confirmed by a following
|
||||
// tool). If no tool follows, they stay standalone.
|
||||
let pending: TranscriptRow[] = [];
|
||||
let currentGid = `turn:${turnId}`;
|
||||
for (const row of rows) {
|
||||
@@ -197,15 +189,13 @@ export function activityGroupsByTurn(
|
||||
}
|
||||
const count = toolCounts.get(currentGid) ?? 0;
|
||||
if (count < 2) continue;
|
||||
const isIntermediateText = row.kind === "agent_text" || row.kind === "agent_thought";
|
||||
const isResolvedApproval = row.kind === "approval" && row.id !== pendingApprovalId;
|
||||
if (isIntermediateText || isResolvedApproval) {
|
||||
if (isResolvedApproval) {
|
||||
pending.push(row);
|
||||
continue;
|
||||
}
|
||||
if (row.kind === "tool_call" || row.kind === "tool_diff") {
|
||||
const group = groups.get(currentGid) ?? { toolCount: count, rows: [], lastToolLabel: "", lastToolArg: "" };
|
||||
// Flush any pending rows — a tool follows, so they're intermediate.
|
||||
group.rows.push(...pending);
|
||||
pending = [];
|
||||
group.rows.push(row);
|
||||
@@ -216,25 +206,6 @@ export function activityGroupsByTurn(
|
||||
groups.set(currentGid, group);
|
||||
}
|
||||
}
|
||||
// Trailing text/thought rows after the last tool in this turn.
|
||||
// While the turn is still live, the newest one stays outside as visible
|
||||
// narration — the operator always sees the agent's latest reasoning.
|
||||
// A final agent_text (the "answer") stays outside regardless.
|
||||
const lastGroup = groups.get(currentGid);
|
||||
if (lastGroup && pending.length > 0) {
|
||||
const allThoughts = pending.every((row) => row.kind === "agent_thought");
|
||||
if (allThoughts) {
|
||||
// Pure thoughts: absorb all when done, keep the newest outside while live.
|
||||
const absorb = turnActive ? pending.slice(0, -1) : pending;
|
||||
lastGroup.rows.push(...absorb);
|
||||
} else if (turnActive) {
|
||||
// Mixed or agent_text trailing: keep the very last row outside while live,
|
||||
// absorb the rest.
|
||||
lastGroup.rows.push(...pending.slice(0, -1));
|
||||
}
|
||||
// When the turn ends with agent_text last, nothing extra is absorbed — the
|
||||
// text stays outside as the final answer.
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user