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
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName {
return { ios: "eye", android: "visibility" };
case "globe":
return { ios: "globe", android: "public" };
case "search":
return "magnifyingglass";
case "hammer":
return { ios: "hammer", android: "construction" };
case "lock":
Expand Down
89 changes: 89 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type OrchestrationV2TurnItem,
} from "@t3tools/contracts";
import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
import { summarizeToolGroup } from "@t3tools/client-runtime/work-log/presentation";
import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

Expand Down Expand Up @@ -59,6 +60,79 @@ it("keeps historical plan detail accessible from its paged turn item", () => {
expect(activity?.getFullDetail()).toContain("Full historical plan text");
});

it("shows only the structured path in expanded mobile read details", () => {
const item: OrchestrationV2TurnItem = {
...base("read-detail", "2026-06-20T00:00:03.000Z", 2),
type: "dynamic_tool",
toolName: "Read",
title: "Read src/env.ts",
input: { path: "src/env.ts" },
output: "---\nname: env\n---\nsecret content",
};
const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
)[0];

expect(activity?.getFullDetail()).toBe("src/env.ts");
expect(activity?.canExpand).toBe(true);
expect(activity?.getCopyText()).not.toContain("secret content");
expect(activity?.getFullDetail()).not.toContain("sourceThreadId");

const withoutPath = buildThreadFeed([
projected({ ...item, id: TurnItemId.make("read-without-path"), input: {} }, 0),
]).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : []))[0];
expect(withoutPath?.getFullDetail()).toBeNull();
expect(withoutPath?.canExpand).toBe(false);
});

it("labels file searches with the adapter title and its search target", () => {
const item: OrchestrationV2TurnItem = {
...base("file-search", "2026-06-20T00:00:03.000Z", 2),
type: "file_search",
title: "Searched TODO in web",
pattern: "TODO",
};
const activity = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
)[0];

expect(activity?.summary).toBe("Searched TODO in web");
expect(activity ? workEntryRowLabel(activity.workEntry) : null).toBe("Searched TODO in web");
});

it("keeps approval prompts rather than presenting them as tool work", () => {
const approval = (
id: string,
requestKind: "file-read" | "command" | "file-change",
ordinal: number,
) =>
({
...base(id, `2026-06-20T00:00:0${ordinal}.000Z`, ordinal),
type: "approval_request",
requestId: RuntimeRequestId.make(`request-${id}`),
requestKind,
prompt: `Allow ${requestKind}?`,
}) satisfies OrchestrationV2TurnItem;
const feed = buildThreadFeed([
projected(approval("approve-read", "file-read", 1), 0),
projected(approval("approve-command", "command", 2), 1),
projected(approval("approve-edit", "file-change", 3), 2),
]);
const activities = feed.flatMap((entry) =>
entry.type === "activity-group" ? entry.activities : [],
);

expect(activities.map((activity) => workEntryRowLabel(activity.workEntry))).toEqual([
"Allow file-read?",
"Allow command?",
"Allow file-change?",
]);
expect(activities[0]?.canExpand).toBe(true);
expect(
summarizeToolGroup(activities.slice(1).map((activity) => activity.workEntry)).summary,
).not.toMatch(/Ran|changed/);
});

function base(id: string, updatedAt: string, ordinal: number) {
const timestamp = DateTime.makeUnsafe(updatedAt);
return {
Expand Down Expand Up @@ -2049,6 +2123,21 @@ it("uses a compact reasoning preview and a short expanded heading", () => {
expect(workEntryRowLabel({ ...entry, toolLifecycleStatus: "completed" }, true)).toBe("Thought");
});

it("keeps search output in expanded details rather than the compact label", () => {
const entry = {
id: "search",
label: "Grep",
toolTitle: "Grep",
createdAt: "2026-09-17T12:00:00Z",
itemType: "dynamic_tool" as const,
tone: "tool" as const,
detail: "---\nfile body",
toolData: {},
};
expect(workEntryRowLabel(entry)).toBe("Grep");
expect(workEntryRowLabel(entry, true)).toBe("---\nfile body");
});

it.each(["First paragraph.\n\nSecond paragraph.", ""])(
"previews live reasoning text %j",
(text) => {
Expand Down
79 changes: 69 additions & 10 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,15 @@ import type {
ScheduledTaskId,
} from "@t3tools/contracts";
import { RunId, ThreadId } from "@t3tools/contracts";
import {
classifyToolActivity,
collectToolFilePaths,
computerUseToolTitle,
formatReadToolLabel,
formatSearchToolLabel,
} from "@t3tools/shared/toolActivity";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
import { computerUseToolTitle } from "@t3tools/shared/toolActivity";
import * as DateTime from "effect/DateTime";

export type PendingApproval = ThreadPendingApproval;
Expand Down Expand Up @@ -80,6 +86,7 @@ export interface ThreadFeedActivity {
| "edit"
| "eye"
| "globe"
| "search"
| "hammer"
| "lock"
| "message"
Expand Down Expand Up @@ -206,9 +213,32 @@ export function workEntryRowLabel(entry: WorkLogPresentationEntry, expanded = fa
const presentation = resolveWorkEntryToolPresentation(entry);
if (presentation) return presentation.displayName;
if (entry.command?.trim()) return compactWorkEntryText(commandDisplayText(entry.command));
const action = toolGroupAction(entry);
const isToolRead = action === "read" && entry.itemType === "dynamic_tool";
if (action === "code-search" || action === "search") {
const toolData =
entry.toolData !== null &&
typeof entry.toolData === "object" &&
!Array.isArray(entry.toolData)
? (entry.toolData as Record<string, unknown>)
: undefined;
// Adapters title file searches with their target; the item keeps only the pattern.
const searchLabel =
entry.itemType === "file_search" ? entry.label : formatSearchToolLabel(toolData);
if (searchLabel) return searchLabel;
}
if (isToolRead) {
const [firstPath] = entry.changedFiles ?? collectToolFilePaths(entry.toolData);
if (firstPath) {
return formatReadToolLabel(firstPath, Math.max(0, (entry.changedFiles?.length ?? 1) - 1));
}
if (!expanded) return "Read file";
}
const preview =
entry.command ??
entry.detail ??
(isToolRead || (!expanded && (action === "code-search" || action === "search"))
? null
: entry.detail) ??
(entry.changedFiles?.length
? entry.changedFiles.length === 1
? entry.changedFiles[0]!
Expand Down Expand Up @@ -414,6 +444,16 @@ function itemWorkLogTone(item: OrchestrationV2TurnItem): WorkLogPresentationEntr

function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
if (item.type === "notification") return "zap";
if (item.type === "dynamic_tool") {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
data: { toolName: item.toolName ?? undefined, input: item.input },
});
if (classified === "read") {
return "eye";
}
if (classified === "search") return "search";
}
switch (item.type) {
case "reasoning":
return "agent";
Expand All @@ -422,7 +462,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
case "file_change":
return "edit";
case "file_search":
return "eye";
return "search";
case "web_search":
return "globe";
case "approval_request":
Expand Down Expand Up @@ -486,7 +526,7 @@ function itemSummary(
? `Changed ${item.changes.length} files`
: `Changed ${item.fileName}`;
case "file_search":
return "Searched files";
return item.title?.trim() || formatSearchToolLabel(item) || "Searched files";
case "web_search":
return "Searched the web";
case "approval_request":
Expand All @@ -507,8 +547,20 @@ function itemSummary(
return "Thread forked";
case "thread_created":
return "Thread created";
case "dynamic_tool":
case "dynamic_tool": {
const classified = classifyToolActivity({
itemType: "dynamic_tool_call",
data: { toolName: item.toolName ?? undefined, input: item.input },
});
if (classified === "read") {
const [path] = collectToolFilePaths({ input: item.input });
return formatReadToolLabel(path ?? "");
}
if (classified === "search") {
return formatSearchToolLabel({ input: item.input }) ?? item.toolName ?? "Tool call";
}
return toolPresentation?.displayName ?? item.toolName ?? "Tool call";
}
case "proposed_plan":
return "Proposed plan";
case "todo_list":
Expand Down Expand Up @@ -651,8 +703,15 @@ function toFeedActivity(
const detail = item.type === "notification" ? null : itemPreview(item);
const createdAt = DateTime.formatIso(item.startedAt ?? item.updatedAt);
const workEntry = toWorkLogEntry(item, createdAt, summary, detail);
const getFullDetail = memoizeValue(() =>
JSON.stringify(
const readPaths =
item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read"
? collectToolFilePaths(item)
: null;
const getFullDetail = memoizeValue(() => {
if (readPaths) {
return readPaths.join("\n") || null;
}
return JSON.stringify(
{
visibility: row.visibility,
sourceThreadId: row.sourceThreadId,
Expand All @@ -661,8 +720,8 @@ function toFeedActivity(
},
null,
2,
),
);
);
});
const getCopyText = memoizeValue(() =>
[summary, detail, getFullDetail()]
.filter(
Expand All @@ -678,7 +737,7 @@ function toFeedActivity(
attemptId,
summary,
detail,
canExpand: !(item.type === "error" && item.status === "failed"),
canExpand: !(item.type === "error" && item.status === "failed") && (readPaths?.length ?? 1) > 0,
getFullDetail,
getCopyText,
icon: workEntry.toolSurface ?? itemIcon(item),
Expand Down
17 changes: 17 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,6 +1054,23 @@ const program = Effect.gen(function* () {
},
],
},
{
sessionUpdate: "tool_call_update",
toolCallId: "structured-read",
title: "Read `src/env.ts`",
kind: "read",
status: "completed",
rawInput: { path: "src/env.ts" },
locations: [{ path: "src/env.ts" }],
},
{
sessionUpdate: "tool_call_update",
toolCallId: "structured-search",
title: "Grep",
kind: "search",
status: "completed",
rawInput: { query: "TODO", path: "apps/web" },
},
{
sessionUpdate: "compaction_update",
compactionId: "compact-1",
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,16 @@ describe("AcpAdapterV2", () => {
item.changes[0]?.oldPath === "/workspace/old.ts",
),
);
const read = items.find((item) => item.type === "dynamic_tool" && item.toolName === "Read");
assert.deepEqual(
read?.type === "dynamic_tool" ? { title: read.title, input: read.input } : null,
{ title: "Read src/env.ts", input: { path: "src/env.ts" } },
);
const search = items.find((item) => item.type === "file_search");
assert.deepEqual(
search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null,
{ title: "Searched TODO in web", pattern: "apps/web" },
);
const completedCompaction = items.find(
(item) =>
item.type === "compaction" &&
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import * as EffectAcpErrors from "effect-acp/errors";
import type * as EffectAcpProtocol from "effect-acp/protocol";
import type * as EffectAcpSchema from "effect-acp/compat";

import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import {
Expand Down Expand Up @@ -3251,9 +3252,28 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV
} else {
switch (toolCall.kind) {
case "read":
turnItem = {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
...base,
title: path ? formatReadToolLabel(path) : (title ?? "Read file"),
type: "dynamic_tool",
toolName: "Read",
input:
path === undefined ||
["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path)
? (rawInputRecord ?? {})
: { ...rawInputRecord, path },
...(rawOutput === undefined ? {} : { output: rawOutput }),
};
break;
case "search":
turnItem = {
...base,
title:
formatSearchToolLabel({
rawInput: rawInputRecord,
input: rawInputRecord,
...(path === undefined ? {} : { pattern: path }),
}) ?? title,
type: "file_search",
...(path === undefined ? {} : { pattern: path }),
...(path === undefined
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2762,6 +2762,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
const tools = [
{ id: "image", name: "Read", input: { file_path: " /workspace/reference.png " } },
{ id: "text", name: "Read", input: { file_path: "/workspace/README.md" } },
{ id: "search", name: "Grep", input: { pattern: "TODO", path: "/workspace/src" } },
{
id: "write",
name: "Write",
Expand Down Expand Up @@ -2823,6 +2824,15 @@ describe("ClaudeAdapterV2 background wake turns", () => {
assert.equal(image?.type, "dynamic_tool");
if (image?.type === "dynamic_tool")
assert.equal(image.viewedImagePath, "/workspace/reference.png");
assert.equal(image?.title, "Read /workspace/reference.png");
assert.equal(
items.find((item) => item.nativeItemRef?.nativeId === "text")?.title,
"Read /workspace/README.md",
);
assert.equal(
items.find((item) => item.nativeItemRef?.nativeId === "search")?.title,
"Searched TODO in src",
);
for (const item of items.filter((item) => item.nativeItemRef?.nativeId !== "image"))
assert.notProperty(item, "viewedImagePath");
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, idAllocatorLayer))),
Expand Down
Loading
Loading