| 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,9 +60,12 @@ import { cn } from "../../lib/cn"; |
| 60 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; | 60 | import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; |
| 61 | import { | 61 | import { |
| 62 | type AgentSpawnSummary, | 62 | type AgentSpawnSummary, |
| 63 | formatItemFullDetail, | ||
| 63 | type ThreadFeedActivity, | 64 | type ThreadFeedActivity, |
| 64 | workEntryRowLabel, | 65 | workEntryRowLabel, |
| 65 | } from "../../lib/threadActivity"; | 66 | } from "../../lib/threadActivity"; |
| 67 | import { turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail"; | ||
| 68 | import { useTurnItemDetail } from "../../state/queries"; | ||
| 66 | import { | 69 | import { |
| 67 | resolveThreadWorkGroupInitialScroll, | 70 | resolveThreadWorkGroupInitialScroll, |
| 68 | shouldFollowThreadWorkGroupAppend, | 71 | shouldFollowThreadWorkGroupAppend, |
| ... | @@ -70,6 +73,7 @@ import { | ... | @@ -70,6 +73,7 @@ import { |
| 70 | } from "./thread-feed-live-follow"; | 73 | } from "./thread-feed-live-follow"; |
| 71 | import { | 74 | import { |
| 72 | resolveWorkEntryToolPresentation, | 75 | resolveWorkEntryToolPresentation, |
| 76 | toolGroupAction, | ||
| 73 | type ToolGroupSummaryKind, | 77 | type ToolGroupSummaryKind, |
| 74 | workEntryViewedImagePath, | 78 | workEntryViewedImagePath, |
| 75 | } from "@t3tools/client-runtime/work-log/presentation"; | 79 | } from "@t3tools/client-runtime/work-log/presentation"; |
| ... | @@ -836,6 +840,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -836,6 +840,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 836 | ) { | 840 | ) { |
| 837 | const { row, expanded } = props; | 841 | const { row, expanded } = props; |
| 838 | const navigation = useNavigation(); | 842 | const navigation = useNavigation(); |
| 843 | const fetchedDetail = useTurnItemDetail( | ||
| 844 | expanded && row.fetchesDetail | ||
| 845 | ? { environmentId: props.environmentId, row: row.projectedItem } | ||
| 846 | : null, | ||
| 847 | ); | ||
| 839 | const failureItem = row.projectedItem.item; | 848 | const failureItem = row.projectedItem.item; |
| 840 | if (failureItem.type === "error" && failureItem.status === "failed") { | 849 | if (failureItem.type === "error" && failureItem.status === "failed") { |
| 841 | const warning = failureItem.failure.class === "usage_limit"; | 850 | const warning = failureItem.failure.class === "usage_limit"; |
| ... | @@ -917,7 +926,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -917,7 +926,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 917 | : undefined; | 926 | : undefined; |
| 918 | const canExpand = row.canExpand && notifiedSubagentThreadId === undefined; | 927 | const canExpand = row.canExpand && notifiedSubagentThreadId === undefined; |
| 919 | const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null; | 928 | const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null; |
| 920 | const fullDetail = expanded && !reasoning ? row.getFullDetail() : null; | 929 | const fetchedItem = fetchedDetail.data?.item ?? null; |
| 930 | // Reads keep their path list; the fetched file contents show as output. | ||
| 931 | const isRead = toolGroupAction(row.workEntry) === "read"; | ||
| 932 | const fullDetail = | ||
| 933 | expanded && !reasoning | ||
| 934 | ? fetchedItem && !isRead | ||
| 935 | ? formatItemFullDetail(row.projectedItem, fetchedItem) | ||
| 936 | : row.getFullDetail() | ||
| 937 | : null; | ||
| 938 | const fetchedOutput = !expanded | ||
| 939 | ? null | ||
| 940 | : fetchedItem | ||
| 941 | ? (turnItemOutputText(fetchedItem) ?? "No output.") | ||
| 942 | : fetchedDetail.error | ||
| 943 | ? `Couldn't load output: ${fetchedDetail.error}` | ||
| 944 | : row.fetchesDetail | ||
| 945 | ? fetchedDetail.data | ||
| 946 | ? "Output is no longer available." | ||
| 947 | : "Loading output…" | ||
| 948 | : null; | ||
| 921 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); | 949 | const viewedImagePath = workEntryViewedImagePath(row.workEntry); |
| 922 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); | 950 | const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); |
| 923 | const previewText = workEntryRowLabel(row.workEntry); | 951 | const previewText = workEntryRowLabel(row.workEntry); |
| ... | @@ -1060,7 +1088,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -1060,7 +1088,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 1060 | </View> | 1088 | </View> |
| 1061 | </WorkLogPressable> | 1089 | </WorkLogPressable> |
| 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 | <Animated.View | 1097 | <Animated.View |
| 1065 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} | 1098 | entering={WORK_LOG_DETAIL_ENTER_TRANSITION} |
| 1066 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} | 1099 | exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} |
| ... | @@ -1092,6 +1125,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( | ... | @@ -1092,6 +1125,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( |
| 1092 | {fullDetail} | 1125 | {fullDetail} |
| 1093 | </Text> | 1126 | </Text> |
| 1094 | )} | 1127 | )} |
| 1128 | {fetchedOutput ? ( | ||
| 1129 | <Text | ||
| 1130 | selectable | ||
| 1131 | className="mt-1.5 font-mono text-2xs leading-normal text-foreground-muted" | ||
| 1132 | > | ||
| 1133 | {fetchedOutput} | ||
| 1134 | </Text> | ||
| 1135 | ) : null} | ||
| 1095 | </ScrollView> | 1136 | </ScrollView> |
| 1096 | </Animated.View> | 1137 | </Animated.View> |
| 1097 | ) : null} | 1138 | ) : null} |
apps/mobile/src/lib/threadActivity.test.ts+30| ... | @@ -271,6 +271,35 @@ describe("buildThreadFeed", () => { | ... | @@ -271,6 +271,35 @@ describe("buildThreadFeed", () => { |
| 271 | expect(items[0]).toMatchObject({ output: rawOutput }); | 271 | expect(items[0]).toMatchObject({ output: rawOutput }); |
| 272 | }); | 272 | }); |
| 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 | it("recognizes automation attribution after projecting a user message", () => { | 303 | it("recognizes automation attribution after projecting a user message", () => { |
| 275 | const feed = buildThreadFeed([ | 304 | const feed = buildThreadFeed([ |
| 276 | projected( | 305 | projected( |
| ... | @@ -1236,6 +1265,7 @@ describe("buildThreadFeed", () => { | ... | @@ -1236,6 +1265,7 @@ describe("buildThreadFeed", () => { |
| 1236 | summary: `Tool ${id}`, | 1265 | summary: `Tool ${id}`, |
| 1237 | detail: null, | 1266 | detail: null, |
| 1238 | canExpand: false, | 1267 | canExpand: false, |
| 1268 | fetchesDetail: false, | ||
| 1239 | getFullDetail: () => null, | 1269 | getFullDetail: () => null, |
| 1240 | getCopyText: () => id, | 1270 | getCopyText: () => id, |
| 1241 | icon: "command", | 1271 | icon: "command", |
apps/mobile/src/lib/threadActivity.ts+33-16| ... | @@ -6,6 +6,10 @@ import type { | ... | @@ -6,6 +6,10 @@ import type { |
| 6 | import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; | 6 | import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; |
| 7 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; | 7 | import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; |
| 8 | import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; | 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 | import { | 13 | import { |
| 10 | commandDisplayText, | 14 | commandDisplayText, |
| 11 | commandProgramName, | 15 | commandProgramName, |
| ... | @@ -74,6 +78,8 @@ export interface ThreadFeedActivity { | ... | @@ -74,6 +78,8 @@ export interface ThreadFeedActivity { |
| 74 | readonly summary: string; | 78 | readonly summary: string; |
| 75 | readonly detail: string | null; | 79 | readonly detail: string | null; |
| 76 | readonly canExpand: boolean; | 80 | readonly canExpand: boolean; |
| 81 | /** Expanding fetches the withheld input and output with getTurnItem. */ | ||
| 82 | readonly fetchesDetail: boolean; | ||
| 77 | readonly getFullDetail: () => string | null; | 83 | readonly getFullDetail: () => string | null; |
| 78 | readonly getCopyText: () => string; | 84 | readonly getCopyText: () => string; |
| 79 | readonly icon: | 85 | readonly icon: |
| ... | @@ -721,6 +727,23 @@ function toWorkLogEntry( | ... | @@ -721,6 +727,23 @@ function toWorkLogEntry( |
| 721 | } | 727 | } |
| 722 | } | 728 | } |
| 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 | function toFeedActivity( | 747 | function toFeedActivity( |
| 725 | row: OrchestrationV2ProjectedTurnItem, | 748 | row: OrchestrationV2ProjectedTurnItem, |
| 726 | attemptId: RunAttemptId | null, | 749 | attemptId: RunAttemptId | null, |
| ... | @@ -735,21 +758,9 @@ function toFeedActivity( | ... | @@ -735,21 +758,9 @@ function toFeedActivity( |
| 735 | item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read" | 758 | item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read" |
| 736 | ? collectToolFilePaths(item) | 759 | ? collectToolFilePaths(item) |
| 737 | : null; | 760 | : null; |
| 738 | const getFullDetail = memoizeValue(() => { | 761 | const getFullDetail = memoizeValue(() => |
| 739 | if (readPaths) { | 762 | readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), |
| 740 | return readPaths.join("\n") || null; | 763 | ); |
| 741 | } | ||
| 742 | return JSON.stringify( | ||
| 743 | { | ||
| 744 | visibility: row.visibility, | ||
| 745 | sourceThreadId: row.sourceThreadId, | ||
| 746 | sourceItemId: row.sourceItemId, | ||
| 747 | item: toolItemForDisplay(item), | ||
| 748 | }, | ||
| 749 | null, | ||
| 750 | 2, | ||
| 751 | ); | ||
| 752 | }); | ||
| 753 | const getCopyText = memoizeValue(() => | 764 | const getCopyText = memoizeValue(() => |
| 754 | [summary, detail, getFullDetail()] | 765 | [summary, detail, getFullDetail()] |
| 755 | .filter( | 766 | .filter( |
| ... | @@ -765,7 +776,13 @@ function toFeedActivity( | ... | @@ -765,7 +776,13 @@ function toFeedActivity( |
| 765 | attemptId, | 776 | attemptId, |
| 766 | summary, | 777 | summary, |
| 767 | detail, | 778 | detail, |
| 768 | canExpand: !(item.type === "error" && item.status === "failed") && (readPaths?.length ?? 1) > 0, | 779 | canExpand: |
| 780 | !(item.type === "error" && item.status === "failed") && | ||
| 781 | (readPaths | ||
| 782 | ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) | ||
| 783 | : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), | ||
| 784 | // Read rows show their paths, then the fetched file contents. | ||
| 785 | fetchesDetail: turnItemNeedsDetailFetch(item), | ||
| 769 | getFullDetail, | 786 | getFullDetail, |
| 770 | getCopyText, | 787 | getCopyText, |
| 771 | icon: workEntry.toolSurface ?? itemIcon(item), | 788 | icon: workEntry.toolSurface ?? itemIcon(item), |
apps/mobile/src/state/queries.ts+23| ... | @@ -2,6 +2,7 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRe | ... | @@ -2,6 +2,7 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRe |
| 2 | import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; | 2 | import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; |
| 3 | import type { | 3 | import type { |
| 4 | EnvironmentId, | 4 | EnvironmentId, |
| 5 | OrchestrationV2ProjectedTurnItem, | ||
| 5 | ProjectId, | 6 | ProjectId, |
| 6 | ThreadId, | 7 | ThreadId, |
| 7 | VcsListRefsResult, | 8 | VcsListRefsResult, |
| ... | @@ -14,6 +15,7 @@ import { | ... | @@ -14,6 +15,7 @@ import { |
| 14 | } from "@t3tools/client-runtime/state/thread-search"; | 15 | } from "@t3tools/client-runtime/state/thread-search"; |
| 15 | import { useAtomValue } from "@effect/atom-react"; | 16 | import { useAtomValue } from "@effect/atom-react"; |
| 16 | import * as Cause from "effect/Cause"; | 17 | import * as Cause from "effect/Cause"; |
| 18 | import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; | ||
| 17 | import * as Option from "effect/Option"; | 19 | import * as Option from "effect/Option"; |
| 18 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 20 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 19 | import { useCallback, useEffect, useMemo, useState } from "react"; | 21 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| ... | @@ -351,3 +353,24 @@ export function useCheckpointDiff(target: CheckpointDiffTarget) { | ... | @@ -351,3 +353,24 @@ export function useCheckpointDiff(target: CheckpointDiffTarget) { |
| 351 | ); | 353 | ); |
| 352 | return targets.fullThread === null ? turn : fullThread; | 354 | return targets.fullThread === null ? turn : fullThread; |
| 353 | } | 355 | } |
| 356 | |||
| 357 | /** Full input and output for one tool row; pass null to skip fetching. */ | ||
| 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,6 +1077,70 @@ const program = Effect.gen(function* () { |
| 1077 | status: "completed", | 1077 | status: "completed", |
| 1078 | rawInput: { query: "TODO", path: "apps/web" }, | 1078 | rawInput: { query: "TODO", path: "apps/web" }, |
| 1079 | }, | 1079 | }, |
| 1080 | // Grok backend searches: the query only arrives in the completed rawOutput. | ||
| 1081 | { | ||
| 1082 | sessionUpdate: "tool_call_update", | ||
| 1083 | toolCallId: "grok-x-search", | ||
| 1084 | title: "X search:", | ||
| 1085 | kind: "search", | ||
| 1086 | status: "in_progress", | ||
| 1087 | rawInput: { variant: "XSearch", backend: true }, | ||
| 1088 | }, | ||
| 1089 | { | ||
| 1090 | sessionUpdate: "tool_call_update", | ||
| 1091 | toolCallId: "grok-x-search", | ||
| 1092 | title: "X search:", | ||
| 1093 | status: "completed", | ||
| 1094 | rawOutput: { | ||
| 1095 | call_id: "xs_call-1", | ||
| 1096 | input: '{"query":"conversation_id:42","limit":"10","mode":"Latest"}', | ||
| 1097 | name: "x_keyword_search", | ||
| 1098 | id: "grok-x-search", | ||
| 1099 | }, | ||
| 1100 | }, | ||
| 1101 | { | ||
| 1102 | sessionUpdate: "tool_call_update", | ||
| 1103 | toolCallId: "grok-web-search", | ||
| 1104 | title: "Web search:", | ||
| 1105 | kind: "search", | ||
| 1106 | status: "completed", | ||
| 1107 | rawInput: { variant: "WebSearch", backend: true }, | ||
| 1108 | rawOutput: { | ||
| 1109 | action: { | ||
| 1110 | type: "search", | ||
| 1111 | query: "t3 code", | ||
| 1112 | sources: [ | ||
| 1113 | { type: "url", url: "https://t3.codes" }, | ||
| 1114 | { type: "url", url: "https://t3.codes" }, | ||
| 1115 | { type: "url", url: "https://github.com/pingdotgg/t3code" }, | ||
| 1116 | ], | ||
| 1117 | }, | ||
| 1118 | id: "grok-web-search", | ||
| 1119 | status: "completed", | ||
| 1120 | }, | ||
| 1121 | }, | ||
| 1122 | { | ||
| 1123 | sessionUpdate: "tool_call_update", | ||
| 1124 | toolCallId: "grok-web-fetch", | ||
| 1125 | title: "Fetch: https://t3.codes", | ||
| 1126 | kind: "fetch", | ||
| 1127 | status: "completed", | ||
| 1128 | rawInput: { variant: "WebFetch", url: "https://t3.codes" }, | ||
| 1129 | rawOutput: { | ||
| 1130 | type: "WebFetch", | ||
| 1131 | Content: { url: "https://t3.codes", content: "T3 Code page" }, | ||
| 1132 | }, | ||
| 1133 | content: [{ type: "content", content: { type: "text", text: "T3 Code page" } }], | ||
| 1134 | }, | ||
| 1135 | { | ||
| 1136 | sessionUpdate: "tool_call_update", | ||
| 1137 | toolCallId: "antigravity-shell", | ||
| 1138 | title: "run_command", | ||
| 1139 | kind: "execute", | ||
| 1140 | status: "completed", | ||
| 1141 | rawInput: { command: "cat probe.txt" }, | ||
| 1142 | rawOutput: { commandLine: "cat probe.txt", exitCode: 0, combinedOutput: "after\n" }, | ||
| 1143 | }, | ||
| 1080 | { | 1144 | { |
| 1081 | sessionUpdate: "compaction_update", | 1145 | sessionUpdate: "compaction_update", |
| 1082 | compactionId: "compact-1", | 1146 | compactionId: "compact-1", |
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -33,6 +33,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -33,6 +33,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 33 | [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, | 33 | [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, |
| 34 | [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, | 34 | [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, |
| 35 | [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, | 35 | [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, |
| 36 | [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope, | ||
| 36 | [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, | 37 | [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, |
| 37 | [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope, | 38 | [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope, |
| 38 | [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, | 39 | [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+35| ... | @@ -1324,6 +1324,12 @@ describe("AcpAdapterV2", () => { | ... | @@ -1324,6 +1324,12 @@ describe("AcpAdapterV2", () => { |
| 1324 | ), | 1324 | ), |
| 1325 | { input: "printf proof", output: "proof" }, | 1325 | { input: "printf proof", output: "proof" }, |
| 1326 | ); | 1326 | ); |
| 1327 | assert.deepInclude( | ||
| 1328 | items.flatMap((item) => | ||
| 1329 | item.type === "command_execution" ? [{ input: item.input, output: item.output }] : [], | ||
| 1330 | ), | ||
| 1331 | { input: "cat probe.txt", output: "after\n" }, | ||
| 1332 | ); | ||
| 1327 | assert.isTrue( | 1333 | assert.isTrue( |
| 1328 | items.some((item) => item.title === "Action required" && item.status === "waiting"), | 1334 | items.some((item) => item.title === "Action required" && item.status === "waiting"), |
| 1329 | ); | 1335 | ); |
| ... | @@ -1345,6 +1351,35 @@ describe("AcpAdapterV2", () => { | ... | @@ -1345,6 +1351,35 @@ describe("AcpAdapterV2", () => { |
| 1345 | search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null, | 1351 | search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null, |
| 1346 | { title: "Searched TODO in web", pattern: "apps/web" }, | 1352 | { title: "Searched TODO in web", pattern: "apps/web" }, |
| 1347 | ); | 1353 | ); |
| 1354 | const webItem = (nativeId: string, status: string) => { | ||
| 1355 | const item = items.findLast( | ||
| 1356 | (candidate) => | ||
| 1357 | candidate.type === "web_search" && | ||
| 1358 | candidate.status === status && | ||
| 1359 | candidate.nativeItemRef?.nativeId?.endsWith(nativeId) === true, | ||
| 1360 | ); | ||
| 1361 | return item?.type === "web_search" | ||
| 1362 | ? { title: item.title, patterns: item.patterns, results: item.results } | ||
| 1363 | : null; | ||
| 1364 | }; | ||
| 1365 | assert.deepEqual(webItem("grok-x-search", "running"), { | ||
| 1366 | title: "X search", | ||
| 1367 | patterns: undefined, | ||
| 1368 | results: undefined, | ||
| 1369 | }); | ||
| 1370 | assert.deepEqual(webItem("grok-x-search", "completed"), { | ||
| 1371 | title: "X search: conversation_id:42", | ||
| 1372 | patterns: ["conversation_id:42"], | ||
| 1373 | results: undefined, | ||
| 1374 | }); | ||
| 1375 | assert.deepEqual(webItem("grok-web-search", "completed"), { | ||
| 1376 | title: "Web search: t3 code", | ||
| 1377 | patterns: ["t3 code"], | ||
| 1378 | results: [{ url: "https://t3.codes" }, { url: "https://github.com/pingdotgg/t3code" }], | ||
| 1379 | }); | ||
| 1380 | assert.deepEqual(webItem("grok-web-fetch", "completed")?.results, [ | ||
| 1381 | { url: "https://t3.codes", snippet: "T3 Code page" }, | ||
| 1382 | ]); | ||
| 1348 | const completedCompaction = items.find( | 1383 | const completedCompaction = items.find( |
| 1349 | (item) => | 1384 | (item) => |
| 1350 | item.type === "compaction" && | 1385 | item.type === "compaction" && |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+76-5| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | type OrchestrationV2Subagent, | 19 | type OrchestrationV2Subagent, |
| 20 | type OrchestrationV2TurnItem, | 20 | type OrchestrationV2TurnItem, |
| 21 | type OrchestrationV2UserInputQuestion, | 21 | type OrchestrationV2UserInputQuestion, |
| 22 | type OrchestrationV2WebSearchResult, | ||
| 22 | type ProviderApprovalDecision, | 23 | type ProviderApprovalDecision, |
| 23 | type ProviderApprovalOption, | 24 | type ProviderApprovalOption, |
| 24 | type ProviderInstanceId, | 25 | type ProviderInstanceId, |
| ... | @@ -846,11 +847,14 @@ function textFromUnknown(value: unknown): string | undefined { | ... | @@ -846,11 +847,14 @@ function textFromUnknown(value: unknown): string | undefined { |
| 846 | return undefined; | 847 | return undefined; |
| 847 | } | 848 | } |
| 848 | // Prefer prompt-facing Grok fields before nested envelopes. | 849 | // Prefer prompt-facing Grok fields before nested envelopes. |
| 850 | // Antigravity reports shell output as combinedOutput. | ||
| 849 | for (const key of [ | 851 | for (const key of [ |
| 850 | "output_for_prompt", | 852 | "output_for_prompt", |
| 851 | "stdout", | 853 | "stdout", |
| 852 | "stderr", | 854 | "stderr", |
| 853 | "output", | 855 | "output", |
| 856 | "combinedOutput", | ||
| 857 | "combined_output", | ||
| 854 | "content", | 858 | "content", |
| 855 | "text", | 859 | "text", |
| 856 | "message", | 860 | "message", |
| ... | @@ -989,6 +993,46 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { | ... | @@ -989,6 +993,46 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { |
| 989 | return undefined; | 993 | return undefined; |
| 990 | } | 994 | } |
| 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 | function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { | 1036 | function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { |
| 993 | switch (kind) { | 1037 | switch (kind) { |
| 994 | case "execute": | 1038 | case "execute": |
| ... | @@ -3297,7 +3341,30 @@ export function makeAcpAdapterV2( | ... | @@ -3297,7 +3341,30 @@ export function makeAcpAdapterV2( |
| 3297 | ...(rawOutput === undefined ? {} : { output: rawOutput }), | 3341 | ...(rawOutput === undefined ? {} : { output: rawOutput }), |
| 3298 | }; | 3342 | }; |
| 3299 | break; | 3343 | break; |
| 3300 | case "search": | 3344 | case "search": { |
| 3345 | const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); | ||
| 3346 | if (backendSearch !== undefined) { | ||
| 3347 | // Grok titles these "X search:" / "Web search:" awaiting the query. | ||
| 3348 | const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( | ||
| 3349 | /:\s*$/u, | ||
| 3350 | "", | ||
| 3351 | ); | ||
| 3352 | turnItem = { | ||
| 3353 | ...base, | ||
| 3354 | title: | ||
| 3355 | backendSearch.query === undefined | ||
| 3356 | ? label | ||
| 3357 | : `${label}: ${backendSearch.query}`, | ||
| 3358 | type: "web_search", | ||
| 3359 | ...(backendSearch.query === undefined | ||
| 3360 | ? {} | ||
| 3361 | : { patterns: [backendSearch.query] }), | ||
| 3362 | ...(backendSearch.results.length === 0 | ||
| 3363 | ? {} | ||
| 3364 | : { results: backendSearch.results }), | ||
| 3365 | }; | ||
| 3366 | break; | ||
| 3367 | } | ||
| 3301 | turnItem = { | 3368 | turnItem = { |
| 3302 | ...base, | 3369 | ...base, |
| 3303 | title: | 3370 | title: |
| ... | @@ -3322,6 +3389,7 @@ export function makeAcpAdapterV2( | ... | @@ -3322,6 +3389,7 @@ export function makeAcpAdapterV2( |
| 3322 | }), | 3389 | }), |
| 3323 | }; | 3390 | }; |
| 3324 | break; | 3391 | break; |
| 3392 | } | ||
| 3325 | case "execute": { | 3393 | case "execute": { |
| 3326 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); | 3394 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); |
| 3327 | turnItem = { | 3395 | turnItem = { |
| ... | @@ -3345,7 +3413,11 @@ export function makeAcpAdapterV2( | ... | @@ -3345,7 +3413,11 @@ export function makeAcpAdapterV2( |
| 3345 | ...(diffText === undefined ? {} : { diffStr: diffText }), | 3413 | ...(diffText === undefined ? {} : { diffStr: diffText }), |
| 3346 | }; | 3414 | }; |
| 3347 | break; | 3415 | break; |
| 3348 | case "fetch": | 3416 | case "fetch": { |
| 3417 | // Grok nests the page under rawOutput.Content, which textFromUnknown | ||
| 3418 | // cannot read; the (bounded) content blocks carry the same text. | ||
| 3419 | const snippet = | ||
| 3420 | textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput); | ||
| 3349 | turnItem = { | 3421 | turnItem = { |
| 3350 | ...base, | 3422 | ...base, |
| 3351 | type: "web_search", | 3423 | type: "web_search", |
| ... | @@ -3356,14 +3428,13 @@ export function makeAcpAdapterV2( | ... | @@ -3356,14 +3428,13 @@ export function makeAcpAdapterV2( |
| 3356 | results: [ | 3428 | results: [ |
| 3357 | { | 3429 | { |
| 3358 | url: path, | 3430 | url: path, |
| 3359 | ...(textFromUnknown(rawOutput) === undefined | 3431 | ...(snippet === undefined ? {} : { snippet }), |
| 3360 | ? {} | ||
| 3361 | : { snippet: textFromUnknown(rawOutput) }), | ||
| 3362 | }, | 3432 | }, |
| 3363 | ], | 3433 | ], |
| 3364 | }), | 3434 | }), |
| 3365 | }; | 3435 | }; |
| 3366 | break; | 3436 | break; |
| 3437 | } | ||
| 3367 | default: | 3438 | default: |
| 3368 | if (projectAsCommandExecution) { | 3439 | if (projectAsCommandExecution) { |
| 3369 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); | 3440 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+83| ... | @@ -4325,6 +4325,89 @@ describe("ClaudeAdapterV2 background wake turns", () => { | ... | @@ -4325,6 +4325,89 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4325 | ), | 4325 | ), |
| 4326 | ); | 4326 | ); |
| 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 | it.effect("answers an approval a held wake turn raises without waiting for the echo", () => | 4411 | it.effect("answers an approval a held wake turn raises without waiting for the echo", () => |
| 4329 | Effect.scoped( | 4412 | Effect.scoped( |
| 4330 | Effect.gen(function* () { | 4413 | Effect.gen(function* () { |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+44-2| ... | @@ -1864,6 +1864,28 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { | ... | @@ -1864,6 +1864,28 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { |
| 1864 | return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); | 1864 | return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); |
| 1865 | } | 1865 | } |
| 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 | function claudeSubagentResultText(output: ClaudeNativeToolOutput): string { | 1889 | function claudeSubagentResultText(output: ClaudeNativeToolOutput): string { |
| 1868 | const value = claudeNativeToolOutputValue(output); | 1890 | const value = claudeNativeToolOutputValue(output); |
| 1869 | const content = Array.isArray(value) | 1891 | const content = Array.isArray(value) |
| ... | @@ -1904,6 +1926,8 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean | ... | @@ -1904,6 +1926,8 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean |
| 1904 | return claudeSubagentResultText(output).startsWith("Async agent launched successfully."); | 1926 | return claudeSubagentResultText(output).startsWith("Async agent launched successfully."); |
| 1905 | } | 1927 | } |
| 1906 | 1928 | ||
| 1929 | const WEB_FETCH_SNIPPET_MAX_CHARS = 8_000; | ||
| 1930 | |||
| 1907 | function webSearchPatternsFromClaudeTool(input: { | 1931 | function webSearchPatternsFromClaudeTool(input: { |
| 1908 | readonly toolInput: ClaudeNativeToolInput; | 1932 | readonly toolInput: ClaudeNativeToolInput; |
| 1909 | readonly output: ClaudeNativeToolOutput; | 1933 | readonly output: ClaudeNativeToolOutput; |
| ... | @@ -3807,8 +3831,12 @@ export function makeClaudeAdapterV2( | ... | @@ -3807,8 +3831,12 @@ export function makeClaudeAdapterV2( |
| 3807 | output: input.output, | 3831 | output: input.output, |
| 3808 | }); | 3832 | }); |
| 3809 | const webSearchResults = webSearchResultsFromClaudeOutput(input.output); | 3833 | const webSearchResults = webSearchResultsFromClaudeOutput(input.output); |
| 3834 | const webFetchUrl = firstStringInputField(input.toolInput, ["url"])?.trim(); | ||
| 3810 | const outputValue = claudeNativeToolOutputValue(input.output); | 3835 | const outputValue = claudeNativeToolOutputValue(input.output); |
| 3811 | const outputText = claudeNativeToolOutputText(input.output); | 3836 | const outputText = |
| 3837 | itemType === "command_execution" | ||
| 3838 | ? claudeCommandOutputText(input.output) | ||
| 3839 | : claudeNativeToolOutputText(input.output); | ||
| 3812 | const turnItem: OrchestrationV2TurnItem = | 3840 | const turnItem: OrchestrationV2TurnItem = |
| 3813 | itemType === "command_execution" | 3841 | itemType === "command_execution" |
| 3814 | ? { | 3842 | ? { |
| ... | @@ -3831,7 +3859,21 @@ export function makeClaudeAdapterV2( | ... | @@ -3831,7 +3859,21 @@ export function makeClaudeAdapterV2( |
| 3831 | ...(webSearchPatterns.length === 0 | 3859 | ...(webSearchPatterns.length === 0 |
| 3832 | ? {} | 3860 | ? {} |
| 3833 | : { patterns: [...webSearchPatterns] }), | 3861 | : { patterns: [...webSearchPatterns] }), |
| 3834 | ...(webSearchResults.length === 0 ? {} : { results: [...webSearchResults] }), | 3862 | ...(webSearchResults.length > 0 |
| 3863 | ? { results: [...webSearchResults] } | ||
| 3864 | : input.classification.normalizedName === "webfetch" && | ||
| 3865 | outputText.trim().length > 0 | ||
| 3866 | ? { | ||
| 3867 | // WebFetch returns page text, not search hits. Keep a | ||
| 3868 | // bounded preview so the row has something to show. | ||
| 3869 | results: [ | ||
| 3870 | { | ||
| 3871 | ...(webFetchUrl === undefined ? {} : { url: webFetchUrl }), | ||
| 3872 | snippet: outputText.slice(0, WEB_FETCH_SNIPPET_MAX_CHARS), | ||
| 3873 | }, | ||
| 3874 | ], | ||
| 3875 | } | ||
| 3876 | : {}), | ||
| 3835 | } | 3877 | } |
| 3836 | : { | 3878 | : { |
| 3837 | ...itemBase, | 3879 | ...itemBase, |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts+32-2| ... | @@ -430,6 +430,26 @@ describe("CursorAdapterV2", () => { | ... | @@ -430,6 +430,26 @@ describe("CursorAdapterV2", () => { |
| 430 | result: { status: "error", error: "ENOENT" }, | 430 | result: { status: "error", error: "ENOENT" }, |
| 431 | }, | 431 | }, |
| 432 | }, | 432 | }, |
| 433 | { | ||
| 434 | type: "tool-call-completed", | ||
| 435 | modelCallId: "native-model-call", | ||
| 436 | callId: "grep-failed", | ||
| 437 | toolCall: { | ||
| 438 | type: "grep", | ||
| 439 | args: { pattern: "TODO", path: "src" }, | ||
| 440 | result: { status: "error", error: "search failed" }, | ||
| 441 | }, | ||
| 442 | }, | ||
| 443 | { | ||
| 444 | type: "tool-call-completed", | ||
| 445 | modelCallId: "native-model-call", | ||
| 446 | callId: "glob-failed", | ||
| 447 | toolCall: { | ||
| 448 | type: "glob", | ||
| 449 | args: { globPattern: "*.ts" }, | ||
| 450 | result: { status: "error", error: "search failed" }, | ||
| 451 | }, | ||
| 452 | }, | ||
| 433 | { | 453 | { |
| 434 | type: "tool-call-completed", | 454 | type: "tool-call-completed", |
| 435 | modelCallId: "native-model-call", | 455 | modelCallId: "native-model-call", |
| ... | @@ -648,7 +668,17 @@ describe("CursorAdapterV2", () => { | ... | @@ -648,7 +668,17 @@ describe("CursorAdapterV2", () => { |
| 648 | { | 668 | { |
| 649 | pattern: path.join(workspace, "missing"), | 669 | pattern: path.join(workspace, "missing"), |
| 650 | status: "failed", | 670 | status: "failed", |
| 651 | results: undefined, | 671 | results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], |
| 672 | }, | ||
| 673 | { | ||
| 674 | pattern: "TODO", | ||
| 675 | status: "failed", | ||
| 676 | results: [{ fileName: "src", preview: "search failed" }], | ||
| 677 | }, | ||
| 678 | { | ||
| 679 | pattern: "*.ts", | ||
| 680 | status: "failed", | ||
| 681 | results: [{ fileName: ".", preview: "search failed" }], | ||
| 652 | }, | 682 | }, |
| 653 | { | 683 | { |
| 654 | pattern: "src/a.ts, src/b.ts", | 684 | pattern: "src/a.ts, src/b.ts", |
| ... | @@ -667,7 +697,7 @@ describe("CursorAdapterV2", () => { | ... | @@ -667,7 +697,7 @@ describe("CursorAdapterV2", () => { |
| 667 | { | 697 | { |
| 668 | pattern: "src/a.ts", | 698 | pattern: "src/a.ts", |
| 669 | status: "failed", | 699 | status: "failed", |
| 670 | results: undefined, | 700 | results: [{ fileName: "src/a.ts", preview: "lint failed" }], |
| 671 | }, | 701 | }, |
| 672 | ], | 702 | ], |
| 673 | ); | 703 | ); |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts+17-2| ... | @@ -1227,7 +1227,10 @@ export function makeCursorAdapterV2( | ... | @@ -1227,7 +1227,10 @@ export function makeCursorAdapterV2( |
| 1227 | toolCall.result?.status === "success" && | 1227 | toolCall.result?.status === "success" && |
| 1228 | toolCall.result.value.diffString !== undefined | 1228 | toolCall.result.value.diffString !== undefined |
| 1229 | ? { diffStr: toolCall.result.value.diffString } | 1229 | ? { diffStr: toolCall.result.value.diffString } |
| 1230 | : {}), | 1230 | : // A failed change keeps its error where the diff would be. |
| 1231 | toolCall.result?.status === "error" && outputText.trim().length > 0 | ||
| 1232 | ? { diffStr: outputText } | ||
| 1233 | : {}), | ||
| 1231 | ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), | 1234 | ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), |
| 1232 | }; | 1235 | }; |
| 1233 | break; | 1236 | break; |
| ... | @@ -1248,8 +1251,20 @@ export function makeCursorAdapterV2( | ... | @@ -1248,8 +1251,20 @@ export function makeCursorAdapterV2( |
| 1248 | case "ls": | 1251 | case "ls": |
| 1249 | case "readLints": | 1252 | case "readLints": |
| 1250 | case "semSearch": { | 1253 | case "semSearch": { |
| 1251 | const results = cursorToolSearchResults(toolCall, path); | ||
| 1252 | const pattern = cursorToolSearchPattern(toolCall); | 1254 | const pattern = cursorToolSearchPattern(toolCall); |
| 1255 | const searchPath = | ||
| 1256 | toolCall.type === "grep" | ||
| 1257 | ? toolCall.args.path | ||
| 1258 | : toolCall.type === "glob" | ||
| 1259 | ? toolCall.args.targetDirectory | ||
| 1260 | : toolCall.type === "semSearch" | ||
| 1261 | ? toolCall.args.targetDirectories?.join(", ") | ||
| 1262 | : pattern; | ||
| 1263 | // A failed search keeps its error as one row under the searched path. | ||
| 1264 | const results = | ||
| 1265 | toolCall.result?.status === "error" && outputText.trim().length > 0 | ||
| 1266 | ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] | ||
| 1267 | : cursorToolSearchResults(toolCall, path); | ||
| 1253 | turnItem = { | 1268 | turnItem = { |
| 1254 | ...base, | 1269 | ...base, |
| 1255 | title: | 1270 | title: |
apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts+23-5| ... | @@ -833,10 +833,12 @@ describe("OpenCodeAdapterV2", () => { | ... | @@ -833,10 +833,12 @@ describe("OpenCodeAdapterV2", () => { |
| 833 | Stream.runCollect, | 833 | Stream.runCollect, |
| 834 | Effect.forkScoped, | 834 | Effect.forkScoped, |
| 835 | ); | 835 | ); |
| 836 | for (const [tool, input] of [ | 836 | for (const [tool, input, output] of [ |
| 837 | ["read", { filePath: "src/env.ts" }], | 837 | ["read", { filePath: "src/env.ts" }, "---\nfile body"], |
| 838 | ["grep", { pattern: "TODO", path: "apps/web" }], | 838 | ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], |
| 839 | ["websearch", { query: "OpenCode documentation" }], | 839 | ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], |
| 840 | ["glob", { pattern: "missing", path: "apps/web" }, ""], | ||
| 841 | ["codesearch", {}, " \n\t"], | ||
| 840 | ] as const) { | 842 | ] as const) { |
| 841 | yield* Effect.promise(() => | 843 | yield* Effect.promise(() => |
| 842 | nativeEvents.push({ | 844 | nativeEvents.push({ |
| ... | @@ -853,7 +855,7 @@ describe("OpenCodeAdapterV2", () => { | ... | @@ -853,7 +855,7 @@ describe("OpenCodeAdapterV2", () => { |
| 853 | state: { | 855 | state: { |
| 854 | status: "completed", | 856 | status: "completed", |
| 855 | input, | 857 | input, |
| 856 | output: "---\nfile body", | 858 | output, |
| 857 | title: tool, | 859 | title: tool, |
| 858 | metadata: {}, | 860 | metadata: {}, |
| 859 | time: { start: 1, end: 2 }, | 861 | time: { start: 1, end: 2 }, |
| ... | @@ -877,10 +879,26 @@ describe("OpenCodeAdapterV2", () => { | ... | @@ -877,10 +879,26 @@ describe("OpenCodeAdapterV2", () => { |
| 877 | const grep = items.find((item) => item.type === "file_search"); | 879 | const grep = items.find((item) => item.type === "file_search"); |
| 878 | assert.equal(grep?.title, "Searched TODO in web"); | 880 | assert.equal(grep?.title, "Searched TODO in web"); |
| 879 | assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); | 881 | assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); |
| 882 | assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ | ||
| 883 | { fileName: "apps/web", preview: "---\nfile body" }, | ||
| 884 | ]); | ||
| 880 | const webSearch = items.find((item) => item.type === "web_search"); | 885 | const webSearch = items.find((item) => item.type === "web_search"); |
| 881 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ | 886 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ |
| 882 | "OpenCode documentation", | 887 | "OpenCode documentation", |
| 883 | ]); | 888 | ]); |
| 889 | assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ | ||
| 890 | { snippet: "---\nfile body" }, | ||
| 891 | ]); | ||
| 892 | const emptyFileSearch = items.find( | ||
| 893 | (item) => item.type === "file_search" && item.pattern === "missing", | ||
| 894 | ); | ||
| 895 | assert.ok(emptyFileSearch?.type === "file_search"); | ||
| 896 | assert.equal(emptyFileSearch.results, undefined); | ||
| 897 | const emptyWebSearch = items.find( | ||
| 898 | (item) => item.type === "web_search" && item.patterns === undefined, | ||
| 899 | ); | ||
| 900 | assert.ok(emptyWebSearch?.type === "web_search"); | ||
| 901 | assert.equal(emptyWebSearch.results, undefined); | ||
| 884 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), | 902 | }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), |
| 885 | ); | 903 | ); |
| 886 | 904 |
apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts+28-1| ... | @@ -6,6 +6,9 @@ | ... | @@ -6,6 +6,9 @@ |
| 6 | import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; | 6 | import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; |
| 7 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | 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 | type ToolItemBase = Omit< | 12 | type ToolItemBase = Omit< |
| 10 | Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>, | 13 | Extract<OrchestrationV2TurnItem, { type: "dynamic_tool" }>, |
| 11 | "type" | "toolName" | "input" | "output" | 14 | "type" | "toolName" | "input" | "output" |
| ... | @@ -96,7 +99,10 @@ export function openCodeToolTurnItem( | ... | @@ -96,7 +99,10 @@ export function openCodeToolTurnItem( |
| 96 | case "file_change": { | 99 | case "file_change": { |
| 97 | const oldStr = recordString(input, "oldString", "oldText"); | 100 | const oldStr = recordString(input, "oldString", "oldText"); |
| 98 | const newStr = recordString(input, "newString", "content", "newText"); | 101 | const newStr = recordString(input, "newString", "content", "newText"); |
| 99 | const diffStr = recordString(tool.completedMetadata, "diff", "patch"); | 102 | // A failed edit has no diff; keep its error where the diff would be. |
| 103 | const diffStr = | ||
| 104 | recordString(tool.completedMetadata, "diff", "patch") ?? | ||
| 105 | (base.status === "failed" && output?.trim() ? output : undefined); | ||
| 100 | return { | 106 | return { |
| 101 | ...base, | 107 | ...base, |
| 102 | type: "file_change", | 108 | type: "file_change", |
| ... | @@ -108,6 +114,9 @@ export function openCodeToolTurnItem( | ... | @@ -108,6 +114,9 @@ export function openCodeToolTurnItem( |
| 108 | } | 114 | } |
| 109 | case "file_search": { | 115 | case "file_search": { |
| 110 | const pattern = recordString(input, "pattern", "query", "path", "filePath"); | 116 | const pattern = recordString(input, "pattern", "query", "path", "filePath"); |
| 117 | // OpenCode reports matches as plain text, so keep it as one result row | ||
| 118 | // under the searched path, like the ACP search projection. | ||
| 119 | const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); | ||
| 111 | return { | 120 | return { |
| 112 | ...base, | 121 | ...base, |
| 113 | title: | 122 | title: |
| ... | @@ -115,14 +124,32 @@ export function openCodeToolTurnItem( | ... | @@ -115,14 +124,32 @@ export function openCodeToolTurnItem( |
| 115 | base.title, | 124 | base.title, |
| 116 | type: "file_search", | 125 | type: "file_search", |
| 117 | ...(pattern === undefined ? {} : { pattern }), | 126 | ...(pattern === undefined ? {} : { pattern }), |
| 127 | ...(!output?.trim() || searchRoot === undefined | ||
| 128 | ? {} | ||
| 129 | : { | ||
| 130 | results: [ | ||
| 131 | { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }, | ||
| 132 | ], | ||
| 133 | }), | ||
| 118 | }; | 134 | }; |
| 119 | } | 135 | } |
| 120 | case "web_search": { | 136 | case "web_search": { |
| 121 | const pattern = recordString(input, "query", "url", "pattern"); | 137 | const pattern = recordString(input, "query", "url", "pattern"); |
| 138 | const url = recordString(input, "url")?.trim(); | ||
| 122 | return { | 139 | return { |
| 123 | ...base, | 140 | ...base, |
| 124 | type: "web_search", | 141 | type: "web_search", |
| 125 | ...(pattern === undefined ? {} : { patterns: [pattern] }), | 142 | ...(pattern === undefined ? {} : { patterns: [pattern] }), |
| 143 | ...(!output?.trim() | ||
| 144 | ? {} | ||
| 145 | : { | ||
| 146 | results: [ | ||
| 147 | { | ||
| 148 | ...(url === undefined ? {} : { url }), | ||
| 149 | snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS), | ||
| 150 | }, | ||
| 151 | ], | ||
| 152 | }), | ||
| 126 | }; | 153 | }; |
| 127 | } | 154 | } |
| 128 | case "dynamic_tool": { | 155 | case "dynamic_tool": { |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts+96| ... | @@ -1155,6 +1155,102 @@ describe("PiAdapterV2", () => { | ... | @@ -1155,6 +1155,102 @@ describe("PiAdapterV2", () => { |
| 1155 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | 1155 | }).pipe(Effect.scoped, Effect.provide(testLayer)), |
| 1156 | ); | 1156 | ); |
| 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 | it.effect("settles a command-only prompt from its deferred ack and idle probe", () => | 1254 | it.effect("settles a command-only prompt from its deferred ack and idle probe", () => |
| 1159 | Effect.gen(function* () { | 1255 | Effect.gen(function* () { |
| 1160 | const fake = yield* makeFakePi; | 1256 | const fake = yield* makeFakePi; |
apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts+8| ... | @@ -988,6 +988,12 @@ export function makePiAdapterV2( | ... | @@ -988,6 +988,12 @@ export function makePiAdapterV2( |
| 988 | if (toolName === "edit" || toolName === "write") { | 988 | if (toolName === "edit" || toolName === "write") { |
| 989 | const fileName = recordString(args, "path") ?? recordString(args, "file_path"); | 989 | const fileName = recordString(args, "path") ?? recordString(args, "file_path"); |
| 990 | if (fileName !== undefined) { | 990 | if (fileName !== undefined) { |
| 991 | // edit reports a unified patch in its result details; write only | ||
| 992 | // carries the new content in its args. A failed call keeps its error. | ||
| 993 | const diffStr = | ||
| 994 | recordString(recordField(resultRecord, "details"), "patch") ?? | ||
| 995 | (isError && outputText.trim().length > 0 ? outputText : undefined); | ||
| 996 | const newStr = toolName === "write" ? recordString(args, "content") : undefined; | ||
| 991 | yield* emit({ | 997 | yield* emit({ |
| 992 | type: "turn_item.updated", | 998 | type: "turn_item.updated", |
| 993 | driver: PI_PROVIDER, | 999 | driver: PI_PROVIDER, |
| ... | @@ -996,6 +1002,8 @@ export function makePiAdapterV2( | ... | @@ -996,6 +1002,8 @@ export function makePiAdapterV2( |
| 996 | title: toolName, | 1002 | title: toolName, |
| 997 | type: "file_change", | 1003 | type: "file_change", |
| 998 | fileName, | 1004 | fileName, |
| 1005 | ...(diffStr === undefined ? {} : { diffStr }), | ||
| 1006 | ...(newStr === undefined ? {} : { newStr }), | ||
| 999 | }, | 1007 | }, |
| 1000 | }); | 1008 | }); |
| 1001 | return; | 1009 | return; |
apps/server/src/orchestration-v2/Orchestrator.ts+14| ... | @@ -49,6 +49,7 @@ import { | ... | @@ -49,6 +49,7 @@ import { |
| 49 | RunId, | 49 | RunId, |
| 50 | ThreadLinkedPullRequest, | 50 | ThreadLinkedPullRequest, |
| 51 | ThreadId, | 51 | ThreadId, |
| 52 | type TurnItemId, | ||
| 52 | } from "@t3tools/contracts"; | 53 | } from "@t3tools/contracts"; |
| 53 | import { modelSelectionsEqual } from "@t3tools/shared/model"; | 54 | import { modelSelectionsEqual } from "@t3tools/shared/model"; |
| 54 | import { | 55 | import { |
| ... | @@ -264,6 +265,10 @@ export interface OrchestratorV2Shape { | ... | @@ -264,6 +265,10 @@ export interface OrchestratorV2Shape { |
| 264 | options: ProjectionTimelinePageOptions, | 265 | options: ProjectionTimelinePageOptions, |
| 265 | ) => Effect.Effect<ProjectionTimelinePage, OrchestratorProjectionError>; | 266 | ) => Effect.Effect<ProjectionTimelinePage, OrchestratorProjectionError>; |
| 266 | readonly getMessageCount: (threadId: ThreadId) => Effect.Effect<number, OrchestratorV2Error>; | 267 | readonly getMessageCount: (threadId: ThreadId) => Effect.Effect<number, OrchestratorV2Error>; |
| 268 | readonly getTurnItem: (input: { | ||
| 269 | readonly threadId: ThreadId; | ||
| 270 | readonly itemId: TurnItemId; | ||
| 271 | }) => Effect.Effect<OrchestrationV2TurnItem | null, OrchestratorV2Error>; | ||
| 267 | readonly getThreadRecords: <K extends ProjectionRecordField>( | 272 | readonly getThreadRecords: <K extends ProjectionRecordField>( |
| 268 | threadId: ThreadId, | 273 | threadId: ThreadId, |
| 269 | fields: ReadonlyArray<K>, | 274 | fields: ReadonlyArray<K>, |
| ... | @@ -10067,6 +10072,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -10067,6 +10072,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10067 | projectionStore | 10072 | projectionStore |
| 10068 | .getMessageCount(threadId) | 10073 | .getMessageCount(threadId) |
| 10069 | .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), | 10074 | .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), |
| 10075 | getTurnItem: (input) => | ||
| 10076 | projectionStore | ||
| 10077 | .getTurnItem(input) | ||
| 10078 | .pipe( | ||
| 10079 | Effect.mapError( | ||
| 10080 | (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), | ||
| 10081 | ), | ||
| 10082 | ), | ||
| 10070 | getThreadRecords: (threadId, fields, filter) => | 10083 | getThreadRecords: (threadId, fields, filter) => |
| 10071 | projectionStore | 10084 | projectionStore |
| 10072 | .getThreadRecords(threadId, fields, filter) | 10085 | .getThreadRecords(threadId, fields, filter) |
| ... | @@ -10188,6 +10201,7 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed( | ... | @@ -10188,6 +10201,7 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed( |
| 10188 | ), | 10201 | ), |
| 10189 | getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), | 10202 | getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), |
| 10190 | getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), | 10203 | getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), |
| 10204 | getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })), | ||
| 10191 | getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), | 10205 | getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), |
| 10192 | getThreadProjection: (threadId) => | 10206 | getThreadProjection: (threadId) => |
| 10193 | Effect.fail( | 10207 | Effect.fail( |
apps/server/src/orchestration-v2/ProjectionStore.test.ts+8| ... | @@ -959,6 +959,14 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { | ... | @@ -959,6 +959,14 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { |
| 959 | assert.strictEqual(older.projection.turnItems[0]?.ordinal, 850); | 959 | assert.strictEqual(older.projection.turnItems[0]?.ordinal, 850); |
| 960 | assert.strictEqual(older.projection.turnItems.at(-1)?.ordinal, 925); | 960 | assert.strictEqual(older.projection.turnItems.at(-1)?.ordinal, 925); |
| 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 | const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2; | 970 | const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2; |
| 963 | const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { | 971 | const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { |
| 964 | rowLimit: sqlPageLimit, | 972 | rowLimit: sqlPageLimit, |
apps/server/src/orchestration-v2/ProjectionStore.ts+25| ... | @@ -318,6 +318,11 @@ export interface ProjectionStoreV2Shape { | ... | @@ -318,6 +318,11 @@ export interface ProjectionStoreV2Shape { |
| 318 | readonly getNextTurnItemOrdinal: ( | 318 | readonly getNextTurnItemOrdinal: ( |
| 319 | threadId: ThreadId, | 319 | threadId: ThreadId, |
| 320 | ) => Effect.Effect<number, ProjectionStoreV2Error>; | 320 | ) => Effect.Effect<number, ProjectionStoreV2Error>; |
| 321 | /** One persisted turn item, or null when the thread has no such item. */ | ||
| 322 | readonly getTurnItem: (input: { | ||
| 323 | readonly threadId: ThreadId; | ||
| 324 | readonly itemId: TurnItemId; | ||
| 325 | }) => Effect.Effect<OrchestrationV2TurnItem | null, ProjectionStoreV2Error>; | ||
| 321 | readonly getThreadRecords: <K extends ProjectionRecordField>( | 326 | readonly getThreadRecords: <K extends ProjectionRecordField>( |
| 322 | threadId: ThreadId, | 327 | threadId: ThreadId, |
| 323 | fields: ReadonlyArray<K>, | 328 | fields: ReadonlyArray<K>, |
| ... | @@ -4504,6 +4509,18 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -4504,6 +4509,18 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 4504 | Effect.mapError(controlReadError(threadId)), | 4509 | Effect.mapError(controlReadError(threadId)), |
| 4505 | ); | 4510 | ); |
| 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 | const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) => | 4524 | const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) => |
| 4508 | sql<{ id: string }>` | 4525 | sql<{ id: string }>` |
| 4509 | SELECT DISTINCT json_extract(attachment.value, '$.id') AS id | 4526 | SELECT DISTINCT json_extract(attachment.value, '$.id') AS id |
| ... | @@ -5539,6 +5556,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -5539,6 +5556,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5539 | hasUnpairedRunInterruptRequest, | 5556 | hasUnpairedRunInterruptRequest, |
| 5540 | getMessageCount, | 5557 | getMessageCount, |
| 5541 | getNextTurnItemOrdinal, | 5558 | getNextTurnItemOrdinal, |
| 5559 | getTurnItem, | ||
| 5542 | getThreadRecords, | 5560 | getThreadRecords, |
| 5543 | getRuntimeRequest, | 5561 | getRuntimeRequest, |
| 5544 | getPlan, | 5562 | getPlan, |
| ... | @@ -5791,6 +5809,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( | ... | @@ -5791,6 +5809,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 5791 | ?.turnItems.reduce((max, item) => Math.max(max, item.ordinal), 0) ?? 0) + 1, | 5809 | ?.turnItems.reduce((max, item) => Math.max(max, item.ordinal), 0) ?? 0) + 1, |
| 5792 | ), | 5810 | ), |
| 5793 | ), | 5811 | ), |
| 5812 | getTurnItem: ({ threadId, itemId }) => | ||
| 5813 | Ref.get(replayState).pipe( | ||
| 5814 | Effect.map( | ||
| 5815 | (state) => | ||
| 5816 | state.projections.get(threadId)?.turnItems.find((item) => item.id === itemId) ?? null, | ||
| 5817 | ), | ||
| 5818 | ), | ||
| 5794 | getThreadAttachmentIds: (threadId) => | 5819 | getThreadAttachmentIds: (threadId) => |
| 5795 | service | 5820 | service |
| 5796 | .getThreadProjection(threadId) | 5821 | .getThreadProjection(threadId) |
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1| ... | @@ -227,6 +227,7 @@ it.effect( | ... | @@ -227,6 +227,7 @@ it.effect( |
| 227 | getTimelinePage: () => Effect.die("Unused timeline read"), | 227 | getTimelinePage: () => Effect.die("Unused timeline read"), |
| 228 | getMessageCount: () => Effect.die("unused message count"), | 228 | getMessageCount: () => Effect.die("unused message count"), |
| 229 | getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), | 229 | getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), |
| 230 | getTurnItem: () => Effect.die("unused turn item read"), | ||
| 230 | getThreadRecords: () => Effect.die("unused record read"), | 231 | getThreadRecords: () => Effect.die("unused record read"), |
| 231 | getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"), | 232 | getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"), |
| 232 | getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"), | 233 | getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"), |
apps/server/src/orchestration-v2/ThreadManagementService.ts+16| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | type ModelSelection, | 10 | type ModelSelection, |
| 11 | type OrchestrationV2Actor, | 11 | type OrchestrationV2Actor, |
| 12 | type OrchestrationV2Command, | 12 | type OrchestrationV2Command, |
| 13 | type OrchestrationV2GetTurnItemResult, | ||
| 13 | type OrchestrationV2ServerCommand, | 14 | type OrchestrationV2ServerCommand, |
| 14 | type OrchestrationV2ConversationMessage, | 15 | type OrchestrationV2ConversationMessage, |
| 15 | type OrchestrationV2CreationSource, | 16 | type OrchestrationV2CreationSource, |
| ... | @@ -22,6 +23,7 @@ import { | ... | @@ -22,6 +23,7 @@ import { |
| 22 | RunId, | 23 | RunId, |
| 23 | type ScheduledTaskId, | 24 | type ScheduledTaskId, |
| 24 | ThreadId, | 25 | ThreadId, |
| 26 | type TurnItemId, | ||
| 25 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| 26 | import * as Context from "effect/Context"; | 28 | import * as Context from "effect/Context"; |
| 27 | import * as DateTime from "effect/DateTime"; | 29 | import * as DateTime from "effect/DateTime"; |
| ... | @@ -32,6 +34,7 @@ import * as Option from "effect/Option"; | ... | @@ -32,6 +34,7 @@ import * as Option from "effect/Option"; |
| 32 | import * as Schema from "effect/Schema"; | 34 | import * as Schema from "effect/Schema"; |
| 33 | 35 | ||
| 34 | import * as Orchestrator from "./Orchestrator.ts"; | 36 | import * as Orchestrator from "./Orchestrator.ts"; |
| 37 | import { projectTurnItemForDetail } from "./WireProjection.ts"; | ||
| 35 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; | 38 | import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; |
| 36 | 39 | ||
| 37 | export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; | 40 | export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; |
| ... | @@ -276,6 +279,14 @@ export interface ThreadManagementServiceShape { | ... | @@ -276,6 +279,14 @@ export interface ThreadManagementServiceShape { |
| 276 | ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>; | 279 | ) => Effect.Effect<Orchestrator.OrchestratorV2DispatchResult, Orchestrator.OrchestratorV2Error>; |
| 277 | readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; | 280 | readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; |
| 278 | readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; | 281 | readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; |
| 282 | /** | ||
| 283 | * One turn item with the input and output the thread stream withholds, | ||
| 284 | * bounded for the wire. Clients fetch it when a tool row is expanded. | ||
| 285 | */ | ||
| 286 | readonly getTurnItem: (input: { | ||
| 287 | readonly threadId: ThreadId; | ||
| 288 | readonly itemId: TurnItemId; | ||
| 289 | }) => Effect.Effect<OrchestrationV2GetTurnItemResult, Orchestrator.OrchestratorV2Error>; | ||
| 279 | readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"]; | 290 | readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"]; |
| 280 | readonly getThreadProjection: ( | 291 | readonly getThreadProjection: ( |
| 281 | threadId: ThreadId, | 292 | threadId: ThreadId, |
| ... | @@ -721,6 +732,11 @@ const make = Effect.gen(function* () { | ... | @@ -721,6 +732,11 @@ const make = Effect.gen(function* () { |
| 721 | ensureProjectionTranscript(threadId).pipe( | 732 | ensureProjectionTranscript(threadId).pipe( |
| 722 | Effect.andThen(orchestrator.getMessageCount(threadId)), | 733 | Effect.andThen(orchestrator.getMessageCount(threadId)), |
| 723 | ), | 734 | ), |
| 735 | getTurnItem: (input) => | ||
| 736 | ensureProjectionTranscript(input.threadId).pipe( | ||
| 737 | Effect.andThen(orchestrator.getTurnItem(input)), | ||
| 738 | Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) })), | ||
| 739 | ), | ||
| 724 | getThreadRecords: (threadId, fields, filter) => | 740 | getThreadRecords: (threadId, fields, filter) => |
| 725 | ensureProjectionTranscript(threadId).pipe( | 741 | ensureProjectionTranscript(threadId).pipe( |
| 726 | Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), | 742 | Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), |
apps/server/src/orchestration-v2/WireProjection.test.ts+28-2| ... | @@ -17,7 +17,11 @@ import { describe, expect, it } from "@effect/vitest"; | ... | @@ -17,7 +17,11 @@ import { describe, expect, it } from "@effect/vitest"; |
| 17 | import * as DateTime from "effect/DateTime"; | 17 | import * as DateTime from "effect/DateTime"; |
| 18 | import * as Schema from "effect/Schema"; | 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 | import { threadShellFromProjection } from "./ProjectionStore.ts"; | 25 | import { threadShellFromProjection } from "./ProjectionStore.ts"; |
| 22 | 26 | ||
| 23 | const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem); | 27 | const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem); |
| ... | @@ -173,7 +177,7 @@ describe("orchestration V2 wire projection", () => { | ... | @@ -173,7 +177,7 @@ describe("orchestration V2 wire projection", () => { |
| 173 | 177 | ||
| 174 | it("omits even small dynamic tool results while retaining input", () => { | 178 | it("omits even small dynamic tool results while retaining input", () => { |
| 175 | const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem; | 179 | const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem; |
| 176 | expect(projectTurnItemForWire(item)).toEqual(base); | 180 | expect(projectTurnItemForWire(item)).toEqual({ ...base, outputOmitted: true }); |
| 177 | expect(item.output).toEqual({ ok: true }); | 181 | expect(item.output).toEqual({ ok: true }); |
| 178 | }); | 182 | }); |
| 179 | 183 | ||
| ... | @@ -252,10 +256,24 @@ describe("orchestration V2 wire projection", () => { | ... | @@ -252,10 +256,24 @@ describe("orchestration V2 wire projection", () => { |
| 252 | const projected = projectTurnItemForWire(item); | 256 | const projected = projectTurnItemForWire(item); |
| 253 | expect(projected).not.toHaveProperty("output"); | 257 | expect(projected).not.toHaveProperty("output"); |
| 254 | expect(projected).toMatchObject({ input: "test", status: "completed" }); | 258 | expect(projected).toMatchObject({ input: "test", status: "completed" }); |
| 259 | // Clients fetch withheld output on demand, so they need to know it exists. | ||
| 260 | expect(projected.type === "command_execution" ? projected.outputOmitted : null).toBe( | ||
| 261 | output ? true : undefined, | ||
| 262 | ); | ||
| 255 | expect(item.output).toBe(output); | 263 | expect(item.output).toBe(output); |
| 256 | }, | 264 | }, |
| 257 | ); | 265 | ); |
| 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 | it("keeps failure evidence without retaining command output", () => { | 277 | it("keeps failure evidence without retaining command output", () => { |
| 260 | const item = { | 278 | const item = { |
| 261 | ...base, | 279 | ...base, |
| ... | @@ -290,6 +308,14 @@ describe("orchestration V2 wire projection", () => { | ... | @@ -290,6 +308,14 @@ describe("orchestration V2 wire projection", () => { |
| 290 | expect(projected).not.toHaveProperty("newStr"); | 308 | expect(projected).not.toHaveProperty("newStr"); |
| 291 | expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 }); | 309 | expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 }); |
| 292 | expect(item.diffStr).toBe("+new code"); | 310 | expect(item.diffStr).toBe("+new code"); |
| 311 | // A failed edit keeps the provider's error so expanding the row can show it. | ||
| 312 | const failed = projectTurnItemForWire({ | ||
| 313 | ...item, | ||
| 314 | status: "failed", | ||
| 315 | diffStr: "String to replace not found", | ||
| 316 | }); | ||
| 317 | expect(failed).toMatchObject({ diffStr: "String to replace not found" }); | ||
| 318 | expect(failed).not.toHaveProperty("newStr"); | ||
| 293 | }); | 319 | }); |
| 294 | 320 | ||
| 295 | it("retains only result identities and failure metadata in live tool events", () => { | 321 | it("retains only result identities and failure metadata in live tool events", () => { |
apps/server/src/orchestration-v2/WireProjection.ts+75-8| ... | @@ -8,19 +8,22 @@ import { compactDynamicToolOutput, toolOutputIndicatesFailure } from "@t3tools/s | ... | @@ -8,19 +8,22 @@ import { compactDynamicToolOutput, toolOutputIndicatesFailure } from "@t3tools/s |
| 8 | 8 | ||
| 9 | const MAX_DETAIL_STRING_BYTES = 32_768; | 9 | const MAX_DETAIL_STRING_BYTES = 32_768; |
| 10 | const MAX_DYNAMIC_VALUE_BYTES = 16_384; | 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 | if ( | 17 | if ( |
| 14 | value === undefined || | 18 | value === undefined || |
| 15 | (value.length <= MAX_DETAIL_STRING_BYTES && | 19 | (value.length <= maxBytes && Buffer.byteLength(value, "utf8") <= maxBytes) |
| 16 | Buffer.byteLength(value, "utf8") <= MAX_DETAIL_STRING_BYTES) | ||
| 17 | ) { | 20 | ) { |
| 18 | return value; | 21 | return value; |
| 19 | } | 22 | } |
| 20 | // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix | 23 | // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix |
| 21 | // that could fit, rather than allocating a buffer for the complete output. | 24 | // that could fit, rather than allocating a buffer for the complete output. |
| 22 | const prefix = Buffer.from(value.slice(0, MAX_DETAIL_STRING_BYTES), "utf8") | 25 | const prefix = Buffer.from(value.slice(0, maxBytes), "utf8") |
| 23 | .subarray(0, MAX_DETAIL_STRING_BYTES) | 26 | .subarray(0, maxBytes) |
| 24 | .toString("utf8") | 27 | .toString("utf8") |
| 25 | .replace(/\uFFFD$/u, ""); | 28 | .replace(/\uFFFD$/u, ""); |
| 26 | return `${prefix}\n… output truncated for transport`; | 29 | return `${prefix}\n… output truncated for transport`; |
| ... | @@ -80,13 +83,20 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra | ... | @@ -80,13 +83,20 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra |
| 80 | (item.exitCode !== undefined && item.exitCode !== 0) || | 83 | (item.exitCode !== undefined && item.exitCode !== 0) || |
| 81 | (output !== undefined && | 84 | (output !== undefined && |
| 82 | toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES))); | 85 | toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES))); |
| 83 | return failed ? { ...projected, outputIndicatesFailure: true } : projected; | 86 | return { |
| 87 | ...projected, | ||
| 88 | ...(failed ? { outputIndicatesFailure: true } : {}), | ||
| 89 | ...(output?.trim() ? { outputOmitted: true } : {}), | ||
| 90 | }; | ||
| 84 | } | 91 | } |
| 85 | case "file_change": { | 92 | case "file_change": { |
| 86 | // File identity and counts are enough for activity. Full diffs already | 93 | // File identity and counts are enough for activity. Full diffs already |
| 87 | // have a dedicated read path and remain intact in persistence. | 94 | // have a dedicated read path and remain intact in persistence. |
| 88 | const { diffStr: _diff, oldStr: _old, newStr: _new, ...projected } = item; | 95 | const { diffStr, oldStr: _old, newStr: _new, ...projected } = item; |
| 89 | return projected; | 96 | // A failed edit stores the provider's error where the diff would be. |
| 97 | return item.status === "failed" && diffStr?.trim() | ||
| 98 | ? { ...projected, diffStr: truncateDetail(diffStr) } | ||
| 99 | : projected; | ||
| 90 | } | 100 | } |
| 91 | case "subagent": | 101 | case "subagent": |
| 92 | return { | 102 | return { |
| ... | @@ -102,6 +112,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra | ... | @@ -102,6 +112,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra |
| 102 | ...projected, | 112 | ...projected, |
| 103 | input: summarizeDynamicValue(item.input), | 113 | input: summarizeDynamicValue(item.input), |
| 104 | ...(output === undefined ? {} : { output }), | 114 | ...(output === undefined ? {} : { output }), |
| 115 | ...(hasDynamicValue(rawOutput) ? { outputOmitted: true } : {}), | ||
| 105 | }; | 116 | }; |
| 106 | } | 117 | } |
| 107 | default: | 118 | default: |
| ... | @@ -109,6 +120,62 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra | ... | @@ -109,6 +120,62 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra |
| 109 | } | 120 | } |
| 110 | } | 121 | } |
| 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 | export function projectContextHandoffForWire( | 179 | export function projectContextHandoffForWire( |
| 113 | handoff: OrchestrationV2ContextHandoff, | 180 | handoff: OrchestrationV2ContextHandoff, |
| 114 | ): OrchestrationV2ContextHandoff { | 181 | ): OrchestrationV2ContextHandoff { |
apps/server/src/relay/AgentAwarenessRelay.test.ts+1| ... | @@ -194,6 +194,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( | ... | @@ -194,6 +194,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( |
| 194 | dispatch: unused, | 194 | dispatch: unused, |
| 195 | getTimelinePage: () => Effect.die("Unused timeline read"), | 195 | getTimelinePage: () => Effect.die("Unused timeline read"), |
| 196 | getMessageCount: () => Effect.die("unused message count"), | 196 | getMessageCount: () => Effect.die("unused message count"), |
| 197 | getTurnItem: () => Effect.die("unused turn item read"), | ||
| 197 | getThreadRecords: () => Effect.die("unused record read"), | 198 | getThreadRecords: () => Effect.die("unused record read"), |
| 198 | getThreadProjection: unused, | 199 | getThreadProjection: unused, |
| 199 | getCheckpointContext: unused, | 200 | getCheckpointContext: unused, |
apps/server/src/ws.ts+15| ... | @@ -1847,6 +1847,21 @@ const makeWsRpcLayer = ( | ... | @@ -1847,6 +1847,21 @@ const makeWsRpcLayer = ( |
| 1847 | readWorkflowScript({ scriptPath: input.scriptPath }), | 1847 | readWorkflowScript({ scriptPath: input.scriptPath }), |
| 1848 | { "rpc.aggregate": "orchestration" }, | 1848 | { "rpc.aggregate": "orchestration" }, |
| 1849 | ), | 1849 | ), |
| 1850 | [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => | ||
| 1851 | observeRpcEffect( | ||
| 1852 | ORCHESTRATION_V2_WS_METHODS.getTurnItem, | ||
| 1853 | threadManagement.getTurnItem(input).pipe( | ||
| 1854 | Effect.mapError( | ||
| 1855 | (cause) => | ||
| 1856 | new OrchestrationV2GetThreadProjectionError({ | ||
| 1857 | threadId: input.threadId, | ||
| 1858 | message: "Failed to load turn item", | ||
| 1859 | cause, | ||
| 1860 | }), | ||
| 1861 | ), | ||
| 1862 | ), | ||
| 1863 | { "rpc.aggregate": "orchestration" }, | ||
| 1864 | ), | ||
| 1850 | [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) => | 1865 | [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) => |
| 1851 | observeRpcEffect( | 1866 | observeRpcEffect( |
| 1852 | ORCHESTRATION_V2_WS_METHODS.getTurnDiff, | 1867 | ORCHESTRATION_V2_WS_METHODS.getTurnDiff, |
apps/web/src/components/chat/MessagesTimeline.tsx+38-7| ... | @@ -45,6 +45,10 @@ import { | ... | @@ -45,6 +45,10 @@ import { |
| 45 | workEntryViewedImagePath, | 45 | workEntryViewedImagePath, |
| 46 | } from "@t3tools/client-runtime/work-log/presentation"; | 46 | } from "@t3tools/client-runtime/work-log/presentation"; |
| 47 | import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; | 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 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 52 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 49 | import { | 53 | import { |
| 50 | subagentGroupSummary, | 54 | subagentGroupSummary, |
| ... | @@ -260,7 +264,7 @@ import { | ... | @@ -260,7 +264,7 @@ import { |
| 260 | formatDayAwareTimestamp, | 264 | formatDayAwareTimestamp, |
| 261 | formatUpcomingTimestamp, | 265 | formatUpcomingTimestamp, |
| 262 | } from "../../timestampFormat"; | 266 | } from "../../timestampFormat"; |
| 263 | import { V2ItemInspector } from "./V2ItemInspector"; | 267 | import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector"; |
| 264 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; | 268 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; |
| 265 | import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible"; | 269 | import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible"; |
| 266 | import { | 270 | import { |
| ... | @@ -5151,10 +5155,25 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { | ... | @@ -5151,10 +5155,25 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5151 | viewedImage ? viewedImagePath : null, | 5155 | viewedImage ? viewedImagePath : null, |
| 5152 | ) | 5156 | ) |
| 5153 | : null; | 5157 | : null; |
| 5158 | // Projected rows expand to the item inspector, so only offer a disclosure | ||
| 5159 | // when it has something to show, even if that output still has to load. | ||
| 5160 | // Reads and skills still fetch the output the timeline withheld. | ||
| 5161 | const plainOutputFetches = | ||
| 5162 | plainOutput !== undefined && | ||
| 5163 | workEntry.projectedItem !== undefined && | ||
| 5164 | turnItemNeedsDetailFetch(workEntry.projectedItem.item); | ||
| 5154 | const canExpandProjectedItem = | 5165 | const canExpandProjectedItem = |
| 5155 | plainOutput !== undefined | 5166 | plainOutput !== undefined |
| 5156 | ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer) | 5167 | ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches) |
| 5157 | : canExpand || workEntry.projectedItem !== undefined; | 5168 | : workEntry.projectedItem === undefined |
| 5169 | ? canExpand | ||
| 5170 | : isReasoning | ||
| 5171 | ? Boolean(workEntry.detail?.trim()) | ||
| 5172 | : Boolean( | ||
| 5173 | viewedImage || | ||
| 5174 | workEntry.questionAnswer || | ||
| 5175 | turnItemHasDetail(workEntry.projectedItem.item), | ||
| 5176 | ); | ||
| 5158 | // Reserve destructive row styling for severe failures, not routine tool errors. | 5177 | // Reserve destructive row styling for severe failures, not routine tool errors. |
| 5159 | const iconWrapperClass = cn( | 5178 | const iconWrapperClass = cn( |
| 5160 | "flex size-4 items-center justify-center", | 5179 | "flex size-4 items-center justify-center", |
| ... | @@ -5325,7 +5344,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { | ... | @@ -5325,7 +5344,9 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5325 | !isReasoning && | 5344 | !isReasoning && |
| 5326 | !workEntry.questionAnswer && | 5345 | !workEntry.questionAnswer && |
| 5327 | canExpandProjectedItem && | 5346 | canExpandProjectedItem && |
| 5328 | (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? ( | 5347 | (expandedBody || |
| 5348 | plainOutputFetches || | ||
| 5349 | (workEntry.projectedItem && plainOutput === undefined)) ? ( | ||
| 5329 | <WorkLogDetails kind="panel"> | 5350 | <WorkLogDetails kind="panel"> |
| 5330 | {workEntry.projectedItem && plainOutput === undefined ? ( | 5351 | {workEntry.projectedItem && plainOutput === undefined ? ( |
| 5331 | <V2ItemInspector | 5352 | <V2ItemInspector |
| ... | @@ -5337,9 +5358,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { | ... | @@ -5337,9 +5358,19 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { |
| 5337 | onOpenTurnDiff={ctx.onOpenTurnDiff} | 5358 | onOpenTurnDiff={ctx.onOpenTurnDiff} |
| 5338 | onRollbackCheckpoint={ctx.onRollbackCheckpoint} | 5359 | onRollbackCheckpoint={ctx.onRollbackCheckpoint} |
| 5339 | /> | 5360 | /> |
| 5340 | ) : expandedBody ? ( | 5361 | ) : ( |
| 5341 | <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre> | 5362 | <> |
| 5342 | ) : null} | 5363 | {expandedBody ? ( |
| 5364 | <pre className={toolCallExpandedBodyClassName}>{expandedBody}</pre> | ||
| 5365 | ) : null} | ||
| 5366 | {plainOutputFetches && workEntry.projectedItem ? ( | ||
| 5367 | <FetchedToolOutput | ||
| 5368 | projectedItem={workEntry.projectedItem} | ||
| 5369 | environmentId={ctx.activeThreadEnvironmentId} | ||
| 5370 | /> | ||
| 5371 | ) : null} | ||
| 5372 | </> | ||
| 5373 | )} | ||
| 5343 | </WorkLogDetails> | 5374 | </WorkLogDetails> |
| 5344 | ) : null} | 5375 | ) : null} |
| 5345 | </WorkLogRow> | 5376 | </WorkLogRow> |
apps/web/src/components/chat/V2ItemInspector.tsx+118-9| ... | @@ -4,12 +4,19 @@ import type { | ... | @@ -4,12 +4,19 @@ import type { |
| 4 | RunId, | 4 | RunId, |
| 5 | ThreadId, | 5 | ThreadId, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { | ||
| 8 | formatToolValue, | ||
| 9 | turnItemDetailRevision, | ||
| 10 | turnItemNeedsDetailFetch, | ||
| 11 | turnItemOutputText, | ||
| 12 | } from "@t3tools/client-runtime/work-log/item-detail"; | ||
| 7 | import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; | 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 | import { useTheme } from "../../hooks/useTheme"; | 16 | import { useTheme } from "../../hooks/useTheme"; |
| 11 | import { resolveDiffThemeName } from "../../lib/diffRendering"; | 17 | import { resolveDiffThemeName } from "../../lib/diffRendering"; |
| 12 | import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; | 18 | import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; |
| 19 | import { useTurnItemDetail } from "../../state/queries"; | ||
| 13 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; | 20 | import { useV2ItemSupport } from "../../state/v2ItemSupport"; |
| 14 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; | 21 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 15 | import { Button } from "../ui/button"; | 22 | import { Button } from "../ui/button"; |
| ... | @@ -81,8 +88,94 @@ function StructuredValue({ | ... | @@ -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 | export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { | 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 | const support = useV2ItemSupport({ | 179 | const support = useV2ItemSupport({ |
| 87 | environmentId: props.environmentId, | 180 | environmentId: props.environmentId, |
| 88 | sourceThreadId: props.projectedItem.sourceThreadId, | 181 | sourceThreadId: props.projectedItem.sourceThreadId, |
| ... | @@ -107,6 +200,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec | ... | @@ -107,6 +200,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 107 | {item.type === "command_execution" ? ( | 200 | {item.type === "command_execution" ? ( |
| 108 | <div className="space-y-2"> | 201 | <div className="space-y-2"> |
| 109 | <StructuredValue value={item.input} highlightJson /> | 202 | <StructuredValue value={item.input} highlightJson /> |
| 203 | {output} | ||
| 110 | {item.exitCode !== undefined ? ( | 204 | {item.exitCode !== undefined ? ( |
| 111 | <p className={item.exitCode === 0 ? "text-success" : "text-destructive"}> | 205 | <p className={item.exitCode === 0 ? "text-success" : "text-destructive"}> |
| 112 | Process exited with code {item.exitCode} | 206 | Process exited with code {item.exitCode} |
| ... | @@ -137,6 +231,9 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec | ... | @@ -137,6 +231,9 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 137 | </Button> | 231 | </Button> |
| 138 | ) : null} | 232 | ) : null} |
| 139 | </div> | 233 | </div> |
| 234 | {item.status === "failed" && item.diffStr?.trim() ? ( | ||
| 235 | <StructuredValue value={item.diffStr} /> | ||
| 236 | ) : null} | ||
| 140 | {item.changes !== undefined && item.changes.length > 0 ? ( | 237 | {item.changes !== undefined && item.changes.length > 0 ? ( |
| 141 | <ul className="space-y-1 font-mono text-muted-foreground"> | 238 | <ul className="space-y-1 font-mono text-muted-foreground"> |
| 142 | {item.changes.map((change, index) => ( | 239 | {item.changes.map((change, index) => ( |
| ... | @@ -153,7 +250,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec | ... | @@ -153,7 +250,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 153 | </div> | 250 | </div> |
| 154 | ) : null} | 251 | ) : null} |
| 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 | <ul className="space-y-1 rounded-md border border-border/45 p-2"> | 262 | <ul className="space-y-1 rounded-md border border-border/45 p-2"> |
| 158 | {item.results.map((result) => ( | 263 | {item.results.map((result) => ( |
| 159 | <li key={JSON.stringify(result)}> | 264 | <li key={JSON.stringify(result)}> |
| ... | @@ -170,7 +275,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec | ... | @@ -170,7 +275,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 170 | </ul> | 275 | </ul> |
| 171 | ) : null} | 276 | ) : null} |
| 172 | 277 | ||
| 173 | {item.type === "web_search" && item.results ? ( | 278 | {item.type === "web_search" && item.results?.length ? ( |
| 174 | <ul className="space-y-1.5 rounded-md border border-border/45 p-2"> | 279 | <ul className="space-y-1.5 rounded-md border border-border/45 p-2"> |
| 175 | {item.results.map((result) => { | 280 | {item.results.map((result) => { |
| 176 | const safeHref = resolveExternalWebLinkHref(result.url); | 281 | const safeHref = resolveExternalWebLinkHref(result.url); |
| ... | @@ -199,11 +304,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec | ... | @@ -199,11 +304,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 199 | ) : null} | 304 | ) : null} |
| 200 | 305 | ||
| 201 | {item.type === "dynamic_tool" ? ( | 306 | {item.type === "dynamic_tool" ? ( |
| 202 | <div> | 307 | <div className="space-y-2"> |
| 203 | <p className="mb-1 text-3xs font-medium tracking-wide uppercase text-muted-foreground"> | 308 | {formatToolValue(item.input) ? ( |
| 204 | Input | 309 | <div> |
| 205 | </p> | 310 | <SectionLabel>Input</SectionLabel> |
| 206 | <StructuredValue value={item.input} highlightJson /> | 311 | <StructuredValue value={formatToolValue(item.input)} highlightJson /> |
| 312 | </div> | ||
| 313 | ) : null} | ||
| 314 | {output} | ||
| 207 | </div> | 315 | </div> |
| 208 | ) : null} | 316 | ) : null} |
| 209 | 317 | ||
| ... | @@ -213,6 +321,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec | ... | @@ -213,6 +321,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec |
| 213 | ) : null} | 321 | ) : null} |
| 214 | {item.type === "notification" ? <StructuredValue value={item.detail} /> : null} | 322 | {item.type === "notification" ? <StructuredValue value={item.detail} /> : null} |
| 215 | {item.type === "system_notice" ? <StructuredValue value={item.message} /> : null} | 323 | {item.type === "system_notice" ? <StructuredValue value={item.message} /> : null} |
| 324 | {item.type === "error" ? <StructuredValue value={item.failure.message} /> : null} | ||
| 216 | {item.type === "proposed_plan" ? <StructuredValue value={item.markdown} /> : null} | 325 | {item.type === "proposed_plan" ? <StructuredValue value={item.markdown} /> : null} |
| 217 | {item.type === "todo_list" ? ( | 326 | {item.type === "todo_list" ? ( |
| 218 | <StructuredValue | 327 | <StructuredValue |
apps/web/src/state/queries.ts+21| ... | @@ -13,6 +13,8 @@ import type { | ... | @@ -13,6 +13,8 @@ import type { |
| 13 | EnvironmentId, | 13 | EnvironmentId, |
| 14 | ProjectContentMatch, | 14 | ProjectContentMatch, |
| 15 | ProjectEntryKind, | 15 | ProjectEntryKind, |
| 16 | ThreadId, | ||
| 17 | TurnItemId, | ||
| 16 | VcsListRefsResult, | 18 | VcsListRefsResult, |
| 17 | VcsRef, | 19 | VcsRef, |
| 18 | } from "@t3tools/contracts"; | 20 | } from "@t3tools/contracts"; |
| ... | @@ -351,3 +353,22 @@ export function useCheckpointDiff( | ... | @@ -351,3 +353,22 @@ export function useCheckpointDiff( |
| 351 | ); | 353 | ); |
| 352 | return fullThreadTarget === null ? turn : fullThread; | 354 | return fullThreadTarget === null ? turn : fullThread; |
| 353 | } | 355 | } |
| 356 | |||
| 357 | /** Full input and output of one timeline item, fetched only while its row is open. */ | ||
| 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,6 +311,10 @@ |
| 311 | "types": "./src/work-log/userInput.ts", | 311 | "types": "./src/work-log/userInput.ts", |
| 312 | "default": "./src/work-log/userInput.ts" | 312 | "default": "./src/work-log/userInput.ts" |
| 313 | }, | 313 | }, |
| 314 | "./work-log/item-detail": { | ||
| 315 | "types": "./src/work-log/itemDetail.ts", | ||
| 316 | "default": "./src/work-log/itemDetail.ts" | ||
| 317 | }, | ||
| 314 | "./work-log/presentation": { | 318 | "./work-log/presentation": { |
| 315 | "types": "./src/work-log/presentation.ts", | 319 | "types": "./src/work-log/presentation.ts", |
| 316 | "default": "./src/work-log/presentation.ts" | 320 | "default": "./src/work-log/presentation.ts" |
packages/client-runtime/src/state/orchestration.ts+7| ... | @@ -44,6 +44,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>( | ... | @@ -44,6 +44,13 @@ export function createOrchestrationEnvironmentAtoms<R, E>( |
| 44 | staleTimeMs: 300_000, | 44 | staleTimeMs: 300_000, |
| 45 | idleTtlMs: 300_000, | 45 | idleTtlMs: 300_000, |
| 46 | }), | 46 | }), |
| 47 | // Keyed by the item revision, so a live row refetches as its output grows. | ||
| 48 | turnItem: createEnvironmentRpcQueryAtomFamily(runtime, { | ||
| 49 | label: "environment-data:orchestration:turn-item", | ||
| 50 | tag: ORCHESTRATION_V2_WS_METHODS.getTurnItem, | ||
| 51 | staleTimeMs: 60_000, | ||
| 52 | idleTtlMs: 60_000, | ||
| 53 | }), | ||
| 47 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { | 54 | fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { |
| 48 | label: "environment-data:orchestration:full-thread-diff", | 55 | label: "environment-data:orchestration:full-thread-diff", |
| 49 | tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, | 56 | tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, |
packages/client-runtime/src/work-log/itemDetail.ts created+179| ... | @@ -0,0 +1,179 @@ | ||
| 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,8 +38,11 @@ export function toolItemForDisplay(item: OrchestrationV2TurnItem): Orchestration |
| 38 | return displayItem; | 38 | return displayItem; |
| 39 | } | 39 | } |
| 40 | case "file_change": { | 40 | case "file_change": { |
| 41 | const { diffStr: _diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item; | 41 | const { diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item; |
| 42 | return displayItem; | 42 | // A failed edit's diffStr holds the provider's error, not a diff. |
| 43 | return item.status === "failed" && diffStr?.trim() | ||
| 44 | ? { ...displayItem, diffStr } | ||
| 45 | : displayItem; | ||
| 43 | } | 46 | } |
| 44 | default: | 47 | default: |
| 45 | return item; | 48 | return item; |
packages/contracts/src/orchestrationV2.ts+27| ... | @@ -1357,6 +1357,8 @@ export const OrchestrationV2TurnItem = Schema.Union([ | ... | @@ -1357,6 +1357,8 @@ export const OrchestrationV2TurnItem = Schema.Union([ |
| 1357 | type: Schema.Literal("command_execution"), | 1357 | type: Schema.Literal("command_execution"), |
| 1358 | input: Schema.String, | 1358 | input: Schema.String, |
| 1359 | output: Schema.optional(Schema.String), | 1359 | output: Schema.optional(Schema.String), |
| 1360 | /** Set on the wire when output was withheld; fetch it with getTurnItem. */ | ||
| 1361 | outputOmitted: Schema.optional(Schema.Boolean), | ||
| 1360 | outputIndicatesFailure: Schema.optional(Schema.Boolean), | 1362 | outputIndicatesFailure: Schema.optional(Schema.Boolean), |
| 1361 | exitCode: Schema.optional(Schema.Int), | 1363 | exitCode: Schema.optional(Schema.Int), |
| 1362 | }), | 1364 | }), |
| ... | @@ -1483,6 +1485,8 @@ export const OrchestrationV2TurnItem = Schema.Union([ | ... | @@ -1483,6 +1485,8 @@ export const OrchestrationV2TurnItem = Schema.Union([ |
| 1483 | viewedImagePath: Schema.optional(TrimmedNonEmptyString), | 1485 | viewedImagePath: Schema.optional(TrimmedNonEmptyString), |
| 1484 | input: Schema.Unknown, | 1486 | input: Schema.Unknown, |
| 1485 | output: Schema.optional(Schema.Unknown), | 1487 | output: Schema.optional(Schema.Unknown), |
| 1488 | /** Set on the wire when output was withheld; fetch it with getTurnItem. */ | ||
| 1489 | outputOmitted: Schema.optional(Schema.Boolean), | ||
| 1486 | }), | 1490 | }), |
| 1487 | ]); | 1491 | ]); |
| 1488 | export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type; | 1492 | export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type; |
| ... | @@ -2089,6 +2093,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ | ... | @@ -2089,6 +2093,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ |
| 2089 | type: Schema.Literal("command_execution"), | 2093 | type: Schema.Literal("command_execution"), |
| 2090 | input: Schema.String, | 2094 | input: Schema.String, |
| 2091 | output: Schema.optional(Schema.String), | 2095 | output: Schema.optional(Schema.String), |
| 2096 | /** Set on the wire when output was withheld; fetch it with getTurnItem. */ | ||
| 2097 | outputOmitted: Schema.optional(Schema.Boolean), | ||
| 2092 | outputIndicatesFailure: Schema.optional(Schema.Boolean), | 2098 | outputIndicatesFailure: Schema.optional(Schema.Boolean), |
| 2093 | exitCode: Schema.optional(Schema.Int), | 2099 | exitCode: Schema.optional(Schema.Int), |
| 2094 | }), | 2100 | }), |
| ... | @@ -2212,6 +2218,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ | ... | @@ -2212,6 +2218,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ |
| 2212 | viewedImagePath: Schema.optional(TrimmedNonEmptyString), | 2218 | viewedImagePath: Schema.optional(TrimmedNonEmptyString), |
| 2213 | input: Schema.Unknown, | 2219 | input: Schema.Unknown, |
| 2214 | output: Schema.optional(Schema.Unknown), | 2220 | output: Schema.optional(Schema.Unknown), |
| 2221 | /** Set on the wire when output was withheld; fetch it with getTurnItem. */ | ||
| 2222 | outputOmitted: Schema.optional(Schema.Boolean), | ||
| 2215 | }), | 2223 | }), |
| 2216 | ]); | 2224 | ]); |
| 2217 | export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type; | 2225 | export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type; |
| ... | @@ -2969,6 +2977,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { | ... | @@ -2969,6 +2977,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { |
| 2969 | getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", | 2977 | getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", |
| 2970 | getThreadProjection: "orchestration.getThreadProjection", | 2978 | getThreadProjection: "orchestration.getThreadProjection", |
| 2971 | getWorkflowScript: "orchestration.getWorkflowScript", | 2979 | getWorkflowScript: "orchestration.getWorkflowScript", |
| 2980 | getTurnItem: "orchestration.getTurnItem", | ||
| 2972 | launchThread: "orchestration.launchThread", | 2981 | launchThread: "orchestration.launchThread", |
| 2973 | subscribeArchivedShell: "orchestration.subscribeArchivedShell", | 2982 | subscribeArchivedShell: "orchestration.subscribeArchivedShell", |
| 2974 | subscribeShell: "orchestration.subscribeShell", | 2983 | subscribeShell: "orchestration.subscribeShell", |
| ... | @@ -3255,6 +3264,20 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ | ... | @@ -3255,6 +3264,20 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ |
| 3255 | export type OrchestrationV2GetWorkflowScriptResult = | 3264 | export type OrchestrationV2GetWorkflowScriptResult = |
| 3256 | typeof OrchestrationV2GetWorkflowScriptResult.Type; | 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 | const WORKFLOW_SCRIPT_ERROR_MESSAGES = { | 3281 | const WORKFLOW_SCRIPT_ERROR_MESSAGES = { |
| 3259 | "invalid-path": "Workflow scripts must be absolute .js paths.", | 3282 | "invalid-path": "Workflow scripts must be absolute .js paths.", |
| 3260 | "root-unavailable": "Script root unavailable.", | 3283 | "root-unavailable": "Script root unavailable.", |
| ... | @@ -3313,6 +3336,10 @@ export const OrchestrationV2RpcSchemas = { | ... | @@ -3313,6 +3336,10 @@ export const OrchestrationV2RpcSchemas = { |
| 3313 | input: OrchestrationV2GetWorkflowScriptInput, | 3336 | input: OrchestrationV2GetWorkflowScriptInput, |
| 3314 | output: OrchestrationV2GetWorkflowScriptResult, | 3337 | output: OrchestrationV2GetWorkflowScriptResult, |
| 3315 | }, | 3338 | }, |
| 3339 | getTurnItem: { | ||
| 3340 | input: OrchestrationV2GetTurnItemInput, | ||
| 3341 | output: OrchestrationV2GetTurnItemResult, | ||
| 3342 | }, | ||
| 3316 | launchThread: { | 3343 | launchThread: { |
| 3317 | input: OrchestrationV2ThreadLaunchInput, | 3344 | input: OrchestrationV2ThreadLaunchInput, |
| 3318 | output: OrchestrationV2ThreadLaunchResult, | 3345 | output: OrchestrationV2ThreadLaunchResult, |
packages/contracts/src/rpc.ts+7| ... | @@ -1549,6 +1549,12 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( | ... | @@ -1549,6 +1549,12 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( |
| 1549 | }, | 1549 | }, |
| 1550 | ); | 1550 | ); |
| 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 | const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, { | 1558 | const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, { |
| 1553 | payload: OrchestrationV2RpcSchemas.launchThread.input, | 1559 | payload: OrchestrationV2RpcSchemas.launchThread.input, |
| 1554 | success: OrchestrationV2RpcSchemas.launchThread.output, | 1560 | success: OrchestrationV2RpcSchemas.launchThread.output, |
| ... | @@ -1858,6 +1864,7 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1858,6 +1864,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 1858 | WsSubscribeResourceTelemetryRpc, | 1864 | WsSubscribeResourceTelemetryRpc, |
| 1859 | WsOrchestrationV2DispatchCommandRpc, | 1865 | WsOrchestrationV2DispatchCommandRpc, |
| 1860 | WsOrchestrationV2GetWorkflowScriptRpc, | 1866 | WsOrchestrationV2GetWorkflowScriptRpc, |
| 1867 | WsOrchestrationV2GetTurnItemRpc, | ||
| 1861 | WsOrchestrationV2GetTurnDiffRpc, | 1868 | WsOrchestrationV2GetTurnDiffRpc, |
| 1862 | WsOrchestrationV2GetFullThreadDiffRpc, | 1869 | WsOrchestrationV2GetFullThreadDiffRpc, |
| 1863 | WsOrchestrationV2SearchThreadsRpc, | 1870 | WsOrchestrationV2SearchThreadsRpc, |