Skip to content

Commit 9cb6092

Browse files
feat(web): summarize T3 orchestration actions
1 parent 7e02b98 commit 9cb6092

7 files changed

Lines changed: 677 additions & 62 deletions

File tree

‎apps/web/src/components/chat/MessagesTimeline.logic.test.ts‎

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { describe, expect, it } from "vite-plus/test";
2+
import { RunId, ThreadId, TurnItemId } from "@t3tools/contracts";
3+
import * as DateTime from "effect/DateTime";
4+
import type { WorkLogEntry } from "../../session-logic";
25
import {
36
computeStableMessagesTimelineRows,
47
computeMessageDurationStart,
@@ -7,8 +10,125 @@ import {
710
resolveAssistantMessageCopyState,
811
resolveTimelineToolPresentation,
912
shouldPreserveAssistantLineBreaks,
13+
summarizeToolGroup,
1014
} from "./MessagesTimeline.logic";
1115

16+
describe("summarizeToolGroup", () => {
17+
const now = DateTime.makeUnsafe("2026-08-29T00:00:00Z");
18+
function work(overrides: Partial<WorkLogEntry> = {}): WorkLogEntry {
19+
return {
20+
id: "tool",
21+
createdAt: "2026-08-29T00:00:00Z",
22+
label: "Tool call",
23+
tone: "tool",
24+
toolLifecycleStatus: "completed",
25+
...overrides,
26+
};
27+
}
28+
function t3(toolName: string, input: unknown = {}, output: unknown = {}): WorkLogEntry {
29+
return work({
30+
itemType: "dynamic_tool",
31+
toolTitle: "Friendly display name",
32+
structuredPayload: {
33+
id: TurnItemId.make("tool"),
34+
threadId: ThreadId.make("parent"),
35+
runId: RunId.make("run"),
36+
nodeId: null,
37+
providerThreadId: null,
38+
providerTurnId: null,
39+
nativeItemRef: null,
40+
parentItemId: null,
41+
ordinal: 0,
42+
status: "completed",
43+
title: "Friendly display name",
44+
startedAt: now,
45+
completedAt: now,
46+
updatedAt: now,
47+
type: "dynamic_tool",
48+
toolName,
49+
input,
50+
output,
51+
},
52+
});
53+
}
54+
55+
it("summarizes commands and thread messages using canonical names despite humanized titles", () => {
56+
const entries = [
57+
work({ command: "git diff" }),
58+
...["mcp__t3-code__t3_thread_send", "t3_code.t3_thread_send", "t3_thread_send"].map(
59+
(toolName, i) =>
60+
t3(
61+
toolName,
62+
{ threadId: `thread-${i}` },
63+
{ messageId: `message-${i}`, threadId: `thread-${i}` },
64+
),
65+
),
66+
work({ command: "git status" }),
67+
];
68+
expect(summarizeToolGroup(entries)).toEqual({
69+
summary: "Ran 2 commands and sent messages to 3 threads",
70+
hasFailure: false,
71+
});
72+
});
73+
74+
it("prioritizes actions over polling, caps the clauses, and retains an omitted failure", () => {
75+
const status = t3("task_status", { taskId: "task-1" }, { taskId: "task-1", status: "running" });
76+
expect(
77+
summarizeToolGroup([
78+
status,
79+
status,
80+
status,
81+
work({ command: "git diff" }),
82+
t3(
83+
"t3_thread_send",
84+
{ threadId: "thread-1" },
85+
{ threadId: "thread-1", messageId: "message-1" },
86+
),
87+
t3(
88+
"schedule_task",
89+
{},
90+
{ isError: true, content: [{ type: "text", text: "Schedule rejected" }] },
91+
),
92+
status,
93+
]),
94+
).toEqual({
95+
summary:
96+
"1 failed · Ran 1 command, sent 1 message to 1 thread, and performed 5 other actions",
97+
hasFailure: true,
98+
});
99+
});
100+
101+
it("counts status checks without treating the reported child failure as a failed tool", () => {
102+
const status = {
103+
...t3("task_status", { taskId: "task-1" }, { taskId: "task-1", status: "failed" }),
104+
detail: "Child failed: command not found",
105+
};
106+
expect(
107+
summarizeToolGroup([
108+
t3("delegate_task", {}, { taskId: "task-1" }),
109+
t3("delegate_task", {}, { taskId: "task-2" }),
110+
status,
111+
status,
112+
status,
113+
status,
114+
]),
115+
).toEqual({
116+
summary: "Delegated 2 tasks and checked task status 4 times",
117+
hasFailure: false,
118+
});
119+
});
120+
121+
it("leaves foreign, unknown, and preview tools generic instead of guessing from display titles", () => {
122+
expect(
123+
summarizeToolGroup([
124+
{ ...t3("mcp__github__t3_thread_send"), toolTitle: "t3-code.t3_thread_send" },
125+
t3("t3-code.future_tool"),
126+
t3("t3-code.preview_snapshot"),
127+
]),
128+
).toEqual({ summary: "Used 3 tools", hasFailure: false });
129+
});
130+
});
131+
12132
describe("shouldPreserveAssistantLineBreaks", () => {
13133
it("preserves Claude insight formatting without changing regular markdown", () => {
14134
expect(
@@ -1242,36 +1362,36 @@ describe("deriveMessagesTimelineRows", () => {
12421362
expect(assistantRow?.showAssistantCopyButton).toBe(false);
12431363
});
12441364

1245-
it("summarizes a settled tool group even when a command in it failed", () => {
1365+
it("retains a failed command in the summary after a later command succeeds", () => {
12461366
const rows = deriveMessagesTimelineRows({
12471367
timelineEntries: [
12481368
{
1249-
id: "work-entry-ok",
1369+
id: "work-entry-failed",
12501370
kind: "work" as const,
12511371
createdAt: "2026-01-01T00:00:01Z",
12521372
entry: {
1253-
id: "work-ok",
1373+
id: "work-failed",
12541374
createdAt: "2026-01-01T00:00:01Z",
12551375
label: "Ran command",
1256-
command: "true",
1376+
command: "ssh host true",
1377+
detail: "connection refused",
12571378
tone: "tool" as const,
12581379
itemType: "command_execution" as const,
1259-
toolLifecycleStatus: "completed" as const,
1380+
toolLifecycleStatus: "failed" as const,
12601381
},
12611382
},
12621383
{
1263-
id: "work-entry-failed",
1384+
id: "work-entry-ok",
12641385
kind: "work" as const,
12651386
createdAt: "2026-01-01T00:00:02Z",
12661387
entry: {
1267-
id: "work-failed",
1388+
id: "work-ok",
12681389
createdAt: "2026-01-01T00:00:02Z",
12691390
label: "Ran command",
1270-
command: "ssh host true",
1271-
detail: "connection refused",
1391+
command: "true",
12721392
tone: "tool" as const,
12731393
itemType: "command_execution" as const,
1274-
toolLifecycleStatus: "failed" as const,
1394+
toolLifecycleStatus: "completed" as const,
12751395
},
12761396
},
12771397
],
@@ -1283,7 +1403,7 @@ describe("deriveMessagesTimelineRows", () => {
12831403
expect(rows).toHaveLength(1);
12841404
expect(rows[0]).toMatchObject({
12851405
kind: "work-toggle",
1286-
summary: "Ran 2 commands",
1406+
summary: "1 failed · Ran 2 commands",
12871407
hasFailure: true,
12881408
});
12891409
});

‎apps/web/src/components/chat/MessagesTimeline.logic.ts‎

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ import {
1717
type RunId,
1818
} from "@t3tools/contracts";
1919
import type { ThreadRunSummary } from "@t3tools/client-runtime/state/shell";
20+
import {
21+
summarizeT3ToolCalls,
22+
type T3ToolSummaryCall,
23+
} from "@t3tools/client-runtime/t3ToolSummary";
2024
import {
2125
resolveT3McpToolPresentation,
26+
resolveT3McpToolSummaryAction,
2227
type T3McpToolPresentation,
28+
type T3McpToolSummaryAction,
2329
} from "@t3tools/shared/t3McpToolPresentation";
2430

2531
export function workEntryIsVisibleInGroup(
@@ -360,24 +366,112 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
360366
}
361367
}
362368

363-
/** Immediate, provider-neutral fallback while generated tool summaries are unavailable. */
364-
export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): string {
365-
const groupedEntries = new Map<ToolGroupAction, WorkLogEntry[]>();
369+
function t3ToolSummaryCall(entry: WorkLogEntry): T3ToolSummaryCall {
370+
const item = entry.structuredPayload ?? entry.projectedItem?.item;
371+
const data =
372+
entry.toolData !== null && typeof entry.toolData === "object"
373+
? (entry.toolData as Record<string, unknown>)
374+
: undefined;
375+
return {
376+
input: item?.type === "dynamic_tool" ? item.input : data?.input,
377+
output: item?.type === "dynamic_tool" ? item.output : data?.output,
378+
// A status/read result may describe a failed child. Only the call's own lifecycle
379+
// and MCP error envelope determine whether the orchestration action failed.
380+
outcome:
381+
entry.toolLifecycleStatus === "failed" ||
382+
entry.toolLifecycleStatus === "declined" ||
383+
entry.tone === "error"
384+
? "failed"
385+
: entry.toolLifecycleStatus === "completed"
386+
? "completed"
387+
: "unfinished",
388+
};
389+
}
390+
391+
function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction): number {
392+
switch (action) {
393+
case "command":
394+
case "edit":
395+
case "delegate":
396+
case "task-cancel":
397+
case "thread-create":
398+
case "thread-send":
399+
case "thread-interrupt":
400+
case "schedule-create":
401+
case "schedule-update":
402+
case "schedule-delete":
403+
return 0;
404+
case "other":
405+
case "update":
406+
return 2;
407+
default:
408+
return 1;
409+
}
410+
}
411+
412+
/** Summarizes at most two action categories; every omitted call still counts in the remainder. */
413+
export function summarizeToolGroup(entries: ReadonlyArray<WorkLogEntry>): {
414+
summary: string;
415+
hasFailure: boolean;
416+
} {
417+
const groups = new Map<
418+
ToolGroupAction | T3McpToolSummaryAction,
419+
{
420+
action: ToolGroupAction;
421+
t3Action: T3McpToolSummaryAction | null;
422+
entries: WorkLogEntry[];
423+
}
424+
>();
366425
for (const entry of entries) {
426+
const item = entry.structuredPayload ?? entry.projectedItem?.item;
427+
const t3Action = resolveT3McpToolSummaryAction(
428+
(item?.type === "dynamic_tool" ? item.toolName : null) ?? entry.toolTitle ?? entry.label,
429+
);
367430
const action = toolGroupAction(entry);
368-
const group = groupedEntries.get(action);
369-
if (group) group.push(entry);
370-
else groupedEntries.set(action, [entry]);
431+
const key = t3Action ?? action;
432+
const group = groups.get(key);
433+
if (group) group.entries.push(entry);
434+
else groups.set(key, { action, t3Action, entries: [entry] });
435+
}
436+
const summaries = [...groups].map(([action, group], index) => ({
437+
index,
438+
count: group.entries.length,
439+
priority: summaryActionPriority(action),
440+
...(group.t3Action
441+
? summarizeT3ToolCalls(group.t3Action, group.entries.map(t3ToolSummaryCall))
442+
: {
443+
label: toolGroupActionLabel(
444+
group.action,
445+
toolGroupActionCount(group.action, group.entries),
446+
),
447+
failedCount: group.entries.filter(workEntryDisplayIndicatesToolFailure).length,
448+
unfinishedCount: 0,
449+
}),
450+
}));
451+
const selected = summaries
452+
.toSorted((a, b) => a.priority - b.priority || a.index - b.index)
453+
.slice(0, 2)
454+
.sort((a, b) => a.index - b.index);
455+
const labels = selected.map(({ label }) => label);
456+
const remainingCount = entries.length - selected.reduce((count, group) => count + group.count, 0);
457+
if (remainingCount > 0) {
458+
labels.push(`Performed ${remainingCount} other ${remainingCount === 1 ? "action" : "actions"}`);
371459
}
372-
const labels = [...groupedEntries].map(([action, actionEntries]) =>
373-
toolGroupActionLabel(action, toolGroupActionCount(action, actionEntries)),
374-
);
375460
const sentenceLabels = labels.map((label, index) =>
376461
index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1),
377462
);
378-
if (sentenceLabels.length < 2) return sentenceLabels[0] ?? "";
379-
if (sentenceLabels.length === 2) return sentenceLabels.join(" and ");
380-
return `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
463+
const summary =
464+
sentenceLabels.length < 3
465+
? sentenceLabels.join(" and ")
466+
: `${sentenceLabels.slice(0, -1).join(", ")}, and ${sentenceLabels.at(-1)}`;
467+
const failedCount = summaries.reduce((count, group) => count + group.failedCount, 0);
468+
const unfinishedCount = summaries.reduce((count, group) => count + group.unfinishedCount, 0);
469+
const statuses = [
470+
...(failedCount > 0 ? [`${failedCount} failed`] : []),
471+
...(unfinishedCount > 0 ? [`${unfinishedCount} unfinished`] : []),
472+
];
473+
// Keep failure counts visible when a narrow timeline truncates the action text.
474+
return { summary: [...statuses, summary].join(" · "), hasFailure: failedCount > 0 };
381475
}
382476

383477
function toolGroupSummaryKind(entries: ReadonlyArray<WorkLogEntry>): ToolGroupSummaryKind {
@@ -833,7 +927,7 @@ export function deriveMessagesTimelineRows(input: {
833927
const groupId = workGroupId(timelineEntry.id);
834928
const expanded = input.expandedWorkGroupIds?.has(groupId) ?? false;
835929
const summaryKind = toolGroupSummaryKind(visibleGroupedEntries);
836-
const latestToolEntry = visibleGroupedEntries.findLast(workLogEntryIsToolLike);
930+
const groupSummary = summarizeToolGroup(visibleGroupedEntries);
837931
nextRows.push({
838932
kind: "work-toggle",
839933
id: `work-toggle:${timelineEntry.id}`,
@@ -845,11 +939,9 @@ export function deriveMessagesTimelineRows(input: {
845939
visibleGroupedEntries.length === 1 &&
846940
!workLogEntryIsToolLike(visibleGroupedEntries[0]!)
847941
? visibleGroupedEntries[0]!.label
848-
: summarizeToolGroup(visibleGroupedEntries),
942+
: groupSummary.summary,
849943
summaryKind,
850-
hasFailure:
851-
latestToolEntry !== undefined &&
852-
workEntryDisplayIndicatesToolFailure(latestToolEntry),
944+
hasFailure: groupSummary.hasFailure,
853945
});
854946
if (expanded) {
855947
for (const [entryIndex, workEntry] of visibleGroupedEntries.entries()) {

‎docs/user/activity-log.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Activity log
2+
3+
On web and desktop, consecutive tool calls appear as an expandable summary. Open it to inspect
4+
each command or tool result.
5+
6+
T3 Orchestrator summaries describe the actions taken, such as **Ran 2 commands and sent messages
7+
to 3 threads**. Repeated messages to the same destinations show both counts: **Sent 5 messages to
8+
2 threads**. Thread creation counts the threads returned by the tool, including batches.
9+
10+
Groups with many kinds of activity show up to two specific action categories and a count of the
11+
remaining actions. Commands, file changes, and orchestration changes take priority over reads and
12+
status checks. Expand the group for the full list.
13+
14+
Failed calls are counted separately and do not count as successful messages or creations. Waiting
15+
on a thread does not mean it finished, and an interrupt or cancellation request does not mean the
16+
thread stopped. When tool details are unavailable, summaries use a broader description instead of
17+
guessing how many threads were affected.

‎packages/client-runtime/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@
4343
"types": "./src/providerSkills.ts",
4444
"default": "./src/providerSkills.ts"
4545
},
46+
"./t3ToolSummary": {
47+
"types": "./src/t3ToolSummary.ts",
48+
"default": "./src/t3ToolSummary.ts"
49+
},
4650
"./relay": {
4751
"types": "./src/relay/index.ts",
4852
"default": "./src/relay/index.ts"

0 commit comments

Comments
 (0)