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