authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 07:58:54-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 10:58:54+00:00
log5a96895a85b43c838da6c89cf21068b99c003d5a
treef044473d6182ef7b47d867e98d570244d27e0bbf
parent33b7d7b3ae2a7b1c3d1bd58c04cb9bf581ec2fe7
signature Signed by PGP key B5690EEEBB952194

fix: expanded tool calls show their output, empty ones don't expand (#15505)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

34 files changed, 1225 insertions(+), 63 deletions(-)

apps/mobile/src/features/threads/thread-work-log.tsx+43-2
......@@ -60,9 +60,12 @@ import { cn } from "../../lib/cn";
6060import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
6161import {
6262 type AgentSpawnSummary,
63 formatItemFullDetail,
6364 type ThreadFeedActivity,
6465 workEntryRowLabel,
6566} from "../../lib/threadActivity";
67import { turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail";
68import { useTurnItemDetail } from "../../state/queries";
6669import {
6770 resolveThreadWorkGroupInitialScroll,
6871 shouldFollowThreadWorkGroupAppend,
......@@ -70,6 +73,7 @@ import {
7073} from "./thread-feed-live-follow";
7174import {
7275 resolveWorkEntryToolPresentation,
76 toolGroupAction,
7377 type ToolGroupSummaryKind,
7478 workEntryViewedImagePath,
7579} from "@t3tools/client-runtime/work-log/presentation";
......@@ -836,6 +840,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
836840) {
837841 const { row, expanded } = props;
838842 const navigation = useNavigation();
843 const fetchedDetail = useTurnItemDetail(
844 expanded && row.fetchesDetail
845 ? { environmentId: props.environmentId, row: row.projectedItem }
846 : null,
847 );
839848 const failureItem = row.projectedItem.item;
840849 if (failureItem.type === "error" && failureItem.status === "failed") {
841850 const warning = failureItem.failure.class === "usage_limit";
......@@ -917,7 +926,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
917926 : undefined;
918927 const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;
919928 const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;
920 const fullDetail = expanded && !reasoning ? row.getFullDetail() : null;
929 const fetchedItem = fetchedDetail.data?.item ?? null;
930 // Reads keep their path list; the fetched file contents show as output.
931 const isRead = toolGroupAction(row.workEntry) === "read";
932 const fullDetail =
933 expanded && !reasoning
934 ? fetchedItem && !isRead
935 ? formatItemFullDetail(row.projectedItem, fetchedItem)
936 : row.getFullDetail()
937 : null;
938 const fetchedOutput = !expanded
939 ? null
940 : fetchedItem
941 ? (turnItemOutputText(fetchedItem) ?? "No output.")
942 : fetchedDetail.error
943 ? `Couldn't load output: ${fetchedDetail.error}`
944 : row.fetchesDetail
945 ? fetchedDetail.data
946 ? "Output is no longer available."
947 : "Loading output…"
948 : null;
921949 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
922950 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
923951 const previewText = workEntryRowLabel(row.workEntry);
......@@ -1060,7 +1088,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
10601088 </View>
10611089 </WorkLogPressable>
10621090
1063 {expanded && (reasoning || fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (
1091 {expanded &&
1092 (reasoning ||
1093 fullDetail ||
1094 fetchedOutput ||
1095 viewedImagePath ||
1096 row.workEntry.questionAnswer) ? (
10641097 <Animated.View
10651098 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
10661099 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
......@@ -1092,6 +1125,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
10921125 {fullDetail}
10931126 </Text>
10941127 )}
1128 {fetchedOutput ? (
1129 <Text
1130 selectable
1131 className="mt-1.5 font-mono text-2xs leading-normal text-foreground-muted"
1132 >
1133 {fetchedOutput}
1134 </Text>
1135 ) : null}
10951136 </ScrollView>
10961137 </Animated.View>
10971138 ) : null}
apps/mobile/src/lib/threadActivity.test.ts+30
......@@ -271,6 +271,35 @@ describe("buildThreadFeed", () => {
271271 expect(items[0]).toMatchObject({ output: rawOutput });
272272 });
273273
274 it("expands tool rows only when they have detail or withheld output", () => {
275 const items: OrchestrationV2TurnItem[] = [
276 { ...command(), input: "", outputOmitted: true },
277 {
278 ...base("dynamic-empty", "2026-06-20T00:00:03.000Z", 2),
279 type: "dynamic_tool",
280 toolName: "example",
281 input: {},
282 },
283 {
284 ...base("read-omitted", "2026-06-20T00:00:04.000Z", 3),
285 type: "dynamic_tool",
286 toolName: "Read",
287 input: { path: "src/env.ts" },
288 outputOmitted: true,
289 },
290 ];
291 const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap(
292 (entry) => (entry.type === "activity-group" ? entry.activities : []),
293 );
294 expect(
295 activities.map(({ canExpand, fetchesDetail }) => ({ canExpand, fetchesDetail })),
296 ).toEqual([
297 { canExpand: true, fetchesDetail: true },
298 { canExpand: false, fetchesDetail: false },
299 { canExpand: true, fetchesDetail: true },
300 ]);
301 });
302
274303 it("recognizes automation attribution after projecting a user message", () => {
275304 const feed = buildThreadFeed([
276305 projected(
......@@ -1236,6 +1265,7 @@ describe("buildThreadFeed", () => {
12361265 summary: `Tool ${id}`,
12371266 detail: null,
12381267 canExpand: false,
1268 fetchesDetail: false,
12391269 getFullDetail: () => null,
12401270 getCopyText: () => id,
12411271 icon: "command",
apps/mobile/src/lib/threadActivity.ts+33-16
......@@ -6,6 +6,10 @@ import type {
66import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
77import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
88import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
9import {
10 turnItemHasDetail,
11 turnItemNeedsDetailFetch,
12} from "@t3tools/client-runtime/work-log/item-detail";
913import {
1014 commandDisplayText,
1115 commandProgramName,
......@@ -74,6 +78,8 @@ export interface ThreadFeedActivity {
7478 readonly summary: string;
7579 readonly detail: string | null;
7680 readonly canExpand: boolean;
81 /** Expanding fetches the withheld input and output with getTurnItem. */
82 readonly fetchesDetail: boolean;
7783 readonly getFullDetail: () => string | null;
7884 readonly getCopyText: () => string;
7985 readonly icon:
......@@ -721,6 +727,23 @@ function toWorkLogEntry(
721727 }
722728}
723729
730/** Expanded detail for a row, from its wire item or the full item from getTurnItem. */
731export function formatItemFullDetail(
732 row: OrchestrationV2ProjectedTurnItem,
733 item: OrchestrationV2TurnItem,
734): string {
735 return JSON.stringify(
736 {
737 visibility: row.visibility,
738 sourceThreadId: row.sourceThreadId,
739 sourceItemId: row.sourceItemId,
740 item: toolItemForDisplay(item),
741 },
742 null,
743 2,
744 );
745}
746
724747function toFeedActivity(
725748 row: OrchestrationV2ProjectedTurnItem,
726749 attemptId: RunAttemptId | null,
......@@ -735,21 +758,9 @@ function toFeedActivity(
735758 item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read"
736759 ? collectToolFilePaths(item)
737760 : null;
738 const getFullDetail = memoizeValue(() => {
739 if (readPaths) {
740 return readPaths.join("\n") || null;
741 }
742 return JSON.stringify(
743 {
744 visibility: row.visibility,
745 sourceThreadId: row.sourceThreadId,
746 sourceItemId: row.sourceItemId,
747 item: toolItemForDisplay(item),
748 },
749 null,
750 2,
751 );
752 });
761 const getFullDetail = memoizeValue(() =>
762 readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item),
763 );
753764 const getCopyText = memoizeValue(() =>
754765 [summary, detail, getFullDetail()]
755766 .filter(
......@@ -765,7 +776,13 @@ function toFeedActivity(
765776 attemptId,
766777 summary,
767778 detail,
768 canExpand: !(item.type === "error" && item.status === "failed") && (readPaths?.length ?? 1) > 0,
779 canExpand:
780 !(item.type === "error" && item.status === "failed") &&
781 (readPaths
782 ? readPaths.length > 0 || turnItemNeedsDetailFetch(item)
783 : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined),
784 // Read rows show their paths, then the fetched file contents.
785 fetchesDetail: turnItemNeedsDetailFetch(item),
769786 getFullDetail,
770787 getCopyText,
771788 icon: workEntry.toolSurface ?? itemIcon(item),
apps/mobile/src/state/queries.ts+23
......@@ -2,6 +2,7 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRe
22import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
33import type {
44 EnvironmentId,
5 OrchestrationV2ProjectedTurnItem,
56 ProjectId,
67 ThreadId,
78 VcsListRefsResult,
......@@ -14,6 +15,7 @@ import {
1415} from "@t3tools/client-runtime/state/thread-search";
1516import { useAtomValue } from "@effect/atom-react";
1617import * as Cause from "effect/Cause";
18import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
1719import * as Option from "effect/Option";
1820import { AsyncResult, Atom } from "effect/unstable/reactivity";
1921import { useCallback, useEffect, useMemo, useState } from "react";
......@@ -351,3 +353,24 @@ export function useCheckpointDiff(target: CheckpointDiffTarget) {
351353 );
352354 return targets.fullThread === null ? turn : fullThread;
353355}
356
357/** Full input and output for one tool row; pass null to skip fetching. */
358export function useTurnItemDetail(
359 target: {
360 readonly environmentId: EnvironmentId;
361 readonly row: OrchestrationV2ProjectedTurnItem;
362 } | null,
363) {
364 return useEnvironmentQuery(
365 target === null
366 ? null
367 : orchestrationEnvironment.turnItem({
368 environmentId: target.environmentId,
369 input: {
370 threadId: target.row.sourceThreadId,
371 itemId: target.row.sourceItemId,
372 revision: turnItemDetailRevision(target.row.item),
373 },
374 }),
375 );
376}
apps/server/scripts/acp-mock-agent.ts+64
......@@ -1077,6 +1077,70 @@ const program = Effect.gen(function* () {
10771077 status: "completed",
10781078 rawInput: { query: "TODO", path: "apps/web" },
10791079 },
1080 // Grok backend searches: the query only arrives in the completed rawOutput.
1081 {
1082 sessionUpdate: "tool_call_update",
1083 toolCallId: "grok-x-search",
1084 title: "X search:",
1085 kind: "search",
1086 status: "in_progress",
1087 rawInput: { variant: "XSearch", backend: true },
1088 },
1089 {
1090 sessionUpdate: "tool_call_update",
1091 toolCallId: "grok-x-search",
1092 title: "X search:",
1093 status: "completed",
1094 rawOutput: {
1095 call_id: "xs_call-1",
1096 input: '{"query":"conversation_id:42","limit":"10","mode":"Latest"}',
1097 name: "x_keyword_search",
1098 id: "grok-x-search",
1099 },
1100 },
1101 {
1102 sessionUpdate: "tool_call_update",
1103 toolCallId: "grok-web-search",
1104 title: "Web search:",
1105 kind: "search",
1106 status: "completed",
1107 rawInput: { variant: "WebSearch", backend: true },
1108 rawOutput: {
1109 action: {
1110 type: "search",
1111 query: "t3 code",
1112 sources: [
1113 { type: "url", url: "https://t3.codes" },
1114 { type: "url", url: "https://t3.codes" },
1115 { type: "url", url: "https://github.com/pingdotgg/t3code" },
1116 ],
1117 },
1118 id: "grok-web-search",
1119 status: "completed",
1120 },
1121 },
1122 {
1123 sessionUpdate: "tool_call_update",
1124 toolCallId: "grok-web-fetch",
1125 title: "Fetch: https://t3.codes",
1126 kind: "fetch",
1127 status: "completed",
1128 rawInput: { variant: "WebFetch", url: "https://t3.codes" },
1129 rawOutput: {
1130 type: "WebFetch",
1131 Content: { url: "https://t3.codes", content: "T3 Code page" },
1132 },
1133 content: [{ type: "content", content: { type: "text", text: "T3 Code page" } }],
1134 },
1135 {
1136 sessionUpdate: "tool_call_update",
1137 toolCallId: "antigravity-shell",
1138 title: "run_command",
1139 kind: "execute",
1140 status: "completed",
1141 rawInput: { command: "cat probe.txt" },
1142 rawOutput: { commandLine: "cat probe.txt", exitCode: 0, combinedOutput: "after\n" },
1143 },
10801144 {
10811145 sessionUpdate: "compaction_update",
10821146 compactionId: "compact-1",
apps/server/src/auth/RpcAuthorization.ts+1
......@@ -33,6 +33,7 @@ export const RPC_REQUIRED_SCOPES = {
3333 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
3434 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
3535 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope,
36 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope,
3637 [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope,
3738 [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope,
3839 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+35
......@@ -1324,6 +1324,12 @@ describe("AcpAdapterV2", () => {
13241324 ),
13251325 { input: "printf proof", output: "proof" },
13261326 );
1327 assert.deepInclude(
1328 items.flatMap((item) =>
1329 item.type === "command_execution" ? [{ input: item.input, output: item.output }] : [],
1330 ),
1331 { input: "cat probe.txt", output: "after\n" },
1332 );
13271333 assert.isTrue(
13281334 items.some((item) => item.title === "Action required" && item.status === "waiting"),
13291335 );
......@@ -1345,6 +1351,35 @@ describe("AcpAdapterV2", () => {
13451351 search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null,
13461352 { title: "Searched TODO in web", pattern: "apps/web" },
13471353 );
1354 const webItem = (nativeId: string, status: string) => {
1355 const item = items.findLast(
1356 (candidate) =>
1357 candidate.type === "web_search" &&
1358 candidate.status === status &&
1359 candidate.nativeItemRef?.nativeId?.endsWith(nativeId) === true,
1360 );
1361 return item?.type === "web_search"
1362 ? { title: item.title, patterns: item.patterns, results: item.results }
1363 : null;
1364 };
1365 assert.deepEqual(webItem("grok-x-search", "running"), {
1366 title: "X search",
1367 patterns: undefined,
1368 results: undefined,
1369 });
1370 assert.deepEqual(webItem("grok-x-search", "completed"), {
1371 title: "X search: conversation_id:42",
1372 patterns: ["conversation_id:42"],
1373 results: undefined,
1374 });
1375 assert.deepEqual(webItem("grok-web-search", "completed"), {
1376 title: "Web search: t3 code",
1377 patterns: ["t3 code"],
1378 results: [{ url: "https://t3.codes" }, { url: "https://github.com/pingdotgg/t3code" }],
1379 });
1380 assert.deepEqual(webItem("grok-web-fetch", "completed")?.results, [
1381 { url: "https://t3.codes", snippet: "T3 Code page" },
1382 ]);
13481383 const completedCompaction = items.find(
13491384 (item) =>
13501385 item.type === "compaction" &&
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+76-5
......@@ -19,6 +19,7 @@ import {
1919 type OrchestrationV2Subagent,
2020 type OrchestrationV2TurnItem,
2121 type OrchestrationV2UserInputQuestion,
22 type OrchestrationV2WebSearchResult,
2223 type ProviderApprovalDecision,
2324 type ProviderApprovalOption,
2425 type ProviderInstanceId,
......@@ -846,11 +847,14 @@ function textFromUnknown(value: unknown): string | undefined {
846847 return undefined;
847848 }
848849 // Prefer prompt-facing Grok fields before nested envelopes.
850 // Antigravity reports shell output as combinedOutput.
849851 for (const key of [
850852 "output_for_prompt",
851853 "stdout",
852854 "stderr",
853855 "output",
856 "combinedOutput",
857 "combined_output",
854858 "content",
855859 "text",
856860 "message",
......@@ -989,6 +993,46 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {
989993 return undefined;
990994}
991995
996/**
997 * Grok runs X and web searches server-side as `search` tools whose rawInput is
998 * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives
999 * with completion: web searches report `action: { query, sources }`, X searches
1000 * the backend call `{ name, input }` with JSON-encoded arguments.
1001 */
1002function acpBackendWebSearch(
1003 rawInput: Record<string, unknown> | undefined,
1004 rawOutput: Record<string, unknown> | undefined,
1005):
1006 | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] }
1007 | undefined {
1008 const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : "";
1009 const action = unknownRecord(rawOutput?.action);
1010 if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") {
1011 return undefined;
1012 }
1013 let args: Record<string, unknown> | undefined;
1014 if (typeof rawOutput?.input === "string") {
1015 try {
1016 args = unknownRecord(JSON.parse(rawOutput.input));
1017 } catch {
1018 args = undefined;
1019 }
1020 }
1021 const argsText = Object.entries(args ?? {})
1022 .filter(([, value]) => typeof value === "string" || typeof value === "number")
1023 .map(([key, value]) => `${key}: ${value}`)
1024 .join(", ");
1025 const query = [action?.query, args?.query, argsText]
1026 .find((value): value is string => typeof value === "string" && value.trim().length > 0)
1027 ?.trim();
1028 const urls = new Set<string>();
1029 for (const source of Array.isArray(action?.sources) ? action.sources : []) {
1030 const url = unknownRecord(source)?.url;
1031 if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim());
1032 }
1033 return { query, results: [...urls].map((url) => ({ url })) };
1034}
1035
9921036function providerRequestKind(kind: string | "unknown"): ProviderRequestKind {
9931037 switch (kind) {
9941038 case "execute":
......@@ -3297,7 +3341,30 @@ export function makeAcpAdapterV2(
32973341 ...(rawOutput === undefined ? {} : { output: rawOutput }),
32983342 };
32993343 break;
3300 case "search":
3344 case "search": {
3345 const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord);
3346 if (backendSearch !== undefined) {
3347 // Grok titles these "X search:" / "Web search:" awaiting the query.
3348 const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace(
3349 /:\s*$/u,
3350 "",
3351 );
3352 turnItem = {
3353 ...base,
3354 title:
3355 backendSearch.query === undefined
3356 ? label
3357 : `${label}: ${backendSearch.query}`,
3358 type: "web_search",
3359 ...(backendSearch.query === undefined
3360 ? {}
3361 : { patterns: [backendSearch.query] }),
3362 ...(backendSearch.results.length === 0
3363 ? {}
3364 : { results: backendSearch.results }),
3365 };
3366 break;
3367 }
33013368 turnItem = {
33023369 ...base,
33033370 title:
......@@ -3322,6 +3389,7 @@ export function makeAcpAdapterV2(
33223389 }),
33233390 };
33243391 break;
3392 }
33253393 case "execute": {
33263394 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
33273395 turnItem = {
......@@ -3345,7 +3413,11 @@ export function makeAcpAdapterV2(
33453413 ...(diffText === undefined ? {} : { diffStr: diffText }),
33463414 };
33473415 break;
3348 case "fetch":
3416 case "fetch": {
3417 // Grok nests the page under rawOutput.Content, which textFromUnknown
3418 // cannot read; the (bounded) content blocks carry the same text.
3419 const snippet =
3420 textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput);
33493421 turnItem = {
33503422 ...base,
33513423 type: "web_search",
......@@ -3356,14 +3428,13 @@ export function makeAcpAdapterV2(
33563428 results: [
33573429 {
33583430 url: path,
3359 ...(textFromUnknown(rawOutput) === undefined
3360 ? {}
3361 : { snippet: textFromUnknown(rawOutput) }),
3431 ...(snippet === undefined ? {} : { snippet }),
33623432 },
33633433 ],
33643434 }),
33653435 };
33663436 break;
3437 }
33673438 default:
33683439 if (projectAsCommandExecution) {
33693440 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+83
......@@ -4325,6 +4325,89 @@ describe("ClaudeAdapterV2 background wake turns", () => {
43254325 ),
43264326 );
43274327
4328 it.effect("stores a Bash result's stdout and stderr as command output", () =>
4329 Effect.scoped(
4330 Effect.gen(function* () {
4331 const harness = yield* makeWakeHarness;
4332 const now = yield* DateTime.now;
4333 const attemptId = RunAttemptId.make("attempt-claude-bash-output");
4334 const bashToolUseId = "toolu_01BashOutput";
4335
4336 yield* harness.runtime.startTurn(
4337 makeClaudeTestTurnInput({
4338 threadId: harness.threadId,
4339 providerThread: harness.providerThread,
4340 now,
4341 attemptId,
4342 text: "Run it.",
4343 attachments: [],
4344 }),
4345 );
4346 yield* Queue.offer(
4347 harness.sdkMessages,
4348 claudeSdkFrame({
4349 type: "assistant",
4350 message: {
4351 model: "claude-sonnet-4-6",
4352 id: "msg_bash_output",
4353 type: "message",
4354 role: "assistant",
4355 content: [
4356 {
4357 type: "tool_use",
4358 id: bashToolUseId,
4359 name: "Bash",
4360 input: { command: "git status" },
4361 },
4362 ],
4363 },
4364 parent_tool_use_id: null,
4365 uuid: "00000000-0000-4000-8000-000000000790",
4366 session_id: WAKE_NATIVE_SESSION,
4367 }),
4368 );
4369 yield* Queue.offer(
4370 harness.sdkMessages,
4371 claudeSdkFrame({
4372 type: "user",
4373 message: {
4374 role: "user",
4375 content: [
4376 { type: "tool_result", tool_use_id: bashToolUseId, content: "On branch main" },
4377 ],
4378 },
4379 parent_tool_use_id: null,
4380 uuid: "00000000-0000-4000-8000-000000000791",
4381 session_id: WAKE_NATIVE_SESSION,
4382 tool_use_result: {
4383 stdout: "On branch main",
4384 stderr: "warning: dirty",
4385 interrupted: false,
4386 isImage: false,
4387 },
4388 }),
4389 );
4390 yield* Queue.offer(
4391 harness.sdkMessages,
4392 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000792", result: "Done." }),
4393 );
4394 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
4395
4396 const bash = harness.events.findLast(
4397 (event) =>
4398 event.type === "turn_item.updated" &&
4399 event.turnItem.nativeItemRef?.nativeId === bashToolUseId,
4400 );
4401 assert.equal(
4402 bash?.type === "turn_item.updated" && bash.turnItem.type === "command_execution"
4403 ? bash.turnItem.output
4404 : undefined,
4405 "On branch main\nwarning: dirty",
4406 );
4407 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
4408 ),
4409 );
4410
43284411 it.effect("answers an approval a held wake turn raises without waiting for the echo", () =>
43294412 Effect.scoped(
43304413 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+44-2
......@@ -1864,6 +1864,28 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string {
18641864 return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value);
18651865}
18661866
1867/**
1868 * Bash results arrive as `{ stdout, stderr, interrupted, ... }`; keep only the
1869 * text. A background run has empty streams, so keep its acknowledgement instead.
1870 */
1871function claudeCommandOutputText(output: ClaudeNativeToolOutput): string {
1872 const value = claudeNativeToolOutputValue(output);
1873 if (typeof value === "object" && value !== null) {
1874 const stdout = Reflect.get(value, "stdout");
1875 const stderr = Reflect.get(value, "stderr");
1876 if (typeof stdout === "string" || typeof stderr === "string") {
1877 const text = [stdout, stderr]
1878 .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
1879 .join("\n");
1880 if (text.length > 0) return text;
1881 return output.type === "structured_tool_use_result" && output.fallbackValue !== undefined
1882 ? claudeSubagentResultText({ type: "content_block", value: output.fallbackValue })
1883 : "";
1884 }
1885 }
1886 return claudeNativeToolOutputText(output);
1887}
1888
18671889function claudeSubagentResultText(output: ClaudeNativeToolOutput): string {
18681890 const value = claudeNativeToolOutputValue(output);
18691891 const content = Array.isArray(value)
......@@ -1904,6 +1926,8 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean
19041926 return claudeSubagentResultText(output).startsWith("Async agent launched successfully.");
19051927}
19061928
1929const WEB_FETCH_SNIPPET_MAX_CHARS = 8_000;
1930
19071931function webSearchPatternsFromClaudeTool(input: {
19081932 readonly toolInput: ClaudeNativeToolInput;
19091933 readonly output: ClaudeNativeToolOutput;
......@@ -3807,8 +3831,12 @@ export function makeClaudeAdapterV2(
38073831 output: input.output,
38083832 });
38093833 const webSearchResults = webSearchResultsFromClaudeOutput(input.output);
3834 const webFetchUrl = firstStringInputField(input.toolInput, ["url"])?.trim();
38103835 const outputValue = claudeNativeToolOutputValue(input.output);
3811 const outputText = claudeNativeToolOutputText(input.output);
3836 const outputText =
3837 itemType === "command_execution"
3838 ? claudeCommandOutputText(input.output)
3839 : claudeNativeToolOutputText(input.output);
38123840 const turnItem: OrchestrationV2TurnItem =
38133841 itemType === "command_execution"
38143842 ? {
......@@ -3831,7 +3859,21 @@ export function makeClaudeAdapterV2(
38313859 ...(webSearchPatterns.length === 0
38323860 ? {}
38333861 : { patterns: [...webSearchPatterns] }),
3834 ...(webSearchResults.length === 0 ? {} : { results: [...webSearchResults] }),
3862 ...(webSearchResults.length > 0
3863 ? { results: [...webSearchResults] }
3864 : input.classification.normalizedName === "webfetch" &&
3865 outputText.trim().length > 0
3866 ? {
3867 // WebFetch returns page text, not search hits. Keep a
3868 // bounded preview so the row has something to show.
3869 results: [
3870 {
3871 ...(webFetchUrl === undefined ? {} : { url: webFetchUrl }),
3872 snippet: outputText.slice(0, WEB_FETCH_SNIPPET_MAX_CHARS),
3873 },
3874 ],
3875 }
3876 : {}),
38353877 }
38363878 : {
38373879 ...itemBase,
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts+32-2
......@@ -430,6 +430,26 @@ describe("CursorAdapterV2", () => {
430430 result: { status: "error", error: "ENOENT" },
431431 },
432432 },
433 {
434 type: "tool-call-completed",
435 modelCallId: "native-model-call",
436 callId: "grep-failed",
437 toolCall: {
438 type: "grep",
439 args: { pattern: "TODO", path: "src" },
440 result: { status: "error", error: "search failed" },
441 },
442 },
443 {
444 type: "tool-call-completed",
445 modelCallId: "native-model-call",
446 callId: "glob-failed",
447 toolCall: {
448 type: "glob",
449 args: { globPattern: "*.ts" },
450 result: { status: "error", error: "search failed" },
451 },
452 },
433453 {
434454 type: "tool-call-completed",
435455 modelCallId: "native-model-call",
......@@ -648,7 +668,17 @@ describe("CursorAdapterV2", () => {
648668 {
649669 pattern: path.join(workspace, "missing"),
650670 status: "failed",
651 results: undefined,
671 results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }],
672 },
673 {
674 pattern: "TODO",
675 status: "failed",
676 results: [{ fileName: "src", preview: "search failed" }],
677 },
678 {
679 pattern: "*.ts",
680 status: "failed",
681 results: [{ fileName: ".", preview: "search failed" }],
652682 },
653683 {
654684 pattern: "src/a.ts, src/b.ts",
......@@ -667,7 +697,7 @@ describe("CursorAdapterV2", () => {
667697 {
668698 pattern: "src/a.ts",
669699 status: "failed",
670 results: undefined,
700 results: [{ fileName: "src/a.ts", preview: "lint failed" }],
671701 },
672702 ],
673703 );
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+17-2
......@@ -1227,7 +1227,10 @@ export function makeCursorAdapterV2(
12271227 toolCall.result?.status === "success" &&
12281228 toolCall.result.value.diffString !== undefined
12291229 ? { diffStr: toolCall.result.value.diffString }
1230 : {}),
1230 : // A failed change keeps its error where the diff would be.
1231 toolCall.result?.status === "error" && outputText.trim().length > 0
1232 ? { diffStr: outputText }
1233 : {}),
12311234 ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}),
12321235 };
12331236 break;
......@@ -1248,8 +1251,20 @@ export function makeCursorAdapterV2(
12481251 case "ls":
12491252 case "readLints":
12501253 case "semSearch": {
1251 const results = cursorToolSearchResults(toolCall, path);
12521254 const pattern = cursorToolSearchPattern(toolCall);
1255 const searchPath =
1256 toolCall.type === "grep"
1257 ? toolCall.args.path
1258 : toolCall.type === "glob"
1259 ? toolCall.args.targetDirectory
1260 : toolCall.type === "semSearch"
1261 ? toolCall.args.targetDirectories?.join(", ")
1262 : pattern;
1263 // A failed search keeps its error as one row under the searched path.
1264 const results =
1265 toolCall.result?.status === "error" && outputText.trim().length > 0
1266 ? [{ fileName: searchPath?.trim() || ".", preview: outputText }]
1267 : cursorToolSearchResults(toolCall, path);
12531268 turnItem = {
12541269 ...base,
12551270 title:
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+23-5
......@@ -833,10 +833,12 @@ describe("OpenCodeAdapterV2", () => {
833833 Stream.runCollect,
834834 Effect.forkScoped,
835835 );
836 for (const [tool, input] of [
837 ["read", { filePath: "src/env.ts" }],
838 ["grep", { pattern: "TODO", path: "apps/web" }],
839 ["websearch", { query: "OpenCode documentation" }],
836 for (const [tool, input, output] of [
837 ["read", { filePath: "src/env.ts" }, "---\nfile body"],
838 ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"],
839 ["websearch", { query: "OpenCode documentation" }, "---\nfile body"],
840 ["glob", { pattern: "missing", path: "apps/web" }, ""],
841 ["codesearch", {}, " \n\t"],
840842 ] as const) {
841843 yield* Effect.promise(() =>
842844 nativeEvents.push({
......@@ -853,7 +855,7 @@ describe("OpenCodeAdapterV2", () => {
853855 state: {
854856 status: "completed",
855857 input,
856 output: "---\nfile body",
858 output,
857859 title: tool,
858860 metadata: {},
859861 time: { start: 1, end: 2 },
......@@ -877,10 +879,26 @@ describe("OpenCodeAdapterV2", () => {
877879 const grep = items.find((item) => item.type === "file_search");
878880 assert.equal(grep?.title, "Searched TODO in web");
879881 assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO");
882 assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [
883 { fileName: "apps/web", preview: "---\nfile body" },
884 ]);
880885 const webSearch = items.find((item) => item.type === "web_search");
881886 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [
882887 "OpenCode documentation",
883888 ]);
889 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [
890 { snippet: "---\nfile body" },
891 ]);
892 const emptyFileSearch = items.find(
893 (item) => item.type === "file_search" && item.pattern === "missing",
894 );
895 assert.ok(emptyFileSearch?.type === "file_search");
896 assert.equal(emptyFileSearch.results, undefined);
897 const emptyWebSearch = items.find(
898 (item) => item.type === "web_search" && item.patterns === undefined,
899 );
900 assert.ok(emptyWebSearch?.type === "web_search");
901 assert.equal(emptyWebSearch.results, undefined);
884902 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
885903 );
886904
apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts+28-1
......@@ -6,6 +6,9 @@
66import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
77import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
88
9// Search results stay on the timeline wire, so keep their text a preview.
10const SEARCH_PREVIEW_MAX_CHARS = 8_000;
11
912type ToolItemBase = Omit<
1013 Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>,
1114 "type" | "toolName" | "input" | "output"
......@@ -96,7 +99,10 @@ export function openCodeToolTurnItem(
9699 case "file_change": {
97100 const oldStr = recordString(input, "oldString", "oldText");
98101 const newStr = recordString(input, "newString", "content", "newText");
99 const diffStr = recordString(tool.completedMetadata, "diff", "patch");
102 // A failed edit has no diff; keep its error where the diff would be.
103 const diffStr =
104 recordString(tool.completedMetadata, "diff", "patch") ??
105 (base.status === "failed" && output?.trim() ? output : undefined);
100106 return {
101107 ...base,
102108 type: "file_change",
......@@ -108,6 +114,9 @@ export function openCodeToolTurnItem(
108114 }
109115 case "file_search": {
110116 const pattern = recordString(input, "pattern", "query", "path", "filePath");
117 // OpenCode reports matches as plain text, so keep it as one result row
118 // under the searched path, like the ACP search projection.
119 const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim();
111120 return {
112121 ...base,
113122 title:
......@@ -115,14 +124,32 @@ export function openCodeToolTurnItem(
115124 base.title,
116125 type: "file_search",
117126 ...(pattern === undefined ? {} : { pattern }),
127 ...(!output?.trim() || searchRoot === undefined
128 ? {}
129 : {
130 results: [
131 { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) },
132 ],
133 }),
118134 };
119135 }
120136 case "web_search": {
121137 const pattern = recordString(input, "query", "url", "pattern");
138 const url = recordString(input, "url")?.trim();
122139 return {
123140 ...base,
124141 type: "web_search",
125142 ...(pattern === undefined ? {} : { patterns: [pattern] }),
143 ...(!output?.trim()
144 ? {}
145 : {
146 results: [
147 {
148 ...(url === undefined ? {} : { url }),
149 snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS),
150 },
151 ],
152 }),
126153 };
127154 }
128155 case "dynamic_tool": {
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts+96
......@@ -1155,6 +1155,102 @@ describe("PiAdapterV2", () => {
11551155 }).pipe(Effect.scoped, Effect.provide(testLayer)),
11561156 );
11571157
1158 it.effect("persists edit patches and write content on file change items", () =>
1159 Effect.gen(function* () {
1160 const fake = yield* makeFakePi;
1161 const { runtime, takeEvent } = yield* openRuntime(fake);
1162 const providerThread = yield* runtime.ensureThread({
1163 threadId: THREAD_ID,
1164 modelSelection: modelSelection("default"),
1165 runtimePolicy,
1166 });
1167 yield* startTurn(runtime, providerThread);
1168 yield* fake.takeRequest("prompt");
1169 yield* fake.emit({ type: "agent_start" });
1170 const patch = "--- a.ts\n+++ a.ts\n@@ -1 +1 @@\n-old\n+new\n";
1171 yield* fake.emit({
1172 type: "tool_execution_start",
1173 toolCallId: "call_edit",
1174 toolName: "edit",
1175 args: { path: "a.ts", edits: [{ oldText: "old", newText: "new" }] },
1176 });
1177 yield* fake.emit({
1178 type: "tool_execution_end",
1179 toolCallId: "call_edit",
1180 toolName: "edit",
1181 isError: false,
1182 result: {
1183 content: [{ type: "text", text: "Successfully replaced 1 block(s) in a.ts." }],
1184 details: { diff: "-1 old\n+1 new", patch, firstChangedLine: 1 },
1185 },
1186 });
1187 const edit = yield* takeEvent(
1188 (event) =>
1189 event.type === "turn_item.updated" &&
1190 event.turnItem.type === "file_change" &&
1191 event.turnItem.status === "completed",
1192 );
1193 assert.isTrue(
1194 edit.type === "turn_item.updated" &&
1195 edit.turnItem.type === "file_change" &&
1196 edit.turnItem.fileName === "a.ts" &&
1197 edit.turnItem.diffStr === patch,
1198 );
1199
1200 yield* fake.emit({
1201 type: "tool_execution_start",
1202 toolCallId: "call_write",
1203 toolName: "write",
1204 args: { path: "b.ts", content: "export {};\n" },
1205 });
1206 yield* fake.emit({
1207 type: "tool_execution_end",
1208 toolCallId: "call_write",
1209 toolName: "write",
1210 isError: false,
1211 result: { content: [{ type: "text", text: "Successfully wrote to b.ts" }] },
1212 });
1213 const write = yield* takeEvent(
1214 (event) =>
1215 event.type === "turn_item.updated" &&
1216 event.turnItem.type === "file_change" &&
1217 event.turnItem.status === "completed",
1218 );
1219 assert.isTrue(
1220 write.type === "turn_item.updated" &&
1221 write.turnItem.type === "file_change" &&
1222 write.turnItem.fileName === "b.ts" &&
1223 write.turnItem.newStr === "export {};\n",
1224 );
1225
1226 // A failed edit has no patch, so it keeps the error to show when expanded.
1227 yield* fake.emit({
1228 type: "tool_execution_start",
1229 toolCallId: "call_edit_failed",
1230 toolName: "edit",
1231 args: { path: "c.ts", edits: [{ oldText: "missing", newText: "new" }] },
1232 });
1233 yield* fake.emit({
1234 type: "tool_execution_end",
1235 toolCallId: "call_edit_failed",
1236 toolName: "edit",
1237 isError: true,
1238 result: { content: [{ type: "text", text: "Could not find the text in c.ts." }] },
1239 });
1240 const failedEdit = yield* takeEvent(
1241 (event) =>
1242 event.type === "turn_item.updated" &&
1243 event.turnItem.type === "file_change" &&
1244 event.turnItem.status === "failed",
1245 );
1246 assert.isTrue(
1247 failedEdit.type === "turn_item.updated" &&
1248 failedEdit.turnItem.type === "file_change" &&
1249 failedEdit.turnItem.diffStr === "Could not find the text in c.ts.",
1250 );
1251 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1252 );
1253
11581254 it.effect("settles a command-only prompt from its deferred ack and idle probe", () =>
11591255 Effect.gen(function* () {
11601256 const fake = yield* makeFakePi;
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts+8
......@@ -988,6 +988,12 @@ export function makePiAdapterV2(
988988 if (toolName === "edit" || toolName === "write") {
989989 const fileName = recordString(args, "path") ?? recordString(args, "file_path");
990990 if (fileName !== undefined) {
991 // edit reports a unified patch in its result details; write only
992 // carries the new content in its args. A failed call keeps its error.
993 const diffStr =
994 recordString(recordField(resultRecord, "details"), "patch") ??
995 (isError && outputText.trim().length > 0 ? outputText : undefined);
996 const newStr = toolName === "write" ? recordString(args, "content") : undefined;
991997 yield* emit({
992998 type: "turn_item.updated",
993999 driver: PI_PROVIDER,
......@@ -996,6 +1002,8 @@ export function makePiAdapterV2(
9961002 title: toolName,
9971003 type: "file_change",
9981004 fileName,
1005 ...(diffStr === undefined ? {} : { diffStr }),
1006 ...(newStr === undefined ? {} : { newStr }),
9991007 },
10001008 });
10011009 return;
apps/server/src/orchestration-v2/Orchestrator.ts+14
......@@ -49,6 +49,7 @@ import {
4949 RunId,
5050 ThreadLinkedPullRequest,
5151 ThreadId,
52 type TurnItemId,
5253} from "@t3tools/contracts";
5354import { modelSelectionsEqual } from "@t3tools/shared/model";
5455import {
......@@ -264,6 +265,10 @@ export interface OrchestratorV2Shape {
264265 options: ProjectionTimelinePageOptions,
265266 ) => Effect.Effect<ProjectionTimelinePage, OrchestratorProjectionError>;
266267 readonly getMessageCount: (threadId: ThreadId) => Effect.Effect<number, OrchestratorV2Error>;
268 readonly getTurnItem: (input: {
269 readonly threadId: ThreadId;
270 readonly itemId: TurnItemId;
271 }) => Effect.Effect<OrchestrationV2TurnItem | null, OrchestratorV2Error>;
267272 readonly getThreadRecords: <K extends ProjectionRecordField>(
268273 threadId: ThreadId,
269274 fields: ReadonlyArray<K>,
......@@ -10067,6 +10072,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
1006710072 projectionStore
1006810073 .getMessageCount(threadId)
1006910074 .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))),
10075 getTurnItem: (input) =>
10076 projectionStore
10077 .getTurnItem(input)
10078 .pipe(
10079 Effect.mapError(
10080 (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }),
10081 ),
10082 ),
1007010083 getThreadRecords: (threadId, fields, filter) =>
1007110084 projectionStore
1007210085 .getThreadRecords(threadId, fields, filter)
......@@ -10188,6 +10201,7 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(
1018810201 ),
1018910202 getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
1019010203 getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
10204 getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })),
1019110205 getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
1019210206 getThreadProjection: (threadId) =>
1019310207 Effect.fail(
apps/server/src/orchestration-v2/ProjectionStore.test.ts+8
......@@ -959,6 +959,14 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {
959959 assert.strictEqual(older.projection.turnItems[0]?.ordinal, 850);
960960 assert.strictEqual(older.projection.turnItems.at(-1)?.ordinal, 925);
961961
962 // A single item reads back with its full output, scoped to its thread.
963 const itemId = TurnItemId.make("turn-item:bounded-sql-history:925");
964 const stored = yield* projectionStore.getTurnItem({ threadId, itemId });
965 assert.strictEqual(stored?.type === "command_execution" ? stored.output : undefined, "ok");
966 assert.isNull(
967 yield* projectionStore.getTurnItem({ threadId: ThreadId.make("thread:other"), itemId }),
968 );
969
962970 const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2;
963971 const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, {
964972 rowLimit: sqlPageLimit,
apps/server/src/orchestration-v2/ProjectionStore.ts+25
......@@ -318,6 +318,11 @@ export interface ProjectionStoreV2Shape {
318318 readonly getNextTurnItemOrdinal: (
319319 threadId: ThreadId,
320320 ) => Effect.Effect<number, ProjectionStoreV2Error>;
321 /** One persisted turn item, or null when the thread has no such item. */
322 readonly getTurnItem: (input: {
323 readonly threadId: ThreadId;
324 readonly itemId: TurnItemId;
325 }) => Effect.Effect<OrchestrationV2TurnItem | null, ProjectionStoreV2Error>;
321326 readonly getThreadRecords: <K extends ProjectionRecordField>(
322327 threadId: ThreadId,
323328 fields: ReadonlyArray<K>,
......@@ -4504,6 +4509,18 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
45044509 Effect.mapError(controlReadError(threadId)),
45054510 );
45064511
4512 const getTurnItem: ProjectionStoreV2Shape["getTurnItem"] = ({ threadId, itemId }) =>
4513 sql<{ payload_json: string }>`SELECT payload_json
4514 FROM orchestration_v2_projection_turn_items
4515 WHERE turn_item_id = ${itemId} AND thread_id = ${threadId}`.pipe(
4516 Effect.flatMap((rows) =>
4517 rows[0] === undefined
4518 ? Effect.succeed(null)
4519 : decodeTurnItemPayload(rows[0].payload_json),
4520 ),
4521 Effect.mapError(controlReadError(threadId)),
4522 );
4523
45074524 const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) =>
45084525 sql<{ id: string }>`
45094526 SELECT DISTINCT json_extract(attachment.value, '$.id') AS id
......@@ -5539,6 +5556,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
55395556 hasUnpairedRunInterruptRequest,
55405557 getMessageCount,
55415558 getNextTurnItemOrdinal,
5559 getTurnItem,
55425560 getThreadRecords,
55435561 getRuntimeRequest,
55445562 getPlan,
......@@ -5791,6 +5809,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
57915809 ?.turnItems.reduce((max, item) => Math.max(max, item.ordinal), 0) ?? 0) + 1,
57925810 ),
57935811 ),
5812 getTurnItem: ({ threadId, itemId }) =>
5813 Ref.get(replayState).pipe(
5814 Effect.map(
5815 (state) =>
5816 state.projections.get(threadId)?.turnItems.find((item) => item.id === itemId) ?? null,
5817 ),
5818 ),
57945819 getThreadAttachmentIds: (threadId) =>
57955820 service
57965821 .getThreadProjection(threadId)
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1
......@@ -227,6 +227,7 @@ it.effect(
227227 getTimelinePage: () => Effect.die("Unused timeline read"),
228228 getMessageCount: () => Effect.die("unused message count"),
229229 getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"),
230 getTurnItem: () => Effect.die("unused turn item read"),
230231 getThreadRecords: () => Effect.die("unused record read"),
231232 getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"),
232233 getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"),
apps/server/src/orchestration-v2/ThreadManagementService.ts+16
......@@ -10,6 +10,7 @@ import {
1010 type ModelSelection,
1111 type OrchestrationV2Actor,
1212 type OrchestrationV2Command,
13 type OrchestrationV2GetTurnItemResult,
1314 type OrchestrationV2ServerCommand,
1415 type OrchestrationV2ConversationMessage,
1516 type OrchestrationV2CreationSource,
......@@ -22,6 +23,7 @@ import {
2223 RunId,
2324 type ScheduledTaskId,
2425 ThreadId,
26 type TurnItemId,
2527} from "@t3tools/contracts";
2628import * as Context from "effect/Context";
2729import * as DateTime from "effect/DateTime";
......@@ -32,6 +34,7 @@ import * as Option from "effect/Option";
3234import * as Schema from "effect/Schema";
3335
3436import * as Orchestrator from "./Orchestrator.ts";
37import { projectTurnItemForDetail } from "./WireProjection.ts";
3538import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
3639
3740export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart";
......@@ -276,6 +279,14 @@ export interface ThreadManagementServiceShape {
276279 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;
277280 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];
278281 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];
282 /**
283 * One turn item with the input and output the thread stream withholds,
284 * bounded for the wire. Clients fetch it when a tool row is expanded.
285 */
286 readonly getTurnItem: (input: {
287 readonly threadId: ThreadId;
288 readonly itemId: TurnItemId;
289 }) => Effect.Effect<OrchestrationV2GetTurnItemResult, Orchestrator.OrchestratorV2Error>;
279290 readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"];
280291 readonly getThreadProjection: (
281292 threadId: ThreadId,
......@@ -721,6 +732,11 @@ const make = Effect.gen(function* () {
721732 ensureProjectionTranscript(threadId).pipe(
722733 Effect.andThen(orchestrator.getMessageCount(threadId)),
723734 ),
735 getTurnItem: (input) =>
736 ensureProjectionTranscript(input.threadId).pipe(
737 Effect.andThen(orchestrator.getTurnItem(input)),
738 Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) })),
739 ),
724740 getThreadRecords: (threadId, fields, filter) =>
725741 ensureProjectionTranscript(threadId).pipe(
726742 Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)),
apps/server/src/orchestration-v2/WireProjection.test.ts+28-2
......@@ -17,7 +17,11 @@ import { describe, expect, it } from "@effect/vitest";
1717import * as DateTime from "effect/DateTime";
1818import * as Schema from "effect/Schema";
1919
20import { projectTurnItemForWire, projectDomainEventForWire } from "./WireProjection.ts";
20import {
21 projectTurnItemForWire,
22 projectTurnItemForDetail,
23 projectDomainEventForWire,
24} from "./WireProjection.ts";
2125import { threadShellFromProjection } from "./ProjectionStore.ts";
2226
2327const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);
......@@ -173,7 +177,7 @@ describe("orchestration V2 wire projection", () => {
173177
174178 it("omits even small dynamic tool results while retaining input", () => {
175179 const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem;
176 expect(projectTurnItemForWire(item)).toEqual(base);
180 expect(projectTurnItemForWire(item)).toEqual({ ...base, outputOmitted: true });
177181 expect(item.output).toEqual({ ok: true });
178182 });
179183
......@@ -252,10 +256,24 @@ describe("orchestration V2 wire projection", () => {
252256 const projected = projectTurnItemForWire(item);
253257 expect(projected).not.toHaveProperty("output");
254258 expect(projected).toMatchObject({ input: "test", status: "completed" });
259 // Clients fetch withheld output on demand, so they need to know it exists.
260 expect(projected.type === "command_execution" ? projected.outputOmitted : null).toBe(
261 output ? true : undefined,
262 );
255263 expect(item.output).toBe(output);
256264 },
257265 );
258266
267 it.each([
268 ["echo ok", "echo ok"],
269 ["a".repeat(262_143) + "😀", "a".repeat(262_143) + "\n… output truncated for transport"],
270 ])("bounds fetched command input without changing persistence, case %#", (input, expected) => {
271 const item = { ...base, type: "command_execution" as const, input, output: "ok" };
272 const projected = projectTurnItemForDetail(item);
273 expect(projected).toMatchObject({ input: expected, output: "ok" });
274 expect(item.input).toBe(input);
275 });
276
259277 it("keeps failure evidence without retaining command output", () => {
260278 const item = {
261279 ...base,
......@@ -290,6 +308,14 @@ describe("orchestration V2 wire projection", () => {
290308 expect(projected).not.toHaveProperty("newStr");
291309 expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 });
292310 expect(item.diffStr).toBe("+new code");
311 // A failed edit keeps the provider's error so expanding the row can show it.
312 const failed = projectTurnItemForWire({
313 ...item,
314 status: "failed",
315 diffStr: "String to replace not found",
316 });
317 expect(failed).toMatchObject({ diffStr: "String to replace not found" });
318 expect(failed).not.toHaveProperty("newStr");
293319 });
294320
295321 it("retains only result identities and failure metadata in live tool events", () => {
apps/server/src/orchestration-v2/WireProjection.ts+75-8
......@@ -8,19 +8,22 @@ import { compactDynamicToolOutput, toolOutputIndicatesFailure } from "@t3tools/s
88
99const MAX_DETAIL_STRING_BYTES = 32_768;
1010const MAX_DYNAMIC_VALUE_BYTES = 16_384;
11const MAX_ON_DEMAND_BYTES = 256 * 1024;
1112
12function truncateDetail(value: string | undefined): string | undefined {
13function truncateDetail(
14 value: string | undefined,
15 maxBytes = MAX_DETAIL_STRING_BYTES,
16): string | undefined {
1317 if (
1418 value === undefined ||
15 (value.length <= MAX_DETAIL_STRING_BYTES &&
16 Buffer.byteLength(value, "utf8") <= MAX_DETAIL_STRING_BYTES)
19 (value.length <= maxBytes && Buffer.byteLength(value, "utf8") <= maxBytes)
1720 ) {
1821 return value;
1922 }
2023 // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix
2124 // that could fit, rather than allocating a buffer for the complete output.
22 const prefix = Buffer.from(value.slice(0, MAX_DETAIL_STRING_BYTES), "utf8")
23 .subarray(0, MAX_DETAIL_STRING_BYTES)
25 const prefix = Buffer.from(value.slice(0, maxBytes), "utf8")
26 .subarray(0, maxBytes)
2427 .toString("utf8")
2528 .replace(/\uFFFD$/u, "");
2629 return `${prefix}\n… output truncated for transport`;
......@@ -80,13 +83,20 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra
8083 (item.exitCode !== undefined && item.exitCode !== 0) ||
8184 (output !== undefined &&
8285 toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES)));
83 return failed ? { ...projected, outputIndicatesFailure: true } : projected;
86 return {
87 ...projected,
88 ...(failed ? { outputIndicatesFailure: true } : {}),
89 ...(output?.trim() ? { outputOmitted: true } : {}),
90 };
8491 }
8592 case "file_change": {
8693 // File identity and counts are enough for activity. Full diffs already
8794 // have a dedicated read path and remain intact in persistence.
88 const { diffStr: _diff, oldStr: _old, newStr: _new, ...projected } = item;
89 return projected;
95 const { diffStr, oldStr: _old, newStr: _new, ...projected } = item;
96 // A failed edit stores the provider's error where the diff would be.
97 return item.status === "failed" && diffStr?.trim()
98 ? { ...projected, diffStr: truncateDetail(diffStr) }
99 : projected;
90100 }
91101 case "subagent":
92102 return {
......@@ -102,6 +112,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra
102112 ...projected,
103113 input: summarizeDynamicValue(item.input),
104114 ...(output === undefined ? {} : { output }),
115 ...(hasDynamicValue(rawOutput) ? { outputOmitted: true } : {}),
105116 };
106117 }
107118 default:
......@@ -109,6 +120,62 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra
109120 }
110121}
111122
123function hasDynamicValue(value: unknown): boolean {
124 if (value === undefined || value === null) return false;
125 if (typeof value === "string") return value.trim().length > 0;
126 if (Array.isArray(value)) return value.length > 0;
127 return typeof value !== "object" || Object.keys(value).length > 0;
128}
129
130function boundDynamicValue(value: unknown): unknown {
131 if (value === undefined) return value;
132 if (typeof value === "string") return truncateDetail(value, MAX_ON_DEMAND_BYTES);
133 let json: string;
134 try {
135 // Compact, so measuring does not inflate the value; clients indent it.
136 json = JSON.stringify(value) ?? String(value);
137 } catch {
138 return "Unserializable tool value";
139 }
140 return Buffer.byteLength(json, "utf8") <= MAX_ON_DEMAND_BYTES
141 ? value
142 : truncateDetail(json, MAX_ON_DEMAND_BYTES);
143}
144
145/**
146 * Projects one item for an on-demand detail read: keeps the input and output
147 * the timeline withholds, bounded so a huge result cannot stall the socket.
148 */
149export function projectTurnItemForDetail(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem {
150 switch (item.type) {
151 case "command_execution":
152 return {
153 ...item,
154 input: truncateDetail(item.input, MAX_ON_DEMAND_BYTES) ?? "",
155 output: truncateDetail(item.output, MAX_ON_DEMAND_BYTES),
156 };
157 case "dynamic_tool":
158 return {
159 ...item,
160 input: boundDynamicValue(item.input),
161 output: boundDynamicValue(item.output),
162 };
163 case "subagent":
164 return {
165 ...item,
166 prompt: truncateDetail(item.prompt, MAX_ON_DEMAND_BYTES) ?? "",
167 progress: truncateDetail(item.progress, MAX_ON_DEMAND_BYTES),
168 result:
169 item.result === null ? null : (truncateDetail(item.result, MAX_ON_DEMAND_BYTES) ?? null),
170 };
171 case "handoff":
172 case "file_change":
173 return projectTurnItemForWire(item);
174 default:
175 return item;
176 }
177}
178
112179export function projectContextHandoffForWire(
113180 handoff: OrchestrationV2ContextHandoff,
114181): OrchestrationV2ContextHandoff {
apps/server/src/relay/AgentAwarenessRelay.test.ts+1
......@@ -194,6 +194,7 @@ const makeTestRelay = Effect.fnUntraced(function* (
194194 dispatch: unused,
195195 getTimelinePage: () => Effect.die("Unused timeline read"),
196196 getMessageCount: () => Effect.die("unused message count"),
197 getTurnItem: () => Effect.die("unused turn item read"),
197198 getThreadRecords: () => Effect.die("unused record read"),
198199 getThreadProjection: unused,
199200 getCheckpointContext: unused,
apps/server/src/ws.ts+15
......@@ -1847,6 +1847,21 @@ const makeWsRpcLayer = (
18471847 readWorkflowScript({ scriptPath: input.scriptPath }),
18481848 { "rpc.aggregate": "orchestration" },
18491849 ),
1850 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) =>
1851 observeRpcEffect(
1852 ORCHESTRATION_V2_WS_METHODS.getTurnItem,
1853 threadManagement.getTurnItem(input).pipe(
1854 Effect.mapError(
1855 (cause) =>
1856 new OrchestrationV2GetThreadProjectionError({
1857 threadId: input.threadId,
1858 message: "Failed to load turn item",
1859 cause,
1860 }),
1861 ),
1862 ),
1863 { "rpc.aggregate": "orchestration" },
1864 ),
18501865 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) =>
18511866 observeRpcEffect(
18521867 ORCHESTRATION_V2_WS_METHODS.getTurnDiff,
apps/web/src/components/chat/MessagesTimeline.tsx+38-7
......@@ -45,6 +45,10 @@ import {
4545 workEntryViewedImagePath,
4646} from "@t3tools/client-runtime/work-log/presentation";
4747import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
48import {
49 turnItemHasDetail,
50 turnItemNeedsDetailFetch,
51} from "@t3tools/client-runtime/work-log/item-detail";
4852import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
4953import {
5054 subagentGroupSummary,
......@@ -260,7 +264,7 @@ import {
260264 formatDayAwareTimestamp,
261265 formatUpcomingTimestamp,
262266} from "../../timestampFormat";
263import { V2ItemInspector } from "./V2ItemInspector";
267import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector";
264268import { useV2ItemSupport } from "../../state/v2ItemSupport";
265269import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible";
266270import {
......@@ -5151,10 +5155,25 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
51515155 viewedImage ? viewedImagePath : null,
51525156 )
51535157 : null;
5158 // Projected rows expand to the item inspector, so only offer a disclosure
5159 // when it has something to show, even if that output still has to load.
5160 // Reads and skills still fetch the output the timeline withheld.
5161 const plainOutputFetches =
5162 plainOutput !== undefined &&
5163 workEntry.projectedItem !== undefined &&
5164 turnItemNeedsDetailFetch(workEntry.projectedItem.item);
51545165 const canExpandProjectedItem =
51555166 plainOutput !== undefined
5156 ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer)
5157 : canExpand || workEntry.projectedItem !== undefined;
5167 ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches)
5168 : workEntry.projectedItem === undefined
5169 ? canExpand
5170 : isReasoning
5171 ? Boolean(workEntry.detail?.trim())
5172 : Boolean(
5173 viewedImage ||
5174 workEntry.questionAnswer ||
5175 turnItemHasDetail(workEntry.projectedItem.item),
5176 );
51585177 // Reserve destructive row styling for severe failures, not routine tool errors.
51595178 const iconWrapperClass = cn(
51605179 "flex size-4 items-center justify-center",
......@@ -5325,7 +5344,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
53255344 !isReasoning &&
53265345 !workEntry.questionAnswer &&
53275346 canExpandProjectedItem &&
5328 (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? (
5347 (expandedBody ||
5348 plainOutputFetches ||
5349 (workEntry.projectedItem && plainOutput === undefined)) ? (
53295350 <WorkLogDetails kind="panel">
53305351 {workEntry.projectedItem && plainOutput === undefined ? (
53315352 <V2ItemInspector
......@@ -5337,9 +5358,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
53375358 onOpenTurnDiff={ctx.onOpenTurnDiff}
53385359 onRollbackCheckpoint={ctx.onRollbackCheckpoint}
53395360 />
5340 ) : expandedBody ? (
5341 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
5342 ) : null}
5361 ) : (
5362 <>
5363 {expandedBody ? (
5364 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>
5365 ) : null}
5366 {plainOutputFetches && workEntry.projectedItem ? (
5367 <FetchedToolOutput
5368 projectedItem={workEntry.projectedItem}
5369 environmentId={ctx.activeThreadEnvironmentId}
5370 />
5371 ) : null}
5372 </>
5373 )}
53435374 </WorkLogDetails>
53445375 ) : null}
53455376 </WorkLogRow>
apps/web/src/components/chat/V2ItemInspector.tsx+118-9
......@@ -4,12 +4,19 @@ import type {
44 RunId,
55 ThreadId,
66} from "@t3tools/contracts";
7import {
8 formatToolValue,
9 turnItemDetailRevision,
10 turnItemNeedsDetailFetch,
11 turnItemOutputText,
12} from "@t3tools/client-runtime/work-log/item-detail";
713import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";
8import { memo, Suspense, use, useMemo } from "react";
14import { memo, type ReactNode, Suspense, use, useMemo } from "react";
915
1016import { useTheme } from "../../hooks/useTheme";
1117import { resolveDiffThemeName } from "../../lib/diffRendering";
1218import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";
19import { useTurnItemDetail } from "../../state/queries";
1320import { useV2ItemSupport } from "../../state/v2ItemSupport";
1421import { formatWorkspaceRelativePath } from "../../filePathDisplay";
1522import { Button } from "../ui/button";
......@@ -81,8 +88,94 @@ function StructuredValue({
8188 );
8289}
8390
91function SectionLabel({ children }: { readonly children: ReactNode }) {
92 return (
93 <p className="mb-1 text-3xs font-medium tracking-wide uppercase text-muted-foreground">
94 {children}
95 </p>
96 );
97}
98
99/**
100 * The item behind a projected row, with the output the timeline withheld
101 * fetched while the row is open.
102 */
103function useFetchedTurnItem(
104 projectedItem: OrchestrationV2ProjectedTurnItem,
105 environmentId: EnvironmentId,
106) {
107 const wireItem = projectedItem.item;
108 const fetches = turnItemNeedsDetailFetch(wireItem);
109 const detail = useTurnItemDetail(
110 fetches
111 ? {
112 environmentId,
113 threadId: projectedItem.sourceThreadId,
114 itemId: projectedItem.sourceItemId,
115 revision: turnItemDetailRevision(wireItem),
116 }
117 : null,
118 );
119 const fetchedItem = detail.data?.item;
120 const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem;
121 return {
122 item,
123 output: {
124 text: turnItemOutputText(item),
125 pending: item === wireItem && detail.isPending,
126 error:
127 item !== wireItem
128 ? null
129 : detail.data?.item === null
130 ? "Output is no longer available."
131 : detail.error,
132 empty: fetches && item !== wireItem,
133 },
134 };
135}
136
137/** Output the timeline withheld, fetched while the row is open. */
138function ToolOutput(props: {
139 readonly text: string | null;
140 readonly pending: boolean;
141 readonly error: string | null;
142 readonly empty: boolean;
143}) {
144 const body = props.text ? (
145 <StructuredValue value={props.text} />
146 ) : props.pending ? (
147 <p className="text-muted-foreground">Loading output…</p>
148 ) : props.error ? (
149 <p className="text-destructive">Couldn&apos;t load output: {props.error}</p>
150 ) : props.empty ? (
151 <p className="text-muted-foreground">No output.</p>
152 ) : null;
153 if (body === null) return null;
154 return (
155 <div>
156 <SectionLabel>Output</SectionLabel>
157 {body}
158 </div>
159 );
160}
161
162/** Fetched output for rows that show their own plain text instead of the inspector. */
163export function FetchedToolOutput(props: {
164 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
165 readonly environmentId: EnvironmentId;
166}) {
167 const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId);
168 return (
169 <div className="mt-2 text-xs">
170 <ToolOutput {...output} />
171 </div>
172 );
173}
174
84175export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) {
85 const { item } = props.projectedItem;
176 const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId);
177 const item = fetched.item;
178 const output = <ToolOutput {...fetched.output} />;
86179 const support = useV2ItemSupport({
87180 environmentId: props.environmentId,
88181 sourceThreadId: props.projectedItem.sourceThreadId,
......@@ -107,6 +200,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
107200 {item.type === "command_execution" ? (
108201 <div className="space-y-2">
109202 <StructuredValue value={item.input} highlightJson />
203 {output}
110204 {item.exitCode !== undefined ? (
111205 <p className={item.exitCode === 0 ? "text-success" : "text-destructive"}>
112206 Process exited with code {item.exitCode}
......@@ -137,6 +231,9 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
137231 </Button>
138232 ) : null}
139233 </div>
234 {item.status === "failed" && item.diffStr?.trim() ? (
235 <StructuredValue value={item.diffStr} />
236 ) : null}
140237 {item.changes !== undefined && item.changes.length > 0 ? (
141238 <ul className="space-y-1 font-mono text-muted-foreground">
142239 {item.changes.map((change, index) => (
......@@ -153,7 +250,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
153250 </div>
154251 ) : null}
155252
156 {item.type === "file_search" && item.results ? (
253 {item.type === "file_search" && item.pattern?.trim() && !item.results?.length ? (
254 <StructuredValue value={item.pattern} />
255 ) : null}
256
257 {item.type === "web_search" && item.patterns?.length && !item.results?.length ? (
258 <StructuredValue value={item.patterns.join("\n")} />
259 ) : null}
260
261 {item.type === "file_search" && item.results?.length ? (
157262 <ul className="space-y-1 rounded-md border border-border/45 p-2">
158263 {item.results.map((result) => (
159264 <li key={JSON.stringify(result)}>
......@@ -170,7 +275,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
170275 </ul>
171276 ) : null}
172277
173 {item.type === "web_search" && item.results ? (
278 {item.type === "web_search" && item.results?.length ? (
174279 <ul className="space-y-1.5 rounded-md border border-border/45 p-2">
175280 {item.results.map((result) => {
176281 const safeHref = resolveExternalWebLinkHref(result.url);
......@@ -199,11 +304,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
199304 ) : null}
200305
201306 {item.type === "dynamic_tool" ? (
202 <div>
203 <p className="mb-1 text-3xs font-medium tracking-wide uppercase text-muted-foreground">
204 Input
205 </p>
206 <StructuredValue value={item.input} highlightJson />
307 <div className="space-y-2">
308 {formatToolValue(item.input) ? (
309 <div>
310 <SectionLabel>Input</SectionLabel>
311 <StructuredValue value={formatToolValue(item.input)} highlightJson />
312 </div>
313 ) : null}
314 {output}
207315 </div>
208316 ) : null}
209317
......@@ -213,6 +321,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
213321 ) : null}
214322 {item.type === "notification" ? <StructuredValue value={item.detail} /> : null}
215323 {item.type === "system_notice" ? <StructuredValue value={item.message} /> : null}
324 {item.type === "error" ? <StructuredValue value={item.failure.message} /> : null}
216325 {item.type === "proposed_plan" ? <StructuredValue value={item.markdown} /> : null}
217326 {item.type === "todo_list" ? (
218327 <StructuredValue
apps/web/src/state/queries.ts+21
......@@ -13,6 +13,8 @@ import type {
1313 EnvironmentId,
1414 ProjectContentMatch,
1515 ProjectEntryKind,
16 ThreadId,
17 TurnItemId,
1618 VcsListRefsResult,
1719 VcsRef,
1820} from "@t3tools/contracts";
......@@ -351,3 +353,22 @@ export function useCheckpointDiff(
351353 );
352354 return fullThreadTarget === null ? turn : fullThread;
353355}
356
357/** Full input and output of one timeline item, fetched only while its row is open. */
358export function useTurnItemDetail(
359 target: {
360 readonly environmentId: EnvironmentId;
361 readonly threadId: ThreadId;
362 readonly itemId: TurnItemId;
363 readonly revision: string;
364 } | null,
365) {
366 return useEnvironmentQuery(
367 target === null
368 ? null
369 : orchestrationEnvironment.turnItem({
370 environmentId: target.environmentId,
371 input: { threadId: target.threadId, itemId: target.itemId, revision: target.revision },
372 }),
373 );
374}
packages/client-runtime/package.json+4
......@@ -311,6 +311,10 @@
311311 "types": "./src/work-log/userInput.ts",
312312 "default": "./src/work-log/userInput.ts"
313313 },
314 "./work-log/item-detail": {
315 "types": "./src/work-log/itemDetail.ts",
316 "default": "./src/work-log/itemDetail.ts"
317 },
314318 "./work-log/presentation": {
315319 "types": "./src/work-log/presentation.ts",
316320 "default": "./src/work-log/presentation.ts"
packages/client-runtime/src/state/orchestration.ts+7
......@@ -44,6 +44,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
4444 staleTimeMs: 300_000,
4545 idleTtlMs: 300_000,
4646 }),
47 // Keyed by the item revision, so a live row refetches as its output grows.
48 turnItem: createEnvironmentRpcQueryAtomFamily(runtime, {
49 label: "environment-data:orchestration:turn-item",
50 tag: ORCHESTRATION_V2_WS_METHODS.getTurnItem,
51 staleTimeMs: 60_000,
52 idleTtlMs: 60_000,
53 }),
4754 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
4855 label: "environment-data:orchestration:full-thread-diff",
4956 tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff,
packages/client-runtime/src/work-log/itemDetail.ts created+179
......@@ -0,0 +1,179 @@
1import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3
4const MAX_TEXT_BLOCK_DEPTH = 4;
5
6function isRecord(value: unknown): value is Record<string, unknown> {
7 return typeof value === "object" && value !== null && !Array.isArray(value);
8}
9
10/** Wire projection replaces a large dynamic input with `{ summary, truncated: true }`. */
11function isSummarizedValue(value: unknown): boolean {
12 return isRecord(value) && value.truncated === true && typeof value.summary === "string";
13}
14
15function textFromBlocks(value: unknown, depth: number): string | null {
16 if (depth > MAX_TEXT_BLOCK_DEPTH) return null;
17 if (typeof value === "string") return value;
18 if (Array.isArray(value)) {
19 const parts = value.map((block) => textFromBlocks(block, depth + 1));
20 return parts.every((part) => part !== null) ? parts.join("\n") : null;
21 }
22 if (!isRecord(value)) return null;
23 if (value.type === "text" && typeof value.text === "string") return value.text;
24 if (value.type === "image") return "[image]";
25 if (value.type === "resource_link" && typeof value.uri === "string") return value.uri;
26 if (value.type === "resource" && isRecord(value.resource)) {
27 const resource = value.resource;
28 if (typeof resource.text === "string") return resource.text;
29 if (typeof resource.uri === "string") return resource.uri;
30 }
31 const keys = Object.keys(value).filter((key) => key !== "isError" && key !== "is_error");
32 // MCP results and provider tool results wrap their text in `content`.
33 // `structuredContent` usually repeats it as data, so it only shows when the
34 // text is empty.
35 if (keys.length === 1 && keys[0] === "content") return textFromBlocks(value.content, depth + 1);
36 if (keys.length === 2 && keys.includes("content") && keys.includes("structuredContent")) {
37 const text = textFromBlocks(value.content, depth + 1);
38 return text?.trim() ? text : null;
39 }
40 return null;
41}
42
43function parseJson(text: string): unknown {
44 try {
45 return JSON.parse(text);
46 } catch {
47 return undefined;
48 }
49}
50
51/** MCP tools often return JSON as minified text, one document per line. Indent each. */
52function prettyJsonText(text: string): string {
53 const trimmed = text.trim();
54 if (!/^[[{]/.test(trimmed)) return text;
55 const whole = parseJson(trimmed);
56 if (whole !== undefined) return JSON.stringify(whole, null, 2);
57 const lines = trimmed.split("\n").filter((line) => line.trim());
58 const documents = lines.map((line) => parseJson(line.trim()));
59 if (documents.some((document) => document === undefined)) return text;
60 return documents.map((document) => JSON.stringify(document, null, 2)).join("\n\n");
61}
62
63/** Formats a tool input or output for display: text blocks as text, the rest as JSON. */
64export function formatToolValue(value: unknown): string | null {
65 if (value === undefined || value === null) return null;
66 const text = textFromBlocks(value, 0);
67 if (text !== null) return text.trim() ? prettyJsonText(text) : null;
68 let json: string | undefined;
69 try {
70 json = JSON.stringify(value, null, 2);
71 } catch {
72 return String(value);
73 }
74 if (json === undefined || json === "{}" || json === "[]") return null;
75 return json;
76}
77
78const LIVE_TURN_ITEM_STATUSES: ReadonlySet<OrchestrationV2TurnItem["status"]> = new Set([
79 "idle",
80 "pending",
81 "running",
82 "waiting",
83]);
84
85/**
86 * Cache key for a fetched item. A running item keeps one key, so an open row
87 * fetches once while it streams and again when it finishes, not on every update.
88 */
89export function turnItemDetailRevision(item: OrchestrationV2TurnItem): string {
90 return LIVE_TURN_ITEM_STATUSES.has(item.status) ? "live" : DateTime.formatIso(item.updatedAt);
91}
92
93/** True when the timeline item withholds content that `getTurnItem` returns. */
94export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean {
95 switch (item.type) {
96 case "command_execution":
97 return item.outputOmitted === true;
98 case "dynamic_tool":
99 return item.outputOmitted === true || isSummarizedValue(item.input);
100 default:
101 return false;
102 }
103}
104
105/** Older Claude bash rows stored the raw `{ stdout, stderr, interrupted, ... }` result. */
106function commandOutputText(output: string): string {
107 if (!output.trimStart().startsWith('{"stdout"')) return output;
108 const parsed = parseJson(output.trim());
109 if (
110 !isRecord(parsed) ||
111 typeof parsed.stdout !== "string" ||
112 typeof parsed.stderr !== "string" ||
113 typeof parsed.interrupted !== "boolean"
114 ) {
115 return output;
116 }
117 return [parsed.stdout, parsed.stderr]
118 .filter((part): part is string => typeof part === "string" && part.trim().length > 0)
119 .join("\n");
120}
121
122/** The tool output carried by a fetched item, formatted for display. */
123export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null {
124 switch (item.type) {
125 case "command_execution":
126 return item.output?.trim() ? commandOutputText(item.output) || null : null;
127 case "dynamic_tool":
128 return item.outputOmitted === true ? null : formatToolValue(item.output);
129 default:
130 return null;
131 }
132}
133
134/**
135 * Whether expanding the item shows anything. Rows without content must not
136 * offer a disclosure, otherwise they open to an empty panel.
137 */
138export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean {
139 switch (item.type) {
140 case "reasoning":
141 return item.text.trim().length > 0;
142 case "command_execution":
143 return (
144 item.input.trim().length > 0 ||
145 item.outputOmitted === true ||
146 Boolean(item.output?.trim()) ||
147 item.exitCode !== undefined
148 );
149 case "file_change":
150 case "checkpoint":
151 case "fork":
152 case "handoff":
153 return true;
154 case "file_search":
155 return (item.results?.length ?? 0) > 0 || Boolean(item.pattern?.trim());
156 case "web_search":
157 return (item.results?.length ?? 0) > 0 || (item.patterns?.length ?? 0) > 0;
158 case "dynamic_tool":
159 return item.outputOmitted === true || formatToolValue(item.input) !== null;
160 case "approval_request":
161 return Boolean(item.prompt?.trim());
162 case "user_input_request":
163 return item.questions.length > 0;
164 case "notification":
165 return Boolean(item.detail?.trim());
166 case "system_notice":
167 return item.message.trim().length > 0;
168 case "error":
169 return item.failure.message.trim().length > 0;
170 case "proposed_plan":
171 return item.markdown.trim().length > 0;
172 case "todo_list":
173 return item.steps.length > 0;
174 case "subagent":
175 return item.childThreadId !== null;
176 default:
177 return false;
178 }
179}
packages/client-runtime/src/work-log/presentation.ts+5-2
......@@ -38,8 +38,11 @@ export function toolItemForDisplay(item: OrchestrationV2TurnItem): Orchestration
3838 return displayItem;
3939 }
4040 case "file_change": {
41 const { diffStr: _diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item;
42 return displayItem;
41 const { diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item;
42 // A failed edit's diffStr holds the provider's error, not a diff.
43 return item.status === "failed" && diffStr?.trim()
44 ? { ...displayItem, diffStr }
45 : displayItem;
4346 }
4447 default:
4548 return item;
packages/contracts/src/orchestrationV2.ts+27
......@@ -1357,6 +1357,8 @@ export const OrchestrationV2TurnItem = Schema.Union([
13571357 type: Schema.Literal("command_execution"),
13581358 input: Schema.String,
13591359 output: Schema.optional(Schema.String),
1360 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
1361 outputOmitted: Schema.optional(Schema.Boolean),
13601362 outputIndicatesFailure: Schema.optional(Schema.Boolean),
13611363 exitCode: Schema.optional(Schema.Int),
13621364 }),
......@@ -1483,6 +1485,8 @@ export const OrchestrationV2TurnItem = Schema.Union([
14831485 viewedImagePath: Schema.optional(TrimmedNonEmptyString),
14841486 input: Schema.Unknown,
14851487 output: Schema.optional(Schema.Unknown),
1488 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
1489 outputOmitted: Schema.optional(Schema.Boolean),
14861490 }),
14871491]);
14881492export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type;
......@@ -2089,6 +2093,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
20892093 type: Schema.Literal("command_execution"),
20902094 input: Schema.String,
20912095 output: Schema.optional(Schema.String),
2096 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
2097 outputOmitted: Schema.optional(Schema.Boolean),
20922098 outputIndicatesFailure: Schema.optional(Schema.Boolean),
20932099 exitCode: Schema.optional(Schema.Int),
20942100 }),
......@@ -2212,6 +2218,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
22122218 viewedImagePath: Schema.optional(TrimmedNonEmptyString),
22132219 input: Schema.Unknown,
22142220 output: Schema.optional(Schema.Unknown),
2221 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
2222 outputOmitted: Schema.optional(Schema.Boolean),
22152223 }),
22162224]);
22172225export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type;
......@@ -2969,6 +2977,7 @@ export const ORCHESTRATION_V2_WS_METHODS = {
29692977 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
29702978 getThreadProjection: "orchestration.getThreadProjection",
29712979 getWorkflowScript: "orchestration.getWorkflowScript",
2980 getTurnItem: "orchestration.getTurnItem",
29722981 launchThread: "orchestration.launchThread",
29732982 subscribeArchivedShell: "orchestration.subscribeArchivedShell",
29742983 subscribeShell: "orchestration.subscribeShell",
......@@ -3255,6 +3264,20 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({
32553264export type OrchestrationV2GetWorkflowScriptResult =
32563265 typeof OrchestrationV2GetWorkflowScriptResult.Type;
32573266
3267export const OrchestrationV2GetTurnItemInput = Schema.Struct({
3268 threadId: ThreadId,
3269 itemId: TurnItemId,
3270 /** The item updatedAt the client last saw. Only keys the client cache. */
3271 revision: Schema.optional(Schema.String),
3272});
3273export type OrchestrationV2GetTurnItemInput = typeof OrchestrationV2GetTurnItemInput.Type;
3274
3275/** One persisted turn item with its full, size-bounded input and output. */
3276export const OrchestrationV2GetTurnItemResult = Schema.Struct({
3277 item: Schema.NullOr(OrchestrationV2TurnItem),
3278});
3279export type OrchestrationV2GetTurnItemResult = typeof OrchestrationV2GetTurnItemResult.Type;
3280
32583281const WORKFLOW_SCRIPT_ERROR_MESSAGES = {
32593282 "invalid-path": "Workflow scripts must be absolute .js paths.",
32603283 "root-unavailable": "Script root unavailable.",
......@@ -3313,6 +3336,10 @@ export const OrchestrationV2RpcSchemas = {
33133336 input: OrchestrationV2GetWorkflowScriptInput,
33143337 output: OrchestrationV2GetWorkflowScriptResult,
33153338 },
3339 getTurnItem: {
3340 input: OrchestrationV2GetTurnItemInput,
3341 output: OrchestrationV2GetTurnItemResult,
3342 },
33163343 launchThread: {
33173344 input: OrchestrationV2ThreadLaunchInput,
33183345 output: OrchestrationV2ThreadLaunchResult,
packages/contracts/src/rpc.ts+7
......@@ -1549,6 +1549,12 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make(
15491549 },
15501550);
15511551
1552const WsOrchestrationV2GetTurnItemRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnItem, {
1553 payload: OrchestrationV2RpcSchemas.getTurnItem.input,
1554 success: OrchestrationV2RpcSchemas.getTurnItem.output,
1555 error: Schema.Union([OrchestrationV2GetThreadProjectionError, EnvironmentAuthorizationError]),
1556});
1557
15521558const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, {
15531559 payload: OrchestrationV2RpcSchemas.launchThread.input,
15541560 success: OrchestrationV2RpcSchemas.launchThread.output,
......@@ -1858,6 +1864,7 @@ export const WsRpcGroup = RpcGroup.make(
18581864 WsSubscribeResourceTelemetryRpc,
18591865 WsOrchestrationV2DispatchCommandRpc,
18601866 WsOrchestrationV2GetWorkflowScriptRpc,
1867 WsOrchestrationV2GetTurnItemRpc,
18611868 WsOrchestrationV2GetTurnDiffRpc,
18621869 WsOrchestrationV2GetFullThreadDiffRpc,
18631870 WsOrchestrationV2SearchThreadsRpc,