diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index b9ca6d4eaa56..88324d7f616c 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -50,9 +50,12 @@ import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; import { type AgentSpawnSummary, + formatItemFullDetail, type ThreadFeedActivity, workEntryRowLabel, } from "../../lib/threadActivity"; +import { turnItemOutputText } from "@t3tools/client-runtime/work-log/item-detail"; +import { useTurnItemDetail } from "../../state/queries"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -60,6 +63,7 @@ import { } from "./thread-feed-live-follow"; import { resolveWorkEntryToolPresentation, + toolGroupAction, type ToolGroupSummaryKind, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; @@ -788,6 +792,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( ) { const { row, expanded } = props; const navigation = useNavigation(); + const fetchedDetail = useTurnItemDetail( + expanded && row.fetchesDetail + ? { environmentId: props.environmentId, row: row.projectedItem } + : null, + ); const failureItem = row.projectedItem.item; if (failureItem.type === "error" && failureItem.status === "failed") { const warning = failureItem.failure.class === "usage_limit"; @@ -861,7 +870,26 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( : undefined; const canExpand = row.canExpand && notifiedSubagentThreadId === undefined; const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : null; - const fullDetail = expanded && !reasoning ? row.getFullDetail() : null; + const fetchedItem = fetchedDetail.data?.item ?? null; + // Reads keep their path list; the fetched file contents show as output. + const isRead = toolGroupAction(row.workEntry) === "read"; + const fullDetail = + expanded && !reasoning + ? fetchedItem && !isRead + ? formatItemFullDetail(row.projectedItem, fetchedItem) + : row.getFullDetail() + : null; + const fetchedOutput = !expanded + ? null + : fetchedItem + ? (turnItemOutputText(fetchedItem) ?? "No output.") + : fetchedDetail.error + ? `Couldn't load output: ${fetchedDetail.error}` + : row.fetchesDetail + ? fetchedDetail.data + ? "Output is no longer available." + : "Loading output…" + : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); const previewText = workEntryRowLabel(row.workEntry); @@ -1004,7 +1032,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( - {expanded && (reasoning || fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? ( + {expanded && + (reasoning || + fullDetail || + fetchedOutput || + viewedImagePath || + row.workEntry.questionAnswer) ? ( )} + {fetchedOutput ? ( + + {fetchedOutput} + + ) : null} ) : null} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 16fd407f537b..5471f24704a9 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -271,6 +271,35 @@ describe("buildThreadFeed", () => { expect(items[0]).toMatchObject({ output: rawOutput }); }); + it("expands tool rows only when they have detail or withheld output", () => { + const items: OrchestrationV2TurnItem[] = [ + { ...command(), input: "", outputOmitted: true }, + { + ...base("dynamic-empty", "2026-06-20T00:00:03.000Z", 2), + type: "dynamic_tool", + toolName: "example", + input: {}, + }, + { + ...base("read-omitted", "2026-06-20T00:00:04.000Z", 3), + type: "dynamic_tool", + toolName: "Read", + input: { path: "src/env.ts" }, + outputOmitted: true, + }, + ]; + const activities = buildThreadFeed(items.map((item, index) => projected(item, index))).flatMap( + (entry) => (entry.type === "activity-group" ? entry.activities : []), + ); + expect( + activities.map(({ canExpand, fetchesDetail }) => ({ canExpand, fetchesDetail })), + ).toEqual([ + { canExpand: true, fetchesDetail: true }, + { canExpand: false, fetchesDetail: false }, + { canExpand: true, fetchesDetail: true }, + ]); + }); + it("recognizes automation attribution after projecting a user message", () => { const feed = buildThreadFeed([ projected( @@ -1236,6 +1265,7 @@ describe("buildThreadFeed", () => { summary: `Tool ${id}`, detail: null, canExpand: false, + fetchesDetail: false, getFullDetail: () => null, getCopyText: () => id, icon: "command", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 3a77324a7ac8..a77491795704 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -6,6 +6,10 @@ import type { import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; +import { + turnItemHasDetail, + turnItemNeedsDetailFetch, +} from "@t3tools/client-runtime/work-log/item-detail"; import { commandDisplayText, commandProgramName, @@ -74,6 +78,8 @@ export interface ThreadFeedActivity { readonly summary: string; readonly detail: string | null; readonly canExpand: boolean; + /** Expanding fetches the withheld input and output with getTurnItem. */ + readonly fetchesDetail: boolean; readonly getFullDetail: () => string | null; readonly getCopyText: () => string; readonly icon: @@ -721,6 +727,23 @@ function toWorkLogEntry( } } +/** Expanded detail for a row, from its wire item or the full item from getTurnItem. */ +export function formatItemFullDetail( + row: OrchestrationV2ProjectedTurnItem, + item: OrchestrationV2TurnItem, +): string { + return JSON.stringify( + { + visibility: row.visibility, + sourceThreadId: row.sourceThreadId, + sourceItemId: row.sourceItemId, + item: toolItemForDisplay(item), + }, + null, + 2, + ); +} + function toFeedActivity( row: OrchestrationV2ProjectedTurnItem, attemptId: RunAttemptId | null, @@ -735,21 +758,9 @@ function toFeedActivity( item.type === "dynamic_tool" && toolGroupAction(workEntry) === "read" ? collectToolFilePaths(item) : null; - const getFullDetail = memoizeValue(() => { - if (readPaths) { - return readPaths.join("\n") || null; - } - return JSON.stringify( - { - visibility: row.visibility, - sourceThreadId: row.sourceThreadId, - sourceItemId: row.sourceItemId, - item: toolItemForDisplay(item), - }, - null, - 2, - ); - }); + const getFullDetail = memoizeValue(() => + readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), + ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] .filter( @@ -765,7 +776,13 @@ function toFeedActivity( attemptId, summary, detail, - canExpand: !(item.type === "error" && item.status === "failed") && (readPaths?.length ?? 1) > 0, + canExpand: + !(item.type === "error" && item.status === "failed") && + (readPaths + ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) + : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), + // Read rows show their paths, then the fetched file contents. + fetchesDetail: turnItemNeedsDetailFetch(item), getFullDetail, getCopyText, icon: workEntry.toolSurface ?? itemIcon(item), diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index d9cce63b65ac..db8269ff130b 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -2,6 +2,7 @@ import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRe import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, + OrchestrationV2ProjectedTurnItem, ProjectId, ThreadId, VcsListRefsResult, @@ -14,6 +15,7 @@ import { } from "@t3tools/client-runtime/state/thread-search"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; +import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -351,3 +353,24 @@ export function useCheckpointDiff(target: CheckpointDiffTarget) { ); return targets.fullThread === null ? turn : fullThread; } + +/** Full input and output for one tool row; pass null to skip fetching. */ +export function useTurnItemDetail( + target: { + readonly environmentId: EnvironmentId; + readonly row: OrchestrationV2ProjectedTurnItem; + } | null, +) { + return useEnvironmentQuery( + target === null + ? null + : orchestrationEnvironment.turnItem({ + environmentId: target.environmentId, + input: { + threadId: target.row.sourceThreadId, + itemId: target.row.sourceItemId, + revision: turnItemDetailRevision(target.row.item), + }, + }), + ); +} diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 6e426b52c4e0..5548bebac84c 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1077,6 +1077,70 @@ const program = Effect.gen(function* () { status: "completed", rawInput: { query: "TODO", path: "apps/web" }, }, + // Grok backend searches: the query only arrives in the completed rawOutput. + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-x-search", + title: "X search:", + kind: "search", + status: "in_progress", + rawInput: { variant: "XSearch", backend: true }, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-x-search", + title: "X search:", + status: "completed", + rawOutput: { + call_id: "xs_call-1", + input: '{"query":"conversation_id:42","limit":"10","mode":"Latest"}', + name: "x_keyword_search", + id: "grok-x-search", + }, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-web-search", + title: "Web search:", + kind: "search", + status: "completed", + rawInput: { variant: "WebSearch", backend: true }, + rawOutput: { + action: { + type: "search", + query: "t3 code", + sources: [ + { type: "url", url: "https://t3.codes" }, + { type: "url", url: "https://t3.codes" }, + { type: "url", url: "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/pingdotgg/t3code" }, + ], + }, + id: "grok-web-search", + status: "completed", + }, + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "grok-web-fetch", + title: "Fetch: https://t3.codes", + kind: "fetch", + status: "completed", + rawInput: { variant: "WebFetch", url: "https://t3.codes" }, + rawOutput: { + type: "WebFetch", + Content: { url: "https://t3.codes", content: "T3 Code page" }, + }, + content: [{ type: "content", content: { type: "text", text: "T3 Code page" } }], + }, + { + sessionUpdate: "tool_call_update", + toolCallId: "antigravity-shell", + title: "run_command", + kind: "execute", + status: "completed", + rawInput: { command: "cat probe.txt" }, + rawOutput: { commandLine: "cat probe.txt", exitCode: 0, combinedOutput: "after\n" }, + }, { sessionUpdate: "compaction_update", compactionId: "compact-1", diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index bba03dd2d600..54d8e2a906bf 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -33,6 +33,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_V2_WS_METHODS.searchThreads]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getArchivedShellSnapshot]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.getThreadProjection]: AuthOrchestrationReadScope, + [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.launchThread]: AuthOrchestrationOperateScope, [ORCHESTRATION_V2_WS_METHODS.subscribeArchivedShell]: AuthOrchestrationReadScope, [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index b6cb0be45acf..8f981fb3384d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1324,6 +1324,12 @@ describe("AcpAdapterV2", () => { ), { input: "printf proof", output: "proof" }, ); + assert.deepInclude( + items.flatMap((item) => + item.type === "command_execution" ? [{ input: item.input, output: item.output }] : [], + ), + { input: "cat probe.txt", output: "after\n" }, + ); assert.isTrue( items.some((item) => item.title === "Action required" && item.status === "waiting"), ); @@ -1345,6 +1351,35 @@ describe("AcpAdapterV2", () => { search?.type === "file_search" ? { title: search.title, pattern: search.pattern } : null, { title: "Searched TODO in web", pattern: "apps/web" }, ); + const webItem = (nativeId: string, status: string) => { + const item = items.findLast( + (candidate) => + candidate.type === "web_search" && + candidate.status === status && + candidate.nativeItemRef?.nativeId?.endsWith(nativeId) === true, + ); + return item?.type === "web_search" + ? { title: item.title, patterns: item.patterns, results: item.results } + : null; + }; + assert.deepEqual(webItem("grok-x-search", "running"), { + title: "X search", + patterns: undefined, + results: undefined, + }); + assert.deepEqual(webItem("grok-x-search", "completed"), { + title: "X search: conversation_id:42", + patterns: ["conversation_id:42"], + results: undefined, + }); + assert.deepEqual(webItem("grok-web-search", "completed"), { + title: "Web search: t3 code", + patterns: ["t3 code"], + results: [{ url: "https://t3.codes" }, { url: "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/pingdotgg/t3code" }], + }); + assert.deepEqual(webItem("grok-web-fetch", "completed")?.results, [ + { url: "https://t3.codes", snippet: "T3 Code page" }, + ]); const completedCompaction = items.find( (item) => item.type === "compaction" && diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index bf01d9cb2d7d..aac767f8c886 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -19,6 +19,7 @@ import { type OrchestrationV2Subagent, type OrchestrationV2TurnItem, type OrchestrationV2UserInputQuestion, + type OrchestrationV2WebSearchResult, type ProviderApprovalDecision, type ProviderApprovalOption, type ProviderInstanceId, @@ -846,11 +847,14 @@ function textFromUnknown(value: unknown): string | undefined { return undefined; } // Prefer prompt-facing Grok fields before nested envelopes. + // Antigravity reports shell output as combinedOutput. for (const key of [ "output_for_prompt", "stdout", "stderr", "output", + "combinedOutput", + "combined_output", "content", "text", "message", @@ -989,6 +993,46 @@ function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { return undefined; } +/** + * Grok runs X and web searches server-side as `search` tools whose rawInput is + * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives + * with completion: web searches report `action: { query, sources }`, X searches + * the backend call `{ name, input }` with JSON-encoded arguments. + */ +function acpBackendWebSearch( + rawInput: Record | undefined, + rawOutput: Record | undefined, +): + | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] } + | undefined { + const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : ""; + const action = unknownRecord(rawOutput?.action); + if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") { + return undefined; + } + let args: Record | undefined; + if (typeof rawOutput?.input === "string") { + try { + args = unknownRecord(JSON.parse(rawOutput.input)); + } catch { + args = undefined; + } + } + const argsText = Object.entries(args ?? {}) + .filter(([, value]) => typeof value === "string" || typeof value === "number") + .map(([key, value]) => `${key}: ${value}`) + .join(", "); + const query = [action?.query, args?.query, argsText] + .find((value): value is string => typeof value === "string" && value.trim().length > 0) + ?.trim(); + const urls = new Set(); + for (const source of Array.isArray(action?.sources) ? action.sources : []) { + const url = unknownRecord(source)?.url; + if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim()); + } + return { query, results: [...urls].map((url) => ({ url })) }; +} + function providerRequestKind(kind: string | "unknown"): ProviderRequestKind { switch (kind) { case "execute": @@ -3297,7 +3341,30 @@ export function makeAcpAdapterV2( ...(rawOutput === undefined ? {} : { output: rawOutput }), }; break; - case "search": + case "search": { + const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord); + if (backendSearch !== undefined) { + // Grok titles these "X search:" / "Web search:" awaiting the query. + const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace( + /:\s*$/u, + "", + ); + turnItem = { + ...base, + title: + backendSearch.query === undefined + ? label + : `${label}: ${backendSearch.query}`, + type: "web_search", + ...(backendSearch.query === undefined + ? {} + : { patterns: [backendSearch.query] }), + ...(backendSearch.results.length === 0 + ? {} + : { results: backendSearch.results }), + }; + break; + } turnItem = { ...base, title: @@ -3322,6 +3389,7 @@ export function makeAcpAdapterV2( }), }; break; + } case "execute": { const exitCode = acpProjectedCommandExitCode(status, rawOutput); turnItem = { @@ -3345,7 +3413,11 @@ export function makeAcpAdapterV2( ...(diffText === undefined ? {} : { diffStr: diffText }), }; break; - case "fetch": + case "fetch": { + // Grok nests the page under rawOutput.Content, which textFromUnknown + // cannot read; the (bounded) content blocks carry the same text. + const snippet = + textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput); turnItem = { ...base, type: "web_search", @@ -3356,14 +3428,13 @@ export function makeAcpAdapterV2( results: [ { url: path, - ...(textFromUnknown(rawOutput) === undefined - ? {} - : { snippet: textFromUnknown(rawOutput) }), + ...(snippet === undefined ? {} : { snippet }), }, ], }), }; break; + } default: if (projectAsCommandExecution) { const exitCode = acpProjectedCommandExitCode(status, rawOutput); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b837575c1faf..2e237bf3bb4c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4325,6 +4325,89 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("stores a Bash result's stdout and stderr as command output", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const attemptId = RunAttemptId.make("attempt-claude-bash-output"); + const bashToolUseId = "toolu_01BashOutput"; + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId, + text: "Run it.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_bash_output", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: bashToolUseId, + name: "Bash", + input: { command: "git status" }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000790", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { type: "tool_result", tool_use_id: bashToolUseId, content: "On branch main" }, + ], + }, + parent_tool_use_id: null, + uuid: "00000000-0000-4000-8000-000000000791", + session_id: WAKE_NATIVE_SESSION, + tool_use_result: { + stdout: "On branch main", + stderr: "warning: dirty", + interrupted: false, + isImage: false, + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000792", result: "Done." }), + ); + yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); + + const bash = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === bashToolUseId, + ); + assert.equal( + bash?.type === "turn_item.updated" && bash.turnItem.type === "command_execution" + ? bash.turnItem.output + : undefined, + "On branch main\nwarning: dirty", + ); + }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))), + ), + ); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0aca36abe77c..ba0cfbd8fbd5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1864,6 +1864,28 @@ function claudeNativeToolOutputText(output: ClaudeNativeToolOutput): string { return typeof value === "string" ? value : value === undefined ? "" : jsonStringifyForTool(value); } +/** + * Bash results arrive as `{ stdout, stderr, interrupted, ... }`; keep only the + * text. A background run has empty streams, so keep its acknowledgement instead. + */ +function claudeCommandOutputText(output: ClaudeNativeToolOutput): string { + const value = claudeNativeToolOutputValue(output); + if (typeof value === "object" && value !== null) { + const stdout = Reflect.get(value, "stdout"); + const stderr = Reflect.get(value, "stderr"); + if (typeof stdout === "string" || typeof stderr === "string") { + const text = [stdout, stderr] + .filter((part): part is string => typeof part === "string" && part.trim().length > 0) + .join("\n"); + if (text.length > 0) return text; + return output.type === "structured_tool_use_result" && output.fallbackValue !== undefined + ? claudeSubagentResultText({ type: "content_block", value: output.fallbackValue }) + : ""; + } + } + return claudeNativeToolOutputText(output); +} + function claudeSubagentResultText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); const content = Array.isArray(value) @@ -1904,6 +1926,8 @@ function isClaudeSubagentAsyncLaunchAck(output: ClaudeNativeToolOutput): boolean return claudeSubagentResultText(output).startsWith("Async agent launched successfully."); } +const WEB_FETCH_SNIPPET_MAX_CHARS = 8_000; + function webSearchPatternsFromClaudeTool(input: { readonly toolInput: ClaudeNativeToolInput; readonly output: ClaudeNativeToolOutput; @@ -3807,8 +3831,12 @@ export function makeClaudeAdapterV2( output: input.output, }); const webSearchResults = webSearchResultsFromClaudeOutput(input.output); + const webFetchUrl = firstStringInputField(input.toolInput, ["url"])?.trim(); const outputValue = claudeNativeToolOutputValue(input.output); - const outputText = claudeNativeToolOutputText(input.output); + const outputText = + itemType === "command_execution" + ? claudeCommandOutputText(input.output) + : claudeNativeToolOutputText(input.output); const turnItem: OrchestrationV2TurnItem = itemType === "command_execution" ? { @@ -3831,7 +3859,21 @@ export function makeClaudeAdapterV2( ...(webSearchPatterns.length === 0 ? {} : { patterns: [...webSearchPatterns] }), - ...(webSearchResults.length === 0 ? {} : { results: [...webSearchResults] }), + ...(webSearchResults.length > 0 + ? { results: [...webSearchResults] } + : input.classification.normalizedName === "webfetch" && + outputText.trim().length > 0 + ? { + // WebFetch returns page text, not search hits. Keep a + // bounded preview so the row has something to show. + results: [ + { + ...(webFetchUrl === undefined ? {} : { url: webFetchUrl }), + snippet: outputText.slice(0, WEB_FETCH_SNIPPET_MAX_CHARS), + }, + ], + } + : {}), } : { ...itemBase, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 3a172baa67c8..1756d6322b4c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -430,6 +430,26 @@ describe("CursorAdapterV2", () => { result: { status: "error", error: "ENOENT" }, }, }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "grep-failed", + toolCall: { + type: "grep", + args: { pattern: "TODO", path: "src" }, + result: { status: "error", error: "search failed" }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "glob-failed", + toolCall: { + type: "glob", + args: { globPattern: "*.ts" }, + result: { status: "error", error: "search failed" }, + }, + }, { type: "tool-call-completed", modelCallId: "native-model-call", @@ -648,7 +668,17 @@ describe("CursorAdapterV2", () => { { pattern: path.join(workspace, "missing"), status: "failed", - results: undefined, + results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], + }, + { + pattern: "TODO", + status: "failed", + results: [{ fileName: "src", preview: "search failed" }], + }, + { + pattern: "*.ts", + status: "failed", + results: [{ fileName: ".", preview: "search failed" }], }, { pattern: "src/a.ts, src/b.ts", @@ -667,7 +697,7 @@ describe("CursorAdapterV2", () => { { pattern: "src/a.ts", status: "failed", - results: undefined, + results: [{ fileName: "src/a.ts", preview: "lint failed" }], }, ], ); diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index bb9de20da64f..dd77ac688370 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -1227,7 +1227,10 @@ export function makeCursorAdapterV2( toolCall.result?.status === "success" && toolCall.result.value.diffString !== undefined ? { diffStr: toolCall.result.value.diffString } - : {}), + : // A failed change keeps its error where the diff would be. + toolCall.result?.status === "error" && outputText.trim().length > 0 + ? { diffStr: outputText } + : {}), ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), }; break; @@ -1248,8 +1251,20 @@ export function makeCursorAdapterV2( case "ls": case "readLints": case "semSearch": { - const results = cursorToolSearchResults(toolCall, path); const pattern = cursorToolSearchPattern(toolCall); + const searchPath = + toolCall.type === "grep" + ? toolCall.args.path + : toolCall.type === "glob" + ? toolCall.args.targetDirectory + : toolCall.type === "semSearch" + ? toolCall.args.targetDirectories?.join(", ") + : pattern; + // A failed search keeps its error as one row under the searched path. + const results = + toolCall.result?.status === "error" && outputText.trim().length > 0 + ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] + : cursorToolSearchResults(toolCall, path); turnItem = { ...base, title: diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index eca4aa9115a4..a53c9b20bf41 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -833,10 +833,12 @@ describe("OpenCodeAdapterV2", () => { Stream.runCollect, Effect.forkScoped, ); - for (const [tool, input] of [ - ["read", { filePath: "src/env.ts" }], - ["grep", { pattern: "TODO", path: "apps/web" }], - ["websearch", { query: "OpenCode documentation" }], + for (const [tool, input, output] of [ + ["read", { filePath: "src/env.ts" }, "---\nfile body"], + ["grep", { pattern: "TODO", path: "apps/web" }, "---\nfile body"], + ["websearch", { query: "OpenCode documentation" }, "---\nfile body"], + ["glob", { pattern: "missing", path: "apps/web" }, ""], + ["codesearch", {}, " \n\t"], ] as const) { yield* Effect.promise(() => nativeEvents.push({ @@ -853,7 +855,7 @@ describe("OpenCodeAdapterV2", () => { state: { status: "completed", input, - output: "---\nfile body", + output, title: tool, metadata: {}, time: { start: 1, end: 2 }, @@ -877,10 +879,26 @@ describe("OpenCodeAdapterV2", () => { const grep = items.find((item) => item.type === "file_search"); assert.equal(grep?.title, "Searched TODO in web"); assert.equal(grep?.type === "file_search" ? grep.pattern : null, "TODO"); + assert.deepEqual(grep?.type === "file_search" ? grep.results : null, [ + { fileName: "apps/web", preview: "---\nfile body" }, + ]); const webSearch = items.find((item) => item.type === "web_search"); assert.deepEqual(webSearch?.type === "web_search" ? webSearch.patterns : null, [ "OpenCode documentation", ]); + assert.deepEqual(webSearch?.type === "web_search" ? webSearch.results : null, [ + { snippet: "---\nfile body" }, + ]); + const emptyFileSearch = items.find( + (item) => item.type === "file_search" && item.pattern === "missing", + ); + assert.ok(emptyFileSearch?.type === "file_search"); + assert.equal(emptyFileSearch.results, undefined); + const emptyWebSearch = items.find( + (item) => item.type === "web_search" && item.patterns === undefined, + ); + assert.ok(emptyWebSearch?.type === "web_search"); + assert.equal(emptyWebSearch.results, undefined); }).pipe(Effect.provide(IdAllocator.layer), Effect.scoped), ); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts index d4d8c86df654..1b01b2b8007c 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts @@ -6,6 +6,9 @@ import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; +// Search results stay on the timeline wire, so keep their text a preview. +const SEARCH_PREVIEW_MAX_CHARS = 8_000; + type ToolItemBase = Omit< Extract, "type" | "toolName" | "input" | "output" @@ -96,7 +99,10 @@ export function openCodeToolTurnItem( case "file_change": { const oldStr = recordString(input, "oldString", "oldText"); const newStr = recordString(input, "newString", "content", "newText"); - const diffStr = recordString(tool.completedMetadata, "diff", "patch"); + // A failed edit has no diff; keep its error where the diff would be. + const diffStr = + recordString(tool.completedMetadata, "diff", "patch") ?? + (base.status === "failed" && output?.trim() ? output : undefined); return { ...base, type: "file_change", @@ -108,6 +114,9 @@ export function openCodeToolTurnItem( } case "file_search": { const pattern = recordString(input, "pattern", "query", "path", "filePath"); + // OpenCode reports matches as plain text, so keep it as one result row + // under the searched path, like the ACP search projection. + const searchRoot = (recordString(input, "path", "filePath") ?? pattern)?.trim(); return { ...base, title: @@ -115,14 +124,32 @@ export function openCodeToolTurnItem( base.title, type: "file_search", ...(pattern === undefined ? {} : { pattern }), + ...(!output?.trim() || searchRoot === undefined + ? {} + : { + results: [ + { fileName: searchRoot, preview: output.slice(0, SEARCH_PREVIEW_MAX_CHARS) }, + ], + }), }; } case "web_search": { const pattern = recordString(input, "query", "url", "pattern"); + const url = recordString(input, "url")?.trim(); return { ...base, type: "web_search", ...(pattern === undefined ? {} : { patterns: [pattern] }), + ...(!output?.trim() + ? {} + : { + results: [ + { + ...(url === undefined ? {} : { url }), + snippet: output.slice(0, SEARCH_PREVIEW_MAX_CHARS), + }, + ], + }), }; } case "dynamic_tool": { diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index fb912714f12f..df2bade7fc06 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1155,6 +1155,102 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("persists edit patches and write content on file change items", () => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ type: "agent_start" }); + const patch = "--- a.ts\n+++ a.ts\n@@ -1 +1 @@\n-old\n+new\n"; + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_edit", + toolName: "edit", + args: { path: "a.ts", edits: [{ oldText: "old", newText: "new" }] }, + }); + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_edit", + toolName: "edit", + isError: false, + result: { + content: [{ type: "text", text: "Successfully replaced 1 block(s) in a.ts." }], + details: { diff: "-1 old\n+1 new", patch, firstChangedLine: 1 }, + }, + }); + const edit = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_change" && + event.turnItem.status === "completed", + ); + assert.isTrue( + edit.type === "turn_item.updated" && + edit.turnItem.type === "file_change" && + edit.turnItem.fileName === "a.ts" && + edit.turnItem.diffStr === patch, + ); + + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_write", + toolName: "write", + args: { path: "b.ts", content: "export {};\n" }, + }); + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_write", + toolName: "write", + isError: false, + result: { content: [{ type: "text", text: "Successfully wrote to b.ts" }] }, + }); + const write = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_change" && + event.turnItem.status === "completed", + ); + assert.isTrue( + write.type === "turn_item.updated" && + write.turnItem.type === "file_change" && + write.turnItem.fileName === "b.ts" && + write.turnItem.newStr === "export {};\n", + ); + + // A failed edit has no patch, so it keeps the error to show when expanded. + yield* fake.emit({ + type: "tool_execution_start", + toolCallId: "call_edit_failed", + toolName: "edit", + args: { path: "c.ts", edits: [{ oldText: "missing", newText: "new" }] }, + }); + yield* fake.emit({ + type: "tool_execution_end", + toolCallId: "call_edit_failed", + toolName: "edit", + isError: true, + result: { content: [{ type: "text", text: "Could not find the text in c.ts." }] }, + }); + const failedEdit = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_change" && + event.turnItem.status === "failed", + ); + assert.isTrue( + failedEdit.type === "turn_item.updated" && + failedEdit.turnItem.type === "file_change" && + failedEdit.turnItem.diffStr === "Could not find the text in c.ts.", + ); + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 6f0bcf258acb..56f3d7b28796 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -988,6 +988,12 @@ export function makePiAdapterV2( if (toolName === "edit" || toolName === "write") { const fileName = recordString(args, "path") ?? recordString(args, "file_path"); if (fileName !== undefined) { + // edit reports a unified patch in its result details; write only + // carries the new content in its args. A failed call keeps its error. + const diffStr = + recordString(recordField(resultRecord, "details"), "patch") ?? + (isError && outputText.trim().length > 0 ? outputText : undefined); + const newStr = toolName === "write" ? recordString(args, "content") : undefined; yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, @@ -996,6 +1002,8 @@ export function makePiAdapterV2( title: toolName, type: "file_change", fileName, + ...(diffStr === undefined ? {} : { diffStr }), + ...(newStr === undefined ? {} : { newStr }), }, }); return; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 112735188d1b..f6d8c37aa2ff 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -48,6 +48,7 @@ import { RunId, ThreadLinkedPullRequest, ThreadId, + type TurnItemId, } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; import { @@ -263,6 +264,10 @@ export interface OrchestratorV2Shape { options: ProjectionTimelinePageOptions, ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; + readonly getTurnItem: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Effect.Effect; readonly getThreadRecords: ( threadId: ThreadId, fields: ReadonlyArray, @@ -9960,6 +9965,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projectionStore .getMessageCount(threadId) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), + getTurnItem: (input) => + projectionStore + .getTurnItem(input) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: input.threadId, cause }), + ), + ), getThreadRecords: (threadId, fields, filter) => projectionStore .getThreadRecords(threadId, fields, filter) @@ -10081,6 +10094,7 @@ const layerUnavailable: Layer.Layer = Layer.succeed( ), getTimelinePage: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), getMessageCount: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), + getTurnItem: ({ threadId }) => Effect.fail(new OrchestratorProjectionError({ threadId })), getThreadRecords: (threadId) => Effect.fail(new OrchestratorProjectionError({ threadId })), getThreadProjection: (threadId) => Effect.fail( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3e979544c87b..3bb7617eef0f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -959,6 +959,14 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { assert.strictEqual(older.projection.turnItems[0]?.ordinal, 850); assert.strictEqual(older.projection.turnItems.at(-1)?.ordinal, 925); + // A single item reads back with its full output, scoped to its thread. + const itemId = TurnItemId.make("turn-item:bounded-sql-history:925"); + const stored = yield* projectionStore.getTurnItem({ threadId, itemId }); + assert.strictEqual(stored?.type === "command_execution" ? stored.output : undefined, "ok"); + assert.isNull( + yield* projectionStore.getTurnItem({ threadId: ThreadId.make("thread:other"), itemId }), + ); + const sqlPageLimit = THREAD_HISTORY_PAGE_POLICY.maxItems + 2; const initialSnapshot = yield* projectionStore.getThreadSnapshotWindow(threadId, { rowLimit: sqlPageLimit, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a1b2e0f061f1..edbdcce13cc6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -321,6 +321,11 @@ export interface ProjectionStoreV2Shape { readonly getNextTurnItemOrdinal: ( threadId: ThreadId, ) => Effect.Effect; + /** One persisted turn item, or null when the thread has no such item. */ + readonly getTurnItem: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Effect.Effect; readonly getThreadRecords: ( threadId: ThreadId, fields: ReadonlyArray, @@ -4501,6 +4506,18 @@ export const layer: Layer.Layer = Effect.mapError(controlReadError(threadId)), ); + const getTurnItem: ProjectionStoreV2Shape["getTurnItem"] = ({ threadId, itemId }) => + sql<{ payload_json: string }>`SELECT payload_json + FROM orchestration_v2_projection_turn_items + WHERE turn_item_id = ${itemId} AND thread_id = ${threadId}`.pipe( + Effect.flatMap((rows) => + rows[0] === undefined + ? Effect.succeed(null) + : decodeTurnItemPayload(rows[0].payload_json), + ), + Effect.mapError(controlReadError(threadId)), + ); + const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = (threadId) => sql<{ id: string }>` SELECT DISTINCT json_extract(attachment.value, '$.id') AS id @@ -5523,6 +5540,7 @@ export const layer: Layer.Layer = hasUnpairedRunInterruptRequest, getMessageCount, getNextTurnItemOrdinal, + getTurnItem, getThreadRecords, getRuntimeRequest, getPlan, @@ -5778,6 +5796,13 @@ export const layerMemory: Layer.Layer = Layer.effect( ?.turnItems.reduce((max, item) => Math.max(max, item.ordinal), 0) ?? 0) + 1, ), ), + getTurnItem: ({ threadId, itemId }) => + Ref.get(replayState).pipe( + Effect.map( + (state) => + state.projections.get(threadId)?.turnItems.find((item) => item.id === itemId) ?? null, + ), + ), getThreadAttachmentIds: (threadId) => service .getThreadProjection(threadId) diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index eba830319aaf..91fe20459ca2 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -227,6 +227,7 @@ it.effect( getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), getNextTurnItemOrdinal: () => Effect.die("unused ordinal read"), + getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), getRuntimeRequest: () => Effect.die("unused getRuntimeRequest"), getRunningTurnContext: () => Effect.die("unused getRunningTurnContext"), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 3bb397728060..f0468364a25c 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -10,6 +10,7 @@ import { type ModelSelection, type OrchestrationV2Actor, type OrchestrationV2Command, + type OrchestrationV2GetTurnItemResult, type OrchestrationV2ServerCommand, type OrchestrationV2ConversationMessage, type OrchestrationV2CreationSource, @@ -22,6 +23,7 @@ import { RunId, type ScheduledTaskId, ThreadId, + type TurnItemId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -32,6 +34,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Orchestrator from "./Orchestrator.ts"; +import { projectTurnItemForDetail } from "./WireProjection.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; @@ -276,6 +279,14 @@ export interface ThreadManagementServiceShape { ) => Effect.Effect; readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; + /** + * One turn item with the input and output the thread stream withholds, + * bounded for the wire. Clients fetch it when a tool row is expanded. + */ + readonly getTurnItem: (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + }) => Effect.Effect; readonly getThreadRecords: Orchestrator.OrchestratorV2["Service"]["getThreadRecords"]; readonly getThreadProjection: ( threadId: ThreadId, @@ -721,6 +732,11 @@ const make = Effect.gen(function* () { ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getMessageCount(threadId)), ), + getTurnItem: (input) => + ensureProjectionTranscript(input.threadId).pipe( + Effect.andThen(orchestrator.getTurnItem(input)), + Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) })), + ), getThreadRecords: (threadId, fields, filter) => ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 846ed1f1a1f1..48a4881bdfb4 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -17,7 +17,11 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Schema from "effect/Schema"; -import { projectTurnItemForWire, projectDomainEventForWire } from "./WireProjection.ts"; +import { + projectTurnItemForWire, + projectTurnItemForDetail, + projectDomainEventForWire, +} from "./WireProjection.ts"; import { threadShellFromProjection } from "./ProjectionStore.ts"; const decodeTurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem); @@ -173,7 +177,7 @@ describe("orchestration V2 wire projection", () => { it("omits even small dynamic tool results while retaining input", () => { const item = { ...base, output: { ok: true } } satisfies OrchestrationV2TurnItem; - expect(projectTurnItemForWire(item)).toEqual(base); + expect(projectTurnItemForWire(item)).toEqual({ ...base, outputOmitted: true }); expect(item.output).toEqual({ ok: true }); }); @@ -252,10 +256,24 @@ describe("orchestration V2 wire projection", () => { const projected = projectTurnItemForWire(item); expect(projected).not.toHaveProperty("output"); expect(projected).toMatchObject({ input: "test", status: "completed" }); + // Clients fetch withheld output on demand, so they need to know it exists. + expect(projected.type === "command_execution" ? projected.outputOmitted : null).toBe( + output ? true : undefined, + ); expect(item.output).toBe(output); }, ); + it.each([ + ["echo ok", "echo ok"], + ["a".repeat(262_143) + "😀", "a".repeat(262_143) + "\n… output truncated for transport"], + ])("bounds fetched command input without changing persistence, case %#", (input, expected) => { + const item = { ...base, type: "command_execution" as const, input, output: "ok" }; + const projected = projectTurnItemForDetail(item); + expect(projected).toMatchObject({ input: expected, output: "ok" }); + expect(item.input).toBe(input); + }); + it("keeps failure evidence without retaining command output", () => { const item = { ...base, @@ -290,6 +308,14 @@ describe("orchestration V2 wire projection", () => { expect(projected).not.toHaveProperty("newStr"); expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 }); expect(item.diffStr).toBe("+new code"); + // A failed edit keeps the provider's error so expanding the row can show it. + const failed = projectTurnItemForWire({ + ...item, + status: "failed", + diffStr: "String to replace not found", + }); + expect(failed).toMatchObject({ diffStr: "String to replace not found" }); + expect(failed).not.toHaveProperty("newStr"); }); it("retains only result identities and failure metadata in live tool events", () => { diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index b72cd0542457..7c17147d220a 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -8,19 +8,22 @@ import { compactDynamicToolOutput, toolOutputIndicatesFailure } from "@t3tools/s const MAX_DETAIL_STRING_BYTES = 32_768; const MAX_DYNAMIC_VALUE_BYTES = 16_384; +const MAX_ON_DEMAND_BYTES = 256 * 1024; -function truncateDetail(value: string | undefined): string | undefined { +function truncateDetail( + value: string | undefined, + maxBytes = MAX_DETAIL_STRING_BYTES, +): string | undefined { if ( value === undefined || - (value.length <= MAX_DETAIL_STRING_BYTES && - Buffer.byteLength(value, "utf8") <= MAX_DETAIL_STRING_BYTES) + (value.length <= maxBytes && Buffer.byteLength(value, "utf8") <= maxBytes) ) { return value; } // UTF-8 needs at least one byte per UTF-16 code unit. Only encode the prefix // that could fit, rather than allocating a buffer for the complete output. - const prefix = Buffer.from(value.slice(0, MAX_DETAIL_STRING_BYTES), "utf8") - .subarray(0, MAX_DETAIL_STRING_BYTES) + const prefix = Buffer.from(value.slice(0, maxBytes), "utf8") + .subarray(0, maxBytes) .toString("utf8") .replace(/\uFFFD$/u, ""); return `${prefix}\n… output truncated for transport`; @@ -80,13 +83,20 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra (item.exitCode !== undefined && item.exitCode !== 0) || (output !== undefined && toolOutputIndicatesFailure(output.slice(0, MAX_DETAIL_STRING_BYTES))); - return failed ? { ...projected, outputIndicatesFailure: true } : projected; + return { + ...projected, + ...(failed ? { outputIndicatesFailure: true } : {}), + ...(output?.trim() ? { outputOmitted: true } : {}), + }; } case "file_change": { // File identity and counts are enough for activity. Full diffs already // have a dedicated read path and remain intact in persistence. - const { diffStr: _diff, oldStr: _old, newStr: _new, ...projected } = item; - return projected; + const { diffStr, oldStr: _old, newStr: _new, ...projected } = item; + // A failed edit stores the provider's error where the diff would be. + return item.status === "failed" && diffStr?.trim() + ? { ...projected, diffStr: truncateDetail(diffStr) } + : projected; } case "subagent": return { @@ -102,6 +112,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra ...projected, input: summarizeDynamicValue(item.input), ...(output === undefined ? {} : { output }), + ...(hasDynamicValue(rawOutput) ? { outputOmitted: true } : {}), }; } default: @@ -109,6 +120,62 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra } } +function hasDynamicValue(value: unknown): boolean { + if (value === undefined || value === null) return false; + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return typeof value !== "object" || Object.keys(value).length > 0; +} + +function boundDynamicValue(value: unknown): unknown { + if (value === undefined) return value; + if (typeof value === "string") return truncateDetail(value, MAX_ON_DEMAND_BYTES); + let json: string; + try { + // Compact, so measuring does not inflate the value; clients indent it. + json = JSON.stringify(value) ?? String(value); + } catch { + return "Unserializable tool value"; + } + return Buffer.byteLength(json, "utf8") <= MAX_ON_DEMAND_BYTES + ? value + : truncateDetail(json, MAX_ON_DEMAND_BYTES); +} + +/** + * Projects one item for an on-demand detail read: keeps the input and output + * the timeline withholds, bounded so a huge result cannot stall the socket. + */ +export function projectTurnItemForDetail(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + switch (item.type) { + case "command_execution": + return { + ...item, + input: truncateDetail(item.input, MAX_ON_DEMAND_BYTES) ?? "", + output: truncateDetail(item.output, MAX_ON_DEMAND_BYTES), + }; + case "dynamic_tool": + return { + ...item, + input: boundDynamicValue(item.input), + output: boundDynamicValue(item.output), + }; + case "subagent": + return { + ...item, + prompt: truncateDetail(item.prompt, MAX_ON_DEMAND_BYTES) ?? "", + progress: truncateDetail(item.progress, MAX_ON_DEMAND_BYTES), + result: + item.result === null ? null : (truncateDetail(item.result, MAX_ON_DEMAND_BYTES) ?? null), + }; + case "handoff": + case "file_change": + return projectTurnItemForWire(item); + default: + return item; + } +} + export function projectContextHandoffForWire( handoff: OrchestrationV2ContextHandoff, ): OrchestrationV2ContextHandoff { diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 003403fc5c09..4467fd9768ea 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -194,6 +194,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( dispatch: unused, getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), getThreadProjection: unused, getCheckpointContext: unused, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fdfefb8a6a04..b95d9223f1bd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1842,6 +1842,21 @@ const makeWsRpcLayer = ( readWorkflowScript({ scriptPath: input.scriptPath }), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.getTurnItem, + threadManagement.getTurnItem(input).pipe( + Effect.mapError( + (cause) => + new OrchestrationV2GetThreadProjectionError({ + threadId: input.threadId, + message: "Failed to load turn item", + cause, + }), + ), + ), + { "rpc.aggregate": "orchestration" }, + ), [ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: (input) => observeRpcEffect( ORCHESTRATION_V2_WS_METHODS.getTurnDiff, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f8d03ffec9d8..288404206a89 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,6 +44,10 @@ import { workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { + turnItemHasDetail, + turnItemNeedsDetailFetch, +} from "@t3tools/client-runtime/work-log/item-detail"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { subagentGroupSummary, @@ -258,7 +262,7 @@ import { formatDayAwareTimestamp, formatUpcomingTimestamp, } from "../../timestampFormat"; -import { V2ItemInspector } from "./V2ItemInspector"; +import { FetchedToolOutput, V2ItemInspector } from "./V2ItemInspector"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { Collapsible, CollapsibleTrigger, CollapsiblePanel } from "../ui/collapsible"; import { @@ -5092,10 +5096,25 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { viewedImage ? viewedImagePath : null, ) : null; + // Projected rows expand to the item inspector, so only offer a disclosure + // when it has something to show, even if that output still has to load. + // Reads and skills still fetch the output the timeline withheld. + const plainOutputFetches = + plainOutput !== undefined && + workEntry.projectedItem !== undefined && + turnItemNeedsDetailFetch(workEntry.projectedItem.item); const canExpandProjectedItem = plainOutput !== undefined - ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer) - : canExpand || workEntry.projectedItem !== undefined; + ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches) + : workEntry.projectedItem === undefined + ? canExpand + : isReasoning + ? Boolean(workEntry.detail?.trim()) + : Boolean( + viewedImage || + workEntry.questionAnswer || + turnItemHasDetail(workEntry.projectedItem.item), + ); // Reserve destructive row styling for severe failures, not routine tool errors. const iconWrapperClass = cn( "flex size-4 items-center justify-center", @@ -5266,7 +5285,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { !isReasoning && !workEntry.questionAnswer && canExpandProjectedItem && - (expandedBody || (workEntry.projectedItem && plainOutput === undefined)) ? ( + (expandedBody || + plainOutputFetches || + (workEntry.projectedItem && plainOutput === undefined)) ? ( {workEntry.projectedItem && plainOutput === undefined ? ( - ) : expandedBody ? ( -
{expandedBody}
- ) : null} + ) : ( + <> + {expandedBody ? ( +
{expandedBody}
+ ) : null} + {plainOutputFetches && workEntry.projectedItem ? ( + + ) : null} + + )}
) : null} diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index b2f0f6b9e762..4f33a7f5b1a7 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -4,12 +4,19 @@ import type { RunId, ThreadId, } from "@t3tools/contracts"; +import { + formatToolValue, + turnItemDetailRevision, + turnItemNeedsDetailFetch, + turnItemOutputText, +} from "@t3tools/client-runtime/work-log/item-detail"; import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; -import { memo, Suspense, use, useMemo } from "react"; +import { memo, type ReactNode, Suspense, use, useMemo } from "react"; import { useTheme } from "../../hooks/useTheme"; import { resolveDiffThemeName } from "../../lib/diffRendering"; import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting"; +import { useTurnItemDetail } from "../../state/queries"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { Button } from "../ui/button"; @@ -81,8 +88,94 @@ function StructuredValue({ ); } +function SectionLabel({ children }: { readonly children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** + * The item behind a projected row, with the output the timeline withheld + * fetched while the row is open. + */ +function useFetchedTurnItem( + projectedItem: OrchestrationV2ProjectedTurnItem, + environmentId: EnvironmentId, +) { + const wireItem = projectedItem.item; + const fetches = turnItemNeedsDetailFetch(wireItem); + const detail = useTurnItemDetail( + fetches + ? { + environmentId, + threadId: projectedItem.sourceThreadId, + itemId: projectedItem.sourceItemId, + revision: turnItemDetailRevision(wireItem), + } + : null, + ); + const fetchedItem = detail.data?.item; + const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem; + return { + item, + output: { + text: turnItemOutputText(item), + pending: item === wireItem && detail.isPending, + error: + item !== wireItem + ? null + : detail.data?.item === null + ? "Output is no longer available." + : detail.error, + empty: fetches && item !== wireItem, + }, + }; +} + +/** Output the timeline withheld, fetched while the row is open. */ +function ToolOutput(props: { + readonly text: string | null; + readonly pending: boolean; + readonly error: string | null; + readonly empty: boolean; +}) { + const body = props.text ? ( + + ) : props.pending ? ( +

Loading output…

+ ) : props.error ? ( +

Couldn't load output: {props.error}

+ ) : props.empty ? ( +

No output.

+ ) : null; + if (body === null) return null; + return ( +
+ Output + {body} +
+ ); +} + +/** Fetched output for rows that show their own plain text instead of the inspector. */ +export function FetchedToolOutput(props: { + readonly projectedItem: OrchestrationV2ProjectedTurnItem; + readonly environmentId: EnvironmentId; +}) { + const { output } = useFetchedTurnItem(props.projectedItem, props.environmentId); + return ( +
+ +
+ ); +} + export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { - const { item } = props.projectedItem; + const fetched = useFetchedTurnItem(props.projectedItem, props.environmentId); + const item = fetched.item; + const output = ; const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, @@ -107,6 +200,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec {item.type === "command_execution" ? (
+ {output} {item.exitCode !== undefined ? (

Process exited with code {item.exitCode} @@ -137,6 +231,9 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null}

+ {item.status === "failed" && item.diffStr?.trim() ? ( + + ) : null} {item.changes !== undefined && item.changes.length > 0 ? (
    {item.changes.map((change, index) => ( @@ -153,7 +250,15 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} - {item.type === "file_search" && item.results ? ( + {item.type === "file_search" && item.pattern?.trim() && !item.results?.length ? ( + + ) : null} + + {item.type === "web_search" && item.patterns?.length && !item.results?.length ? ( + + ) : null} + + {item.type === "file_search" && item.results?.length ? (
      {item.results.map((result) => (
    • @@ -170,7 +275,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec
    ) : null} - {item.type === "web_search" && item.results ? ( + {item.type === "web_search" && item.results?.length ? (
      {item.results.map((result) => { const safeHref = resolveExternalWebLinkHref(result.url); @@ -199,11 +304,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "dynamic_tool" ? ( -
      -

      - Input -

      - +
      + {formatToolValue(item.input) ? ( +
      + Input + +
      + ) : null} + {output}
      ) : null} @@ -213,6 +321,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "notification" ? : null} {item.type === "system_notice" ? : null} + {item.type === "error" ? : null} {item.type === "proposed_plan" ? : null} {item.type === "todo_list" ? ( ( staleTimeMs: 300_000, idleTtlMs: 300_000, }), + // Keyed by the item revision, so a live row refetches as its output grows. + turnItem: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:orchestration:turn-item", + tag: ORCHESTRATION_V2_WS_METHODS.getTurnItem, + staleTimeMs: 60_000, + idleTtlMs: 60_000, + }), fullThreadDiff: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:orchestration:full-thread-diff", tag: ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff, diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts new file mode 100644 index 000000000000..c4c03291813e --- /dev/null +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -0,0 +1,179 @@ +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + +const MAX_TEXT_BLOCK_DEPTH = 4; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Wire projection replaces a large dynamic input with `{ summary, truncated: true }`. */ +function isSummarizedValue(value: unknown): boolean { + return isRecord(value) && value.truncated === true && typeof value.summary === "string"; +} + +function textFromBlocks(value: unknown, depth: number): string | null { + if (depth > MAX_TEXT_BLOCK_DEPTH) return null; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const parts = value.map((block) => textFromBlocks(block, depth + 1)); + return parts.every((part) => part !== null) ? parts.join("\n") : null; + } + if (!isRecord(value)) return null; + if (value.type === "text" && typeof value.text === "string") return value.text; + if (value.type === "image") return "[image]"; + if (value.type === "resource_link" && typeof value.uri === "string") return value.uri; + if (value.type === "resource" && isRecord(value.resource)) { + const resource = value.resource; + if (typeof resource.text === "string") return resource.text; + if (typeof resource.uri === "string") return resource.uri; + } + const keys = Object.keys(value).filter((key) => key !== "isError" && key !== "is_error"); + // MCP results and provider tool results wrap their text in `content`. + // `structuredContent` usually repeats it as data, so it only shows when the + // text is empty. + if (keys.length === 1 && keys[0] === "content") return textFromBlocks(value.content, depth + 1); + if (keys.length === 2 && keys.includes("content") && keys.includes("structuredContent")) { + const text = textFromBlocks(value.content, depth + 1); + return text?.trim() ? text : null; + } + return null; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** MCP tools often return JSON as minified text, one document per line. Indent each. */ +function prettyJsonText(text: string): string { + const trimmed = text.trim(); + if (!/^[[{]/.test(trimmed)) return text; + const whole = parseJson(trimmed); + if (whole !== undefined) return JSON.stringify(whole, null, 2); + const lines = trimmed.split("\n").filter((line) => line.trim()); + const documents = lines.map((line) => parseJson(line.trim())); + if (documents.some((document) => document === undefined)) return text; + return documents.map((document) => JSON.stringify(document, null, 2)).join("\n\n"); +} + +/** Formats a tool input or output for display: text blocks as text, the rest as JSON. */ +export function formatToolValue(value: unknown): string | null { + if (value === undefined || value === null) return null; + const text = textFromBlocks(value, 0); + if (text !== null) return text.trim() ? prettyJsonText(text) : null; + let json: string | undefined; + try { + json = JSON.stringify(value, null, 2); + } catch { + return String(value); + } + if (json === undefined || json === "{}" || json === "[]") return null; + return json; +} + +const LIVE_TURN_ITEM_STATUSES: ReadonlySet = new Set([ + "idle", + "pending", + "running", + "waiting", +]); + +/** + * Cache key for a fetched item. A running item keeps one key, so an open row + * fetches once while it streams and again when it finishes, not on every update. + */ +export function turnItemDetailRevision(item: OrchestrationV2TurnItem): string { + return LIVE_TURN_ITEM_STATUSES.has(item.status) ? "live" : DateTime.formatIso(item.updatedAt); +} + +/** True when the timeline item withholds content that `getTurnItem` returns. */ +export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean { + switch (item.type) { + case "command_execution": + return item.outputOmitted === true; + case "dynamic_tool": + return item.outputOmitted === true || isSummarizedValue(item.input); + default: + return false; + } +} + +/** Older Claude bash rows stored the raw `{ stdout, stderr, interrupted, ... }` result. */ +function commandOutputText(output: string): string { + if (!output.trimStart().startsWith('{"stdout"')) return output; + const parsed = parseJson(output.trim()); + if ( + !isRecord(parsed) || + typeof parsed.stdout !== "string" || + typeof parsed.stderr !== "string" || + typeof parsed.interrupted !== "boolean" + ) { + return output; + } + return [parsed.stdout, parsed.stderr] + .filter((part): part is string => typeof part === "string" && part.trim().length > 0) + .join("\n"); +} + +/** The tool output carried by a fetched item, formatted for display. */ +export function turnItemOutputText(item: OrchestrationV2TurnItem): string | null { + switch (item.type) { + case "command_execution": + return item.output?.trim() ? commandOutputText(item.output) || null : null; + case "dynamic_tool": + return item.outputOmitted === true ? null : formatToolValue(item.output); + default: + return null; + } +} + +/** + * Whether expanding the item shows anything. Rows without content must not + * offer a disclosure, otherwise they open to an empty panel. + */ +export function turnItemHasDetail(item: OrchestrationV2TurnItem): boolean { + switch (item.type) { + case "reasoning": + return item.text.trim().length > 0; + case "command_execution": + return ( + item.input.trim().length > 0 || + item.outputOmitted === true || + Boolean(item.output?.trim()) || + item.exitCode !== undefined + ); + case "file_change": + case "checkpoint": + case "fork": + case "handoff": + return true; + case "file_search": + return (item.results?.length ?? 0) > 0 || Boolean(item.pattern?.trim()); + case "web_search": + return (item.results?.length ?? 0) > 0 || (item.patterns?.length ?? 0) > 0; + case "dynamic_tool": + return item.outputOmitted === true || formatToolValue(item.input) !== null; + case "approval_request": + return Boolean(item.prompt?.trim()); + case "user_input_request": + return item.questions.length > 0; + case "notification": + return Boolean(item.detail?.trim()); + case "system_notice": + return item.message.trim().length > 0; + case "error": + return item.failure.message.trim().length > 0; + case "proposed_plan": + return item.markdown.trim().length > 0; + case "todo_list": + return item.steps.length > 0; + case "subagent": + return item.childThreadId !== null; + default: + return false; + } +} diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 84229d3bac7f..f5e9daab7490 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -38,8 +38,11 @@ export function toolItemForDisplay(item: OrchestrationV2TurnItem): Orchestration return displayItem; } case "file_change": { - const { diffStr: _diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item; - return displayItem; + const { diffStr, oldStr: _oldStr, newStr: _newStr, ...displayItem } = item; + // A failed edit's diffStr holds the provider's error, not a diff. + return item.status === "failed" && diffStr?.trim() + ? { ...displayItem, diffStr } + : displayItem; } default: return item; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6004ab119917..d0a62bfcde61 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1332,6 +1332,8 @@ export const OrchestrationV2TurnItem = Schema.Union([ type: Schema.Literal("command_execution"), input: Schema.String, output: Schema.optional(Schema.String), + /** Set on the wire when output was withheld; fetch it with getTurnItem. */ + outputOmitted: Schema.optional(Schema.Boolean), outputIndicatesFailure: Schema.optional(Schema.Boolean), exitCode: Schema.optional(Schema.Int), }), @@ -1458,6 +1460,8 @@ export const OrchestrationV2TurnItem = Schema.Union([ viewedImagePath: Schema.optional(TrimmedNonEmptyString), input: Schema.Unknown, output: Schema.optional(Schema.Unknown), + /** Set on the wire when output was withheld; fetch it with getTurnItem. */ + outputOmitted: Schema.optional(Schema.Boolean), }), ]); export type OrchestrationV2TurnItem = typeof OrchestrationV2TurnItem.Type; @@ -2059,6 +2063,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ type: Schema.Literal("command_execution"), input: Schema.String, output: Schema.optional(Schema.String), + /** Set on the wire when output was withheld; fetch it with getTurnItem. */ + outputOmitted: Schema.optional(Schema.Boolean), outputIndicatesFailure: Schema.optional(Schema.Boolean), exitCode: Schema.optional(Schema.Int), }), @@ -2182,6 +2188,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ viewedImagePath: Schema.optional(TrimmedNonEmptyString), input: Schema.Unknown, output: Schema.optional(Schema.Unknown), + /** Set on the wire when output was withheld; fetch it with getTurnItem. */ + outputOmitted: Schema.optional(Schema.Boolean), }), ]); export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type; @@ -2928,6 +2936,7 @@ export const ORCHESTRATION_V2_WS_METHODS = { getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot", getThreadProjection: "orchestration.getThreadProjection", getWorkflowScript: "orchestration.getWorkflowScript", + getTurnItem: "orchestration.getTurnItem", launchThread: "orchestration.launchThread", subscribeArchivedShell: "orchestration.subscribeArchivedShell", subscribeShell: "orchestration.subscribeShell", @@ -3234,6 +3243,20 @@ export const OrchestrationV2GetWorkflowScriptResult = Schema.Struct({ export type OrchestrationV2GetWorkflowScriptResult = typeof OrchestrationV2GetWorkflowScriptResult.Type; +export const OrchestrationV2GetTurnItemInput = Schema.Struct({ + threadId: ThreadId, + itemId: TurnItemId, + /** The item updatedAt the client last saw. Only keys the client cache. */ + revision: Schema.optional(Schema.String), +}); +export type OrchestrationV2GetTurnItemInput = typeof OrchestrationV2GetTurnItemInput.Type; + +/** One persisted turn item with its full, size-bounded input and output. */ +export const OrchestrationV2GetTurnItemResult = Schema.Struct({ + item: Schema.NullOr(OrchestrationV2TurnItem), +}); +export type OrchestrationV2GetTurnItemResult = typeof OrchestrationV2GetTurnItemResult.Type; + const WORKFLOW_SCRIPT_ERROR_MESSAGES = { "invalid-path": "Workflow scripts must be absolute .js paths.", "root-unavailable": "Script root unavailable.", @@ -3292,6 +3315,10 @@ export const OrchestrationV2RpcSchemas = { input: OrchestrationV2GetWorkflowScriptInput, output: OrchestrationV2GetWorkflowScriptResult, }, + getTurnItem: { + input: OrchestrationV2GetTurnItemInput, + output: OrchestrationV2GetTurnItemResult, + }, launchThread: { input: OrchestrationV2ThreadLaunchInput, output: OrchestrationV2ThreadLaunchResult, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6df5f4c28f90..9fb0f186751a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1549,6 +1549,12 @@ const WsOrchestrationV2GetWorkflowScriptRpc = Rpc.make( }, ); +const WsOrchestrationV2GetTurnItemRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.getTurnItem, { + payload: OrchestrationV2RpcSchemas.getTurnItem.input, + success: OrchestrationV2RpcSchemas.getTurnItem.output, + error: Schema.Union([OrchestrationV2GetThreadProjectionError, EnvironmentAuthorizationError]), +}); + const WsOrchestrationV2LaunchThreadRpc = Rpc.make(ORCHESTRATION_V2_WS_METHODS.launchThread, { payload: OrchestrationV2RpcSchemas.launchThread.input, success: OrchestrationV2RpcSchemas.launchThread.output, @@ -1858,6 +1864,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, + WsOrchestrationV2GetTurnItemRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, WsOrchestrationV2SearchThreadsRpc,