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";...@@ -60,9 +60,12 @@ import { cn } from "../../lib/cn";
60import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";60import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout";
61import {61import {
62 type AgentSpawnSummary,62 type AgentSpawnSummary,
63 formatItemFullDetail,
63 type ThreadFeedActivity,64 type ThreadFeedActivity,
64 workEntryRowLabel,65 workEntryRowLabel,
65} from "../../lib/threadActivity";66} from "../../lib/threadActivity";
67import { turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail";
68import { useTurnItemDetail } from "../../state/queries";
66import {69import {
67 resolveThreadWorkGroupInitialScroll,70 resolveThreadWorkGroupInitialScroll,
68 shouldFollowThreadWorkGroupAppend,71 shouldFollowThreadWorkGroupAppend,
...@@ -70,6 +73,7 @@ import {...@@ -70,6 +73,7 @@ import {
70} from "./thread-feed-live-follow";73} from "./thread-feed-live-follow";
71import {74import {
72 resolveWorkEntryToolPresentation,75 resolveWorkEntryToolPresentation,
76 toolGroupAction,
73 type ToolGroupSummaryKind,77 type ToolGroupSummaryKind,
74 workEntryViewedImagePath,78 workEntryViewedImagePath,
75} from "@t3tools/client-runtime/work-log/presentation";79} from "@t3tools/client-runtime/work-log/presentation";
...@@ -836,6 +840,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -836,6 +840,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
836) {840) {
837 const { row, expanded } = props;841 const { row, expanded } = props;
838 const navigation = useNavigation();842 const navigation = useNavigation();
843 const fetchedDetail = useTurnItemDetail(
844 expanded && row.fetchesDetail
845 ? { environmentId: props.environmentId, row: row.projectedItem }
846 : null,
847 );
839 const failureItem = row.projectedItem.item;848 const failureItem = row.projectedItem.item;
840 if (failureItem.type === "error" && failureItem.status === "failed") {849 if (failureItem.type === "error" && failureItem.status === "failed") {
841 const warning = failureItem.failure.class === "usage_limit";850 const warning = failureItem.failure.class === "usage_limit";
...@@ -917,7 +926,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -917,7 +926,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
917 : undefined;926 : undefined;
918 const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;927 const canExpand = row.canExpand && notifiedSubagentThreadId === undefined;
919 const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null;928 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;
921 const viewedImagePath = workEntryViewedImagePath(row.workEntry);949 const viewedImagePath = workEntryViewedImagePath(row.workEntry);
922 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);950 const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry);
923 const previewText = workEntryRowLabel(row.workEntry);951 const previewText = workEntryRowLabel(row.workEntry);
...@@ -1060,7 +1088,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -1060,7 +1088,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
1060 </View>1088 </View>
1061 </WorkLogPressable>1089 </WorkLogPressable>
10621090
1063 {expanded && (reasoning || fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? (1091 {expanded &&
1092 (reasoning ||
1093 fullDetail ||
1094 fetchedOutput ||
1095 viewedImagePath ||
1096 row.workEntry.questionAnswer) ? (
1064 <Animated.View1097 <Animated.View
1065 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}1098 entering={WORK_LOG_DETAIL_ENTER_TRANSITION}
1066 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}1099 exiting={WORK_LOG_DETAIL_EXIT_TRANSITION}
...@@ -1092,6 +1125,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(...@@ -1092,6 +1125,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
1092 {fullDetail}1125 {fullDetail}
1093 </Text>1126 </Text>
1094 )}1127 )}
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}
1095 </ScrollView>1136 </ScrollView>
1096 </Animated.View>1137 </Animated.View>
1097 ) : null}1138 ) : null}
apps/mobile/src/lib/threadActivity.test.ts+30
...@@ -271,6 +271,35 @@ describe("buildThreadFeed", () => {...@@ -271,6 +271,35 @@ describe("buildThreadFeed", () => {
271 expect(items[0]).toMatchObject({ output: rawOutput });271 expect(items[0]).toMatchObject({ output: rawOutput });
272 });272 });
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
274 it("recognizes automation attribution after projecting a user message", () => {303 it("recognizes automation attribution after projecting a user message", () => {
275 const feed = buildThreadFeed([304 const feed = buildThreadFeed([
276 projected(305 projected(
...@@ -1236,6 +1265,7 @@ describe("buildThreadFeed", () => {...@@ -1236,6 +1265,7 @@ describe("buildThreadFeed", () => {
1236 summary: `Tool ${id}`,1265 summary: `Tool ${id}`,
1237 detail: null,1266 detail: null,
1238 canExpand: false,1267 canExpand: false,
1268 fetchesDetail: false,
1239 getFullDetail: () => null,1269 getFullDetail: () => null,
1240 getCopyText: () => id,1270 getCopyText: () => id,
1241 icon: "command",1271 icon: "command",
apps/mobile/src/lib/threadActivity.ts+33-16
...@@ -6,6 +6,10 @@ import type {...@@ -6,6 +6,10 @@ import type {
6import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";6import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation";
7import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";7import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display";
8import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";8import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation";
9import {
10 turnItemHasDetail,
11 turnItemNeedsDetailFetch,
12} from "@t3tools/client-runtime/work-log/item-detail";
9import {13import {
10 commandDisplayText,14 commandDisplayText,
11 commandProgramName,15 commandProgramName,
...@@ -74,6 +78,8 @@ export interface ThreadFeedActivity {...@@ -74,6 +78,8 @@ export interface ThreadFeedActivity {
74 readonly summary: string;78 readonly summary: string;
75 readonly detail: string | null;79 readonly detail: string | null;
76 readonly canExpand: boolean;80 readonly canExpand: boolean;
81 /** Expanding fetches the withheld input and output with getTurnItem. */
82 readonly fetchesDetail: boolean;
77 readonly getFullDetail: () => string | null;83 readonly getFullDetail: () => string | null;
78 readonly getCopyText: () => string;84 readonly getCopyText: () => string;
79 readonly icon:85 readonly icon:
...@@ -721,6 +727,23 @@ function toWorkLogEntry(...@@ -721,6 +727,23 @@ function toWorkLogEntry(
721 }727 }
722}728}
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
724function toFeedActivity(747function toFeedActivity(
725 row: OrchestrationV2ProjectedTurnItem,748 row: OrchestrationV2ProjectedTurnItem,
726 attemptId: RunAttemptId | null,749 attemptId: RunAttemptId | null,
...@@ -735,21 +758,9 @@ function toFeedActivity(...@@ -735,21 +758,9 @@ function toFeedActivity(
735 item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read"758 item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read"
736 ? collectToolFilePaths(item)759 ? collectToolFilePaths(item)
737 : null;760 : null;
738 const getFullDetail = memoizeValue(() => {761 const getFullDetail = memoizeValue(() =>
739 if (readPaths) {762 readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item),
740 return readPaths.join("\n") || null;763 );
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 });
753 const getCopyText = memoizeValue(() =>764 const getCopyText = memoizeValue(() =>
754 [summary, detail, getFullDetail()]765 [summary, detail, getFullDetail()]
755 .filter(766 .filter(
...@@ -765,7 +776,13 @@ function toFeedActivity(...@@ -765,7 +776,13 @@ function toFeedActivity(
765 attemptId,776 attemptId,
766 summary,777 summary,
767 detail,778 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),
769 getFullDetail,786 getFullDetail,
770 getCopyText,787 getCopyText,
771 icon: workEntry.toolSurface ?? itemIcon(item),788 icon: workEntry.toolSurface ?? itemIcon(item),
apps/mobile/src/state/queries.ts+23
...@@ -2,6 +2,7 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRe...@@ -2,6 +2,7 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRe
2import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";2import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
5 OrchestrationV2ProjectedTurnItem,
5 ProjectId,6 ProjectId,
6 ThreadId,7 ThreadId,
7 VcsListRefsResult,8 VcsListRefsResult,
...@@ -14,6 +15,7 @@ import {...@@ -14,6 +15,7 @@ import {
14} from "@t3tools/client-runtime/state/thread-search";15} from "@t3tools/client-runtime/state/thread-search";
15import { useAtomValue } from "@effect/atom-react";16import { useAtomValue } from "@effect/atom-react";
16import * as Cause from "effect/Cause";17import * as Cause from "effect/Cause";
18import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
17import * as Option from "effect/Option";19import * as Option from "effect/Option";
18import { AsyncResult, Atom } from "effect/unstable/reactivity";20import { AsyncResult, Atom } from "effect/unstable/reactivity";
19import { useCallback, useEffect, useMemo, useState } from "react";21import { useCallback, useEffect, useMemo, useState } from "react";
...@@ -351,3 +353,24 @@ export function useCheckpointDiff(target: CheckpointDiffTarget) {...@@ -351,3 +353,24 @@ export function useCheckpointDiff(target: CheckpointDiffTarget) {
351 );353 );
352 return targets.fullThread === null ? turn : fullThread;354 return targets.fullThread === null ? turn : fullThread;
353}355}
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* () {...@@ -1077,6 +1077,70 @@ const program = Effect.gen(function* () {
1077 status: "completed",1077 status: "completed",
1078 rawInput: { query: "TODO", path: "apps/web" },1078 rawInput: { query: "TODO", path: "apps/web" },
1079 },1079 },
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 },
1080 {1144 {
1081 sessionUpdate: "compaction_update",1145 sessionUpdate: "compaction_update",
1082 compactionId: "compact-1",1146 compactionId: "compact-1",
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -33,6 +33,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -33,6 +33,7 @@ export const RPC_REQUIRED_SCOPES = {
33 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,33 [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope,
34 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,34 [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope,
35 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope,35 [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope,
36 [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope,
36 [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope,37 [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope,
37 [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope,38 [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope,
38 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,39 [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+35
...@@ -1324,6 +1324,12 @@ describe("AcpAdapterV2", () => {...@@ -1324,6 +1324,12 @@ describe("AcpAdapterV2", () => {
1324 ),1324 ),
1325 { input: "printf proof", output: "proof" },1325 { input: "printf proof", output: "proof" },
1326 );1326 );
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 );
1327 assert.isTrue(1333 assert.isTrue(
1328 items.some((item) => item.title === "Action required" && item.status === "waiting"),1334 items.some((item) => item.title === "Action required" && item.status === "waiting"),
1329 );1335 );
...@@ -1345,6 +1351,35 @@ describe("AcpAdapterV2", () => {...@@ -1345,6 +1351,35 @@ describe("AcpAdapterV2", () => {
1345 search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null,1351 search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null,
1346 { title: "Searched TODO in web", pattern: "apps/web" },1352 { title: "Searched TODO in web", pattern: "apps/web" },
1347 );1353 );
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 ]);
1348 const completedCompaction = items.find(1383 const completedCompaction = items.find(
1349 (item) =>1384 (item) =>
1350 item.type === "compaction" &&1385 item.type === "compaction" &&
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+76-5
...@@ -19,6 +19,7 @@ import {...@@ -19,6 +19,7 @@ import {
19 type OrchestrationV2Subagent,19 type OrchestrationV2Subagent,
20 type OrchestrationV2TurnItem,20 type OrchestrationV2TurnItem,
21 type OrchestrationV2UserInputQuestion,21 type OrchestrationV2UserInputQuestion,
22 type OrchestrationV2WebSearchResult,
22 type ProviderApprovalDecision,23 type ProviderApprovalDecision,
23 type ProviderApprovalOption,24 type ProviderApprovalOption,
24 type ProviderInstanceId,25 type ProviderInstanceId,
...@@ -846,11 +847,14 @@ function textFromUnknown(value: unknown): string | undefined {...@@ -846,11 +847,14 @@ function textFromUnknown(value: unknown): string | undefined {
846 return undefined;847 return undefined;
847 }848 }
848 // Prefer prompt-facing Grok fields before nested envelopes.849 // Prefer prompt-facing Grok fields before nested envelopes.
850 // Antigravity reports shell output as combinedOutput.
849 for (const key of [851 for (const key of [
850 "output_for_prompt",852 "output_for_prompt",
851 "stdout",853 "stdout",
852 "stderr",854 "stderr",
853 "output",855 "output",
856 "combinedOutput",
857 "combined_output",
854 "content",858 "content",
855 "text",859 "text",
856 "message",860 "message",
...@@ -989,6 +993,46 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {...@@ -989,6 +993,46 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {
989 return undefined;993 return undefined;
990}994}
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
992function providerRequestKind(kind: string | "unknown"): ProviderRequestKind {1036function providerRequestKind(kind: string | "unknown"): ProviderRequestKind {
993 switch (kind) {1037 switch (kind) {
994 case "execute":1038 case "execute":
...@@ -3297,7 +3341,30 @@ export function makeAcpAdapterV2(...@@ -3297,7 +3341,30 @@ export function makeAcpAdapterV2(
3297 ...(rawOutput === undefined ? {} : { output: rawOutput }),3341 ...(rawOutput === undefined ? {} : { output: rawOutput }),
3298 };3342 };
3299 break;3343 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 }
3301 turnItem = {3368 turnItem = {
3302 ...base,3369 ...base,
3303 title:3370 title:
...@@ -3322,6 +3389,7 @@ export function makeAcpAdapterV2(...@@ -3322,6 +3389,7 @@ export function makeAcpAdapterV2(
3322 }),3389 }),
3323 };3390 };
3324 break;3391 break;
3392 }
3325 case "execute": {3393 case "execute": {
3326 const exitCode = acpProjectedCommandExitCode(status, rawOutput);3394 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
3327 turnItem = {3395 turnItem = {
...@@ -3345,7 +3413,11 @@ export function makeAcpAdapterV2(...@@ -3345,7 +3413,11 @@ export function makeAcpAdapterV2(
3345 ...(diffText === undefined ? {} : { diffStr: diffText }),3413 ...(diffText === undefined ? {} : { diffStr: diffText }),
3346 };3414 };
3347 break;3415 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);
3349 turnItem = {3421 turnItem = {
3350 ...base,3422 ...base,
3351 type: "web_search",3423 type: "web_search",
...@@ -3356,14 +3428,13 @@ export function makeAcpAdapterV2(...@@ -3356,14 +3428,13 @@ export function makeAcpAdapterV2(
3356 results: [3428 results: [
3357 {3429 {
3358 url: path,3430 url: path,
3359 ...(textFromUnknown(rawOutput) === undefined3431 ...(snippet === undefined ? {} : { snippet }),
3360 ? {}
3361 : { snippet: textFromUnknown(rawOutput) }),
3362 },3432 },
3363 ],3433 ],
3364 }),3434 }),
3365 };3435 };
3366 break;3436 break;
3437 }
3367 default:3438 default:
3368 if (projectAsCommandExecution) {3439 if (projectAsCommandExecution) {
3369 const exitCode = acpProjectedCommandExitCode(status, rawOutput);3440 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+83
...@@ -4325,6 +4325,89 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4325,6 +4325,89 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4325 ),4325 ),
4326 );4326 );
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
4328 it.effect("answers an approval a held wake turn raises without waiting for the echo", () =>4411 it.effect("answers an approval a held wake turn raises without waiting for the echo", () =>
4329 Effect.scoped(4412 Effect.scoped(
4330 Effect.gen(function* () {4413 Effect.gen(function* () {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+44-2
...@@ -1864,6 +1864,28 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string {...@@ -1864,6 +1864,28 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string {
1864 return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value);1864 return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value);
1865}1865}
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
1867function claudeSubagentResultText(output: ClaudeNativeToolOutput): string {1889function claudeSubagentResultText(output: ClaudeNativeToolOutput): string {
1868 const value = claudeNativeToolOutputValue(output);1890 const value = claudeNativeToolOutputValue(output);
1869 const content = Array.isArray(value)1891 const content = Array.isArray(value)
...@@ -1904,6 +1926,8 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean...@@ -1904,6 +1926,8 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean
1904 return claudeSubagentResultText(output).startsWith("Async agent launched successfully.");1926 return claudeSubagentResultText(output).startsWith("Async agent launched successfully.");
1905}1927}
19061928
1929const WEB_FETCH_SNIPPET_MAX_CHARS = 8_000;
1930
1907function webSearchPatternsFromClaudeTool(input: {1931function webSearchPatternsFromClaudeTool(input: {
1908 readonly toolInput: ClaudeNativeToolInput;1932 readonly toolInput: ClaudeNativeToolInput;
1909 readonly output: ClaudeNativeToolOutput;1933 readonly output: ClaudeNativeToolOutput;
...@@ -3807,8 +3831,12 @@ export function makeClaudeAdapterV2(...@@ -3807,8 +3831,12 @@ export function makeClaudeAdapterV2(
3807 output: input.output,3831 output: input.output,
3808 });3832 });
3809 const webSearchResults = webSearchResultsFromClaudeOutput(input.output);3833 const webSearchResults = webSearchResultsFromClaudeOutput(input.output);
3834 const webFetchUrl = firstStringInputField(input.toolInput, ["url"])?.trim();
3810 const outputValue = claudeNativeToolOutputValue(input.output);3835 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);
3812 const turnItem: OrchestrationV2TurnItem =3840 const turnItem: OrchestrationV2TurnItem =
3813 itemType === "command_execution"3841 itemType === "command_execution"
3814 ? {3842 ? {
...@@ -3831,7 +3859,21 @@ export function makeClaudeAdapterV2(...@@ -3831,7 +3859,21 @@ export function makeClaudeAdapterV2(
3831 ...(webSearchPatterns.length === 03859 ...(webSearchPatterns.length === 0
3832 ? {}3860 ? {}
3833 : { patterns: [...webSearchPatterns] }),3861 : { 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 : {}),
3835 }3877 }
3836 : {3878 : {
3837 ...itemBase,3879 ...itemBase,
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts+32-2
...@@ -430,6 +430,26 @@ describe("CursorAdapterV2", () => {...@@ -430,6 +430,26 @@ describe("CursorAdapterV2", () => {
430 result: { status: "error", error: "ENOENT" },430 result: { status: "error", error: "ENOENT" },
431 },431 },
432 },432 },
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 },
433 {453 {
434 type: "tool-call-completed",454 type: "tool-call-completed",
435 modelCallId: "native-model-call",455 modelCallId: "native-model-call",
...@@ -648,7 +668,17 @@ describe("CursorAdapterV2", () => {...@@ -648,7 +668,17 @@ describe("CursorAdapterV2", () => {
648 {668 {
649 pattern: path.join(workspace, "missing"),669 pattern: path.join(workspace, "missing"),
650 status: "failed",670 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" }],
652 },682 },
653 {683 {
654 pattern: "src/a.ts, src/b.ts",684 pattern: "src/a.ts, src/b.ts",
...@@ -667,7 +697,7 @@ describe("CursorAdapterV2", () => {...@@ -667,7 +697,7 @@ describe("CursorAdapterV2", () => {
667 {697 {
668 pattern: "src/a.ts",698 pattern: "src/a.ts",
669 status: "failed",699 status: "failed",
670 results: undefined,700 results: [{ fileName: "src/a.ts", preview: "lint failed" }],
671 },701 },
672 ],702 ],
673 );703 );
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+17-2
...@@ -1227,7 +1227,10 @@ export function makeCursorAdapterV2(...@@ -1227,7 +1227,10 @@ export function makeCursorAdapterV2(
1227 toolCall.result?.status === "success" &&1227 toolCall.result?.status === "success" &&
1228 toolCall.result.value.diffString !== undefined1228 toolCall.result.value.diffString !== undefined
1229 ? { diffStr: toolCall.result.value.diffString }1229 ? { 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 : {}),
1231 ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}),1234 ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}),
1232 };1235 };
1233 break;1236 break;
...@@ -1248,8 +1251,20 @@ export function makeCursorAdapterV2(...@@ -1248,8 +1251,20 @@ export function makeCursorAdapterV2(
1248 case "ls":1251 case "ls":
1249 case "readLints":1252 case "readLints":
1250 case "semSearch": {1253 case "semSearch": {
1251 const results = cursorToolSearchResults(toolCall, path);
1252 const pattern = cursorToolSearchPattern(toolCall);1254 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);
1253 turnItem = {1268 turnItem = {
1254 ...base,1269 ...base,
1255 title:1270 title:
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+23-5
...@@ -833,10 +833,12 @@ describe("OpenCodeAdapterV2", () => {...@@ -833,10 +833,12 @@ describe("OpenCodeAdapterV2", () => {
833 Stream.runCollect,833 Stream.runCollect,
834 Effect.forkScoped,834 Effect.forkScoped,
835 );835 );
836 for (const [tool, input] of [836 for (const [tool, input, output] of [
837 ["read", { filePath: "src/env.ts" }],837 ["read", { filePath: "src/env.ts" }, "---\nfile body"],
838 ["grep", { pattern: "TODO", path: "apps/web" }],838 ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"],
839 ["websearch", { query: "OpenCode documentation" }],839 ["websearch", { query: "OpenCode documentation" }, "---\nfile body"],
840 ["glob", { pattern: "missing", path: "apps/web" }, ""],
841 ["codesearch", {}, " \n\t"],
840 ] as const) {842 ] as const) {
841 yield* Effect.promise(() =>843 yield* Effect.promise(() =>
842 nativeEvents.push({844 nativeEvents.push({
...@@ -853,7 +855,7 @@ describe("OpenCodeAdapterV2", () => {...@@ -853,7 +855,7 @@ describe("OpenCodeAdapterV2", () => {
853 state: {855 state: {
854 status: "completed",856 status: "completed",
855 input,857 input,
856 output: "---\nfile body",858 output,
857 title: tool,859 title: tool,
858 metadata: {},860 metadata: {},
859 time: { start: 1, end: 2 },861 time: { start: 1, end: 2 },
...@@ -877,10 +879,26 @@ describe("OpenCodeAdapterV2", () => {...@@ -877,10 +879,26 @@ describe("OpenCodeAdapterV2", () => {
877 const grep = items.find((item) => item.type === "file_search");879 const grep = items.find((item) => item.type === "file_search");
878 assert.equal(grep?.title, "Searched TODO in web");880 assert.equal(grep?.title, "Searched TODO in web");
879 assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO");881 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 ]);
880 const webSearch = items.find((item) => item.type === "web_search");885 const webSearch = items.find((item) => item.type === "web_search");
881 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [886 assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [
882 "OpenCode documentation",887 "OpenCode documentation",
883 ]);888 ]);
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);
884 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),902 }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped),
885 );903 );
886904
apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts+28-1
...@@ -6,6 +6,9 @@...@@ -6,6 +6,9 @@
6import type { OrchestrationV2TurnItem } from "@t3tools/contracts";6import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
7import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";7import { 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
9type ToolItemBase = Omit<12type ToolItemBase = Omit<
10 Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>,13 Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>,
11 "type" | "toolName" | "input" | "output"14 "type" | "toolName" | "input" | "output"
...@@ -96,7 +99,10 @@ export function openCodeToolTurnItem(...@@ -96,7 +99,10 @@ export function openCodeToolTurnItem(
96 case "file_change": {99 case "file_change": {
97 const oldStr = recordString(input, "oldString", "oldText");100 const oldStr = recordString(input, "oldString", "oldText");
98 const newStr = recordString(input, "newString", "content", "newText");101 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);
100 return {106 return {
101 ...base,107 ...base,
102 type: "file_change",108 type: "file_change",
...@@ -108,6 +114,9 @@ export function openCodeToolTurnItem(...@@ -108,6 +114,9 @@ export function openCodeToolTurnItem(
108 }114 }
109 case "file_search": {115 case "file_search": {
110 const pattern = recordString(input, "pattern", "query", "path", "filePath");116 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();
111 return {120 return {
112 ...base,121 ...base,
113 title:122 title:
...@@ -115,14 +124,32 @@ export function openCodeToolTurnItem(...@@ -115,14 +124,32 @@ export function openCodeToolTurnItem(
115 base.title,124 base.title,
116 type: "file_search",125 type: "file_search",
117 ...(pattern === undefined ? {} : { pattern }),126 ...(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 }),
118 };134 };
119 }135 }
120 case "web_search": {136 case "web_search": {
121 const pattern = recordString(input, "query", "url", "pattern");137 const pattern = recordString(input, "query", "url", "pattern");
138 const url = recordString(input, "url")?.trim();
122 return {139 return {
123 ...base,140 ...base,
124 type: "web_search",141 type: "web_search",
125 ...(pattern === undefined ? {} : { patterns: [pattern] }),142 ...(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 }),
126 };153 };
127 }154 }
128 case "dynamic_tool": {155 case "dynamic_tool": {
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts+96
...@@ -1155,6 +1155,102 @@ describe("PiAdapterV2", () => {...@@ -1155,6 +1155,102 @@ describe("PiAdapterV2", () => {
1155 }).pipe(Effect.scoped, Effect.provide(testLayer)),1155 }).pipe(Effect.scoped, Effect.provide(testLayer)),
1156 );1156 );
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
1158 it.effect("settles a command-only prompt from its deferred ack and idle probe", () =>1254 it.effect("settles a command-only prompt from its deferred ack and idle probe", () =>
1159 Effect.gen(function* () {1255 Effect.gen(function* () {
1160 const fake = yield* makeFakePi;1256 const fake = yield* makeFakePi;
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts+8
...@@ -988,6 +988,12 @@ export function makePiAdapterV2(...@@ -988,6 +988,12 @@ export function makePiAdapterV2(
988 if (toolName === "edit" || toolName === "write") {988 if (toolName === "edit" || toolName === "write") {
989 const fileName = recordString(args, "path") ?? recordString(args, "file_path");989 const fileName = recordString(args, "path") ?? recordString(args, "file_path");
990 if (fileName !== undefined) {990 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;
991 yield* emit({997 yield* emit({
992 type: "turn_item.updated",998 type: "turn_item.updated",
993 driver: PI_PROVIDER,999 driver: PI_PROVIDER,
...@@ -996,6 +1002,8 @@ export function makePiAdapterV2(...@@ -996,6 +1002,8 @@ export function makePiAdapterV2(
996 title: toolName,1002 title: toolName,
997 type: "file_change",1003 type: "file_change",
998 fileName,1004 fileName,
1005 ...(diffStr === undefined ? {} : { diffStr }),
1006 ...(newStr === undefined ? {} : { newStr }),
999 },1007 },
1000 });1008 });
1001 return;1009 return;
apps/server/src/orchestration-v2/Orchestrator.ts+14
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49 RunId,49 RunId,
50 ThreadLinkedPullRequest,50 ThreadLinkedPullRequest,
51 ThreadId,51 ThreadId,
52 type TurnItemId,
52} from "@t3tools/contracts";53} from "@t3tools/contracts";
53import { modelSelectionsEqual } from "@t3tools/shared/model";54import { modelSelectionsEqual } from "@t3tools/shared/model";
54import {55import {
...@@ -264,6 +265,10 @@ export interface OrchestratorV2Shape {...@@ -264,6 +265,10 @@ export interface OrchestratorV2Shape {
264 options: ProjectionTimelinePageOptions,265 options: ProjectionTimelinePageOptions,
265 ) => Effect.Effect<ProjectionTimelinePage, OrchestratorProjectionError>;266 ) => Effect.Effect<ProjectionTimelinePage, OrchestratorProjectionError>;
266 readonly getMessageCount: (threadId: ThreadId) => Effect.Effect<number, OrchestratorV2Error>;267 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>;
267 readonly getThreadRecords: <K extends ProjectionRecordField>(272 readonly getThreadRecords: <K extends ProjectionRecordField>(
268 threadId: ThreadId,273 threadId: ThreadId,
269 fields: ReadonlyArray<K>,274 fields: ReadonlyArray<K>,
...@@ -10067,6 +10072,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -10067,6 +10072,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
10067 projectionStore10072 projectionStore
10068 .getMessageCount(threadId)10073 .getMessageCount(threadId)
10069 .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))),10074 .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 ),
10070 getThreadRecords: (threadId, fields, filter) =>10083 getThreadRecords: (threadId, fields, filter) =>
10071 projectionStore10084 projectionStore
10072 .getThreadRecords(threadId, fields, filter)10085 .getThreadRecords(threadId, fields, filter)
...@@ -10188,6 +10201,7 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(...@@ -10188,6 +10201,7 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed(
10188 ),10201 ),
10189 getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),10202 getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
10190 getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),10203 getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
10204 getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })),
10191 getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),10205 getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })),
10192 getThreadProjection: (threadId) =>10206 getThreadProjection: (threadId) =>
10193 Effect.fail(10207 Effect.fail(
apps/server/src/orchestration-v2/ProjectionStore.test.ts+8
...@@ -959,6 +959,14 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {...@@ -959,6 +959,14 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => {
959 assert.strictEqual(older.projection.turnItems[0]?.ordinal, 850);959 assert.strictEqual(older.projection.turnItems[0]?.ordinal, 850);
960 assert.strictEqual(older.projection.turnItems.at(-1)?.ordinal, 925);960 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
962 const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2;970 const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2;
963 const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, {971 const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, {
964 rowLimit: sqlPageLimit,972 rowLimit: sqlPageLimit,
apps/server/src/orchestration-v2/ProjectionStore.ts+25
...@@ -318,6 +318,11 @@ export interface ProjectionStoreV2Shape {...@@ -318,6 +318,11 @@ export interface ProjectionStoreV2Shape {
318 readonly getNextTurnItemOrdinal: (318 readonly getNextTurnItemOrdinal: (
319 threadId: ThreadId,319 threadId: ThreadId,
320 ) => Effect.Effect<number, ProjectionStoreV2Error>;320 ) => 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>;
321 readonly getThreadRecords: <K extends ProjectionRecordField>(326 readonly getThreadRecords: <K extends ProjectionRecordField>(
322 threadId: ThreadId,327 threadId: ThreadId,
323 fields: ReadonlyArray<K>,328 fields: ReadonlyArray<K>,
...@@ -4504,6 +4509,18 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -4504,6 +4509,18 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
4504 Effect.mapError(controlReadError(threadId)),4509 Effect.mapError(controlReadError(threadId)),
4505 );4510 );
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
4507 const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) =>4524 const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) =>
4508 sql<{ id: string }>`4525 sql<{ id: string }>`
4509 SELECT DISTINCT json_extract(attachment.value, '$.id') AS id4526 SELECT DISTINCT json_extract(attachment.value, '$.id') AS id
...@@ -5539,6 +5556,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =...@@ -5539,6 +5556,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
5539 hasUnpairedRunInterruptRequest,5556 hasUnpairedRunInterruptRequest,
5540 getMessageCount,5557 getMessageCount,
5541 getNextTurnItemOrdinal,5558 getNextTurnItemOrdinal,
5559 getTurnItem,
5542 getThreadRecords,5560 getThreadRecords,
5543 getRuntimeRequest,5561 getRuntimeRequest,
5544 getPlan,5562 getPlan,
...@@ -5791,6 +5809,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(...@@ -5791,6 +5809,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
5791 ?.turnItems.reduce((max, item) => Math.max(max, item.ordinal), 0) ?? 0) + 1,5809 ?.turnItems.reduce((max, item) => Math.max(max, item.ordinal), 0) ?? 0) + 1,
5792 ),5810 ),
5793 ),5811 ),
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 ),
5794 getThreadAttachmentIds: (threadId) =>5819 getThreadAttachmentIds: (threadId) =>
5795 service5820 service
5796 .getThreadProjection(threadId)5821 .getThreadProjection(threadId)
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1
...@@ -227,6 +227,7 @@ it.effect(...@@ -227,6 +227,7 @@ it.effect(
227 getTimelinePage: () => Effect.die("Unused timeline read"),227 getTimelinePage: () => Effect.die("Unused timeline read"),
228 getMessageCount: () => Effect.die("unused message count"),228 getMessageCount: () => Effect.die("unused message count"),
229 getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"),229 getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"),
230 getTurnItem: () => Effect.die("unused turn item read"),
230 getThreadRecords: () => Effect.die("unused record read"),231 getThreadRecords: () => Effect.die("unused record read"),
231 getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"),232 getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"),
232 getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"),233 getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"),
apps/server/src/orchestration-v2/ThreadManagementService.ts+16
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 type ModelSelection,10 type ModelSelection,
11 type OrchestrationV2Actor,11 type OrchestrationV2Actor,
12 type OrchestrationV2Command,12 type OrchestrationV2Command,
13 type OrchestrationV2GetTurnItemResult,
13 type OrchestrationV2ServerCommand,14 type OrchestrationV2ServerCommand,
14 type OrchestrationV2ConversationMessage,15 type OrchestrationV2ConversationMessage,
15 type OrchestrationV2CreationSource,16 type OrchestrationV2CreationSource,
...@@ -22,6 +23,7 @@ import {...@@ -22,6 +23,7 @@ import {
22 RunId,23 RunId,
23 type ScheduledTaskId,24 type ScheduledTaskId,
24 ThreadId,25 ThreadId,
26 type TurnItemId,
25} from "@t3tools/contracts";27} from "@t3tools/contracts";
26import * as Context from "effect/Context";28import * as Context from "effect/Context";
27import * as DateTime from "effect/DateTime";29import * as DateTime from "effect/DateTime";
...@@ -32,6 +34,7 @@ import * as Option from "effect/Option";...@@ -32,6 +34,7 @@ import * as Option from "effect/Option";
32import * as Schema from "effect/Schema";34import * as Schema from "effect/Schema";
3335
34import * as Orchestrator from "./Orchestrator.ts";36import * as Orchestrator from "./Orchestrator.ts";
37import { projectTurnItemForDetail } from "./WireProjection.ts";
35import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";38import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
3639
37export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart";40export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart";
...@@ -276,6 +279,14 @@ export interface ThreadManagementServiceShape {...@@ -276,6 +279,14 @@ export interface ThreadManagementServiceShape {
276 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;279 ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>;
277 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];280 readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"];
278 readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"];281 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>;
279 readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"];290 readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"];
280 readonly getThreadProjection: (291 readonly getThreadProjection: (
281 threadId: ThreadId,292 threadId: ThreadId,
...@@ -721,6 +732,11 @@ const make = Effect.gen(function* () {...@@ -721,6 +732,11 @@ const make = Effect.gen(function* () {
721 ensureProjectionTranscript(threadId).pipe(732 ensureProjectionTranscript(threadId).pipe(
722 Effect.andThen(orchestrator.getMessageCount(threadId)),733 Effect.andThen(orchestrator.getMessageCount(threadId)),
723 ),734 ),
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 ),
724 getThreadRecords: (threadId, fields, filter) =>740 getThreadRecords: (threadId, fields, filter) =>
725 ensureProjectionTranscript(threadId).pipe(741 ensureProjectionTranscript(threadId).pipe(
726 Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)),742 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";...@@ -17,7 +17,11 @@ import { describe, expect, it } from "@effect/vitest";
17import * as DateTime from "effect/DateTime";17import * as DateTime from "effect/DateTime";
18import * as Schema from "effect/Schema";18import * as Schema from "effect/Schema";
1919
20import { projectTurnItemForWire, projectDomainEventForWire } from "./WireProjection.ts";20import {
21 projectTurnItemForWire,
22 projectTurnItemForDetail,
23 projectDomainEventForWire,
24} from "./WireProjection.ts";
21import { threadShellFromProjection } from "./ProjectionStore.ts";25import { threadShellFromProjection } from "./ProjectionStore.ts";
2226
23const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);27const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem);
...@@ -173,7 +177,7 @@ describe("orchestration V2 wire projection", () => {...@@ -173,7 +177,7 @@ describe("orchestration V2 wire projection", () => {
173177
174 it("omits even small dynamic tool results while retaining input", () => {178 it("omits even small dynamic tool results while retaining input", () => {
175 const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem;179 const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem;
176 expect(projectTurnItemForWire(item)).toEqual(base);180 expect(projectTurnItemForWire(item)).toEqual({ ...base, outputOmitted: true });
177 expect(item.output).toEqual({ ok: true });181 expect(item.output).toEqual({ ok: true });
178 });182 });
179183
...@@ -252,10 +256,24 @@ describe("orchestration V2 wire projection", () => {...@@ -252,10 +256,24 @@ describe("orchestration V2 wire projection", () => {
252 const projected = projectTurnItemForWire(item);256 const projected = projectTurnItemForWire(item);
253 expect(projected).not.toHaveProperty("output");257 expect(projected).not.toHaveProperty("output");
254 expect(projected).toMatchObject({ input: "test", status: "completed" });258 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 );
255 expect(item.output).toBe(output);263 expect(item.output).toBe(output);
256 },264 },
257 );265 );
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
259 it("keeps failure evidence without retaining command output", () => {277 it("keeps failure evidence without retaining command output", () => {
260 const item = {278 const item = {
261 ...base,279 ...base,
...@@ -290,6 +308,14 @@ describe("orchestration V2 wire projection", () => {...@@ -290,6 +308,14 @@ describe("orchestration V2 wire projection", () => {
290 expect(projected).not.toHaveProperty("newStr");308 expect(projected).not.toHaveProperty("newStr");
291 expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 });309 expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 });
292 expect(item.diffStr).toBe("+new code");310 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");
293 });319 });
294320
295 it("retains only result identities and failure metadata in live tool events", () => {321 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...@@ -8,19 +8,22 @@ import { compactDynamicToolOutput, toolOutputIndicatesFailure } from "@t3tools/s
88
9const MAX_DETAIL_STRING_BYTES = 32_768;9const MAX_DETAIL_STRING_BYTES = 32_768;
10const MAX_DYNAMIC_VALUE_BYTES = 16_384;10const 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 {
13 if (17 if (
14 value === undefined ||18 value === undefined ||
15 (value.length <= MAX_DETAIL_STRING_BYTES &&19 (value.length <= maxBytes && Buffer.byteLength(value, "utf8") <= maxBytes)
16 Buffer.byteLength(value, "utf8") <= MAX_DETAIL_STRING_BYTES)
17 ) {20 ) {
18 return value;21 return value;
19 }22 }
20 // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix23 // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix
21 // that could fit, rather than allocating a buffer for the complete output.24 // 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")25 const prefix = Buffer.from(value.slice(0, maxBytes), "utf8")
23 .subarray(0, MAX_DETAIL_STRING_BYTES)26 .subarray(0, maxBytes)
24 .toString("utf8")27 .toString("utf8")
25 .replace(/\uFFFD$/u, "");28 .replace(/\uFFFD$/u, "");
26 return `${prefix}\n… output truncated for transport`;29 return `${prefix}\n… output truncated for transport`;
...@@ -80,13 +83,20 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra...@@ -80,13 +83,20 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra
80 (item.exitCode !== undefined && item.exitCode !== 0) ||83 (item.exitCode !== undefined && item.exitCode !== 0) ||
81 (output !== undefined &&84 (output !== undefined &&
82 toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES)));85 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 };
84 }91 }
85 case "file_change": {92 case "file_change": {
86 // File identity and counts are enough for activity. Full diffs already93 // File identity and counts are enough for activity. Full diffs already
87 // have a dedicated read path and remain intact in persistence.94 // have a dedicated read path and remain intact in persistence.
88 const { diffStr: _diff, oldStr: _old, newStr: _new, ...projected } = item;95 const { diffStr, oldStr: _old, newStr: _new, ...projected } = item;
89 return projected;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;
90 }100 }
91 case "subagent":101 case "subagent":
92 return {102 return {
...@@ -102,6 +112,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra...@@ -102,6 +112,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra
102 ...projected,112 ...projected,
103 input: summarizeDynamicValue(item.input),113 input: summarizeDynamicValue(item.input),
104 ...(output === undefined ? {} : { output }),114 ...(output === undefined ? {} : { output }),
115 ...(hasDynamicValue(rawOutput) ? { outputOmitted: true } : {}),
105 };116 };
106 }117 }
107 default:118 default:
...@@ -109,6 +120,62 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra...@@ -109,6 +120,62 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra
109 }120 }
110}121}
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
112export function projectContextHandoffForWire(179export function projectContextHandoffForWire(
113 handoff: OrchestrationV2ContextHandoff,180 handoff: OrchestrationV2ContextHandoff,
114): OrchestrationV2ContextHandoff {181): OrchestrationV2ContextHandoff {
apps/server/src/relay/AgentAwarenessRelay.test.ts+1
...@@ -194,6 +194,7 @@ const makeTestRelay = Effect.fnUntraced(function* (...@@ -194,6 +194,7 @@ const makeTestRelay = Effect.fnUntraced(function* (
194 dispatch: unused,194 dispatch: unused,
195 getTimelinePage: () => Effect.die("Unused timeline read"),195 getTimelinePage: () => Effect.die("Unused timeline read"),
196 getMessageCount: () => Effect.die("unused message count"),196 getMessageCount: () => Effect.die("unused message count"),
197 getTurnItem: () => Effect.die("unused turn item read"),
197 getThreadRecords: () => Effect.die("unused record read"),198 getThreadRecords: () => Effect.die("unused record read"),
198 getThreadProjection: unused,199 getThreadProjection: unused,
199 getCheckpointContext: unused,200 getCheckpointContext: unused,
apps/server/src/ws.ts+15
...@@ -1847,6 +1847,21 @@ const makeWsRpcLayer = (...@@ -1847,6 +1847,21 @@ const makeWsRpcLayer = (
1847 readWorkflowScript({ scriptPath: input.scriptPath }),1847 readWorkflowScript({ scriptPath: input.scriptPath }),
1848 { "rpc.aggregate": "orchestration" },1848 { "rpc.aggregate": "orchestration" },
1849 ),1849 ),
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 ),
1850 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) =>1865 [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) =>
1851 observeRpcEffect(1866 observeRpcEffect(
1852 ORCHESTRATION_V2_WS_METHODS.getTurnDiff,1867 ORCHESTRATION_V2_WS_METHODS.getTurnDiff,
apps/web/src/components/chat/MessagesTimeline.tsx+38-7
...@@ -45,6 +45,10 @@ import {...@@ -45,6 +45,10 @@ import {
45 workEntryViewedImagePath,45 workEntryViewedImagePath,
46} from "@t3tools/client-runtime/work-log/presentation";46} from "@t3tools/client-runtime/work-log/presentation";
47import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";47import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
48import {
49 turnItemHasDetail,
50 turnItemNeedsDetailFetch,
51} from "@t3tools/client-runtime/work-log/item-detail";
48import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";52import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
49import {53import {
50 subagentGroupSummary,54 subagentGroupSummary,
...@@ -260,7 +264,7 @@ import {...@@ -260,7 +264,7 @@ import {
260 formatDayAwareTimestamp,264 formatDayAwareTimestamp,
261 formatUpcomingTimestamp,265 formatUpcomingTimestamp,
262} from "../../timestampFormat";266} from "../../timestampFormat";
263import { V2ItemInspector } from "./V2ItemInspector";267import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector";
264import { useV2ItemSupport } from "../../state/v2ItemSupport";268import { useV2ItemSupport } from "../../state/v2ItemSupport";
265import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible";269import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible";
266import {270import {
...@@ -5151,10 +5155,25 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5151,10 +5155,25 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5151 viewedImage ? viewedImagePath : null,5155 viewedImage ? viewedImagePath : null,
5152 )5156 )
5153 : null;5157 : 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);
5154 const canExpandProjectedItem =5165 const canExpandProjectedItem =
5155 plainOutput !== undefined5166 plainOutput !== undefined
5156 ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer)5167 ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches)
5157 : canExpand || workEntry.projectedItem !== undefined;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 );
5158 // Reserve destructive row styling for severe failures, not routine tool errors.5177 // Reserve destructive row styling for severe failures, not routine tool errors.
5159 const iconWrapperClass = cn(5178 const iconWrapperClass = cn(
5160 "flex size-4 items-center justify-center",5179 "flex size-4 items-center justify-center",
...@@ -5325,7 +5344,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5325,7 +5344,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5325 !isReasoning &&5344 !isReasoning &&
5326 !workEntry.questionAnswer &&5345 !workEntry.questionAnswer &&
5327 canExpandProjectedItem &&5346 canExpandProjectedItem &&
5328 (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? (5347 (expandedBody ||
5348 plainOutputFetches ||
5349 (workEntry.projectedItem && plainOutput === undefined)) ? (
5329 <WorkLogDetails kind="panel">5350 <WorkLogDetails kind="panel">
5330 {workEntry.projectedItem && plainOutput === undefined ? (5351 {workEntry.projectedItem && plainOutput === undefined ? (
5331 <V2ItemInspector5352 <V2ItemInspector
...@@ -5337,9 +5358,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {...@@ -5337,9 +5358,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
5337 onOpenTurnDiff={ctx.onOpenTurnDiff}5358 onOpenTurnDiff={ctx.onOpenTurnDiff}
5338 onRollbackCheckpoint={ctx.onRollbackCheckpoint}5359 onRollbackCheckpoint={ctx.onRollbackCheckpoint}
5339 />5360 />
5340 ) : expandedBody ? (5361 ) : (
5341 <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre>5362 <>
5342 ) : null}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 )}
5343 </WorkLogDetails>5374 </WorkLogDetails>
5344 ) : null}5375 ) : null}
5345 </WorkLogRow>5376 </WorkLogRow>
apps/web/src/components/chat/V2ItemInspector.tsx+118-9
...@@ -4,12 +4,19 @@ import type {...@@ -4,12 +4,19 @@ import type {
4 RunId,4 RunId,
5 ThreadId,5 ThreadId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {
8 formatToolValue,
9 turnItemDetailRevision,
10 turnItemNeedsDetailFetch,
11 turnItemOutputText,
12} from "@t3tools/client-runtime/work-log/item-detail";
7import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";13import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react";
8import { memo, Suspense, use, useMemo } from "react";14import { memo, type ReactNode, Suspense, use, useMemo } from "react";
915
10import { useTheme } from "../../hooks/useTheme";16import { useTheme } from "../../hooks/useTheme";
11import { resolveDiffThemeName } from "../../lib/diffRendering";17import { resolveDiffThemeName } from "../../lib/diffRendering";
12import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";18import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";
19import { useTurnItemDetail } from "../../state/queries";
13import { useV2ItemSupport } from "../../state/v2ItemSupport";20import { useV2ItemSupport } from "../../state/v2ItemSupport";
14import { formatWorkspaceRelativePath } from "../../filePathDisplay";21import { formatWorkspaceRelativePath } from "../../filePathDisplay";
15import { Button } from "../ui/button";22import { Button } from "../ui/button";
...@@ -81,8 +88,94 @@ function StructuredValue({...@@ -81,8 +88,94 @@ function StructuredValue({
81 );88 );
82}89}
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
84export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) {175export 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} />;
86 const support = useV2ItemSupport({179 const support = useV2ItemSupport({
87 environmentId: props.environmentId,180 environmentId: props.environmentId,
88 sourceThreadId: props.projectedItem.sourceThreadId,181 sourceThreadId: props.projectedItem.sourceThreadId,
...@@ -107,6 +200,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec...@@ -107,6 +200,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
107 {item.type === "command_execution" ? (200 {item.type === "command_execution" ? (
108 <div className="space-y-2">201 <div className="space-y-2">
109 <StructuredValue value={item.input} highlightJson />202 <StructuredValue value={item.input} highlightJson />
203 {output}
110 {item.exitCode !== undefined ? (204 {item.exitCode !== undefined ? (
111 <p className={item.exitCode === 0 ? "text-success" : "text-destructive"}>205 <p className={item.exitCode === 0 ? "text-success" : "text-destructive"}>
112 Process exited with code {item.exitCode}206 Process exited with code {item.exitCode}
...@@ -137,6 +231,9 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec...@@ -137,6 +231,9 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
137 </Button>231 </Button>
138 ) : null}232 ) : null}
139 </div>233 </div>
234 {item.status === "failed" && item.diffStr?.trim() ? (
235 <StructuredValue value={item.diffStr} />
236 ) : null}
140 {item.changes !== undefined && item.changes.length > 0 ? (237 {item.changes !== undefined && item.changes.length > 0 ? (
141 <ul className="space-y-1 font-mono text-muted-foreground">238 <ul className="space-y-1 font-mono text-muted-foreground">
142 {item.changes.map((change, index) => (239 {item.changes.map((change, index) => (
...@@ -153,7 +250,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec...@@ -153,7 +250,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
153 </div>250 </div>
154 ) : null}251 ) : 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 ? (
157 <ul className="space-y-1 rounded-md border border-border/45 p-2">262 <ul className="space-y-1 rounded-md border border-border/45 p-2">
158 {item.results.map((result) => (263 {item.results.map((result) => (
159 <li key={JSON.stringify(result)}>264 <li key={JSON.stringify(result)}>
...@@ -170,7 +275,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec...@@ -170,7 +275,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
170 </ul>275 </ul>
171 ) : null}276 ) : null}
172277
173 {item.type === "web_search" && item.results ? (278 {item.type === "web_search" && item.results?.length ? (
174 <ul className="space-y-1.5 rounded-md border border-border/45 p-2">279 <ul className="space-y-1.5 rounded-md border border-border/45 p-2">
175 {item.results.map((result) => {280 {item.results.map((result) => {
176 const safeHref = resolveExternalWebLinkHref(result.url);281 const safeHref = resolveExternalWebLinkHref(result.url);
...@@ -199,11 +304,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec...@@ -199,11 +304,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
199 ) : null}304 ) : null}
200305
201 {item.type === "dynamic_tool" ? (306 {item.type === "dynamic_tool" ? (
202 <div>307 <div className="space-y-2">
203 <p className="mb-1 text-3xs font-medium tracking-wide uppercase text-muted-foreground">308 {formatToolValue(item.input) ? (
204 Input309 <div>
205 </p>310 <SectionLabel>Input</SectionLabel>
206 <StructuredValue value={item.input} highlightJson />311 <StructuredValue value={formatToolValue(item.input)} highlightJson />
312 </div>
313 ) : null}
314 {output}
207 </div>315 </div>
208 ) : null}316 ) : null}
209317
...@@ -213,6 +321,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec...@@ -213,6 +321,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
213 ) : null}321 ) : null}
214 {item.type === "notification" ? <StructuredValue value={item.detail} /> : null}322 {item.type === "notification" ? <StructuredValue value={item.detail} /> : null}
215 {item.type === "system_notice" ? <StructuredValue value={item.message} /> : null}323 {item.type === "system_notice" ? <StructuredValue value={item.message} /> : null}
324 {item.type === "error" ? <StructuredValue value={item.failure.message} /> : null}
216 {item.type === "proposed_plan" ? <StructuredValue value={item.markdown} /> : null}325 {item.type === "proposed_plan" ? <StructuredValue value={item.markdown} /> : null}
217 {item.type === "todo_list" ? (326 {item.type === "todo_list" ? (
218 <StructuredValue327 <StructuredValue
apps/web/src/state/queries.ts+21
...@@ -13,6 +13,8 @@ import type {...@@ -13,6 +13,8 @@ import type {
13 EnvironmentId,13 EnvironmentId,
14 ProjectContentMatch,14 ProjectContentMatch,
15 ProjectEntryKind,15 ProjectEntryKind,
16 ThreadId,
17 TurnItemId,
16 VcsListRefsResult,18 VcsListRefsResult,
17 VcsRef,19 VcsRef,
18} from "@t3tools/contracts";20} from "@t3tools/contracts";
...@@ -351,3 +353,22 @@ export function useCheckpointDiff(...@@ -351,3 +353,22 @@ export function useCheckpointDiff(
351 );353 );
352 return fullThreadTarget === null ? turn : fullThread;354 return fullThreadTarget === null ? turn : fullThread;
353}355}
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 @@...@@ -311,6 +311,10 @@
311 "types": "./src/work-log/userInput.ts",311 "types": "./src/work-log/userInput.ts",
312 "default": "./src/work-log/userInput.ts"312 "default": "./src/work-log/userInput.ts"
313 },313 },
314 "./work-log/item-detail": {
315 "types": "./src/work-log/itemDetail.ts",
316 "default": "./src/work-log/itemDetail.ts"
317 },
314 "./work-log/presentation": {318 "./work-log/presentation": {
315 "types": "./src/work-log/presentation.ts",319 "types": "./src/work-log/presentation.ts",
316 "default": "./src/work-log/presentation.ts"320 "default": "./src/work-log/presentation.ts"
packages/client-runtime/src/state/orchestration.ts+7
...@@ -44,6 +44,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>(...@@ -44,6 +44,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>(
44 staleTimeMs: 300_000,44 staleTimeMs: 300_000,
45 idleTtlMs: 300_000,45 idleTtlMs: 300_000,
46 }),46 }),
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 }),
47 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {54 fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, {
48 label: "environment-data:orchestration:full-thread-diff",55 label: "environment-data:orchestration:full-thread-diff",
49 tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff,56 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...@@ -38,8 +38,11 @@ export function toolItemForDisplay(item: OrchestrationV2TurnItem): Orchestration
38 return displayItem;38 return displayItem;
39 }39 }
40 case "file_change": {40 case "file_change": {
41 const { diffStr: _diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item;41 const { diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item;
42 return displayItem;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;
43 }46 }
44 default:47 default:
45 return item;48 return item;
packages/contracts/src/orchestrationV2.ts+27
...@@ -1357,6 +1357,8 @@ export const OrchestrationV2TurnItem = Schema.Union([...@@ -1357,6 +1357,8 @@ export const OrchestrationV2TurnItem = Schema.Union([
1357 type: Schema.Literal("command_execution"),1357 type: Schema.Literal("command_execution"),
1358 input: Schema.String,1358 input: Schema.String,
1359 output: Schema.optional(Schema.String),1359 output: Schema.optional(Schema.String),
1360 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
1361 outputOmitted: Schema.optional(Schema.Boolean),
1360 outputIndicatesFailure: Schema.optional(Schema.Boolean),1362 outputIndicatesFailure: Schema.optional(Schema.Boolean),
1361 exitCode: Schema.optional(Schema.Int),1363 exitCode: Schema.optional(Schema.Int),
1362 }),1364 }),
...@@ -1483,6 +1485,8 @@ export const OrchestrationV2TurnItem = Schema.Union([...@@ -1483,6 +1485,8 @@ export const OrchestrationV2TurnItem = Schema.Union([
1483 viewedImagePath: Schema.optional(TrimmedNonEmptyString),1485 viewedImagePath: Schema.optional(TrimmedNonEmptyString),
1484 input: Schema.Unknown,1486 input: Schema.Unknown,
1485 output: Schema.optional(Schema.Unknown),1487 output: Schema.optional(Schema.Unknown),
1488 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
1489 outputOmitted: Schema.optional(Schema.Boolean),
1486 }),1490 }),
1487]);1491]);
1488export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type;1492export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type;
...@@ -2089,6 +2093,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([...@@ -2089,6 +2093,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
2089 type: Schema.Literal("command_execution"),2093 type: Schema.Literal("command_execution"),
2090 input: Schema.String,2094 input: Schema.String,
2091 output: Schema.optional(Schema.String),2095 output: Schema.optional(Schema.String),
2096 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
2097 outputOmitted: Schema.optional(Schema.Boolean),
2092 outputIndicatesFailure: Schema.optional(Schema.Boolean),2098 outputIndicatesFailure: Schema.optional(Schema.Boolean),
2093 exitCode: Schema.optional(Schema.Int),2099 exitCode: Schema.optional(Schema.Int),
2094 }),2100 }),
...@@ -2212,6 +2218,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([...@@ -2212,6 +2218,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
2212 viewedImagePath: Schema.optional(TrimmedNonEmptyString),2218 viewedImagePath: Schema.optional(TrimmedNonEmptyString),
2213 input: Schema.Unknown,2219 input: Schema.Unknown,
2214 output: Schema.optional(Schema.Unknown),2220 output: Schema.optional(Schema.Unknown),
2221 /** Set on the wire when output was withheld; fetch it with getTurnItem. */
2222 outputOmitted: Schema.optional(Schema.Boolean),
2215 }),2223 }),
2216]);2224]);
2217export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type;2225export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type;
...@@ -2969,6 +2977,7 @@ export const ORCHESTRATION_V2_WS_METHODS = {...@@ -2969,6 +2977,7 @@ export const ORCHESTRATION_V2_WS_METHODS = {
2969 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",2977 getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
2970 getThreadProjection: "orchestration.getThreadProjection",2978 getThreadProjection: "orchestration.getThreadProjection",
2971 getWorkflowScript: "orchestration.getWorkflowScript",2979 getWorkflowScript: "orchestration.getWorkflowScript",
2980 getTurnItem: "orchestration.getTurnItem",
2972 launchThread: "orchestration.launchThread",2981 launchThread: "orchestration.launchThread",
2973 subscribeArchivedShell: "orchestration.subscribeArchivedShell",2982 subscribeArchivedShell: "orchestration.subscribeArchivedShell",
2974 subscribeShell: "orchestration.subscribeShell",2983 subscribeShell: "orchestration.subscribeShell",
...@@ -3255,6 +3264,20 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({...@@ -3255,6 +3264,20 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({
3255export type OrchestrationV2GetWorkflowScriptResult =3264export type OrchestrationV2GetWorkflowScriptResult =
3256 typeof OrchestrationV2GetWorkflowScriptResult.Type;3265 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
3258const WORKFLOW_SCRIPT_ERROR_MESSAGES = {3281const WORKFLOW_SCRIPT_ERROR_MESSAGES = {
3259 "invalid-path": "Workflow scripts must be absolute .js paths.",3282 "invalid-path": "Workflow scripts must be absolute .js paths.",
3260 "root-unavailable": "Script root unavailable.",3283 "root-unavailable": "Script root unavailable.",
...@@ -3313,6 +3336,10 @@ export const OrchestrationV2RpcSchemas = {...@@ -3313,6 +3336,10 @@ export const OrchestrationV2RpcSchemas = {
3313 input: OrchestrationV2GetWorkflowScriptInput,3336 input: OrchestrationV2GetWorkflowScriptInput,
3314 output: OrchestrationV2GetWorkflowScriptResult,3337 output: OrchestrationV2GetWorkflowScriptResult,
3315 },3338 },
3339 getTurnItem: {
3340 input: OrchestrationV2GetTurnItemInput,
3341 output: OrchestrationV2GetTurnItemResult,
3342 },
3316 launchThread: {3343 launchThread: {
3317 input: OrchestrationV2ThreadLaunchInput,3344 input: OrchestrationV2ThreadLaunchInput,
3318 output: OrchestrationV2ThreadLaunchResult,3345 output: OrchestrationV2ThreadLaunchResult,
packages/contracts/src/rpc.ts+7
...@@ -1549,6 +1549,12 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make(...@@ -1549,6 +1549,12 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make(
1549 },1549 },
1550);1550);
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
1552const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, {1558const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, {
1553 payload: OrchestrationV2RpcSchemas.launchThread.input,1559 payload: OrchestrationV2RpcSchemas.launchThread.input,
1554 success: OrchestrationV2RpcSchemas.launchThread.output,1560 success: OrchestrationV2RpcSchemas.launchThread.output,
...@@ -1858,6 +1864,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1858,6 +1864,7 @@ export const WsRpcGroup = RpcGroup.make(
1858 WsSubscribeResourceTelemetryRpc,1864 WsSubscribeResourceTelemetryRpc,
1859 WsOrchestrationV2DispatchCommandRpc,1865 WsOrchestrationV2DispatchCommandRpc,
1860 WsOrchestrationV2GetWorkflowScriptRpc,1866 WsOrchestrationV2GetWorkflowScriptRpc,
1867 WsOrchestrationV2GetTurnItemRpc,
1861 WsOrchestrationV2GetTurnDiffRpc,1868 WsOrchestrationV2GetTurnDiffRpc,
1862 WsOrchestrationV2GetFullThreadDiffRpc,1869 WsOrchestrationV2GetFullThreadDiffRpc,
1863 WsOrchestrationV2SearchThreadsRpc,1870 WsOrchestrationV2SearchThreadsRpc,