From 97a3ad65afcca656cc694217904f19689e37b842 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:50:25 +0000 Subject: [PATCH 01/16] feat(server): fetch a tool call's full input and output on demand The thread stream withholds tool output to stay small. Wire items now flag withheld output, and orchestration.getTurnItem returns one persisted item with its input and output bounded to 256 KB. --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../orchestration-v2/WireProjection.test.ts | 6 +- .../src/orchestration-v2/WireProjection.ts | 71 ++++++++- .../src/orchestration-v2/turnItemQuery.ts | 30 ++++ apps/server/src/ws.ts | 17 +++ packages/client-runtime/package.json | 4 + .../client-runtime/src/state/orchestration.ts | 7 + .../client-runtime/src/work-log/itemDetail.ts | 143 ++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 27 ++++ packages/contracts/src/rpc.ts | 7 + 10 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/orchestration-v2/turnItemQuery.ts create mode 100644 packages/client-runtime/src/work-log/itemDetail.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 046bf8ab2186..8d23a38a382a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -29,6 +29,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/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 846ed1f1a1f1..8791773e63c2 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -173,7 +173,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,6 +252,10 @@ 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); }, ); diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index b72cd0542457..77be091947c0 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,7 +83,11 @@ 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 @@ -102,6 +109,7 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra ...projected, input: summarizeDynamicValue(item.input), ...(output === undefined ? {} : { output }), + ...(hasDynamicValue(rawOutput) ? { outputOmitted: true } : {}), }; } default: @@ -109,6 +117,57 @@ 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 { + json = JSON.stringify(value, null, 2) ?? 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, 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/orchestration-v2/turnItemQuery.ts b/apps/server/src/orchestration-v2/turnItemQuery.ts new file mode 100644 index 000000000000..326b72d41dc7 --- /dev/null +++ b/apps/server/src/orchestration-v2/turnItemQuery.ts @@ -0,0 +1,30 @@ +/** + * Reads one persisted turn item for the timeline's expanded tool rows. The + * thread stream withholds tool output to keep it small; clients fetch it here + * only when the user opens a row. + */ +import { OrchestrationV2TurnItemJson, type ThreadId, type TurnItemId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { projectTurnItemForDetail } from "./WireProjection.ts"; + +const decodeTurnItem = Schema.decodeUnknownEffect( + Schema.fromJsonString(OrchestrationV2TurnItemJson), +); + +export const readTurnItem = Effect.fn("orchestration.readTurnItem")(function* (input: { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; +}) { + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ readonly payload_json: string }>` + SELECT payload_json FROM orchestration_v2_projection_turn_items + WHERE turn_item_id = ${input.itemId} AND thread_id = ${input.threadId} + `; + const row = rows[0]; + if (row === undefined) return { item: null }; + const item = yield* decodeTurnItem(row.payload_json); + return { item: projectTurnItemForDetail(item) }; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2a0006ddb24b..0f229b212514 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -185,6 +185,7 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import { readTurnItem } from "./orchestration-v2/turnItemQuery.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -1802,6 +1803,22 @@ const makeWsRpcLayer = ( readWorkflowScript({ scriptPath: input.scriptPath }), { "rpc.aggregate": "orchestration" }, ), + [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => + observeRpcEffect( + ORCHESTRATION_V2_WS_METHODS.getTurnItem, + readTurnItem(input).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + 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/packages/client-runtime/package.json b/packages/client-runtime/package.json index 087d29f698d7..9746de76c693 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -307,6 +307,10 @@ "types": "./src/work-log/userInput.ts", "default": "./src/work-log/userInput.ts" }, + "./work-log/item-detail": { + "types": "./src/work-log/itemDetail.ts", + "default": "./src/work-log/itemDetail.ts" + }, "./work-log/presentation": { "types": "./src/work-log/presentation.ts", "default": "./src/work-log/presentation.ts" diff --git a/packages/client-runtime/src/state/orchestration.ts b/packages/client-runtime/src/state/orchestration.ts index 728c08593731..94877ebc091d 100644 --- a/packages/client-runtime/src/state/orchestration.ts +++ b/packages/client-runtime/src/state/orchestration.ts @@ -44,6 +44,13 @@ export function createOrchestrationEnvironmentAtoms( 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..3293dea7de0a --- /dev/null +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -0,0 +1,143 @@ +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; + +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`. + if (keys.length === 1 && keys[0] === "content") return textFromBlocks(value.content, depth + 1); + return null; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** + * MCP tools often return JSON as minified text, sometimes once per content + * block with identical copies. Indent each document and drop the repeats. + */ +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 [...new Set(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; +} + +/** 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; + } +} + +/** 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() ? item.output : 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/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1820008b77dd..b75fba3ff29a 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1331,6 +1331,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), }), @@ -1457,6 +1459,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; @@ -2058,6 +2062,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), }), @@ -2181,6 +2187,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; @@ -2894,6 +2902,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", @@ -3200,6 +3209,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.", @@ -3258,6 +3281,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 ed6df13a6947..f6935eeb6a6b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1548,6 +1548,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, @@ -1847,6 +1853,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeResourceTelemetryRpc, WsOrchestrationV2DispatchCommandRpc, WsOrchestrationV2GetWorkflowScriptRpc, + WsOrchestrationV2GetTurnItemRpc, WsOrchestrationV2GetTurnDiffRpc, WsOrchestrationV2GetFullThreadDiffRpc, WsOrchestrationV2SearchThreadsRpc, From 2a286d75bf89fd4646cf294b077b4c9576efad1a Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:50:25 +0000 Subject: [PATCH 02/16] fix(server): providers keep tool queries and results Grok X and web searches keep their query and sources, Grok fetches keep the page text, Antigravity shell calls keep their output, OpenCode searches keep their matches, Pi edits keep their patch, and Claude WebFetch keeps a preview of the fetched page. --- apps/server/scripts/acp-mock-agent.ts | 64 +++++++++++++++ .../Adapters/AcpAdapterV2.test.ts | 35 ++++++++ .../orchestration-v2/Adapters/AcpAdapterV2.ts | 81 +++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 19 ++++- .../Adapters/OpenCodeAdapterV2.test.ts | 6 ++ .../Adapters/OpenCodeToolItems.ts | 24 ++++++ .../Adapters/PiAdapterV2.test.ts | 70 ++++++++++++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 6 ++ 8 files changed, 299 insertions(+), 6 deletions(-) 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/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 146924586763..2c8c6b9b18c6 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.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2cf91c619594..8e4416fd54d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1869,6 +1869,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; @@ -3791,7 +3793,22 @@ export function makeClaudeAdapterV2( ...(webSearchPatterns.length === 0 ? {} : { patterns: [...webSearchPatterns] }), - ...(webSearchResults.length === 0 ? {} : { results: [...webSearchResults] }), + ...(webSearchResults.length > 0 + ? { results: [...webSearchResults] } + : outputText.trim().length > 0 + ? { + // WebFetch returns page text, not search hits. Keep a + // bounded preview so the row has something to show. + results: [ + { + ...(webSearchPatterns[0] === undefined + ? {} + : { url: webSearchPatterns[0].trim() }), + snippet: outputText.slice(0, WEB_FETCH_SNIPPET_MAX_CHARS), + }, + ], + } + : {}), } : { ...itemBase, diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index eca4aa9115a4..36e261f11932 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts @@ -877,10 +877,16 @@ 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" }, + ]); }).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..3e465f77da3d 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" @@ -108,6 +111,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 +121,32 @@ export function openCodeToolTurnItem( base.title, type: "file_search", ...(pattern === undefined ? {} : { pattern }), + ...(output === undefined || 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 === undefined + ? {} + : { + 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..cc9f8e74b1d3 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1155,6 +1155,76 @@ 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", + ); + }).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..23c2c2e15088 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -988,6 +988,10 @@ 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. + const diffStr = recordString(recordField(resultRecord, "details"), "patch"); + const newStr = toolName === "write" ? recordString(args, "content") : undefined; yield* emit({ type: "turn_item.updated", driver: PI_PROVIDER, @@ -996,6 +1000,8 @@ export function makePiAdapterV2( title: toolName, type: "file_change", fileName, + ...(diffStr === undefined ? {} : { diffStr }), + ...(newStr === undefined ? {} : { newStr }), }, }); return; From 9a5035e4a96051e046002a329fbdb59352d23311 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sat, 3 Oct 2026 05:50:25 +0000 Subject: [PATCH 03/16] fix(clients): expanded tool calls show their output, empty ones don't expand Web and mobile fetch withheld output when a row opens and only offer a disclosure when the item has something to show. --- .../src/features/threads/thread-work-log.tsx | 35 +++++++- apps/mobile/src/lib/threadActivity.test.ts | 30 +++++++ apps/mobile/src/lib/threadActivity.ts | 49 +++++++---- apps/mobile/src/state/queries.ts | 23 +++++ .../src/components/chat/MessagesTimeline.tsx | 13 ++- .../src/components/chat/V2ItemInspector.tsx | 83 +++++++++++++++++-- apps/web/src/state/queries.ts | 21 +++++ 7 files changed, 229 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 826b9205ac75..be0a1c65d935 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, @@ -788,6 +791,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 +869,24 @@ 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; + const fullDetail = + expanded && !reasoning + ? fetchedItem + ? formatItemFullDetail(row.projectedItem, fetchedItem) + : row.getFullDetail() + : null; + const fetchedOutput = !expanded + ? null + : fetchedItem + ? turnItemOutputText(fetchedItem) + : 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); @@ -1036,6 +1061,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( {fullDetail} )} + {fetchedOutput ? ( + + {fetchedOutput} + + ) : null} ) : null} diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 4f0c140ac229..5a25b95fca66 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: false }, + ]); + }); + it("recognizes automation attribution after projecting a user message", () => { const feed = buildThreadFeed([ projected( @@ -1181,6 +1210,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 259b76c9b173..a4422812d5db 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 + : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), + // Read rows deliberately show only their paths, never the file contents. + fetchesDetail: !readPaths && 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..5ae2a0e1284e 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 * as DateTime from "effect/DateTime"; 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: DateTime.formatIso(target.row.item.updatedAt), + }, + }), + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5dec1b6d411d..d609a6e1f5e4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,6 +44,7 @@ import { workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; +import { turnItemHasDetail } from "@t3tools/client-runtime/work-log/item-detail"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { subagentGroupSummary, @@ -5076,10 +5077,20 @@ 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. const canExpandProjectedItem = plainOutput !== undefined ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer) - : canExpand || workEntry.projectedItem !== undefined; + : 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", diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index b7bf49654dcf..56c8a12a8951 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -4,9 +4,16 @@ import type { RunId, ThreadId, } from "@t3tools/contracts"; +import { + formatToolValue, + turnItemNeedsDetailFetch, + turnItemOutputText, +} from "@t3tools/client-runtime/work-log/item-detail"; +import * as DateTime from "effect/DateTime"; import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; -import { memo } from "react"; +import { memo, type ReactNode } from "react"; +import { useTurnItemDetail } from "../../state/queries"; import { useV2ItemSupport } from "../../state/v2ItemSupport"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { Button } from "../ui/button"; @@ -36,8 +43,57 @@ function StructuredValue({ value }: { readonly value: unknown }) { ); } +function SectionLabel({ children }: { readonly children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** Output the timeline withheld, fetched while the row is open. */ +function ToolOutput(props: { + readonly text: string | null; + readonly pending: boolean; + readonly error: string | null; +}) { + const body = props.text ? ( + + ) : props.pending ? ( +

Loading output…

+ ) : props.error ? ( +

Couldn't load output: {props.error}

+ ) : null; + if (body === null) return null; + return ( +
+ Output + {body} +
+ ); +} + export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { - const { item } = props.projectedItem; + const wireItem = props.projectedItem.item; + const detail = useTurnItemDetail( + turnItemNeedsDetailFetch(wireItem) + ? { + environmentId: props.environmentId, + threadId: props.projectedItem.sourceThreadId, + itemId: props.projectedItem.sourceItemId, + revision: DateTime.formatIso(wireItem.updatedAt), + } + : null, + ); + const fetchedItem = detail.data?.item; + const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem; + const output = ( + + ); const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, @@ -62,6 +118,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec {item.type === "command_execution" ? (
+ {output} {item.exitCode !== undefined ? (

Process exited with code {item.exitCode} @@ -108,6 +165,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec

) : null} + {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 ? (
    {item.results.map((result) => ( @@ -154,11 +219,14 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} {item.type === "dynamic_tool" ? ( -
    -

    - Input -

    - +
    + {formatToolValue(item.input) ? ( +
    + Input + +
    + ) : null} + {output}
    ) : null} @@ -168,6 +236,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" ? ( Date: Sun, 4 Oct 2026 01:18:15 +0000 Subject: [PATCH 04/16] fix(claude): bash output shows its text instead of the raw result json Claude's Bash result arrives as { stdout, stderr, interrupted, ... }. Store stdout and stderr as the command output, unwrap rows already stored as json, and drop MCP structuredContent, which repeats the text result. --- .../Adapters/ClaudeAdapterV2.test.ts | 83 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 20 ++++- .../client-runtime/src/work-log/itemDetail.ts | 19 ++++- 3 files changed, 118 insertions(+), 4 deletions(-) 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 c70c3cd1af3e..05df4d9450d2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1864,6 +1864,21 @@ 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. */ +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") { + return [stdout, stderr] + .filter((part): part is string => typeof part === "string" && part.trim().length > 0) + .join("\n"); + } + } + return claudeNativeToolOutputText(output); +} + function claudeSubagentResultText(output: ClaudeNativeToolOutput): string { const value = claudeNativeToolOutputValue(output); const content = Array.isArray(value) @@ -3810,7 +3825,10 @@ export function makeClaudeAdapterV2( }); const webSearchResults = webSearchResultsFromClaudeOutput(input.output); 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" ? { diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts index 3293dea7de0a..6498405c90b6 100644 --- a/packages/client-runtime/src/work-log/itemDetail.ts +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -27,8 +27,11 @@ function textFromBlocks(value: unknown, depth: number): string | null { 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`. + const keys = Object.keys(value).filter( + (key) => key !== "isError" && key !== "is_error" && key !== "structuredContent", + ); + // MCP results and provider tool results wrap their text in `content`; + // `structuredContent` repeats it as data. if (keys.length === 1 && keys[0] === "content") return textFromBlocks(value.content, depth + 1); return null; } @@ -83,11 +86,21 @@ export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean } } +/** Older Claude bash rows stored the raw `{ stdout, stderr, ... }` result. */ +function commandOutputText(output: string): string { + if (!output.trimStart().startsWith('{"stdout"')) return output; + const parsed = parseJson(output.trim()); + if (!isRecord(parsed)) 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() ? item.output : null; + return item.output?.trim() ? commandOutputText(item.output) || null : null; case "dynamic_tool": return item.outputOmitted === true ? null : formatToolValue(item.output); default: From bb37f89e1bab6c9e1ea4f4477b31c81f3dbdf329 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:14:46 +0000 Subject: [PATCH 05/16] fix(server): read turn items through ThreadManagementService and tighten tool output fallbacks getTurnItem now goes through the projection store, orchestrator and ThreadManagementService instead of a query module in the ws handler. WebFetch's text preview no longer applies to WebSearch and takes the fetched url from the tool input. Repeated JSON lines in tool output are kept. Empty search results and a deleted item no longer render blank. --- .../Adapters/ClaudeAdapterV2.ts | 8 ++--- .../src/orchestration-v2/Orchestrator.ts | 14 +++++++++ .../orchestration-v2/ProjectionStore.test.ts | 8 +++++ .../src/orchestration-v2/ProjectionStore.ts | 24 +++++++++++++++ .../ProviderTurnControlService.test.ts | 1 + .../ThreadManagementService.ts | 15 ++++++++++ .../src/orchestration-v2/WireProjection.ts | 3 +- .../src/orchestration-v2/turnItemQuery.ts | 30 ------------------- .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.ts | 4 +-- .../src/components/chat/V2ItemInspector.tsx | 12 ++++++-- .../client-runtime/src/work-log/itemDetail.ts | 7 ++--- 12 files changed, 81 insertions(+), 46 deletions(-) delete mode 100644 apps/server/src/orchestration-v2/turnItemQuery.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 05df4d9450d2..f2794ad601dd 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3824,6 +3824,7 @@ 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 = itemType === "command_execution" @@ -3853,15 +3854,14 @@ export function makeClaudeAdapterV2( : { patterns: [...webSearchPatterns] }), ...(webSearchResults.length > 0 ? { results: [...webSearchResults] } - : outputText.trim().length > 0 + : 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: [ { - ...(webSearchPatterns[0] === undefined - ? {} - : { url: webSearchPatterns[0].trim() }), + ...(webFetchUrl === undefined ? {} : { url: webFetchUrl }), snippet: outputText.slice(0, WEB_FETCH_SNIPPET_MAX_CHARS), }, ], 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..ec0638faa462 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,16 @@ 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 +5538,7 @@ export const layer: Layer.Layer = hasUnpairedRunInterruptRequest, getMessageCount, getNextTurnItemOrdinal, + getTurnItem, getThreadRecords, getRuntimeRequest, getPlan, @@ -5778,6 +5794,14 @@ 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..4f87902ec15f 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,10 @@ const make = Effect.gen(function* () { ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getMessageCount(threadId)), ), + getTurnItem: (input) => + orchestrator + .getTurnItem(input) + .pipe(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.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 77be091947c0..aaa42823e51b 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -129,7 +129,8 @@ function boundDynamicValue(value: unknown): unknown { if (typeof value === "string") return truncateDetail(value, MAX_ON_DEMAND_BYTES); let json: string; try { - json = JSON.stringify(value, null, 2) ?? String(value); + // Compact, so measuring does not inflate the value; clients indent it. + json = JSON.stringify(value) ?? String(value); } catch { return "Unserializable tool value"; } diff --git a/apps/server/src/orchestration-v2/turnItemQuery.ts b/apps/server/src/orchestration-v2/turnItemQuery.ts deleted file mode 100644 index 326b72d41dc7..000000000000 --- a/apps/server/src/orchestration-v2/turnItemQuery.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Reads one persisted turn item for the timeline's expanded tool rows. The - * thread stream withholds tool output to keep it small; clients fetch it here - * only when the user opens a row. - */ -import { OrchestrationV2TurnItemJson, type ThreadId, type TurnItemId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import { projectTurnItemForDetail } from "./WireProjection.ts"; - -const decodeTurnItem = Schema.decodeUnknownEffect( - Schema.fromJsonString(OrchestrationV2TurnItemJson), -); - -export const readTurnItem = Effect.fn("orchestration.readTurnItem")(function* (input: { - readonly threadId: ThreadId; - readonly itemId: TurnItemId; -}) { - const sql = yield* SqlClient.SqlClient; - const rows = yield* sql<{ readonly payload_json: string }>` - SELECT payload_json FROM orchestration_v2_projection_turn_items - WHERE turn_item_id = ${input.itemId} AND thread_id = ${input.threadId} - `; - const row = rows[0]; - if (row === undefined) return { item: null }; - const item = yield* decodeTurnItem(row.payload_json); - return { item: projectTurnItemForDetail(item) }; -}); 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 27f4bfa95ce3..b95d9223f1bd 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -187,7 +187,6 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; -import { readTurnItem } from "./orchestration-v2/turnItemQuery.ts"; import { readWorkflowScript } from "./orchestration-v2/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -1846,8 +1845,7 @@ const makeWsRpcLayer = ( [ORCHESTRATION_V2_WS_METHODS.getTurnItem]: (input) => observeRpcEffect( ORCHESTRATION_V2_WS_METHODS.getTurnItem, - readTurnItem(input).pipe( - Effect.provideService(SqlClient.SqlClient, sql), + threadManagement.getTurnItem(input).pipe( Effect.mapError( (cause) => new OrchestrationV2GetThreadProjectionError({ diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 16e90f0b6d80..186147dcc5be 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -136,7 +136,13 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ); const support = useV2ItemSupport({ @@ -218,7 +224,7 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec ) : null} - {item.type === "file_search" && item.results ? ( + {item.type === "file_search" && item.results?.length ? (
      {item.results.map((result) => (
    • @@ -235,7 +241,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); diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts index 6498405c90b6..0d1bce2a1d66 100644 --- a/packages/client-runtime/src/work-log/itemDetail.ts +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -44,10 +44,7 @@ function parseJson(text: string): unknown { } } -/** - * MCP tools often return JSON as minified text, sometimes once per content - * block with identical copies. Indent each document and drop the repeats. - */ +/** 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; @@ -56,7 +53,7 @@ function prettyJsonText(text: string): string { 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 [...new Set(documents.map((document) => JSON.stringify(document, null, 2)))].join("\n\n"); + 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. */ From 2059d4849f343d216d139e47ec410e6458c14a28 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:32:15 +0000 Subject: [PATCH 06/16] style(server): format turn item read --- apps/server/src/orchestration-v2/ProjectionStore.ts | 7 ++++--- .../server/src/orchestration-v2/ThreadManagementService.ts | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ec0638faa462..edbdcce13cc6 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -4511,7 +4511,9 @@ export const layer: Layer.Layer = 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), + rows[0] === undefined + ? Effect.succeed(null) + : decodeTurnItemPayload(rows[0].payload_json), ), Effect.mapError(controlReadError(threadId)), ); @@ -5798,8 +5800,7 @@ export const layerMemory: Layer.Layer = Layer.effect( Ref.get(replayState).pipe( Effect.map( (state) => - state.projections.get(threadId)?.turnItems.find((item) => item.id === itemId) ?? - null, + state.projections.get(threadId)?.turnItems.find((item) => item.id === itemId) ?? null, ), ), getThreadAttachmentIds: (threadId) => diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 4f87902ec15f..9858639499d2 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -735,7 +735,9 @@ const make = Effect.gen(function* () { getTurnItem: (input) => orchestrator .getTurnItem(input) - .pipe(Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) }))), + .pipe( + Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) })), + ), getThreadRecords: (threadId, fields, filter) => ensureProjectionTranscript(threadId).pipe( Effect.andThen(orchestrator.getThreadRecords(threadId, fields, filter)), From f6c136308f5a68e7b975489c420299ffad287044 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:03:42 +0000 Subject: [PATCH 07/16] fix(clients): read and skill rows show fetched output, live rows fetch once Read and skill rows keep their paths or arguments and now show the output the timeline withheld. A running item fetches once and again when it finishes instead of on every update. Empty fetched output says so. The legacy Claude bash unwrap only applies to the real result shape, and MCP structuredContent shows when the text content is empty. --- .../src/features/threads/thread-work-log.tsx | 7 +- apps/mobile/src/lib/threadActivity.test.ts | 2 +- apps/mobile/src/lib/threadActivity.ts | 6 +- apps/mobile/src/state/queries.ts | 4 +- .../src/components/chat/MessagesTimeline.tsx | 34 ++++++-- .../src/components/chat/V2ItemInspector.tsx | 85 +++++++++++++------ .../client-runtime/src/work-log/itemDetail.ts | 40 +++++++-- 7 files changed, 129 insertions(+), 49 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 0a4110d6b656..1b1e627ddb51 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -63,6 +63,7 @@ import { } from "./thread-feed-live-follow"; import { resolveWorkEntryToolPresentation, + toolGroupAction, type ToolGroupSummaryKind, workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; @@ -870,16 +871,18 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( const canExpand = row.canExpand && notifiedSubagentThreadId === undefined; const reasoning = row.projectedItem.item.type === "reasoning" ? row.projectedItem.item : 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 + ? fetchedItem && !isRead ? formatItemFullDetail(row.projectedItem, fetchedItem) : row.getFullDetail() : null; const fetchedOutput = !expanded ? null : fetchedItem - ? turnItemOutputText(fetchedItem) + ? (turnItemOutputText(fetchedItem) ?? "No output.") : fetchedDetail.error ? `Couldn't load output: ${fetchedDetail.error}` : row.fetchesDetail diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 3cecc592ec06..5471f24704a9 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -296,7 +296,7 @@ describe("buildThreadFeed", () => { ).toEqual([ { canExpand: true, fetchesDetail: true }, { canExpand: false, fetchesDetail: false }, - { canExpand: true, fetchesDetail: false }, + { canExpand: true, fetchesDetail: true }, ]); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 94660f054332..a77491795704 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -779,10 +779,10 @@ function toFeedActivity( canExpand: !(item.type === "error" && item.status === "failed") && (readPaths - ? readPaths.length > 0 + ? readPaths.length > 0 || turnItemNeedsDetailFetch(item) : turnItemHasDetail(item) || workEntry.questionAnswer !== undefined), - // Read rows deliberately show only their paths, never the file contents. - fetchesDetail: !readPaths && turnItemNeedsDetailFetch(item), + // 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 5ae2a0e1284e..db8269ff130b 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -15,7 +15,7 @@ import { } from "@t3tools/client-runtime/state/thread-search"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; -import * as DateTime from "effect/DateTime"; +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"; @@ -369,7 +369,7 @@ export function useTurnItemDetail( input: { threadId: target.row.sourceThreadId, itemId: target.row.sourceItemId, - revision: DateTime.formatIso(target.row.item.updatedAt), + revision: turnItemDetailRevision(target.row.item), }, }), ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 049c43fb564b..288404206a89 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,7 +44,10 @@ import { workEntryViewedImagePath, } from "@t3tools/client-runtime/work-log/presentation"; import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor"; -import { turnItemHasDetail } from "@t3tools/client-runtime/work-log/item-detail"; +import { + turnItemHasDetail, + turnItemNeedsDetailFetch, +} from "@t3tools/client-runtime/work-log/item-detail"; import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; import { subagentGroupSummary, @@ -259,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 { @@ -5095,9 +5098,14 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { : 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) + ? Boolean(plainOutput || viewedImage || workEntry.questionAnswer || plainOutputFetches) : workEntry.projectedItem === undefined ? canExpand : isReasoning @@ -5277,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 186147dcc5be..f4681fe19c8d 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -6,10 +6,10 @@ import type { } from "@t3tools/contracts"; import { formatToolValue, + turnItemDetailRevision, turnItemNeedsDetailFetch, turnItemOutputText, } from "@t3tools/client-runtime/work-log/item-detail"; -import * as DateTime from "effect/DateTime"; import { ExternalLinkIcon, GitBranchIcon, RotateCcwIcon } from "lucide-react"; import { memo, type ReactNode, Suspense, use, useMemo } from "react"; @@ -96,11 +96,50 @@ function SectionLabel({ children }: { readonly children: ReactNode }) { ); } +/** + * 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 ? ( @@ -108,6 +147,8 @@ function ToolOutput(props: {

      Loading output…

      ) : props.error ? (

      Couldn't load output: {props.error}

      + ) : props.empty ? ( +

      No output.

      ) : null; if (body === null) return null; return ( @@ -118,33 +159,23 @@ function ToolOutput(props: { ); } -export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspectorProps) { - const wireItem = props.projectedItem.item; - const detail = useTurnItemDetail( - turnItemNeedsDetailFetch(wireItem) - ? { - environmentId: props.environmentId, - threadId: props.projectedItem.sourceThreadId, - itemId: props.projectedItem.sourceItemId, - revision: DateTime.formatIso(wireItem.updatedAt), - } - : null, - ); - const fetchedItem = detail.data?.item; - const item = fetchedItem?.type === wireItem.type ? fetchedItem : wireItem; - const output = ( - +/** 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 fetched = useFetchedTurnItem(props.projectedItem, props.environmentId); + const item = fetched.item; + const output = ; const support = useV2ItemSupport({ environmentId: props.environmentId, sourceThreadId: props.projectedItem.sourceThreadId, diff --git a/packages/client-runtime/src/work-log/itemDetail.ts b/packages/client-runtime/src/work-log/itemDetail.ts index 0d1bce2a1d66..c4c03291813e 100644 --- a/packages/client-runtime/src/work-log/itemDetail.ts +++ b/packages/client-runtime/src/work-log/itemDetail.ts @@ -1,4 +1,5 @@ import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; const MAX_TEXT_BLOCK_DEPTH = 4; @@ -27,12 +28,15 @@ function textFromBlocks(value: unknown, depth: number): string | null { 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" && key !== "structuredContent", - ); - // MCP results and provider tool results wrap their text in `content`; - // `structuredContent` repeats it as data. + 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; } @@ -71,6 +75,21 @@ export function formatToolValue(value: unknown): string | 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) { @@ -83,11 +102,18 @@ export function turnItemNeedsDetailFetch(item: OrchestrationV2TurnItem): boolean } } -/** Older Claude bash rows stored the raw `{ stdout, stderr, ... }` result. */ +/** 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)) return output; + 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"); From 9cb0abda33c7526b7a0f74ae5422f53c87e0b59b Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:29:48 +0000 Subject: [PATCH 08/16] fix: hydrate migrated threads before reading a turn item, show mobile read output without paths --- apps/mobile/src/features/threads/thread-work-log.tsx | 7 ++++++- .../src/orchestration-v2/ThreadManagementService.ts | 9 ++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 1b1e627ddb51..88324d7f616c 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1032,7 +1032,12 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( - {expanded && (reasoning || fullDetail || viewedImagePath || row.workEntry.questionAnswer) ? ( + {expanded && + (reasoning || + fullDetail || + fetchedOutput || + viewedImagePath || + row.workEntry.questionAnswer) ? ( - orchestrator - .getTurnItem(input) - .pipe( - Effect.map((item) => ({ item: item === null ? null : projectTurnItemForDetail(item) })), - ), + 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)), From d413c532b3dafe57a4b7714a4e008a85f7c70b82 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:53:18 +0000 Subject: [PATCH 09/16] fix(claude): keep a background bash call's acknowledgement as its output --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f2794ad601dd..ba0cfbd8fbd5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1864,16 +1864,23 @@ 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. */ +/** + * 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") { - return [stdout, stderr] + 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); From f7d748964ec45140c72d60d530176c344f5c2bd4 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:55:13 +0000 Subject: [PATCH 10/16] fix: failed file edits keep their error for the expanded row --- apps/server/src/orchestration-v2/WireProjection.test.ts | 8 ++++++++ apps/server/src/orchestration-v2/WireProjection.ts | 7 +++++-- apps/web/src/components/chat/V2ItemInspector.tsx | 3 +++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index 8791773e63c2..adc2501f7f9a 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -294,6 +294,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 aaa42823e51b..4658659decb5 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -92,8 +92,11 @@ export function projectTurnItemForWire(item: OrchestrationV2TurnItem): Orchestra 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 { diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index f4681fe19c8d..4f33a7f5b1a7 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -231,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) => ( From 8b40c027c7826126a846f4ccebd1536294676cc0 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:04:21 +0000 Subject: [PATCH 11/16] fix: Pi and OpenCode failed edits keep their error, mobile shows it --- .../Adapters/OpenCodeToolItems.ts | 5 +++- .../Adapters/PiAdapterV2.test.ts | 26 +++++++++++++++++++ .../orchestration-v2/Adapters/PiAdapterV2.ts | 6 +++-- .../src/work-log/presentation.ts | 7 +++-- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts index 3e465f77da3d..feab33e1f81a 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts @@ -99,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", diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts index cc9f8e74b1d3..df2bade7fc06 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts @@ -1222,6 +1222,32 @@ describe("PiAdapterV2", () => { 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)), ); diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts index 23c2c2e15088..56f3d7b28796 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts @@ -989,8 +989,10 @@ export function makePiAdapterV2( 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. - const diffStr = recordString(recordField(resultRecord, "details"), "patch"); + // 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", 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; From 49a9ca0f87a4278552eb07bc37c33d1d9b4617fc Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 04:24:29 +0000 Subject: [PATCH 12/16] fix(cursor): failed edits and searches keep their error --- .../orchestration-v2/Adapters/CursorAdapterV2.test.ts | 2 +- .../src/orchestration-v2/Adapters/CursorAdapterV2.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 3a172baa67c8..810fcc09a2bc 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -648,7 +648,7 @@ describe("CursorAdapterV2", () => { { pattern: path.join(workspace, "missing"), status: "failed", - results: undefined, + results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], }, { pattern: "src/a.ts, src/b.ts", diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index bb9de20da64f..5467c36f3ac2 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,12 @@ export function makeCursorAdapterV2( case "ls": case "readLints": case "semSearch": { - const results = cursorToolSearchResults(toolCall, path); const pattern = cursorToolSearchPattern(toolCall); + // A failed search keeps its error as one row under the searched path. + const results = + toolCall.result?.status === "error" && outputText.trim().length > 0 + ? [{ fileName: pattern?.trim() || cursorToolName(toolCall), preview: outputText }] + : cursorToolSearchResults(toolCall, path); turnItem = { ...base, title: From 91fde5e4e2895ee2410ef94de048129ae0af3ff0 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:05:31 +0000 Subject: [PATCH 13/16] test(cursor): expect errors from failed lint calls --- .../src/orchestration-v2/Adapters/CursorAdapterV2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 810fcc09a2bc..5244cf188ca1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts @@ -667,7 +667,7 @@ describe("CursorAdapterV2", () => { { pattern: "src/a.ts", status: "failed", - results: undefined, + results: [{ fileName: "src/a.ts", preview: "lint failed" }], }, ], ); From c62d16cde9a769887215140a89ad358eebfb4240 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:13:28 +0000 Subject: [PATCH 14/16] fix(cursor): label search errors with the searched path --- .../Adapters/CursorAdapterV2.test.ts | 30 +++++++++++++++++++ .../Adapters/CursorAdapterV2.ts | 10 ++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts index 5244cf188ca1..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", @@ -650,6 +670,16 @@ describe("CursorAdapterV2", () => { status: "failed", 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", status: "completed", diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 5467c36f3ac2..dd77ac688370 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -1252,10 +1252,18 @@ export function makeCursorAdapterV2( case "readLints": case "semSearch": { 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: pattern?.trim() || cursorToolName(toolCall), preview: outputText }] + ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] : cursorToolSearchResults(toolCall, path); turnItem = { ...base, From 10af40592065800f04f90cd2adff7b27e0c038d1 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:07:27 +0000 Subject: [PATCH 15/16] fix(opencode): omit empty search results --- .../Adapters/OpenCodeAdapterV2.test.ts | 22 ++++++++++++++----- .../Adapters/OpenCodeToolItems.ts | 4 ++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.test.ts index 36e261f11932..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 }, @@ -887,6 +889,16 @@ describe("OpenCodeAdapterV2", () => { 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 feab33e1f81a..1b01b2b8007c 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeToolItems.ts @@ -124,7 +124,7 @@ export function openCodeToolTurnItem( base.title, type: "file_search", ...(pattern === undefined ? {} : { pattern }), - ...(output === undefined || searchRoot === undefined + ...(!output?.trim() || searchRoot === undefined ? {} : { results: [ @@ -140,7 +140,7 @@ export function openCodeToolTurnItem( ...base, type: "web_search", ...(pattern === undefined ? {} : { patterns: [pattern] }), - ...(output === undefined + ...(!output?.trim() ? {} : { results: [ From 33e58e59328a0f3a81db318930c1a5c65b4ab5f4 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Sun, 4 Oct 2026 06:31:39 +0000 Subject: [PATCH 16/16] fix(server): bound fetched command input --- .../src/orchestration-v2/WireProjection.test.ts | 16 +++++++++++++++- .../src/orchestration-v2/WireProjection.ts | 6 +++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index adc2501f7f9a..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); @@ -260,6 +264,16 @@ describe("orchestration V2 wire projection", () => { }, ); + 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, diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index 4658659decb5..7c17147d220a 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -149,7 +149,11 @@ function boundDynamicValue(value: unknown): unknown { export function projectTurnItemForDetail(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { switch (item.type) { case "command_execution": - return { ...item, output: truncateDetail(item.output, MAX_ON_DEMAND_BYTES) }; + return { + ...item, + input: truncateDetail(item.input, MAX_ON_DEMAND_BYTES) ?? "", + output: truncateDetail(item.output, MAX_ON_DEMAND_BYTES), + }; case "dynamic_tool": return { ...item,