Skip to content
Open
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
49 changes: 48 additions & 1 deletion apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,50 @@ function makeThread(
}

describe("buildThreadFeed", () => {
it("retains untitled tool identity through sparse lifecycle updates", () => {
const turnId = TurnId.make("untitled-turn");
const activities = ["tool.updated", "tool.updated", "tool.completed"].map((kind, index) =>
makeActivity({
id: EventId.make(`untitled-${index}`),
kind,
tone: "tool",
summary: kind === "tool.completed" ? "Tool" : "Tool updated",
createdAt: `2026-09-15T00:00:0${index}.000Z`,
turnId,
payload: {
itemType: "dynamic_tool_call",
toolCallId: "read-1",
...(index === 0 ? { data: { toolName: "Read", kind: "read" } } : {}),
},
}),
);
for (let count = 1; count <= activities.length; count++) {
const [group] = buildThreadFeed(
makeThread({
id: ThreadId.make("untitled-tools"),
projectId: ProjectId.make("project-1"),
title: "Untitled tools",
activities: activities.slice(0, count),
}),
);
expect(group?.type).toBe("activity-group");
if (group?.type !== "activity-group") return;
expect(group.activities).toHaveLength(1);
expect(workEntryRowLabel(group.activities[0]!.workEntry)).toBe("Read file");
const rows = deriveThreadFeedPresentation(
[group],
{
turnId,
state: count < activities.length ? "running" : "completed",
startedAt: activities[0]!.createdAt,
completedAt: count < activities.length ? null : activities.at(-1)!.createdAt,
},
new Set([turnId]),
);
expect(rows.find((row) => row.type === "work-toggle")?.summary).toBe("Read file");
}
});

it("reuses unchanged feed and presentation rows during an assistant text update", () => {
const completedTurnId = TurnId.make("completed-turn");
const activeTurnId = TurnId.make("active-turn");
Expand Down Expand Up @@ -579,7 +623,10 @@ describe("buildThreadFeed", () => {
expect(row?.workEntry.detail).toBe(command);
expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`);
expect(row?.canExpand).toBe(true);
expect(workEntryRowLabel(row!.workEntry, true)).toBe("Command");
expect(workEntryRowLabel(row!.workEntry, true)).toBe("Ran printf");
expect(workEntryRowLabel({ ...row!.workEntry, toolLifecycleStatus: "inProgress" }, true)).toBe(
"Running printf",
);
});

it.each([
Expand Down
46 changes: 25 additions & 21 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,8 +463,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
: null;
const commandPreview = extractToolCommand(payload);
const changedFiles = extractChangedFiles(payload);
const title = extractToolTitle(payload);
const toolPresentation = extractToolActivityPresentation(payload);
const toolPresentation = extractToolActivityPresentation(payload, activity.summary);
const title = toolPresentation.toolTitle ?? null;
// Terminal task updates carry identity so they replace each child's progress row.
const isTaskActivity =
activity.kind === "task.started" ||
Expand Down Expand Up @@ -1018,7 +1018,9 @@ export function workEntryRowLabel(entry: WorkLogEntry, expanded = false): string
if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn);
const presentation = resolveWorkEntryToolPresentation(entry);
if (presentation) return presentation.displayName;
if (expanded && entry.command?.trim()) return "Command";
if (expanded && entry.command?.trim()) {
return commandWorkEntryLabel(entry.command, entry.toolLifecycleStatus);
}
const preview = workEntryPreview(entry);
if (expanded) return preview?.trim() || workEntryHeading(entry);
const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview));
Expand Down Expand Up @@ -1381,10 +1383,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
};
}

function extractToolTitle(payload: Record<string, unknown> | null): string | null {
return asTrimmedString(payload?.title);
}

function stripTrailingExitCode(value: string): {
output: string | null;
exitCode?: number | undefined;
Expand Down Expand Up @@ -2079,20 +2077,26 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo
if (presentation) return presentation.displayName;
const command = activity.workEntry.command?.trim();
if (command) {
const program = commandProgramName(command);
const verb =
status === "inProgress"
? "Running"
: status === "failed"
? "Failed"
: status === "declined"
? "Declined"
: status === "stopped"
? "Stopped"
: "Ran";
return `${verb} ${program ?? "command"}`;
}
return activity.detail ?? activity.summary;
return commandWorkEntryLabel(command, status);
}
return workEntryRowLabel(activity.workEntry);
}

function commandWorkEntryLabel(
command: string,
status: WorkLogToolLifecycleStatus | undefined,
): string {
const verb =
status === "inProgress"
? "Running"
: status === "failed"
? "Failed"
: status === "declined"
? "Declined"
: status === "stopped"
? "Stopped"
: "Ran";
return `${verb} ${commandProgramName(command) ?? "command"}`;
}

export function setPendingUserInputCustomAnswer(
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/orchestration/ActivityPayloadProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,63 @@ function activity(payload: Record<string, unknown>): OrchestrationThreadActivity
* assertions are the tripwire.
*/
describe("projectActivityPayload", () => {
it.each([
{
data: { toolName: "Read", input: { file_path: "/repo/app.ts" } },
title: "Read file",
detail: "/repo/app.ts",
},
{
data: { kind: "read", locations: [{ path: "/repo/app.ts" }] },
title: "Read file",
detail: "/repo/app.ts",
},
{
data: { tool: "grep", input: { pattern: "cleanup" } },
title: "Searched files",
detail: "cleanup",
},
{
data: { toolName: "WebSearch", input: { query: "React cleanup" } },
title: "Searched the web",
detail: "React cleanup",
},
{
data: { toolName: "Edit", input: { file_path: "/repo/app.ts" } },
title: "Changed files",
detail: "/repo/app.ts",
},
{
data: { item: { tool: "list_issues", server: "github" } },
title: "github.list_issues",
detail: undefined,
},
])("retains untitled $title identity before slimming", ({ data, title, detail }) => {
const itemType = "item" in data ? "mcp_tool_call" : "dynamic_tool_call";
const source = activity({ itemType, data });
const projected = projectActivityPayload(source);
expect(projected.payload).toMatchObject({ title, ...(detail ? { detail } : {}) });
expect(source.payload).toEqual({ itemType, data });
expect(projectActivityPayload(projected)).toEqual(projected);
});

it("bounds inferred targets without replacing existing output or useful labels", () => {
const data = { toolName: "Read", input: { file_path: `/repo/${"a".repeat(1000)}.ts` } };
expect(
projectActivityPayload(activity({ itemType: "dynamic_tool_call", data })).payload,
).toMatchObject({ title: "Read file", detail: data.input.file_path.slice(0, 180) });
expect(
projectActivityPayload(
activity({ itemType: "dynamic_tool_call", detail: "Permission denied", data }),
).payload,
).toMatchObject({ title: "Read file", detail: "Permission denied" });
expect(
projectActivityPayload(
activity({ itemType: "dynamic_tool_call", title: "Inspect config", data }),
).payload,
).toMatchObject({ title: "Inspect config" });
});

it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => {
const projected = projectActivityPayload(
activity({
Expand Down
27 changes: 25 additions & 2 deletions apps/server/src/orchestration/ActivityPayloadProjection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { projectQuestionToolInput } from "@t3tools/shared/toolActivity";
import {
deriveToolActivityPresentation,
isGenericToolLabel,
projectQuestionToolInput,
} from "@t3tools/shared/toolActivity";
import { isToolLifecycleItemType } from "@t3tools/contracts";
import type {
OrchestrationEvent,
OrchestrationThreadActivity,
Expand Down Expand Up @@ -425,12 +430,30 @@ function projectAcpContent(value: unknown): Record<string, unknown> | undefined
export function projectActivityPayload(
activity: OrchestrationThreadActivity,
): OrchestrationThreadActivity {
const payload = asRecord(activity.payload);
let payload = asRecord(activity.payload);
const data = asRecord(payload?.data);
if (!payload || !data) {
return activity;
}

// Derive identity before slimming drops primary arguments, including historical rows.
if (
typeof payload.itemType === "string" &&
isToolLifecycleItemType(payload.itemType) &&
isGenericToolLabel(asTrimmedString(payload.title) ?? activity.summary)
) {
const presentation = deriveToolActivityPresentation({ itemType: payload.itemType, data });
if (!isGenericToolLabel(presentation.summary)) {
payload = {
...payload,
title: presentation.summary.slice(0, 180),
...(!payload.detail && presentation.detail
? { detail: presentation.detail.slice(0, 180) }
: {}),
};
}
}

const itemStatus = asRecord(data.item)?.status;
const statusPayload =
payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined")
Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
: null;
const commandPreview = extractToolCommand(payload);
const changedFiles = extractChangedFiles(payload);
const title = extractToolTitle(payload);
const toolPresentation = extractToolActivityPresentation(payload);
const toolPresentation = extractToolActivityPresentation(payload, activity.summary);
const title = toolPresentation.toolTitle ?? null;
const isTaskActivity =
activity.kind === "task.started" ||
activity.kind === "task.progress" ||
Expand Down Expand Up @@ -1142,10 +1142,6 @@ function extractToolCommand(payload: Record<string, unknown> | null): {
};
}

function extractToolTitle(payload: Record<string, unknown> | null): string | null {
return asTrimmedString(payload?.title);
}

function extractToolCallId(payload: Record<string, unknown> | null): string | null {
const data = asRecord(payload?.data);
return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
Expand Down
9 changes: 9 additions & 0 deletions packages/client-runtime/src/work-log/toolPresentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { describe, expect, it } from "@effect/vitest";
import { extractToolActivityPresentation } from "./toolPresentation.ts";

describe("extractToolActivityPresentation", () => {
it("uses retained tool metadata without inventing identity for sparse updates", () => {
const payload = { itemType: "dynamic_tool_call", title: "Tool updated" };
expect(
extractToolActivityPresentation({ ...payload, data: { toolName: "Read" } }, "Tool updated"),
).toEqual({ toolTitle: "Read file" });
expect(extractToolActivityPresentation(payload, "Tool updated")).toEqual({});
expect(extractToolActivityPresentation({ itemType: "web_search" }, "Web search")).toEqual({});
});

it("reads provider-neutral presentation fields", () => {
expect(
extractToolActivityPresentation({
Expand Down
13 changes: 13 additions & 0 deletions packages/client-runtime/src/work-log/toolPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import type {
ToolActivitySource,
ToolActivitySurface,
} from "@t3tools/contracts";
import { isToolLifecycleItemType } from "@t3tools/contracts";
import { deriveToolActivityPresentation, isGenericToolLabel } from "@t3tools/shared/toolActivity";

export interface ExtractedToolActivityPresentation {
readonly toolTitle?: string;
readonly toolSurface?: ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
readonly toolSource?: ToolActivitySource;
Expand Down Expand Up @@ -107,15 +110,25 @@ function activitySource(value: unknown): ToolActivitySource | undefined {

export function extractToolActivityPresentation(
payloadValue: unknown,
fallbackSummary?: string,
): ExtractedToolActivityPresentation {
const payload = asRecord(payloadValue);
const title = typeof payload?.title === "string" ? payload.title.trim() : undefined;
const label = title || fallbackSummary;
const toolTitle =
isGenericToolLabel(label) &&
typeof payload?.itemType === "string" &&
isToolLifecycleItemType(payload.itemType)
? deriveToolActivityPresentation({ itemType: payload.itemType, data: payload.data }).summary
: title;
const toolSurface =
payload?.toolSurface === "browser" || payload?.toolSurface === "computer"
? payload.toolSurface
: undefined;
const toolIcon = activityIcon(payload?.toolIcon);
const toolSource = activitySource(payload?.toolSource);
return {
...(toolTitle && !isGenericToolLabel(toolTitle) ? { toolTitle } : {}),
...(toolSurface ? { toolSurface } : {}),
...(toolIcon ? { toolIcon } : {}),
...(toolSource ? { toolSource } : {}),
Expand Down
29 changes: 29 additions & 0 deletions packages/shared/src/toolActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,35 @@ import { describe, expect, it } from "vite-plus/test";
import { deriveToolActivityPresentation } from "./toolActivity.ts";

describe("toolActivity", () => {
it.each([
{ data: { rawInput: {}, input: { query: " cleanup " } }, detail: "cleanup" },
{
data: {
rawInput: { query: " ", pattern: 42 },
input: { searchTerm: null },
item: { arguments: { pattern: " TODO " } },
},
detail: "TODO",
},
{
data: {
rawInput: { searchTerm: "raw" },
input: { query: "input" },
item: { arguments: { query: "item" } },
},
detail: "raw",
},
{
data: { rawInput: { query: "query", pattern: "pattern", searchTerm: "term" } },
detail: "query",
},
])("uses the first valid search query ($detail)", ({ data, detail }) => {
expect(deriveToolActivityPresentation({ title: "Grep", data })).toEqual({
summary: "Searched files",
detail,
});
});

it("normalizes command tools to a stable ran-command label", () => {
expect(
deriveToolActivityPresentation({
Expand Down
Loading
Loading