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