From 9cf26ababcb4bb27f92c736d1415ff32ba3f1f19 Mon Sep 17 00:00:00 2001 From: colafornia Date: Tue, 8 Sep 2026 17:20:53 +0800 Subject: [PATCH] feat(desktop): improve tool output rendering Refine WebFetch citation cards, bounded text previews, shell tail-following, archived output access, and tool invocation labels across the Desktop transcript surface. Generated-by: OpenAI Codex --- .../runtime-host-artifacts-ipc-main.test.ts | 29 +++ .../runtime-host-client-operations.test.ts | 15 ++ .../main/runtime-host-artifacts-ipc-main.ts | 26 ++- apps/desktop/src/main/runtime-host-client.ts | 20 ++ apps/desktop/src/preload/bridge-contract.d.ts | 2 + apps/desktop/src/preload/preload.ts | 4 + .../composition/desktop-feature-services.tsx | 6 +- .../controller/use-workbar-controller.ts | 12 + .../src/renderer/features/workbar/index.ts | 2 + .../src/renderer/features/workbar/ports.ts | 2 + .../workbar/tools/artifacts/artifact-pane.tsx | 12 +- .../tools/artifacts/artifact-preview.tsx | 10 +- .../artifacts/tool-output-preview-context.tsx | 48 ++++ .../tools/artifacts/tool-output-preview.tsx | 106 +++++++++ .../desktop/create-workbar-services.ts | 1 + .../src/renderer/styles/chat-message.css | 33 +-- .../src/renderer/styles/workbar/shell.css | 7 + .../stories/session-workbar.stories.tsx | 29 ++- docs/astryx-surface-file-inventory.md | 6 +- docs/astryx-surface-file-inventory.paths | 4 + .../src/__tests__/pty-output-view.test.ts | 39 ++++ .../src/__tests__/tool-quiet-preview.test.ts | 8 + packages/core/src/artifacts.ts | 6 + packages/core/src/events.ts | 1 + packages/core/src/pty-output-view.ts | 13 +- packages/core/src/tool-quiet-preview.ts | 101 ++++++++ .../core/src/tool-result-record-schema.ts | 3 +- .../__tests__/artifact-coordinator.test.ts | 80 +++++++ .../src/__tests__/execution-artifacts.test.ts | 79 ++++++- .../src/__tests__/web-fetch-tool.test.ts | 2 +- .../runtime-host/src/protocol/artifact.ts | 58 ++++- packages/runtime-host/src/protocol/index.ts | 2 +- .../src/server/artifact-coordinator.ts | 116 +++++++++- .../src/server/execution-composition.ts | 1 + .../src/__tests__/web-fetch-tool.test.ts | 15 +- packages/runtime/src/shell-run-tool-result.ts | 7 +- packages/runtime/src/web-fetch-tool.ts | 19 +- .../tool-activity-presentation.test.ts | 126 +++++++++- .../tool-output-interaction.test.tsx | 132 +++++++++++ packages/ui/src/chat-view.tsx | 3 + packages/ui/src/index.ts | 4 + packages/ui/src/styles.css | 12 + packages/ui/src/tool-activity.tsx | 179 ++++++++------- packages/ui/src/tool-activity/copy.ts | 77 ++++++- .../ui/src/tool-activity/preview-utils.ts | 56 ++++- .../ui/src/tool-activity/result-projection.ts | 46 ++++ .../ui/src/tool-activity/tool-code-block.tsx | 2 + .../src/tool-activity/tool-result-context.tsx | 40 ++++ .../src/tool-activity/tool-result-preview.tsx | 215 +++++++++++------- .../src/tool-activity/tool-text-preview.tsx | 102 +++++++++ .../ui/src/transcript-scroll-authority.tsx | 6 +- packages/ui/stories/tool-activity.stories.tsx | 39 ++++ 52 files changed, 1711 insertions(+), 252 deletions(-) create mode 100644 apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview-context.tsx create mode 100644 apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview.tsx create mode 100644 packages/core/src/__tests__/pty-output-view.test.ts create mode 100644 packages/ui/src/__tests__/tool-output-interaction.test.tsx create mode 100644 packages/ui/src/tool-activity/tool-result-context.tsx create mode 100644 packages/ui/src/tool-activity/tool-text-preview.tsx diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts index 77f5186af0..501522da80 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts @@ -23,6 +23,7 @@ import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import { createHash } from "node:crypto"; import { registerRuntimeHostArtifactsIpc } from "../runtime-host-artifacts-ipc-main.js"; type Handler = (event: unknown, ...args: any[]) => unknown; @@ -32,6 +33,34 @@ type StreamArtifact = ( writeChunk: (chunk: Uint8Array) => Promise, ) => Promise; +test('routes archive reads to the Host and rejects inconsistent reference evidence', async () => { + const handlers = new Map(); + const text = JSON.stringify('中文 retained output '.repeat(4_000)); + const bytes = Buffer.from(text); + const identity = { artifactId: 'archive-1', originalBytes: bytes.length, + bodySha256: createHash('sha256').update(bytes).digest('hex') }; + registerRuntimeHostArtifactsIpc({ + uiLocale: () => 'zh-CN' as const, + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) }, + client: { + hostEpoch: 'host-1', + async readToolResult(sessionId: string, ref: string) { + assert.equal(sessionId, 'session-1'); + assert.equal(ref, `maka://archive/archive-1/${identity.bodySha256}/${bytes.length}`); + return { ok: true, text }; + }, + } as never, + mainWindowController: {} as never, + showItemInFolder() {}, + }); + const read = handlers.get('artifacts:readToolResult'); + assert.ok(read); + assert.deepEqual(await read({}, 'session-1', identity), { ok: true, text }); + assert.deepEqual(await read({}, 'session-1', { ...identity, + resourceRef: `maka://archive/archive-1/${'0'.repeat(64)}/${bytes.length}` }), + { ok: false, reason: 'not_allowed' }); +}); + // Exercise the public Save As result and destination bytes together. Faults // use real temporary files; only the failing filesystem operation is mocked. for (const [fault, reason] of [ diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 302d644243..451f2f0f69 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -913,6 +913,21 @@ interface RecordedRequest { input: unknown; } +test('assembles archived UTF-8 output across Host chunks and preserves read failures', async () => { + const bytes = Buffer.from('中文 retained output '.repeat(2000)); + const split = 32768; + const responses = [0, split].map(offset => ({ kind: 'archive_chunk', sessionId: 'session-1', + offset, totalBytes: bytes.length, chunkBase64: bytes.subarray(offset, offset ? undefined : split).toString('base64'), + nextOffset: offset ? null : split })); + const { client, requests } = clientWithResponses(responses); + assert.deepEqual(await client.readToolResult('session-1', 'archive-ref'), { ok: true, text: bytes.toString('utf8') }); + assert.deepEqual(requests.map(request => request.input), [0, split].map(offset => ({ + kind: 'read_archive_chunk', sessionId: 'session-1', ref: 'archive-ref', offset, + }))); + const failure = clientWithResponses([{ kind: 'archive_unavailable', sessionId: 'session-1', reason: 'not_found' }]); + assert.deepEqual(await failure.client.readToolResult('session-1', 'archive-ref'), { ok: false, reason: 'not_found' }); +}); + function clientWithResponses(responses: unknown[]): { client: DesktopRuntimeHostClient; requests: RecordedRequest[]; diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index 6b136f81ba..1c65ffd4ad 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -27,7 +27,10 @@ import { normalizeArtifactImagePreviewMime, resolveArtifactImagePreview, type ArtifactSaveResult, + type ArtifactTextReadResult, + type ToolResultArchiveIdentity, } from '@maka/core/artifacts'; +import { buildToolResultArchiveResourceRef, parseToolResultArchiveResourceRef } from '@maka/runtime/tool-result-archive-resource'; import { sanitizeArtifactName } from "@maka/storage/artifact-stores"; import { handleReconnectableRead, @@ -50,7 +53,7 @@ type RuntimeHostAttachmentPreviewIpcDeps = Pick< 'ipcMain' | 'client' >; -const ATTACHMENT_PREVIEW_LIMIT_EXCEEDED = Symbol("attachment-preview-limit-exceeded"); +const ARTIFACT_READ_LIMIT_EXCEEDED = Symbol("artifact-read-limit-exceeded"); export function registerRuntimeHostArtifactsIpc( deps: RuntimeHostArtifactsIpcDeps, @@ -76,6 +79,23 @@ export function registerRuntimeHostArtifactsIpc( (_event, sessionId: string, artifactId: string) => deps.client.readArtifactBinary(sessionId, artifactId), ); + handleReconnectableRead( + deps.ipcMain, + 'artifacts:readToolResult', + async (_event, sessionId: string, identity: ToolResultArchiveIdentity): Promise => { + if (!identity || (!identity.resourceRef && typeof identity.artifactId !== 'string') + || !Number.isSafeInteger(identity.originalBytes) || identity.originalBytes < 0 + || typeof identity.bodySha256 !== 'string' || !/^[a-f0-9]{64}$/.test(identity.bodySha256)) return { ok: false, reason: 'not_allowed' }; + const ref = identity.resourceRef ?? buildToolResultArchiveResourceRef({ + artifactId: identity.artifactId!, originalBytes: identity.originalBytes, bodySha256: identity.bodySha256, + }); + if (typeof ref !== 'string') return { ok: false, reason: 'not_allowed' }; + const parsed = parseToolResultArchiveResourceRef(ref); + if (!parsed || parsed.originalBytes !== identity.originalBytes || parsed.bodySha256 !== identity.bodySha256) + return { ok: false, reason: 'not_allowed' }; + return deps.client.readToolResult(sessionId, ref); + }, + ); deps.ipcMain.handle( "artifacts:delete", (_event, sessionId: string, artifactId: string) => @@ -163,12 +183,12 @@ export function registerRuntimeHostAttachmentPreviewIpc( await deps.client.streamArtifact(sessionId, artifactId, async (chunk) => { received += chunk.byteLength; if (received > ARTIFACT_IMAGE_PREVIEW_MAX_BYTES) { - throw ATTACHMENT_PREVIEW_LIMIT_EXCEEDED; + throw ARTIFACT_READ_LIMIT_EXCEEDED; } chunks.push(Buffer.from(chunk)); }); } catch (error) { - if (error === ATTACHMENT_PREVIEW_LIMIT_EXCEEDED) { + if (error === ARTIFACT_READ_LIMIT_EXCEEDED) { return { ok: false as const, reason: "too_large" }; } throw error; diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 966bc58334..cf8964a489 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -18,6 +18,8 @@ */ import { createHash, randomUUID } from "node:crypto"; +import type { ArtifactTextReadResult } from '@maka/core/artifacts'; +import { TOOL_RESULT_ARCHIVE_MAX_BYTES } from '@maka/runtime/tool-result-archive-resource'; import type { AttachmentRef, ShellRunUpdate } from "@maka/core/events"; import type { PlanSessionState, PlanUserControlInput } from "@maka/core/plan"; import { @@ -888,6 +890,24 @@ export class DesktopRuntimeHostClient { return result.preview; } + async readToolResult(sessionId: string, ref: string): Promise { + const chunks: Buffer[] = []; + let offset = 0; + let total: number | undefined; + while (true) { + const result = await this.request('artifact.query', { kind: 'read_archive_chunk', sessionId, ref, offset }); + if (result.kind === 'archive_unavailable' && result.sessionId === sessionId) return { ok: false, reason: result.reason }; + if (result.kind !== 'archive_chunk' || result.sessionId !== sessionId || result.offset !== offset + || (total !== undefined && result.totalBytes !== total) || result.totalBytes > TOOL_RESULT_ARCHIVE_MAX_BYTES) { + throw invalidProjection('Archived output'); + } + total = result.totalBytes; + chunks.push(Buffer.from(result.chunkBase64, 'base64')); + if (result.nextOffset === null) return { ok: true, text: Buffer.concat(chunks).toString('utf8') }; + offset = result.nextOffset; + } + } + async readArtifactBinary( sessionId: string, artifactId: string, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 68465dac92..0effcc11e9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -84,6 +84,7 @@ import type { ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, + ToolResultArchiveIdentity, } from '@maka/core/artifacts'; import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities'; import type { LocalMemoryState } from '@maka/core/local-memory'; @@ -1781,6 +1782,7 @@ export interface MakaBridge { }; artifacts: { list(sessionId: string): Promise; + readToolResult(sessionId: string, identity: ToolResultArchiveIdentity): Promise; readText(sessionId: string, artifactId: string): Promise; readBinary(sessionId: string, artifactId: string): Promise; delete(sessionId: string, artifactId: string): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 23164f1de7..d9fec73d4f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -165,6 +165,7 @@ import type { ArtifactDescriptor, ArtifactSaveResult, ArtifactTextReadResult, + ToolResultArchiveIdentity, } from '@maka/core/artifacts'; import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities'; import type { LocalMemoryState } from '@maka/core/local-memory'; @@ -3693,6 +3694,9 @@ const makaBridge = { list(sessionId: string): Promise { return invokeProjectedSessionRuntimeHost('artifacts:list', sessionId); }, + readToolResult(sessionId: string, identity: ToolResultArchiveIdentity): Promise { + return invokeSessionRuntimeHost('artifacts:readToolResult', sessionId, identity); + }, readText(sessionId: string, artifactId: string): Promise { return invokeSessionRuntimeHost('artifacts:readText', sessionId, artifactId); }, diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx index 70a402938b..64fed8bc9b 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -30,7 +30,7 @@ import { SessionNavigationServicesProvider } from '../features/session-navigatio import { WorkHubComposerServicesProvider } from '../features/workhub/index.js'; import { SessionSettingsServicesProvider } from '../features/session-settings'; import { TaskEntryServicesProvider } from '../features/task-entry'; -import { WorkbarServicesProvider } from '../features/workbar'; +import { WorkbarServicesProvider, ToolOutputPreviewProvider } from '../features/workbar'; import { createDesktopAppUpdateServices } from '../platform/desktop/create-app-update-services'; import { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services'; @@ -77,7 +77,9 @@ export function DesktopFeatureServicesProvider(props: { - {props.children} + + {props.children} + diff --git a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts index 1ec9769211..956c451c22 100644 --- a/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts +++ b/apps/desktop/src/renderer/features/workbar/controller/use-workbar-controller.ts @@ -36,6 +36,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from '../../../browser-stora import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js'; import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { sideChatTitleFromPrompt } from '../../../side-chat-command.js'; +import { useToolOutputPreview } from '../tools/artifacts/tool-output-preview-context.js'; import { useWorkbarServices } from '../services-context.js'; import type { WorkbarHostModel } from '../ui/workbar-host.js'; import { SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY } from '../ui/side-chat-close-confirmation.js'; @@ -166,6 +167,8 @@ export function useWorkbarController( input: UseWorkbarControllerInput, ): WorkbarController { const locale = useUiLocale(); + const toolOutput = useToolOutputPreview(); + const openedOutput = useRef(undefined); const terminalCopy = getDesktopConversationCopy(locale).terminalPanel; const { browser, sideChat, terminal } = useWorkbarServices(); const activeSessionId = input.activeSession?.id; @@ -649,6 +652,15 @@ export function useWorkbarController( return () => window.removeEventListener('keydown', handleShortcut, true); }, [activeSessionId, input.available, input.shellObscured, openTool]); + useEffect(() => { + if (!toolOutput?.preview?.visible) { openedOutput.current = undefined; return; } + if (openedOutput.current === toolOutput.preview) return; + openedOutput.current = toolOutput.preview; + openTool('files'); + }, [toolOutput?.preview, openTool]); + const closeToolOutput = toolOutput?.close; + useLayoutEffect(() => () => closeToolOutput?.(), [activeSessionId, closeToolOutput]); + const confirmPendingClose = useCallback( (skipFutureConfirmations: boolean) => { if (pendingSideChatClose.length === 0) return; diff --git a/apps/desktop/src/renderer/features/workbar/index.ts b/apps/desktop/src/renderer/features/workbar/index.ts index 2b2decd585..7620653a29 100644 --- a/apps/desktop/src/renderer/features/workbar/index.ts +++ b/apps/desktop/src/renderer/features/workbar/index.ts @@ -28,3 +28,5 @@ export { WorkbarServicesProvider } from './services-context'; export { useWorkbarController } from './controller/use-workbar-controller'; export type { SessionWorkbarTabKind } from './model/workbar-tabs'; export type { WorkbarServices } from './ports'; + +export { ToolOutputPreviewProvider } from './tools/artifacts/tool-output-preview-context.js'; diff --git a/apps/desktop/src/renderer/features/workbar/ports.ts b/apps/desktop/src/renderer/features/workbar/ports.ts index 4ee8b1cd65..53e66ecfc2 100644 --- a/apps/desktop/src/renderer/features/workbar/ports.ts +++ b/apps/desktop/src/renderer/features/workbar/ports.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { ToolResultArchiveIdentity } from '@maka/core/artifacts'; import type { QuoteRef, SessionEvent, @@ -125,6 +126,7 @@ export type WorkbarOpenArtifactResult = }; export interface WorkbarArtifactsService { + readToolResult?(sessionId: string, identity: ToolResultArchiveIdentity): Promise; list(sessionId: string): Promise; readText( sessionId: string, diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index 135bc78349..4fa2d65b90 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -79,6 +79,8 @@ import { nextArtifactListAction } from './artifact-list-keyboard'; import { filterUserVisibleArtifacts } from './artifact-visibility'; import { openPathFailureCopy } from '../../../../open-path'; import { getArtifactCopy, type ArtifactCopy } from '../../../../locales/artifact-copy'; +import { ToolOutputPreview } from './tool-output-preview.js'; +import { useToolOutputPreview } from './tool-output-preview-context.js'; import { useWorkbarServices } from '../../services-context.js'; export function ArtifactPane(props: { @@ -88,6 +90,7 @@ export function ArtifactPane(props: { onDismiss?: () => void; }) { const { sessionId } = props; + const toolOutput = useToolOutputPreview(); const { artifacts } = useWorkbarServices(); const toast = useToast(); const locale = useUiLocale(); @@ -446,7 +449,10 @@ export function ArtifactPane(props: { } } - return ( + return <> + {toolOutput?.preview?.visible && { setView({ kind: 'list' }); toolOutput.hide(); }} />} + {!toolOutput?.preview?.visible && (
{activeListError && (
) : null} - - ); + )} + ; } // ---- helpers --------------------------------------------------------------- diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx index 4cf547a0d9..c42daa2ca8 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx @@ -107,10 +107,10 @@ function FilePreview(props: { record: ArtifactDescriptor; copy: ArtifactCopy }) return ; } -function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy }) { +export function TextFilePreview(props: { name: string; text: string; copy: ArtifactCopy; complete?: boolean }) { const markdown = /\.(?:md|markdown)$/i.test(props.name); const [mode, setMode] = useState<'rendered' | 'source'>(markdown ? 'rendered' : 'source'); - const bounded = boundPreviewText(props.text); + const bounded = boundPreviewText(props.text, props.complete); return (
@@ -255,10 +255,10 @@ type BoundedPreviewText = { isDisplayTruncated: boolean; }; -function boundPreviewText(text: string): BoundedPreviewText { +function boundPreviewText(text: string, complete = false): BoundedPreviewText { const bytes = new TextEncoder().encode(text); const decoder = new TextDecoder(); - const displayText = decoder.decode(utf8Prefix(bytes, TEXT_DISPLAY_LIMIT_BYTES)); + const displayText = complete ? text : decoder.decode(utf8Prefix(bytes, TEXT_DISPLAY_LIMIT_BYTES)); const highlightCandidate = decoder.decode(utf8Prefix(bytes, TEXT_HIGHLIGHT_LIMIT_BYTES)); const lineBreak = highlightCandidate.lastIndexOf('\n'); let highlightedText = bytes.length > TEXT_HIGHLIGHT_LIMIT_BYTES && lineBreak > 0 @@ -274,7 +274,7 @@ function boundPreviewText(text: string): BoundedPreviewText { highlightedText, plainRemainder, hasPlainRemainder: plainRemainder.length > 0, - isDisplayTruncated: bytes.length > TEXT_DISPLAY_LIMIT_BYTES, + isDisplayTruncated: !complete && bytes.length > TEXT_DISPLAY_LIMIT_BYTES, }; } diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview-context.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview-context.tsx new file mode 100644 index 0000000000..28596bf718 --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview-context.tsx @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'; +import { ToolResultHostProvider, type ToolOutputOpenRequest } from '@maka/ui'; + +const ToolOutputPreviewContext = createContext<{ + preview?: { request: ToolOutputOpenRequest; id: number; visible: boolean }; + close(): void; + hide(): void; +} | undefined>(undefined); + +export const useToolOutputPreview = () => useContext(ToolOutputPreviewContext); + +/** Retained tool output is a transient selection in the existing Files viewer. */ +export function ToolOutputPreviewProvider(props: { children?: ReactNode }) { + const [preview, setPreview] = useState<{ request: ToolOutputOpenRequest; id: number; visible: boolean }>(); + const opener = useRef(null); + const close = useCallback(() => { setPreview(undefined); opener.current = null; }, []); + const hide = useCallback(() => { + setPreview(current => current ? { ...current, visible: false } : current); + if (opener.current?.isConnected) opener.current.focus(); + }, []); + const openOutput = useCallback((request: ToolOutputOpenRequest) => { + opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setPreview(current => ({ request, id: (current?.id ?? 0) + 1, visible: true })); + }, []); + const value = useMemo(() => ({ preview, close, hide }), [preview, close, hide]); + return + {props.children} + ; +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview.tsx new file mode 100644 index 0000000000..b75cc3355c --- /dev/null +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview.tsx @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ArtifactReadFailureReason } from '@maka/core/artifacts'; +import { useEffect, useRef, useState } from 'react'; +import { Button, getToolActivityCopy, useClipboardCopyFeedback, useUiLocale, redactSecrets, type ToolOutputOpenRequest } from '@maka/ui'; +import { ArrowLeft, ICON_SIZE } from '@maka/ui/icons'; +import { formatSavedToolJson } from '@maka/core/tool-quiet-preview'; +import { getArtifactCopy } from '../../../../locales/artifact-copy.js'; +import { TextFilePreview } from './artifact-preview.js'; +import { useWorkbarServices } from '../../services-context.js'; + +export function ToolOutputPreview(props: { request: ToolOutputOpenRequest; onClose(): void }) { + const locale = useUiLocale(); + const copy = getToolActivityCopy(locale); + const artifactCopy = getArtifactCopy(locale); + const [result, setResult] = useState<{ text: string; partial: boolean } | 'failed' | 'unavailable' | ArtifactReadFailureReason>(); + const [attempt, setAttempt] = useState(0); + const region = useRef(null); + const feedback = useClipboardCopyFeedback(); + const { artifacts } = useWorkbarServices(); + useEffect(() => { + let cancelled = false; + const source = props.request.source; + const load = source.kind === 'text' + ? Promise.resolve(source.text) + : (async () => { + const result = await artifacts.readToolResult?.( + source.sessionId, + source.identity, + ); + return result?.ok ? result.text : result; + })(); + load.then((raw) => { + if (cancelled) return; + if (raw === undefined) { setResult('unavailable'); return; } + if (typeof raw !== 'string') { setResult(raw.reason); return; } + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { /* Plain output is valid too. */ } + let partial = props.request.truncated === true; + // ArchiveRead wraps a retained page in protocol metadata. Only unwrap + // successful body responses; ordinary JSON and diagnostics stay intact. + if (props.request.toolName === 'ArchiveRead' && parsed && typeof parsed === 'object' && 'kind' in parsed && parsed.kind === 'tool_result_archive' + && 'ok' in parsed && parsed.ok === true && 'operation' in parsed + && (parsed.operation === 'read' || parsed.operation === 'query') + && 'content' in parsed && typeof parsed.content === 'string') { + partial ||= ('hasMore' in parsed && parsed.hasMore === true) + || ('offset' in parsed && typeof parsed.offset === 'number' && parsed.offset > 0) + || ('lineOffset' in parsed && typeof parsed.lineOffset === 'number' && parsed.lineOffset > 0); + parsed = parsed.content; + } + const text = parsed === undefined ? redactSecrets(raw) + : typeof parsed === 'string' ? redactSecrets(parsed) : formatSavedToolJson(parsed); + setResult({ text, partial }); + }).catch(() => { if (!cancelled) setResult('failed'); }); + return () => { cancelled = true; }; + }, [artifacts, props.request, attempt]); + useEffect(() => { + region.current?.focus(); + }, []); + const phase = feedback.phaseFor('output'); + return
{ + if (event.key === 'Escape') { event.stopPropagation(); props.onClose(); } + }}> +
+
+
+
+ {!result &&

{copy.detail.loading}

} + {result === 'unavailable' &&

{copy.detail.unavailable}

} + {result && typeof result === 'string' && result !== 'read_failed' && result in copy.detail.readFailure && +

{copy.detail.readFailure[result as ArtifactReadFailureReason]}

} + {(result === 'failed' || result === 'read_failed') &&

{copy.detail.loadFailed}

+
} + {result && typeof result === 'object' && <> + {result.partial &&

{getToolActivityCopy(locale).result.outputTruncated}

} + + } +
+
+
; +} diff --git a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts index 39a6e490c5..cd692a7327 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-workbar-services.ts @@ -68,6 +68,7 @@ export function createDesktopWorkbarServices( subscribeLive: (handler) => bridge.browser.onLive(handler), }, artifacts: { + readToolResult: (sessionId, identity) => bridge.artifacts.readToolResult(sessionId, identity), list: (sessionId) => bridge.artifacts.list(sessionId), readText: (sessionId, artifactId) => bridge.artifacts.readText(sessionId, artifactId), diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 1380bcac24..b704e5f194 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -210,34 +210,13 @@ /* Cursor for tool/reasoning disclosure triggers is owned by styles/native-cursor.css (role=button + default !important). */ -.maka-turn .astryx-chat-tool-calls [role="button"] > span:nth-child(2) { - font: var(--maka-text-label); -} - -/* Reasoning and tool-call rows read at BODY size, not supporting size. - Astryx puts the label and preview on inner spans styled as `supporting` - (12px on this scale). Measured on main they rendered at 9.75px — Astryx's - sm tier at 0.75rem against the old 13px root — which made them the least - legible text in the product, and they carry real content — what the agent is - thinking and which tool is running — not metadata. - Cursor makes the same call: its tool rows sit at body size and lean - entirely on --text-secondary/tertiary for de-emphasis. Weight and colour - already do that here, so size does not need to. - - Rebinding the role token rather than restyling the spans. Astryx's - supporting atoms are `font-size: var(--text-supporting-size)` and - `line-height: var(--text-supporting-leading)`, so redefining those two on - the trigger reaches every span that opts into the role, at any depth, - through inheritance — no !important, since nothing else declares them - here, and no dependency on child order. - The earlier form of this rule selected `> span:not(:last-child)`, which - read as "every span except the chevron". It was wrong for exactly the - reason positional selectors are: ChatReasoning wraps its label, duration - and preview in a `
` (packages/ui/src/astryx-chat-reasoning.tsx), so - the rule enlarged the leading icon wrapper and left the reasoning text at - supporting size — the one row the retune was for. */ +/* Tool typography belongs to the activity container, independent of whether + a row has expandable details. Rebind Astryx's supporting tokens once so + group headers, interactive rows and static rows inherit the same body size. + Keep Astryx's font families and weights; detail CodeBlocks retain their + own code-role token override in packages/ui/src/styles.css. */ .maka-turn .astryx-chat-reasoning [role="button"], -.maka-turn .astryx-chat-tool-calls [role="button"] { +.maka-turn .maka-tool-activity-card { --text-supporting-size: var(--text-body-size); --text-supporting-leading: var(--maka-line-body); } diff --git a/apps/desktop/src/renderer/styles/workbar/shell.css b/apps/desktop/src/renderer/styles/workbar/shell.css index 36fc0cd910..4460b4a51a 100644 --- a/apps/desktop/src/renderer/styles/workbar/shell.css +++ b/apps/desktop/src/renderer/styles/workbar/shell.css @@ -272,6 +272,13 @@ overflow: hidden; } +/* Section's inner container must shrink with the panel so its viewer scrolls. */ +.maka-session-workbar-panel > .astryx-section { + min-height: 0; + height: 100%; + overflow: hidden; +} + .maka-workbar-panel-loading { display: grid; place-items: center; diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 645e408027..7e6faddc07 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useState, type CSSProperties } from 'react'; +import { useState, type CSSProperties, type ReactNode } from 'react'; import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ArtifactRecord } from '@maka/core/artifacts'; @@ -26,8 +26,8 @@ import type { GitReviewReadResult, GitReviewSnapshot } from '@maka/core/git-revi import type { SessionSummary } from '@maka/core/session'; import type { SessionTrace } from '@maka/core/session-trace'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; -import { ToastProvider } from '@maka/ui'; -import { WorkbarServicesProvider, WorkbarTitlebarActions } from '../src/renderer/features/workbar'; +import { ToastProvider, ToolCallDetail } from '@maka/ui'; +import { WorkbarServicesProvider, WorkbarTitlebarActions, ToolOutputPreviewProvider } from '../src/renderer/features/workbar'; import { WorkbarSurface } from '../src/renderer/features/workbar/stories'; import { createFakeWorkbarServices, @@ -920,6 +920,7 @@ function bridge(options: { * column. Its 990px media query is what stacks the column in narrow windows. */ function Workbar(props: { + conversation?: ReactNode; tab?: SessionWorkbarTabKind; /** Extra faces opened after `tab`, so the strip can be seen with several. */ alsoOpen?: readonly Exclude[]; @@ -991,6 +992,7 @@ function Workbar(props: { } as CSSProperties} >
+ {props.conversation} {props.collapsible && ( , }; + + +// Real path: a retained-output action in chat opens the existing Files preview. +export const RetainedToolOutput: Story = { + decorators: [(Story) => , bridge()], + render: () => + +
} />, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: /打开完整输出|開啟完整輸出|Open full output/ })); + await waitFor(() => expect(canvasElement.querySelector('.maka-artifact-preview-plain-remainder')?.textContent).toContain('DOCUMENT_END')); + expect(canvasElement.querySelector('.mainColumn')?.textContent).not.toContain('DOCUMENT_END'); + const viewer = canvasElement.querySelector('.maka-artifact-preview')!; + expect(viewer.scrollHeight).toBeGreaterThan(viewer.clientHeight); + }, +}; diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 8e8865ad5f..dbd9ca8a06 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 260 files — blocker 0, reimplementation 0, polish 1, aligned 259. +**Totals:** 264 files — blocker 0, reimplementation 0, polish 1, aligned 263. ## Exclusions (explicit) @@ -88,6 +88,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, MoreMenu | aligned — uses Astryx (Banner, Button, EmptyState, MoreMenu) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx` | shell-chrome-or-panel | Banner, Button, Spinner | aligned — uses Astryx (Banner, Button, Spinner) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx` | shell-chrome-or-panel | Banner, Button, CodeBlock, Spinner | aligned — uses Astryx (Banner, Button, CodeBlock, Spinner) | aligned | +| `apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | +| `apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview.tsx` | shell-chrome-or-panel | Button | aligned — uses Astryx (Button) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx` | shell-chrome-or-panel | EmptyState, IconButton, TextInput, Toolbar, Tooltip | aligned — uses Astryx (EmptyState, IconButton, TextInput, Toolbar, Tooltip) | aligned | | `apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx` | shell-chrome-or-panel | Banner, Button, EmptyState, Heading, Section, Text, VStack | aligned — uses Astryx (Banner, Button, EmptyState, Heading, Section, Text, VStack) | aligned | @@ -282,7 +284,9 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/tool-activity.tsx` | ui-composition | Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden | aligned — uses Astryx (Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden) | aligned | | `packages/ui/src/tool-activity/diff-code-preview.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/tool-activity/tool-code-block.tsx` | ui-composition | CodeBlock | aligned — uses Astryx (CodeBlock) | aligned | +| `packages/ui/src/tool-activity/tool-result-context.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/tool-activity/tool-result-preview.tsx` | ui-composition | Button, Link | aligned — uses Astryx (Button, Link) | aligned | +| `packages/ui/src/tool-activity/tool-text-preview.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | | `packages/ui/src/transcript-scroll-authority.tsx` | ui-composition | ChatLayoutScrollButton | aligned — uses Astryx (ChatLayoutScrollButton) | aligned | | `packages/ui/src/ui.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/user-question-prompt.tsx` | ui-composition | Button, RadioList, RadioListItem, TextInput | aligned — uses Astryx (Button, RadioList, RadioListItem, TextInput) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 8855985d24..28bdcaafc1 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -59,6 +59,8 @@ apps/desktop/src/renderer/features/workbar/services-context.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview.tsx +apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview-context.tsx +apps/desktop/src/renderer/features/workbar/tools/artifacts/tool-output-preview.tsx apps/desktop/src/renderer/features/workbar/tools/browser/browser-panel.tsx apps/desktop/src/renderer/features/workbar/tools/inspector/live-context-usage-probe.tsx apps/desktop/src/renderer/features/workbar/tools/inspector/session-inspector-panel.tsx @@ -253,7 +255,9 @@ packages/ui/src/toast.tsx packages/ui/src/tool-activity.tsx packages/ui/src/tool-activity/diff-code-preview.tsx packages/ui/src/tool-activity/tool-code-block.tsx +packages/ui/src/tool-activity/tool-result-context.tsx packages/ui/src/tool-activity/tool-result-preview.tsx +packages/ui/src/tool-activity/tool-text-preview.tsx packages/ui/src/transcript-scroll-authority.tsx packages/ui/src/ui.tsx packages/ui/src/user-question-prompt.tsx diff --git a/packages/core/src/__tests__/pty-output-view.test.ts b/packages/core/src/__tests__/pty-output-view.test.ts new file mode 100644 index 0000000000..51cc03d5d6 --- /dev/null +++ b/packages/core/src/__tests__/pty-output-view.test.ts @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { it } from 'node:test'; +import type { PtyShellOutput } from '../shell-run.js'; +import { PTY_TRUNCATED_MARKER, ptyHumanTerminalText } from '../pty-output-view.js'; + +it('keeps structured PTY truncation metadata out of the human-readable text', () => { + const output: PtyShellOutput = { + mode: 'pty', + screen: 'ALL_TESTS_PASSED', + scrollback: `${PTY_TRUNCATED_MARKER}\nretained output`, + cols: 80, + rows: 24, + cursor: { x: 0, y: 0, visible: true }, + alternateScreen: false, + truncated: true, + redacted: false, + }; + + assert.equal(ptyHumanTerminalText(output), 'retained output\nALL_TESTS_PASSED'); +}); diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index 1404b67ad2..3f8affa0ee 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -21,6 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { formatAsKeyValueLines, + formatBoundedQuietJsonValue, formatQuietJsonValue, formatToolInvocationLine, projectToolArgsPreview, @@ -28,6 +29,13 @@ import { import { projectToolActivityArgs } from '../tool-activity-args.js'; describe('tool quiet preview', () => { + it('reports omitted object fields when the preview budget is exactly exhausted', () => { + const retained = Object.fromEntries(['a', 'b', 'c', 'd'].map((key) => [key, 'x'.repeat(1999)])); + assert.equal(formatBoundedQuietJsonValue(retained, 'en').truncated, false); + const preview = formatBoundedQuietJsonValue({ ...retained, z: 'LAST_FIELD' }, 'en'); + assert.doesNotMatch(preview.body, /LAST_FIELD/); + assert.equal(preview.truncated, true); + }); it('redacts secrets in values and embedded keys', () => { const value = formatQuietJsonValue({ password: 'correct-horse', ok: true }, 'en').body; assert.doesNotMatch(value, /correct-horse/); diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 43921173bc..e9ff3ee8c8 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -189,6 +189,12 @@ export type ArtifactTextReadResult = | { ok: true; text: string } | { ok: false; reason: ArtifactReadFailureReason }; +/** Identity retained by an archived tool result; verified before exposing its saved bytes. */ +export type ToolResultArchiveIdentity = { + originalBytes: number; + bodySha256: string; +} & ({ resourceRef: string; artifactId?: never } | { artifactId: string; resourceRef?: never }); + export type ArtifactBinaryReadResult = | { ok: true; base64: string; mimeType: string } | { ok: false; reason: ArtifactBinaryReadFailureReason }; diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 889069e08a..2cbc76f119 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -822,6 +822,7 @@ export type ToolResultContent = | { kind: 'text'; text: string; + truncated?: boolean; sandboxDenial?: SandboxDenialSignal; sandboxFailure?: SandboxBoundaryFailureSignal; uncertainOutcome?: ToolUncertainOutcomeSignal; diff --git a/packages/core/src/pty-output-view.ts b/packages/core/src/pty-output-view.ts index 22bddb6759..6fe37698f8 100644 --- a/packages/core/src/pty-output-view.ts +++ b/packages/core/src/pty-output-view.ts @@ -19,11 +19,22 @@ import type { PtyShellOutput } from './shell-run.js'; +export const PTY_TRUNCATED_MARKER = '[terminal snapshot truncated to fit the output limit]'; + export function ptyHumanTerminalText(output: PtyShellOutput): string { const current = output.alternateScreen ? output.screen : joinNonEmpty(output.scrollback, output.screen); - return current.trim().length > 0 ? current : (output.lastAlternateScreen ?? ''); + const text = current.trim().length > 0 ? current : (output.lastAlternateScreen ?? ''); + return output.truncated ? stripTruncatedMarker(text) : text; +} + +function stripTruncatedMarker(text: string): string { + return text === PTY_TRUNCATED_MARKER + ? '' + : text.startsWith(`${PTY_TRUNCATED_MARKER}\n`) + ? text.slice(PTY_TRUNCATED_MARKER.length + 1) + : text; } export function ptyTuiTerminalRows(output: PtyShellOutput, maxRows = 6): string[] { diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index 760d8990d4..a95d69c60b 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -520,6 +520,107 @@ export interface QuietPreview { body: string; } +/** Explicit saved-output view: preserve JSON structure and ordinary user keys. */ +export function formatSavedToolJson(value: unknown): string { + return ( + JSON.stringify( + value, + (_key, raw: unknown) => { + if (typeof raw === 'string') return redactSecrets(raw); + const record = asRecord(raw); + if (!record) return raw; + return Object.fromEntries( + Object.entries(record).map(([key, entry]) => [ + maskSensitiveKeyPayload(key), + maskSensitiveValue(key, entry), + ]), + ); + }, + 2, + ) ?? '' + ); +} + +const PREVIEW_DIAGNOSTIC_KEYS = [...REMAINDER_PRIORITY, 'warning', 'warnings', 'partial'] as const; + +/** Bound the value before quiet formatting; retain diagnostic fields before bulk lists. */ +export function formatBoundedQuietJsonValue( + value: unknown, + locale: UiLocale, +): QuietPreview & { truncated: boolean } { + let remaining = 8_000; + let nodes = 100; + let truncated = false; + function visit(raw: unknown, depth: number): unknown { + if (--nodes < 0 || remaining <= 0) { + truncated = true; + return '…'; + } + if (typeof raw === 'string') { + const safe = redactSecrets(raw); + const limit = Math.min(remaining, 2_000); + remaining -= Math.min(safe.length, limit); + if (safe.length <= limit) return safe; + truncated = true; + return `${safe.slice(0, limit).replace(/[\uD800-\uDBFF]$/, '')}…`; + } + if (!raw || typeof raw !== 'object') return raw; + if (depth > 3) { + truncated = true; + return '…'; + } + if (Array.isArray(raw)) { + const result: unknown[] = []; + for (const item of raw) { + if (result.length >= 20 || nodes <= 0 || remaining <= 0) break; + result.push(visit(item, depth + 1)); + } + truncated ||= result.length < raw.length; + return result; + } + const record = raw as Record; + const result: Record = Object.create(null); + const picked = new Set(); + const add = (key: string) => { + if (picked.has(key) || !Object.hasOwn(record, key)) return; + picked.add(key); + if (picked.size > 20 || remaining <= 0 || nodes <= 0) { + truncated = true; + return; + } + const safeKey = String(visit(safeKeyLabel(key), depth + 1)); + result[safeKey] = visit(maskSensitiveValue(key, record[key]), depth + 1); + }; + for (const key of [...PREVIEW_DIAGNOSTIC_KEYS, ...HEADLINE_KEYS, ...LIST_KEYS, ...BODY_KEYS]) + add(key); + for (const key in record) { + add(key); + if (picked.size > 20 || remaining <= 0 || nodes <= 0) break; + } + truncated ||= picked.size < Object.keys(record).length; + return result; + } + const bounded = visit(value, 0); + const record = asRecord(bounded); + const diagnostics: Record = {}; + if (record) { + for (const key of PREVIEW_DIAGNOSTIC_KEYS) { + if (!Object.hasOwn(record, key)) continue; + diagnostics[key] = record[key]; + delete record[key]; + } + } + const preview = formatQuietJsonValue(bounded, locale); + const diagnosticText = formatAsKeyValueLines(diagnostics, 0, locale); + if (diagnosticText) { + preview.body = + record && Object.keys(record).length === 0 + ? diagnosticText + : `${diagnosticText}\n${preview.body}`; + } + return { ...preview, truncated }; +} + /** * Format any tool JSON/result payload for the quiet panel. * Always returns a body — never `undefined` for object values so callers diff --git a/packages/core/src/tool-result-record-schema.ts b/packages/core/src/tool-result-record-schema.ts index 4f75d318f0..20fa7a1b34 100644 --- a/packages/core/src/tool-result-record-schema.ts +++ b/packages/core/src/tool-result-record-schema.ts @@ -41,7 +41,7 @@ type RiveResult = Result<'rive_workflow'>; const TEXT_SHAPE = defineObjectShape>()( ['kind', 'text'], - ['sandboxDenial', 'sandboxFailure', 'uncertainOutcome'], + ['sandboxDenial', 'sandboxFailure', 'uncertainOutcome', 'truncated'], ); const SANDBOX_FAILURE_SHAPE = defineObjectShape['sandboxFailure']>>()( ['reason'], @@ -203,6 +203,7 @@ function isNonShellToolResultContent(value: unknown): value is ToolResultContent return ( hasExactShape(value, TEXT_SHAPE) && typeof value.text === 'string' && + (value.truncated === undefined || typeof value.truncated === 'boolean') && (value.sandboxDenial === undefined || isSandboxDenialSignal(value.sandboxDenial)) && (value.sandboxFailure === undefined || (isRecord(value.sandboxFailure) && diff --git a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts index 8e0dd1e7a5..7798ffa40a 100644 --- a/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/artifact-coordinator.test.ts @@ -29,6 +29,7 @@ import { HostArtifactCoordinator } from '../server/artifact-coordinator.js'; import type { ConnectionContext } from '../server/operation-dispatcher.js'; import { SessionAdmissionGate } from '../server/session-admission-gate.js'; import { ARTIFACT_READ_CHUNK_MAX_BYTES } from '../protocol/index.js'; +import { buildToolResultArchiveResourceRef } from '@maka/runtime/tool-result-archive-resource'; const connectionContext: ConnectionContext = { hostEpoch: 'host-epoch-1', @@ -37,6 +38,85 @@ const connectionContext: ConnectionContext = { acquireResidency: () => ({ release: () => undefined }), }; +test('an archive transfer validates the full body once and releases it after the final chunk', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-archive-transfer-')); + const owner = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(owner); + const store = await openInteractiveArtifactStoreForWrite(owner.lease); + try { + const serializedResult = 'x'.repeat(4 * 1024 * 1024); + const ref = buildToolResultArchiveResourceRef({ + artifactId: 'archive-1', + bodySha256: createHash('sha256').update(serializedResult).digest('hex'), + originalBytes: serializedResult.length, + }); + let reads = 0; + let now = 0; + let present = true; + const coordinator = new HostArtifactCoordinator( + store, + () => assert.fail('read must not drain'), + new SessionAdmissionGate(), + { probeSessionRemoval: async () => (present ? { kind: 'present' } : { kind: 'removed' }) }, + () => now, + undefined, + async () => { + reads++; + return { ok: true, serializedResult }; + }, + ); + const chunks: Buffer[] = []; + for ( + let offset = 0; + offset < serializedResult.length; + offset += ARTIFACT_READ_CHUNK_MAX_BYTES + ) { + const result = await coordinator.handlers['artifact.query']( + { kind: 'read_archive_chunk', sessionId: 'session-1', ref, offset }, + connectionContext, + ); + assert.ok(result.ok && result.result.kind === 'archive_chunk'); + chunks.push(Buffer.from(result.result.chunkBase64, 'base64')); + } + assert.equal(Buffer.concat(chunks).toString(), serializedResult); + assert.equal(reads, 1); + await coordinator.handlers['artifact.query']( + { kind: 'read_archive_chunk', sessionId: 'session-1', ref, offset: 0 }, + connectionContext, + ); + assert.equal(reads, 2); + const continueRead = (context = connectionContext, sessionId = 'session-1') => + coordinator.handlers['artifact.query']( + { kind: 'read_archive_chunk', sessionId, ref, offset: ARTIFACT_READ_CHUNK_MAX_BYTES }, + context, + ); + await continueRead({ ...connectionContext, connectionId: 'other-connection' }); + assert.equal(reads, 3, 'connections cannot reuse each other’s validated bodies'); + await continueRead(connectionContext, 'other-session'); + assert.equal(reads, 4, 'Sessions cannot reuse each other’s validated bodies'); + coordinator.releaseConnection(connectionContext.connectionId); + await continueRead(); + assert.equal(reads, 5, 'disconnect releases the transfer'); + now = 60_000; + await continueRead(); + assert.equal(reads, 6, 'expired transfers are revalidated'); + present = false; + const removed = await continueRead(); + assert.ok(!removed.ok && removed.error.code === 'not_found'); + assert.equal(reads, 6); + present = true; + await continueRead(); + assert.equal(reads, 7, 'a removed Session cannot leave a reusable snapshot'); + } finally { + store.close(); + await owner.close(); + await rm(root, { recursive: true, force: true }); + await rm(owner.controlDirectory, { recursive: true, force: true }); + } +}); + test('Artifact ingest is connection-bound, replay-safe, and commits one durable AttachmentRef', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-artifact-ingest-')); const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); diff --git a/packages/runtime-host/src/__tests__/execution-artifacts.test.ts b/packages/runtime-host/src/__tests__/execution-artifacts.test.ts index f939e91ec4..1242912919 100644 --- a/packages/runtime-host/src/__tests__/execution-artifacts.test.ts +++ b/packages/runtime-host/src/__tests__/execution-artifacts.test.ts @@ -27,7 +27,12 @@ import { } from '@maka/core/model-projection-transition'; import { buildLedgerArchivedToolResultPlaceholder } from '@maka/runtime/tool-result-archive'; import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; -import { parseToolResultArchiveResourceRef } from '@maka/runtime/tool-result-archive-resource'; +import { + buildToolResultArchiveResourceRef, + parseToolResultArchiveResourceRef, +} from '@maka/runtime/tool-result-archive-resource'; +import { HostArtifactCoordinator } from '../server/artifact-coordinator.js'; +import { decodeArtifactQueryInput, decodeArtifactQueryResult } from '../protocol/artifact.js'; import { mkdir, mkdtemp, rm, stat, truncate, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -225,6 +230,78 @@ for (const largeImage of [false, true]) { ); const identity = parseToolResultArchiveResourceRef(placeholder.resourceRef!); assert.ok(identity); + const coordinator = new HostArtifactCoordinator( + artifacts, + () => assert.fail('read must not drain'), + new SessionAdmissionGate(), + { probeSessionRemoval: async () => ({ kind: 'present' }) }, + Date.now, + undefined, + services.toolResultArchive.services.readArchivedToolResultResource, + ); + const context = { + hostEpoch: 'epoch', + connectionId: 'desktop', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }; + for (const ref of [ + placeholder.resourceRef!, + buildToolResultArchiveResourceRef({ + artifactId: old.id, + bodySha256, + originalBytes: input.originalBytes, + }), + ]) { + const response = await coordinator.handlers['artifact.query']( + decodeArtifactQueryInput({ + kind: 'read_archive_chunk', + sessionId: 'session', + ref, + offset: 0, + }), + context, + ); + assert.ok(response.ok); + assert.deepEqual(decodeArtifactQueryResult(response.result), { + kind: 'archive_chunk', + sessionId: 'session', + offset: 0, + totalBytes: input.originalBytes, + chunkBase64: Buffer.from(serializedResult).toString('base64'), + nextOffset: null, + }); + const denied = await coordinator.handlers['artifact.query']( + decodeArtifactQueryInput({ + kind: 'read_archive_chunk', + sessionId: 'other', + ref, + offset: 0, + }), + context, + ); + assert.ok(denied.ok); + assert.equal(denied.result.kind, 'archive_unavailable'); + const corrupted = await coordinator.handlers['artifact.query']( + decodeArtifactQueryInput({ + kind: 'read_archive_chunk', + sessionId: 'session', + ref: ref.replace(bodySha256, '0'.repeat(64)), + offset: 0, + }), + context, + ); + assert.deepEqual(corrupted, { + ok: true, + result: { + kind: 'archive_unavailable', + sessionId: 'session', + // Ledger evidence rejects a mismatched identity before reading bytes; + // legacy artifacts detect the mismatch from the body digest. + reason: ref === placeholder.resourceRef ? 'not_allowed' : 'read_failed', + }, + }); + } assert.deepEqual( await services.toolResultArchive.services.readArchivedToolResultResource({ ...identity, diff --git a/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts b/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts index c0fe8521da..f8471ca0ed 100644 --- a/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts +++ b/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts @@ -68,7 +68,7 @@ test('Host WebFetch uses the resolved proxy snapshot and closes its transport', const result = await tool.impl({ url: 'https://example.com/page' }, context()); - assert.equal(result, 'fetched body'); + assert.deepEqual(result, { kind: 'text', text: 'fetched body' }); assert.deepEqual(proxy, { enabled: true, type: 'http', diff --git a/packages/runtime-host/src/protocol/artifact.ts b/packages/runtime-host/src/protocol/artifact.ts index 0c78f530ee..20a02a4a03 100644 --- a/packages/runtime-host/src/protocol/artifact.ts +++ b/packages/runtime-host/src/protocol/artifact.ts @@ -93,6 +93,12 @@ export type ArtifactQueryInput = | { readonly kind: 'get'; readonly sessionId: string; readonly artifactId: string } | { readonly kind: 'read_text'; readonly sessionId: string; readonly artifactId: string } | { readonly kind: 'read_binary'; readonly sessionId: string; readonly artifactId: string } + | { + readonly kind: 'read_archive_chunk'; + readonly sessionId: string; + readonly ref: string; + readonly offset: number; + } | { readonly kind: 'read_chunk'; readonly sessionId: string; @@ -108,6 +114,19 @@ export type ArtifactBinaryPreview = | { readonly ok: false; readonly reason: ArtifactBinaryReadFailureReason }; export type ArtifactQueryResult = + | { + readonly kind: 'archive_unavailable'; + readonly sessionId: string; + readonly reason: ArtifactReadFailureReason; + } + | { + readonly kind: 'archive_chunk'; + readonly sessionId: string; + readonly offset: number; + readonly totalBytes: number; + readonly chunkBase64: string; + readonly nextOffset: number | null; + } | { readonly kind: 'page'; readonly sessionId: string; @@ -361,6 +380,20 @@ export function decodeArtifactIngestResult(value: unknown): ArtifactIngestResult export function decodeArtifactQueryInput(value: unknown): ArtifactQueryInput { const input = requireRecord(value, 'artifact query input'); + if (input.kind === 'read_archive_chunk') { + const exact = requireExactRecord(input, 'archive chunk query', [ + 'kind', + 'sessionId', + 'ref', + 'offset', + ]); + return { + kind: 'read_archive_chunk', + sessionId: artifactEntityId(exact.sessionId, 'sessionId'), + ref: boundedText(exact.ref, 'archive ref', 16384), + offset: requireCount(exact.offset, 'archive offset'), + }; + } if (input.kind === 'list_start') { const exact = requireExactRecord(input, 'artifact list start input', ['kind', 'sessionId']); return { kind: 'list_start', sessionId: artifactEntityId(exact.sessionId, 'sessionId') }; @@ -490,11 +523,24 @@ export function decodeArtifactQueryResult(value: unknown): ArtifactQueryResult { artifactId: artifactEntityId(exact.artifactId, 'artifactId'), preview: decodeBinaryPreview(exact.preview), }; - } else if (result.kind === 'chunk') { + } else if (result.kind === 'archive_unavailable') { + const exact = requireExactRecord(result, 'archive unavailable', [ + 'kind', + 'sessionId', + 'reason', + ]); + const preview = decodeTextPreview({ ok: false, reason: exact.reason }); + if (preview.ok) throw invalidProtocolFrame('Invalid archive failure'); + decoded = { + kind: 'archive_unavailable', + sessionId: artifactEntityId(exact.sessionId, 'sessionId'), + reason: preview.reason, + }; + } else if (result.kind === 'chunk' || result.kind === 'archive_chunk') { const exact = requireExactRecord(result, 'artifact chunk result', [ 'kind', 'sessionId', - 'artifactId', + ...(result.kind === 'chunk' ? ['artifactId'] : []), 'offset', 'totalBytes', 'chunkBase64', @@ -526,15 +572,17 @@ export function decodeArtifactQueryResult(value: unknown): ArtifactQueryResult { ) { throw invalidProtocolFrame('Invalid artifact chunk continuation'); } - decoded = { - kind: 'chunk', + const chunk = { sessionId: artifactEntityId(exact.sessionId, 'sessionId'), - artifactId: artifactEntityId(exact.artifactId, 'artifactId'), offset, totalBytes, chunkBase64, nextOffset, }; + decoded = + result.kind === 'chunk' + ? { ...chunk, kind: 'chunk', artifactId: artifactEntityId(exact.artifactId, 'artifactId') } + : { ...chunk, kind: 'archive_chunk' }; } else { throw invalidProtocolFrame('Invalid artifact query result kind'); } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 3078a049b2..cc9c1ecb05 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -101,7 +101,7 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 133 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 134 as const; // 133: WorkHub actions carry attachments and new-Work model/permission defaults. // Epoch-132 peers reject these additional fields on strict action shapes. // 132: new Tool Result archives use versioned ledger references, not Artifact payloads. diff --git a/packages/runtime-host/src/server/artifact-coordinator.ts b/packages/runtime-host/src/server/artifact-coordinator.ts index 9b507ba550..e86fe0e98d 100644 --- a/packages/runtime-host/src/server/artifact-coordinator.ts +++ b/packages/runtime-host/src/server/artifact-coordinator.ts @@ -18,9 +18,18 @@ */ import { createHash } from 'node:crypto'; +import { + parseToolResultArchiveResourceRef, + TOOL_RESULT_ARCHIVE_MAX_BYTES, + type ToolResultArchiveResourceReader, +} from '@maka/runtime/tool-result-archive-resource'; import { attachmentKindFromMimeType } from '@maka/core/attachments'; import type { AttachmentRef } from '@maka/core/events'; -import { isArtifactSharedSessionReadable, type ArtifactRecord } from '@maka/core/artifacts'; +import { + isArtifactSharedSessionReadable, + type ArtifactRecord, + type ArtifactReadFailureReason, +} from '@maka/core/artifacts'; import { authenticateInteractiveArtifactStoreWriter, sanitizeArtifactName, @@ -47,6 +56,21 @@ import type { ArtifactOperationHandlerMap, ConnectionContext } from './operation import { SessionAdmissionGate } from './session-admission-gate.js'; import type { SessionPresenceReader } from './session-presence.js'; import { ConnectionBoundChunkUploads } from './connection-bound-chunk-uploads.js'; +import type { ToolResultArchiveReadFailureReason } from '@maka/runtime/tool-result-archive'; + +// At most 16 MiB of verified bodies, scoped to active transfers rather than a +// persistent ref cache. Eviction only costs a fresh validated read. +const MAX_ARCHIVE_TRANSFERS = 4; +const ARCHIVE_TRANSFER_TTL_MS = 60_000; +interface ArchiveTransfer { + readonly connectionId: string; + readonly sessionId: string; + readonly ref: string; + readonly expiresAt: number; + readonly body: Promise< + { ok: true; bytes: Buffer } | { ok: false; reason: ToolResultArchiveReadFailureReason } + >; +} const MAX_ACTIVE_ARTIFACT_UPLOADS = 16; const MAX_STAGED_ARTIFACT_UPLOAD_BYTES = 128 * 1024 * 1024; @@ -77,6 +101,11 @@ export class HostArtifactCoordinator { | Pick | undefined; readonly #uploads: ConnectionBoundChunkUploads; + readonly #archiveTransfers = new Map(); + readonly #now: () => number; + readonly #readArchive: + | ToolResultArchiveResourceReader['readArchivedToolResultResource'] + | undefined; constructor( store: InteractiveArtifactStoreWriter, @@ -85,12 +114,15 @@ export class HostArtifactCoordinator { sessions: SessionPresenceReader, now: () => number = Date.now, sessionAccessAuthority?: Pick, + readArchive?: ToolResultArchiveResourceReader['readArchivedToolResultResource'], ) { this.#store = authenticateInteractiveArtifactStoreWriter(store); this.#requestDrain = requestDrain; this.#sessionAdmission = sessionAdmission; this.#sessions = sessions; this.#sessionAccessAuthority = sessionAccessAuthority; + this.#readArchive = readArchive; + this.#now = now; this.#uploads = new ConnectionBoundChunkUploads( { maxActive: MAX_ACTIVE_ARTIFACT_UPLOADS, @@ -103,6 +135,9 @@ export class HostArtifactCoordinator { releaseConnection(connectionId: string): void { this.#uploads.releaseConnection(connectionId); + for (const [key, transfer] of this.#archiveTransfers) { + if (transfer.connectionId === connectionId) this.#archiveTransfers.delete(key); + } } async validateTurnAttachments( @@ -321,6 +356,9 @@ export class HostArtifactCoordinator { ): Promise> { try { if ((await this.#sessions.probeSessionRemoval(input.sessionId)).kind !== 'present') { + for (const [key, transfer] of this.#archiveTransfers) { + if (transfer.sessionId === input.sessionId) this.#archiveTransfers.delete(key); + } return notFound('artifact.query', 'Session was not found'); } let sharedGrantId: string | undefined; @@ -328,6 +366,82 @@ export class HostArtifactCoordinator { sharedGrantId = await this.#sharedArtifactGrantId(context.principal, input); if (!sharedGrantId) return notFound('artifact.query', 'Artifact was not found'); } + if (input.kind === 'read_archive_chunk') { + const unavailable = (reason: ArtifactReadFailureReason) => + querySuccess( + encodeArtifactQueryResult({ + kind: 'archive_unavailable', + sessionId: input.sessionId, + reason, + }), + ); + const identity = parseToolResultArchiveResourceRef(input.ref); + if (!identity) return unavailable('not_allowed'); + if (identity.originalBytes > TOOL_RESULT_ARCHIVE_MAX_BYTES) return unavailable('too_large'); + if (input.offset > identity.originalBytes) return invalidQuery('Archive offset is invalid'); + if (!this.#readArchive) return unavailable('read_failed'); + const now = this.#now(); + for (const [key, transfer] of this.#archiveTransfers) { + if (transfer.expiresAt <= now) this.#archiveTransfers.delete(key); + } + const key = JSON.stringify([context.connectionId, input.sessionId, input.ref]); + let transfer = this.#archiveTransfers.get(key); + if (input.offset === 0 || transfer?.ref !== input.ref) { + this.#archiveTransfers.delete(key); + while (this.#archiveTransfers.size >= MAX_ARCHIVE_TRANSFERS) { + this.#archiveTransfers.delete(this.#archiveTransfers.keys().next().value!); + } + transfer = { + connectionId: context.connectionId, + sessionId: input.sessionId, + ref: input.ref, + expiresAt: now + ARCHIVE_TRANSFER_TTL_MS, + body: Promise.resolve() + .then(() => + this.#readArchive!({ + ...identity, + sessionId: input.sessionId, + maxBytes: TOOL_RESULT_ARCHIVE_MAX_BYTES, + }), + ) + .then((read) => + read.ok ? { ok: true as const, bytes: Buffer.from(read.serializedResult) } : read, + ), + }; + this.#archiveTransfers.set(key, transfer); + } + const release = () => { + // A disconnect or another request may have replaced the entry while + // the reader awaited I/O. Never resurrect it or remove its successor. + if (this.#archiveTransfers.get(key) === transfer) this.#archiveTransfers.delete(key); + }; + const read = await transfer.body.catch((error) => { + release(); + throw error; + }); + if (!read.ok) release(); + if (!read.ok) + return unavailable( + read.reason === 'not_found' + ? 'not_found' + : read.reason === 'source_mismatch' + ? 'not_allowed' + : 'read_failed', + ); + const bytes = read.bytes; + const end = Math.min(bytes.length, input.offset + ARTIFACT_READ_CHUNK_MAX_BYTES); + if (end === bytes.length) release(); + return querySuccess( + encodeArtifactQueryResult({ + kind: 'archive_chunk', + sessionId: input.sessionId, + offset: input.offset, + totalBytes: bytes.length, + chunkBase64: bytes.subarray(input.offset, end).toString('base64'), + nextOffset: end < bytes.length ? end : null, + }), + ); + } if (input.kind === 'read_text' || input.kind === 'read_binary') { if (input.kind === 'read_text') { const preview = await this.#store.readTextInSession(input.sessionId, input.artifactId, { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index cfb2070c78..cd441aa942 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -1203,6 +1203,7 @@ export async function createExecutionRuntimeHostComposition( stores.sessionStore, Date.now, context.sessionAccessAuthority, + executionArtifacts.toolResultArchive.services.readArchivedToolResultResource, ); rootCoordinator = new RootTurnCoordinator( manager, diff --git a/packages/runtime/src/__tests__/web-fetch-tool.test.ts b/packages/runtime/src/__tests__/web-fetch-tool.test.ts index 3d7d32d2f6..d2ff14cbeb 100644 --- a/packages/runtime/src/__tests__/web-fetch-tool.test.ts +++ b/packages/runtime/src/__tests__/web-fetch-tool.test.ts @@ -39,7 +39,7 @@ test('WebFetch forwards the canonical URL to its executor', async () => { const result = await tool.impl({ url: 'https://example.com/a/../page' }, context(abort.signal)); - assert.equal(result, 'page body'); + assert.deepEqual(result, { kind: 'text', text: 'page body' }); assert.deepEqual(received, { url: 'https://example.com/page', sessionId: 'session-1', @@ -66,11 +66,14 @@ test('WebFetch bounds model output with a head-truncation marker', async () => { context(new AbortController().signal), ); - assert.ok(typeof result === 'string'); - assert.match(result, /^begin:/); - assert.doesNotMatch(result, /:end$/); - assert.match(result, /WebFetch content truncated/); - assert.ok(Buffer.byteLength(result, 'utf8') <= WEB_FETCH_MODEL_OUTPUT_MAX_BYTES); + assert.equal(result.truncated, true); + assert.match(result.text, /^begin:/); + assert.doesNotMatch(result.text, /:end$|WebFetch content truncated/); + const model = tool.toModelOutput!({ toolCallId: 'fetch', input: {}, output: result }); + assert.equal(model.type, 'text'); + if (model.type !== 'text') throw new Error('Expected text projection'); + assert.match(model.value, /WebFetch content truncated/); + assert.ok(Buffer.byteLength(model.value, 'utf8') <= WEB_FETCH_MODEL_OUTPUT_MAX_BYTES); }); test('privacy mode removes WebFetch from a turn', () => { diff --git a/packages/runtime/src/shell-run-tool-result.ts b/packages/runtime/src/shell-run-tool-result.ts index 113360a002..d81888963e 100644 --- a/packages/runtime/src/shell-run-tool-result.ts +++ b/packages/runtime/src/shell-run-tool-result.ts @@ -32,6 +32,7 @@ import type { ToolResultContent, } from '@maka/core/events'; import { encodedTerminalInputActionsByteLength } from '@maka/core/terminal-input'; +import { PTY_TRUNCATED_MARKER } from '@maka/core/pty-output-view'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; @@ -41,8 +42,6 @@ import { isLikelySandboxDenial } from './sandbox/detect.js'; export const PTY_MODEL_TEXT_BUDGET_BYTES = 50 * 1024; -const TRUNCATED_MARKER = '[terminal snapshot truncated to fit the output limit]'; - export type TerminalToolResult = Extract; export type ShellRunToolResult = Extract; @@ -258,10 +257,10 @@ function takePrioritizedText(text: string, budget: number): { text: string; trun function takeTailText(text: string, budget: number): { text: string; truncated: boolean } { if (Buffer.byteLength(text, 'utf8') <= budget) return { text, truncated: false }; if (budget <= 0) return { text: '', truncated: text.length > 0 }; - const markerBytes = Buffer.byteLength(TRUNCATED_MARKER, 'utf8'); + const markerBytes = Buffer.byteLength(PTY_TRUNCATED_MARKER, 'utf8'); if (budget <= markerBytes) return { text: '', truncated: true }; const tail = sliceUtf8Tail(text, budget - markerBytes - 1); - return { text: `${TRUNCATED_MARKER}\n${tail}`, truncated: true }; + return { text: `${PTY_TRUNCATED_MARKER}\n${tail}`, truncated: true }; } function sliceUtf8Tail(text: string, budget: number): string { diff --git a/packages/runtime/src/web-fetch-tool.ts b/packages/runtime/src/web-fetch-tool.ts index 18abc15bcf..64b63a7bbc 100644 --- a/packages/runtime/src/web-fetch-tool.ts +++ b/packages/runtime/src/web-fetch-tool.ts @@ -18,6 +18,7 @@ */ import { z } from 'zod'; +import type { ToolResultContent } from '@maka/core/events'; import type { MakaTool } from './tool-runtime.js'; const WEB_FETCH_TOOL_NAME = 'WebFetch'; @@ -41,7 +42,9 @@ export interface WebFetchExecutor { } /** Builds the model-facing tool while the host owns policy and transport. */ -export function buildWebFetchTool(executor: WebFetchExecutor): MakaTool { +export function buildWebFetchTool( + executor: WebFetchExecutor, +): MakaTool<{ url: string }, Extract> { return { name: WEB_FETCH_TOOL_NAME, categoryHint: 'web_read', @@ -49,6 +52,13 @@ export function buildWebFetchTool(executor: WebFetchExecutor): MakaTool { description: 'Read the main content of a specific HTTP or HTTPS URL. Use it when a concrete URL is already known.', parameters: z.object({ url: httpUrlSchema }).strict(), + toModelOutput: ({ output }) => { + const result = output as Extract; + return { + type: 'text', + value: result.text + (result.truncated ? WEB_FETCH_TRUNCATION_MARKER : ''), + }; + }, impl: async ({ url }, context) => { const canonicalUrl = new URL(httpUrlSchema.parse(url)).toString(); const content = await executor.fetch({ @@ -71,12 +81,13 @@ export function routeWebFetchTools( : [...tools]; } -function truncateWebFetchOutput(content: string): string { - if (Buffer.byteLength(content, 'utf8') <= WEB_FETCH_MODEL_OUTPUT_MAX_BYTES) return content; +function truncateWebFetchOutput(content: string): Extract { + if (Buffer.byteLength(content, 'utf8') <= WEB_FETCH_MODEL_OUTPUT_MAX_BYTES) + return { kind: 'text', text: content }; const markerBytes = Buffer.byteLength(WEB_FETCH_TRUNCATION_MARKER, 'utf8'); const kept = Buffer.from(content, 'utf8') .subarray(0, WEB_FETCH_MODEL_OUTPUT_MAX_BYTES - markerBytes) .toString('utf8') .replace(/�+$/, ''); - return kept + WEB_FETCH_TRUNCATION_MARKER; + return { kind: 'text', text: kept, truncated: true }; } diff --git a/packages/ui/src/__tests__/tool-activity-presentation.test.ts b/packages/ui/src/__tests__/tool-activity-presentation.test.ts index 349c60a1ad..b7cf4f8a52 100644 --- a/packages/ui/src/__tests__/tool-activity-presentation.test.ts +++ b/packages/ui/src/__tests__/tool-activity-presentation.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { createElement, type ReactNode } from 'react'; import { renderToStaticMarkup as renderReactToStaticMarkup } from 'react-dom/server'; +import { parseHTML } from 'linkedom'; import { computerUseModelCallArgs } from '@maka/core/computer-use'; import { UI_LOCALES, type UiCatalog, type UiLocale } from '@maka/core/ui-locale'; import { ToolCallDetail, ToolTrow } from '../tool-activity.js'; @@ -42,6 +43,125 @@ function renderToStaticMarkup(node: ReactNode, locale: UiLocale = 'zh-CN'): stri } describe('tool activity presentation', () => { + it('projects semantic collapsed targets without dumping generic arguments', () => { + const rowText = (item: ToolActivityItem) => parseHTML(renderToStaticMarkup( + createElement(ToolTrow, { items: [item] }), 'en', + )).document.querySelector('[data-slot="chat-tool-call-row"]')?.textContent ?? ''; + const running = (toolName: string, args: unknown): ToolActivityItem => ({ + toolUseId: toolName, toolName, status: 'running', args, + }); + + assert.match(rowText(running('Read', { path: '/repo/a/index.ts', offset: 20, limit: 10 })), /index\.ts · L20\+10/); + assert.doesNotMatch(rowText(running('Read', { path: '/repo/a/index.ts' })), /\/repo\/a/); + assert.match(rowText(running('Read', { ref: `maka:\/\/archive\/artifact\/${'a'.repeat(64)}\/42` })), /Archived result/); + assert.doesNotMatch(rowText(running('Read', { ref: 'maka://runtime/background-tasks/task-1' })), /maka:\/\//); + assert.match(rowText(running('Grep', { pattern: 'needle', path: '/repo/src', glob: '*.ts' })), /needle in \/repo\/src \(\*\.ts\)/); + assert.match(rowText(running('WebFetch', { url: 'https://example.com/docs' })), /https:\/\/example\.com\/docs/); + assert.equal(rowText(running('CustomTool', { opaque: { nested: true } })).includes('opaque'), false); + + const command = `printf ${'x'.repeat(180)}`; + assert.match(rowText(running('Bash', { command })), new RegExp(`printf x{180}`)); + }); + + it('pairs collapsed targets with result-specific stats', () => { + const rowText = (item: ToolActivityItem) => parseHTML(renderToStaticMarkup( + createElement(ToolTrow, { items: [item] }), 'en', + )).document.querySelector('[data-slot="chat-tool-call-row"]')?.textContent ?? ''; + const completed = (toolName: string, args: unknown, result: ToolActivityItem['result']): ToolActivityItem => ({ + toolUseId: toolName, toolName, status: 'completed', args, result, + }); + + assert.match(rowText(completed('WebFetch', { url: 'https://example.com/docs' }, { kind: 'text', text: '# API docs\nbody' })), /example\.com\/docs.*API docs|API docs.*example\.com\/docs/); + assert.match(rowText(completed('Grep', { pattern: 'needle', path: '/repo' }, { kind: 'json', value: ['one', 'two'] })), /needle in \/repo.*2 items returned|2 items returned.*needle in \/repo/); + assert.match(rowText(completed('Write', { path: '/repo/out.txt' }, { kind: 'file_write', path: '/repo/out.txt', bytes: 42 })), /out\.txt.*42 B|42 B.*out\.txt/); + assert.match(rowText(completed('Read', { path: '/repo/index.ts' }, { kind: 'text', text: 'source\nsecond' })), /index\.ts.*2 lines|2 lines.*index\.ts/); + }); + + it('shows native Read JSON bodies as text with line counts in existing sessions', () => { + const item: ToolActivityItem = { + toolUseId: 'read-json', toolName: 'Read', status: 'completed', + args: { path: '/repo/events.ts', offset: 118, limit: 60 }, + result: { kind: 'json', value: { content: 'first line\n\nthird line' } }, + }; + const row = parseHTML(renderToStaticMarkup(createElement(ToolTrow, { items: [item] }), 'en')) + .document.querySelector('[data-slot="chat-tool-call-row"]')!.textContent ?? ''; + assert.match(row, /3 lines/); + assert.equal(row.match(/events\.ts/g)?.length, 1); + const detail = renderToStaticMarkup(createElement(ToolCallDetail, { item }), 'en'); + assert.match(detail, /data-kind="text"/); + assert.doesNotMatch(detail, /data-kind="json"/); + assert.match(detail, /first line/); + for (const [text, count] of [['', 0], ['one\n', 2], ['one\r\ntwo', 2]] as const) { + const markup = renderToStaticMarkup(createElement(ToolTrow, { items: [{ + ...item, result: { kind: 'json', value: { content: text } }, + }] }), 'zh-CN'); + assert.match(markup, new RegExp(`${count} 行`)); + } + }); + + it('omits redundant success disclosure but keeps additional diagnostic fields inspectable', () => { + const item: ToolActivityItem = { + toolUseId: 'receipt', toolName: 'Update', status: 'completed', args: {}, + result: { kind: 'json', value: { ok: true } }, + }; + const row = (value: ToolActivityItem) => parseHTML(renderToStaticMarkup( + createElement(ToolTrow, { items: [value] }), 'en', + )).document.querySelector('[data-slot="chat-tool-call-row"]')!; + assert.equal(row(item).getAttribute('aria-expanded'), null); + assert.match(row(item).textContent ?? '', /Succeeded/); + const diagnostic = row({ ...item, result: { kind: 'json', value: { ok: true, warning: 'Partial update' } } }); + assert.equal(diagnostic.getAttribute('aria-expanded'), 'false'); + assert.equal(row({ ...item, status: 'running', result: undefined }).getAttribute('aria-expanded'), 'false'); + }); + + it('does not expose an empty disclosure for a failed boundary request', () => { + const markup = renderToStaticMarkup(createElement(ToolTrow, { items: [{ + toolUseId: 'sandbox-boundary', + toolName: 'request_sandbox_boundary', + status: 'errored', + args: undefined, + }] }), 'en'); + const row = parseHTML(markup).document.querySelector('[data-slot="chat-tool-call-row"]')!; + assert.equal(row.getAttribute('aria-expanded'), null); + }); + + it('renders fetched pages as references and preserves failure diagnostics', () => { + const item: ToolActivityItem = { + toolUseId: 'fetch', toolName: 'WebFetch', status: 'completed', + args: { url: 'https://example.com/docs' }, result: { kind: 'text', text: 'FETCHED_BODY_SENTINEL\n' + 'body\n'.repeat(100) + 'FETCHED_TAIL' }, + }; + const markup = renderToStaticMarkup(createElement(ToolCallDetail, { item }), 'en'); + assert.doesNotMatch(markup, /Open full output/); + assert.match(markup, /href="https:\/\/example.com\/docs"/); + assert.match(markup, /534 B.*102 lines/); + assert.doesNotMatch(markup, /FETCHED_BODY_SENTINEL/); + assert.doesNotMatch(markup, /FETCHED_TAIL/); + const failed = renderToStaticMarkup(createElement(ToolCallDetail, { + item: { ...item, status: 'errored', result: { kind: 'text', text: 'HTTP 403: Forbidden' } }, + }), 'en'); + assert.match(failed, /HTTP 403/); + assert.doesNotMatch(failed, /Open full output/); + }); + + it('bounds ordinary JSON and fallback previews while retaining diagnostics', () => { + const content = { + kind: 'json' as const, + value: { rows: Array.from({ length: 2000 }, (_, i) => `entry-${i}`), error: 'Partial result', token: 'private-value' }, + }; + for (const node of [ + createElement(ToolResultPreview, { content }), + createElement(ToolCallDetail, { item: { + toolUseId: 'large-json', toolName: 'Inspect', status: 'completed', args: {}, result: content, + } }), + ]) { + const markup = renderToStaticMarkup(node, 'en'); + assert.match(markup, /Partial result/); + assert.match(markup, /entry-0/); + assert.doesNotMatch(markup, /entry-1999|private-value/); + assert.match(markup, /Open full output/); + } + }); + it('localizes client capability boundary failures and offers recovery', () => { const item: ToolActivityItem = { toolUseId: 'client-capability-boundary', @@ -527,17 +647,17 @@ describe('collapsed tool row target', () => { assert.match(markup, /npm test/); }); - it('caps a long command so the collapsed row stays single-line', async () => { + it('keeps ordinary commands intact and retains a generous DOM safety cap', async () => { const { ToolTrow } = await import('../tool-activity.js'); const markup = renderToStaticMarkup(createElement(ToolTrow, { items: [{ ...baseItem, - args: { command: `echo ${'x'.repeat(300)}` }, + args: { command: `echo ${'x'.repeat(700)}` }, }], })); const matches = markup.match(/x{100,}/g) ?? []; for (const run of matches) { - assert.ok(run.length <= 119, `expected a capped run, got ${run.length}`); + assert.ok(run.length <= 494, `expected a capped run, got ${run.length}`); } assert.match(markup, /…/); }); diff --git a/packages/ui/src/__tests__/tool-output-interaction.test.tsx b/packages/ui/src/__tests__/tool-output-interaction.test.tsx new file mode 100644 index 0000000000..b5d24ed6e5 --- /dev/null +++ b/packages/ui/src/__tests__/tool-output-interaction.test.tsx @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, it } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { parseHTML } from 'linkedom'; +import { SessionToolResultProvider, ToolResultHostProvider, type ToolOutputOpenRequest } from '../tool-activity/tool-result-context.js'; +import { LocaleProvider } from '../locale-context.js'; +import { ToolCallDetail, ToolTrow } from '../tool-activity.js'; +import { ToolResultPreview } from '../tool-activity/tool-result-preview.js'; + +const navigatorDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); +const actEnvironmentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +const globals = { document: globalThis.document, window: globalThis.window, + matchMedia: globalThis.matchMedia, requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, ResizeObserver: globalThis.ResizeObserver, MutationObserver: globalThis.MutationObserver }; +let root: ReturnType | undefined; +afterEach(async () => { + if (root) await act(() => root!.unmount()); + root = undefined; + Object.assign(globalThis, globals); + if (navigatorDescriptor) Object.defineProperty(globalThis, 'navigator', navigatorDescriptor); + else Reflect.deleteProperty(globalThis, 'navigator'); + if (actEnvironmentDescriptor) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actEnvironmentDescriptor); + else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); +}); +function mount() { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => ({ direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true, + MutationObserver: window.MutationObserver, + ResizeObserver: class { observe() {} disconnect() {} }, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: () => 1, cancelAnimationFrame() {} }); + const container = document.querySelector('#root')!; + root = createRoot(container); + const click = async (label: string) => { + const button = Array.from(container.querySelectorAll('button')).find((el) => el.textContent === label); + assert.ok(button, `missing action: ${label}`); + await act(async () => { button.click(); }); + }; + return { container, click }; +} + +it('keeps all retained shell output in a tail-pinned viewport', () => { + const markup = renderToStaticMarkup(); + assert.match(markup, /ALL_TESTS_PASSED/); + assert.match(markup, /FIRST_LINE/); + assert.match(markup, /role="region"/); + assert.match(markup, /tabindex="0"/); + assert.doesNotMatch(markup, /Open full output|more lines/); +}); + +it('pauses on a one-pixel upward scroll and resumes only through Jump to bottom', async () => { + const { container, click } = mount(); + const render = (seq: number) => ; + await act(async () => { root!.render(render(1)); }); + const pre = container.querySelector('pre')!; + let top = 0; + Object.defineProperties(pre, { scrollHeight: { value: 1000 }, clientHeight: { value: 200 }, + scrollTop: { get: () => top, set: (value: number) => { top = Math.min(800, Math.max(0, value)); } } }); + await act(async () => { root!.render(render(2)); }); + assert.equal(pre.scrollTop, 800); + pre.scrollTop = 799; + await act(async () => { pre.dispatchEvent(new window.Event('scroll')); }); + await act(async () => { root!.render(render(3)); }); + assert.equal(pre.scrollTop, 799); + pre.scrollTop = 800; + await act(async () => { pre.dispatchEvent(new window.Event('scroll')); }); + await click('Jump to bottom'); + await act(async () => { root!.render(render(4)); }); + assert.equal(pre.scrollTop, 800); + assert.doesNotMatch(container.textContent!, /Jump to bottom/); +}); + +it('passes the archive source bound to the originating session', async () => { + const { click } = mount(); + const requests: ToolOutputOpenRequest[] = []; + const render = (sessionId: string) => requests.push(request)} + >; + await act(() => root!.render(render('old'))); + await click('Open full output'); + await act(() => root!.render(render('new'))); + await click('Open full output'); + assert.deepEqual(requests.map(request => request.source), [ + { kind: 'archive', sessionId: 'old', identity: { + resourceRef: 'maka://archive-ledger/v1/evidence', bodySha256: '0'.repeat(64), originalBytes: 42, + } }, + { kind: 'archive', sessionId: 'new', identity: { + resourceRef: 'maka://archive-ledger/v1/evidence', bodySha256: '0'.repeat(64), originalBytes: 42, + } }, + ]); +}); + +it('keeps complete diff hunks when only the following hunk is outside the budget', () => { + const diff = '@@ -1,1 +1,1 @@\n-old\n+new\n@@ -10,27 +10,27 @@\n' + + Array.from({ length: 27 }, (_, i) => `-old${i}\n+new${i}`).join('\n') + + '\n context\n context\n@@ -100,1 +100,1 @@\n-last\n+last'; + const markup = renderToStaticMarkup(); + assert.match(markup, /new26/); +}); diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index a6d69f683e..de40ca7083 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -17,6 +17,7 @@ * under the License. */ +import { SessionToolResultProvider } from './tool-activity/tool-result-context.js'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { ICON_SIZE, @@ -764,6 +765,7 @@ export function ChatView(props: { ); return ( + + ); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 187a1084ae..da5cf52423 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -190,3 +190,7 @@ export { } from '@astryxdesign/core'; export { PromptAnchorRail, type PromptAnchorRailTurn } from './prompt-anchor-rail.js'; + +export type { ToolOutputOpenRequest } from './tool-activity/tool-result-context.js'; +export { useClipboardCopyFeedback } from './clipboard-feedback.js'; +export { ToolResultHostProvider } from './tool-activity/tool-result-context.js'; diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index cc267fd5b3..dc346064f2 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -665,6 +665,11 @@ .maka-tool-output-command-copy[data-copy-feedback="pending"] { cursor: progress; } .maka-tool-output-command-copy[data-copy-feedback="copied"] { color: var(--link); } .maka-tool-output-command-copy[data-copy-feedback="failed"] { color: var(--destructive); } +@media (hover: hover) { + .maka-tool-call-detail :is(.maka-tool-output-command-copy, .astryx-code-block-copy-button) { opacity: 0; } + .maka-tool-call-detail:hover :is(.maka-tool-output-command-copy, .astryx-code-block-copy-button), + .maka-tool-call-detail:focus-within :is(.maka-tool-output-command-copy, .astryx-code-block-copy-button) { opacity: 1; } +} .maka-tool-output-body { font: var(--maka-text-code); max-height: 256px; margin: 0; overflow-y: auto; color: var(--muted-foreground); font-variant-ligatures: none; scroll-behavior: auto; white-space: pre-wrap; word-break: break-word; } @@ -1159,3 +1164,10 @@ outline: none; box-shadow: inset 0 0 0 var(--focus-ring-width) var(--focus-ring); } + +.maka-tool-output-scroller { position: relative; min-height: 0; } +.maka-tool-output-jump { position: absolute; bottom: var(--space-2); right: var(--space-2); } + +/* Readability headings can be much longer than the reference card. */ +.maka-tool-web-fetch-title { + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/ui/src/tool-activity.tsx b/packages/ui/src/tool-activity.tsx index 340f7eda2b..df55912408 100644 --- a/packages/ui/src/tool-activity.tsx +++ b/packages/ui/src/tool-activity.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { countDiffLineStats } from '@maka/core/unified-diff'; import { isInFlightToolStatus } from '@maka/core/tool-result-status'; import { type ToolResultContent } from '@maka/core/events'; @@ -53,6 +53,8 @@ import { } from './tool-activity/computer-action-label.js'; import { extractErrorText, + toolHasDetail, + toolResultStats, isCancelledToolResult, isPermissionDeniedToolResult, isRequiresBypassToolResult, @@ -73,6 +75,7 @@ import { type ChatToolCallItem, VisuallyHidden, } from '@astryxdesign/core'; +import { ToolOutputScroller } from './tool-activity/tool-text-preview.js'; import { ToolCodeBlock, ToolDetailReveal } from './tool-activity/tool-code-block.js'; import { cn } from './ui.js'; import { @@ -219,25 +222,9 @@ export function ToolCallDetail({ locale, }) : undefined; - const quietJson = - displayResult?.kind === 'json' - ? formatQuietJsonValue(displayResult.value, locale) - : undefined; - // Drop headline when it duplicates the invocation (e.g. Write path === path). - const showInvocation = invocationLine !== undefined; - const resultHeadline = quietJson?.headline - && quietJson.headline !== invocationLine - ? quietJson.headline - : undefined; - // Live streaming has its own surface below, so this covers only the settled - // stack. - const hasSharedPanelContent = - !ownsPanel && !showLiveStream && ( - showInvocation - || !!resultHeadline - || showResult - || (!!item.args && !permissionDenied && !invocationLine) - ); + const hasSharedPanelContent = !ownsPanel && !showLiveStream && ( + invocationLine !== undefined || showResult || (!!item.args && !permissionDenied) + ); return (
@@ -258,6 +245,7 @@ export function ToolCallDetail({ chunk.text).join(''))} + heading={invocationLine} actionIdentity={outputActionIdentity} > - {(() => { - const argsBody = !showInvocation && !resultHeadline && item.args !== undefined - && !permissionDenied && !showResult - ? formatQuietJsonValue(item.args, locale).body - : undefined; - const body = quietJson?.body ?? argsBody; - const title = resultHeadline ?? (showInvocation ? invocationLine : undefined); - if (body) { - return ( - - ); - } - if (showInvocation && invocationLine && !showResult) { - return ; - } - if (showResult && !ownsPanel && displayResult) { - return ( - - ); - } - if (showInvocation && invocationLine) { - return ; - } - return null; - })()} + {showResult && displayResult ? ( + + ) : ( + + )}
)}
@@ -486,6 +456,8 @@ function standardToolCall( inferredTarget?: string, onSwitchToBypassAndRetry?: () => void | Promise, ): ChatToolCallItem { + const target = collapsedToolTarget(item, locale, inferredTarget); + const resultStats = toolResultStats(item, locale); return { key: item.toolUseId, // The name is what a person reads to tell one call from the next, and for @@ -494,31 +466,30 @@ function standardToolCall( // arguments says what happened instead. name: computerActionLabel(item, locale) ?? resolveToolDisplayName(item, locale), status: astryxToolStatus(item), - target: collapsedToolTarget(item, locale, inferredTarget), + target, duration: formatDuration(item.durationMs) ?? undefined, errorMessage: toolCallErrorMessage(item, locale), stats: item.progress && isInFlightToolStatus(toolActivityPresentationStatus(item)) ? `${item.progress.current}/${item.progress.total}` - : outcomeWord(item, locale), + : outcomeWord(item, locale) ?? (resultStats === target ? undefined : resultStats), ...diffStats(itemDiffs(item)), - resultDetail: ( + resultDetail: toolHasDetail(item) ? ( - ), + ) : undefined, }; } /** * What the collapsed row (and a collapsed group's header) says about the call. * `intent` wins when the runtime authored one; otherwise fall back to the - * shared invocation line derived from the call's args — or, during the live - * window, from the bounded wire args preview (full args arrive at turn end). - * Only the first line is shown, hard-capped so a long command cannot stretch - * the group header (Astryx ellipsizes too, but the header row is shared). + * tool-specific semantic projection of the call's args — or, during the live + * window, its bounded wire args preview (full args arrive at turn end). + * Expanded details keep the shared invocation formatter and its full paths. */ function collapsedToolTarget( item: ToolActivityItem, @@ -526,11 +497,67 @@ function collapsedToolTarget( preferred?: string, ): string | undefined { if (item.intent) return formatToolIntent(item.intent); - const line = preferred ?? formatToolInvocationLine(item, locale); + const args = item.args ?? item.argsPreview; + const record = args && typeof args === 'object' && !Array.isArray(args) + ? args as Record + : undefined; + const scalar = (key: string) => { + const value = record?.[key]; + return typeof value === 'string' && value.trim() ? value : undefined; + }; + const path = scalar('path') ?? scalar('file'); + const baseName = path?.split(/[\\/]/).filter(Boolean).at(-1); + const invocation = () => formatToolInvocationLine({ toolName: item.toolName, args }, locale); + let line = preferred; + if (!line) { + switch (item.toolName) { + case 'Read': { + const offset = typeof record?.offset === 'number' ? record.offset : undefined; + const limit = typeof record?.limit === 'number' ? record.limit : undefined; + const range = offset === undefined && limit === undefined + ? '' + : ` · L${offset ?? 0}${limit === undefined ? '' : `+${limit}`}`; + line = baseName ? `${baseName}${range}` : semanticInternalRef(scalar('ref'), locale); + break; + } + case 'Write': + case 'Edit': + line = baseName; + break; + case 'Bash': + case 'Grep': + case 'Glob': + case 'Find': + case 'WriteStdin': + case 'deep_research_start': + case 'GoalSet': + case 'AskUserQuestion': + line = invocation(); + break; + case 'WebFetch': + line = scalar('url'); + break; + default: { + const ref = scalar('ref'); + line = (ref?.startsWith('maka://') ? semanticInternalRef(ref, locale) : undefined) + ?? ['query', 'pattern', 'url', 'name', 'title', 'path', 'file', 'id'] + .map(scalar) + .find(Boolean) + ?? ref; + } + } + } if (!line) return undefined; - const firstLine = line.split('\n')[0]!.trim(); + const firstLine = redactSecrets(line).split('\n')[0]!.trim(); if (!firstLine) return undefined; - return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine; + return firstLine.length > 500 ? `${firstLine.slice(0, 499)}…` : firstLine; +} + +function semanticInternalRef(ref: string | undefined, locale: UiLocale): string | undefined { + if (!ref?.startsWith('maka://')) return ref; + return ref.startsWith('maka://archive/') + ? getToolActivityCopy(locale).detail.archivedResult + : undefined; } function linkedAgentRows( @@ -713,18 +740,14 @@ function ToolOutputStream(props: { live: boolean; truncated: boolean; }) { - const copy = getToolActivityCopy(useUiLocale()).output; - const preRef = useRef(null); - useEffect(() => { - if (!props.live) return; - const el = preRef.current; - if (!el) return; - el.scrollTop = el.scrollHeight; - }, [props.chunks, props.live]); - + const activityCopy = getToolActivityCopy(useUiLocale()); + const copy = activityCopy.output; return ( <> -
+      
         {props.chunks.map((chunk) => (
           
         ))}
-      
+ {props.truncated && (

{copy.truncated}

)} diff --git a/packages/ui/src/tool-activity/copy.ts b/packages/ui/src/tool-activity/copy.ts index 8b042f8a71..52d6c8217f 100644 --- a/packages/ui/src/tool-activity/copy.ts +++ b/packages/ui/src/tool-activity/copy.ts @@ -17,6 +17,7 @@ * under the License. */ +import type { ArtifactReadFailureReason } from '@maka/core/artifacts'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; type BackgroundTerminalStatus = 'running' | 'completed' | 'failed' | 'timed_out' | 'cancelled' | 'orphaned'; @@ -24,6 +25,23 @@ type WebCredentialCopyKey = 'env' | 'settings' | 'missing' | 'unknown'; type WebGuidanceKey = 'env' | 'settings' | 'rate_limited' | 'not_configured' | 'timed_out' | 'privacy_mode' | 'unknown'; export interface ToolActivityCopy { + detail: { + viewSaved: string; + loading: string; + loadFailed: string; + unavailable: string; + retry: string; + copySaved: string; + jumpToBottom: string; + outputRegion: string; + archivedResult: string; + previewTruncated: string; + jsonHidden: string; + lines: (count: number) => string; + readFailure: Record; + sourceTruncated: string; + returned: ((count: number) => string); + }; errorLabel: string; /** The two outcomes a tool row spells out next to its name. */ status: { @@ -155,8 +173,6 @@ export interface ToolActivityCopy { disconnected: string; terminalTruncated: string; terminalRedacted: string; - streamHidden: (stream: 'stdout' | 'stderr', count: number) => string; - streamsTruncated: (limit: number) => string; outputTruncated: string; outputRedacted: string; backgroundStatus: Record; @@ -183,6 +199,23 @@ export interface ToolActivityCopy { const TOOL_ACTIVITY_COPY = { 'zh-CN': { + detail: { + viewSaved: '打开完整输出', + loading: '正在加载…', + loadFailed: '无法加载输出', + unavailable: '已保存输出不可用', + retry: '重试', + copySaved: '复制全部输出', + jumpToBottom: '跳到底部', + outputRegion: '工具输出', + archivedResult: '归档结果', + previewTruncated: '预览已截断', + jsonHidden: '部分内容未展示', + lines: (count) => `${count} 行`, + readFailure: { not_found: '输出不存在或已删除', not_allowed: '输出未通过访问或完整性校验', too_large: '输出超过查看器的 4 MB 上限', read_failed: '读取输出失败,请重试' }, + sourceTruncated: '工具输出已截断,以下仅为已保留内容', + returned: (count) => `返回 ${count} 项`, + }, errorLabel: '错误', status: { sandboxBlocked: '可能被沙箱阻止', interrupted: '已中断' }, output: { redacted: '[已脱敏]', truncated: '输出已截断' }, @@ -278,7 +311,7 @@ const TOOL_ACTIVITY_COPY = { }, permissionDenied: '用户已拒绝权限请求', result: { - hiddenLines: (n) => `… 已隐藏 ${n} 行`, ptyFailed: '后台终端交互失败', queued: '已输入', notQueued: '未输入', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}:${preview}` : `${action}:${preview}… · 共 ${bytes} 字节`, byteCount: (action, bytes) => `${action} ${bytes} 字节`, resizeNotApplied: (size) => `未调整为 ${size}`, resized: (size) => `已调整为 ${size}`, sizeUnchanged: (size) => `尺寸已是 ${size}`, ptyCompleted: '后台终端交互已完成', terminalUnavailable: '终端输出不可用', noTerminalFrame: '(无可用终端画面)', noOutputYet: '(尚无输出)', noOutput: '(无输出)', exitCode: (code) => `退出码 ${code}`, managedBySource: '由源任务管理', sourceUnavailable: '源任务不可用', running: '运行中', success: '成功', failed: '失败', timedOut: '已超时', cancelled: '已取消', disconnected: '已断开', terminalTruncated: '终端输出已截断', terminalRedacted: '终端输出已脱敏', streamHidden: (stream, n) => `… ${stream} 已隐藏 ${n} 行`, streamsTruncated: (limit) => `输出已截断 · 每路仅展示前 ${limit} 行`, outputTruncated: '输出已截断', outputRedacted: '输出已脱敏', + hiddenLines: (n) => `… 已隐藏 ${n} 行`, ptyFailed: '后台终端交互失败', queued: '已输入', notQueued: '未输入', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}:${preview}` : `${action}:${preview}… · 共 ${bytes} 字节`, byteCount: (action, bytes) => `${action} ${bytes} 字节`, resizeNotApplied: (size) => `未调整为 ${size}`, resized: (size) => `已调整为 ${size}`, sizeUnchanged: (size) => `尺寸已是 ${size}`, ptyCompleted: '后台终端交互已完成', terminalUnavailable: '终端输出不可用', noTerminalFrame: '(无可用终端画面)', noOutputYet: '(尚无输出)', noOutput: '(无输出)', exitCode: (code) => `退出码 ${code}`, managedBySource: '由源任务管理', sourceUnavailable: '源任务不可用', running: '运行中', success: '成功', failed: '失败', timedOut: '已超时', cancelled: '已取消', disconnected: '已断开', terminalTruncated: '终端输出已截断', terminalRedacted: '终端输出已脱敏', outputTruncated: '输出已截断', outputRedacted: '输出已脱敏', backgroundStatus: { running: '后台运行中', completed: '后台已完成', failed: '后台失败', timed_out: '后台超时', cancelled: '后台已取消', orphaned: '后台任务已断开' }, backgroundUnknown: (status) => `后台 · ${status}`, workflow: { action: '动作', status: '状态', error: '错误', nodes: '节点摘要', diagnostics: '诊断片段' }, webNoResults: '没有结果', webResults: (n) => `${n} 条结果`, credentialSource: { env: '环境变量', settings: '本机已保存 key', missing: '未配置', unknown: '来源未知' }, webFailure: '搜索失败', webSearch: '联网搜索', webGuidance: { env: '请检查 TAVILY_API_KEY / MAKA_TAVILY_API_KEY 后重启。', settings: '请在 设置 · 联网搜索 中更新 Tavily key。', rate_limited: 'Tavily 当前限流,请稍后重试或更换可用凭据。', not_configured: '请先完成联网搜索配置后再重试。', timed_out: '请求超时,请稍后重试。', privacy_mode: '隐私模式下不会发起联网搜索。', unknown: '请检查网络或稍后重试。' }, workflowCompleted: 'Rive 工作流已完成', @@ -291,6 +324,23 @@ const TOOL_ACTIVITY_COPY = { }, }, 'zh-TW': { + detail: { + viewSaved: '開啟完整輸出', + loading: '正在載入…', + loadFailed: '無法載入輸出', + unavailable: '已儲存輸出無法使用', + retry: '重試', + copySaved: '複製全部輸出', + jumpToBottom: '跳到底部', + outputRegion: '工具輸出', + archivedResult: '封存結果', + previewTruncated: '預覽已截斷', + jsonHidden: '部分內容未顯示', + lines: (count) => `${count} 行`, + readFailure: { not_found: '輸出不存在或已刪除', not_allowed: '輸出未通過存取或完整性校驗', too_large: '輸出超過檢視器的 4 MB 上限', read_failed: '讀取輸出失敗,請重試' }, + sourceTruncated: '工具輸出已截斷,以下僅為已保留內容', + returned: (count) => `傳回 ${count} 項`, + }, errorLabel: '錯誤', status: { sandboxBlocked: '可能被沙箱阻止', interrupted: '已中斷' }, output: { redacted: '[已脫敏]', truncated: '輸出已截斷' }, @@ -386,7 +436,7 @@ const TOOL_ACTIVITY_COPY = { }, permissionDenied: '使用者已拒絕權限請求', result: { - hiddenLines: (n) => `… 已隱藏 ${n} 行`, ptyFailed: '後臺終端互動失敗', queued: '已輸入', notQueued: '未輸入', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}:${preview}` : `${action}:${preview}… · 共 ${bytes} 位元組`, byteCount: (action, bytes) => `${action} ${bytes} 位元組`, resizeNotApplied: (size) => `未調整為 ${size}`, resized: (size) => `已調整為 ${size}`, sizeUnchanged: (size) => `尺寸已是 ${size}`, ptyCompleted: '後臺終端互動已完成', terminalUnavailable: '終端輸出不可用', noTerminalFrame: '(無可用終端畫面)', noOutputYet: '(尚無輸出)', noOutput: '(無輸出)', exitCode: (code) => `退出碼 ${code}`, managedBySource: '由源任務管理', sourceUnavailable: '源任務不可用', running: '執行中', success: '成功', failed: '失敗', timedOut: '已超時', cancelled: '已取消', disconnected: '已斷開', terminalTruncated: '終端輸出已截斷', terminalRedacted: '終端輸出已脫敏', streamHidden: (stream, n) => `… ${stream} 已隱藏 ${n} 行`, streamsTruncated: (limit) => `輸出已截斷 · 每路僅展示前 ${limit} 行`, outputTruncated: '輸出已截斷', outputRedacted: '輸出已脫敏', + hiddenLines: (n) => `… 已隱藏 ${n} 行`, ptyFailed: '後臺終端互動失敗', queued: '已輸入', notQueued: '未輸入', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}:${preview}` : `${action}:${preview}… · 共 ${bytes} 位元組`, byteCount: (action, bytes) => `${action} ${bytes} 位元組`, resizeNotApplied: (size) => `未調整為 ${size}`, resized: (size) => `已調整為 ${size}`, sizeUnchanged: (size) => `尺寸已是 ${size}`, ptyCompleted: '後臺終端互動已完成', terminalUnavailable: '終端輸出不可用', noTerminalFrame: '(無可用終端畫面)', noOutputYet: '(尚無輸出)', noOutput: '(無輸出)', exitCode: (code) => `退出碼 ${code}`, managedBySource: '由源任務管理', sourceUnavailable: '源任務不可用', running: '執行中', success: '成功', failed: '失敗', timedOut: '已超時', cancelled: '已取消', disconnected: '已斷開', terminalTruncated: '終端輸出已截斷', terminalRedacted: '終端輸出已脫敏', outputTruncated: '輸出已截斷', outputRedacted: '輸出已脫敏', backgroundStatus: { running: '後臺執行中', completed: '後臺已完成', failed: '後臺失敗', timed_out: '後臺超時', cancelled: '後臺已取消', orphaned: '後臺任務已斷開' }, backgroundUnknown: (status) => `後臺 · ${status}`, workflow: { action: '動作', status: '狀態', error: '錯誤', nodes: '節點摘要', diagnostics: '診斷片段' }, webNoResults: '沒有結果', webResults: (n) => `${n} 條結果`, credentialSource: { env: '環境變數', settings: '本機已儲存 key', missing: '未設定', unknown: '來源未知' }, webFailure: '搜尋失敗', webSearch: '聯網搜尋', webGuidance: { env: '請檢查 TAVILY_API_KEY / MAKA_TAVILY_API_KEY 後重啟。', settings: '請在 設定 · 聯網搜尋 中更新 Tavily key。', rate_limited: 'Tavily 目前限流,請稍後重試或更換可用憑據。', not_configured: '請先完成聯網搜尋設定後再重試。', timed_out: '請求超時,請稍後重試。', privacy_mode: '隱私模式下不會發起聯網搜尋。', unknown: '請檢查網路或稍後重試。' }, workflowCompleted: 'Rive 工作流已完成', @@ -399,6 +449,23 @@ const TOOL_ACTIVITY_COPY = { }, }, en: { + detail: { + viewSaved: 'Open full output', + loading: 'Loading…', + loadFailed: 'Could not load output', + unavailable: 'Saved output unavailable', + retry: 'Retry', + copySaved: 'Copy full output', + jumpToBottom: 'Jump to bottom', + outputRegion: 'Tool output', + archivedResult: 'Archived result', + previewTruncated: 'Preview truncated', + jsonHidden: 'Some content is not shown', + lines: (count) => `${count} lines`, + readFailure: { not_found: 'Output does not exist or was deleted', not_allowed: 'Output failed access or integrity validation', too_large: 'Output exceeds the viewer’s 4 MB limit', read_failed: 'Could not read output; try again' }, + sourceTruncated: 'Tool output was truncated; only retained content is available', + returned: (count) => `${count} items returned`, + }, errorLabel: 'Error', status: { sandboxBlocked: 'Possibly blocked by sandbox', interrupted: 'Interrupted' }, output: { redacted: '[Redacted]', truncated: 'Output truncated' }, @@ -491,7 +558,7 @@ const TOOL_ACTIVITY_COPY = { }, permissionDenied: 'User denied the permission request', result: { - hiddenLines: (n) => `… ${n} ${n === 1 ? 'line' : 'lines'} hidden`, ptyFailed: 'Background terminal interaction failed', queued: 'Entered', notQueued: 'Not entered', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}: ${preview}` : `${action}: ${preview}… · ${bytes} bytes total`, byteCount: (action, bytes) => `${action} ${bytes} bytes`, resizeNotApplied: (size) => `Not resized to ${size}`, resized: (size) => `Resized to ${size}`, sizeUnchanged: (size) => `Size already ${size}`, ptyCompleted: 'Background terminal interaction completed', terminalUnavailable: 'Terminal output unavailable', noTerminalFrame: '(No terminal frame available)', noOutputYet: '(No output yet)', noOutput: '(No output)', exitCode: (code) => `exit code ${code}`, managedBySource: 'Managed by the source task', sourceUnavailable: 'Source task unavailable', running: 'Running', success: 'Succeeded', failed: 'Failed', timedOut: 'Timed out', cancelled: 'Cancelled', disconnected: 'Disconnected', terminalTruncated: 'Terminal output truncated', terminalRedacted: 'Terminal output redacted', streamHidden: (stream, n) => `… ${n} ${stream} ${n === 1 ? 'line' : 'lines'} hidden`, streamsTruncated: (limit) => `Output truncated · showing the first ${limit} lines of each stream`, outputTruncated: 'Output truncated', outputRedacted: 'Output redacted', + hiddenLines: (n) => `… ${n} ${n === 1 ? 'line' : 'lines'} hidden`, ptyFailed: 'Background terminal interaction failed', queued: 'Entered', notQueued: 'Not entered', queuedPreview: (action, preview, bytes) => bytes === undefined ? `${action}: ${preview}` : `${action}: ${preview}… · ${bytes} bytes total`, byteCount: (action, bytes) => `${action} ${bytes} bytes`, resizeNotApplied: (size) => `Not resized to ${size}`, resized: (size) => `Resized to ${size}`, sizeUnchanged: (size) => `Size already ${size}`, ptyCompleted: 'Background terminal interaction completed', terminalUnavailable: 'Terminal output unavailable', noTerminalFrame: '(No terminal frame available)', noOutputYet: '(No output yet)', noOutput: '(No output)', exitCode: (code) => `exit code ${code}`, managedBySource: 'Managed by the source task', sourceUnavailable: 'Source task unavailable', running: 'Running', success: 'Succeeded', failed: 'Failed', timedOut: 'Timed out', cancelled: 'Cancelled', disconnected: 'Disconnected', terminalTruncated: 'Terminal output truncated', terminalRedacted: 'Terminal output redacted', outputTruncated: 'Output truncated', outputRedacted: 'Output redacted', backgroundStatus: { running: 'Running in background', completed: 'Background task completed', failed: 'Background task failed', timed_out: 'Background task timed out', cancelled: 'Background task cancelled', orphaned: 'Background task disconnected' }, backgroundUnknown: (status) => `Background · ${status}`, workflow: { action: 'Action', status: 'Status', error: 'Error', nodes: 'Node summary', diagnostics: 'Diagnostic excerpts' }, webNoResults: 'No results', webResults: (n) => `${n} ${n === 1 ? 'result' : 'results'}`, credentialSource: { env: 'Environment variable', settings: 'Locally saved key', missing: 'Not configured', unknown: 'Unknown source' }, webFailure: 'Search failed', webSearch: 'Web search', webGuidance: { env: 'Check TAVILY_API_KEY / MAKA_TAVILY_API_KEY and restart.', settings: 'Update the Tavily key in Settings · Web search.', rate_limited: 'Tavily is rate-limiting requests. Try again later or use another credential.', not_configured: 'Configure web search before retrying.', timed_out: 'The request timed out. Try again later.', privacy_mode: 'Web search is disabled in privacy mode.', unknown: 'Check the network connection or try again later.' }, workflowCompleted: 'Rive workflow completed', diff --git a/packages/ui/src/tool-activity/preview-utils.ts b/packages/ui/src/tool-activity/preview-utils.ts index 61a1db266f..c358edd97a 100644 --- a/packages/ui/src/tool-activity/preview-utils.ts +++ b/packages/ui/src/tool-activity/preview-utils.ts @@ -17,18 +17,49 @@ * under the License. */ +import { normalizeSearchUrl } from '@maka/core/search'; +import type { ToolResultContent } from '@maka/core/events'; +import { redactSecrets } from '../redact.js'; import type { UiLocale } from '@maka/core/ui-locale'; import { getToolActivityCopy } from './copy.js'; export const TOOL_LINE_CAP = 500; -export function capLines(text: string): { body: string; capped: number } { +/** Read persists its file body inside a JSON content envelope. */ +export function readResultText(result: ToolResultContent | undefined): string | undefined { + if (result?.kind === 'text') return result.text; + if (result?.kind !== 'json' || !result.value || typeof result.value !== 'object' || Array.isArray(result.value)) return undefined; + const record = result.value as Record; + // Preserve other fields (including diagnostics) in the generic JSON preview. + return Object.keys(record).length === 1 && typeof record.content === 'string' ? record.content : undefined; +} + +export function capLines( + text: string, + options: { lines?: number; chars?: number; tail?: boolean; paragraphs?: boolean } = {}, +): { body: string; capped: number; hiddenChars: number } { + const limit = options.lines ?? TOOL_LINE_CAP; const lines = text.split('\n'); - if (lines.length <= TOOL_LINE_CAP) return { body: text, capped: 0 }; - return { - body: lines.slice(0, TOOL_LINE_CAP).join('\n'), - capped: lines.length - TOOL_LINE_CAP, - }; + const kept = options.tail ? lines.slice(-limit) : lines.slice(0, limit); + const joined = kept.join('\n'); + const chars = options.chars ?? Number.POSITIVE_INFINITY; + let body = options.tail ? joined.slice(-chars) : joined.slice(0, chars); + if (!options.tail && body.length < text.length) { + // Prefer complete paragraphs for prose, then complete lines. A single + // oversized line still needs a hard budget, but can end at a word boundary. + const paragraph = options.paragraphs ? body.lastIndexOf('\n\n') : -1; + const line = body.lastIndexOf('\n'); + if (paragraph > 0) body = body.slice(0, paragraph); + else if (joined.length > chars && line > 0) body = body.slice(0, line); + else if (options.paragraphs && joined.length > chars) { + const word = body.search(/\s+\S*$/); + if (word > 0) body = body.slice(0, word); + } + } + // Never leave half a surrogate at a display boundary. + if (options.tail && /^[\uDC00-\uDFFF]/.test(body)) body = body.slice(1); + if (!options.tail && /[\uD800-\uDBFF]$/.test(body)) body = body.slice(0, -1); + return { body, capped: Math.max(0, lines.length - body.split('\n').length), hiddenChars: text.length - body.length }; } export function formatBytes(bytes: number): string { @@ -64,3 +95,16 @@ export function summarizeErrorText(text: string): string { const trimmed = lines.slice(0, MAX_LINES).join('\n').slice(0, MAX_CHARS); return `${trimmed}…`; } + +/** A citation label shared by the collapsed row and the expanded fetch card. */ +export function webFetchReference(text: string, args: unknown) { + const rawUrl = args && typeof args === 'object' && 'url' in args ? args.url : undefined; + const normalized = typeof rawUrl === 'string' ? normalizeSearchUrl(redactSecrets(rawUrl)) : undefined; + const url = normalized?.ok ? new URL(normalized.value) : undefined; + const heading = /^ {0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/m.exec(text.slice(0, 16_000))?.[1]; + return { + title: redactSecrets(heading ?? url?.hostname ?? 'WebFetch').slice(0, 160), + href: url?.href, + location: url ? `${url.host}${url.pathname}` : undefined, + }; +} diff --git a/packages/ui/src/tool-activity/result-projection.ts b/packages/ui/src/tool-activity/result-projection.ts index 7c23c2feee..ed184b6471 100644 --- a/packages/ui/src/tool-activity/result-projection.ts +++ b/packages/ui/src/tool-activity/result-projection.ts @@ -23,6 +23,52 @@ import type { ToolActivityItem } from '../materialize.js'; import { formatQuietJsonValue } from './builtin-preview.js'; import { isConnectorTool } from './display-name.js'; import { getToolActivityCopy } from './copy.js'; +import { redactSecrets } from '../redact.js'; +import { formatBytes, readResultText, webFetchReference } from './preview-utils.js'; + +function isSuccessReceipt(result: ToolActivityItem['result']): boolean { + if (result?.kind === 'file_write') return true; + if (result?.kind !== 'json' || !result.value || typeof result.value !== 'object' || Array.isArray(result.value)) return false; + const record = result.value as Record; + const keys = Object.keys(record); + return keys.length > 0 && keys.every((key) => + key === 'ok' && record.ok === true + || key === 'status' && (record.status === 'completed' || record.status === 'success'), + ); +} + +export function toolHasDetail(item: ToolActivityItem): boolean { + if (item.outputChunks?.length || item.outputTruncated) return true; + if (item.status !== 'completed' && item.args !== undefined) return true; + if (item.status === 'completed' && isSuccessReceipt(item.result)) return false; + if (item.result?.kind === 'text') return item.result.text.trim().length > 0; + return item.result !== undefined; +} + +export function toolResultStats(item: ToolActivityItem, locale: UiLocale): string | undefined { + if (item.status !== 'completed') return undefined; + const copy = getToolActivityCopy(locale); + const result = item.result; + if (result?.kind === 'file_write') return formatBytes(result.bytes); + if (isSuccessReceipt(result)) return copy.result.success; + if (item.toolName === 'WebFetch') return webFetchReference(result?.kind === 'text' ? result.text : '', item.args).title; + if (item.toolName === 'Read' && item.args && typeof item.args === 'object' && 'path' in item.args && typeof item.args.path === 'string') { + const name = redactSecrets(item.args.path.split(/[\\/]/).pop() ?? item.args.path); + const text = readResultText(result); + return text === undefined ? name : copy.detail.lines(text === '' ? 0 : text.split('\n').length); + } + if (result?.kind === 'web_search') return copy.detail.returned(result.rows.length); + if (['Grep', 'Glob', 'Find'].includes(item.toolName) && result?.kind === 'json') { + if (Array.isArray(result.value)) return copy.detail.returned(result.value.length); + if (result.value && typeof result.value === 'object') { + const record = result.value as Record; + for (const key of ['matches', 'files', 'results', 'items', 'paths']) { + if (Array.isArray(record[key])) return copy.detail.returned(record[key].length); + } + } + } + return undefined; +} export function extractErrorText(result: ToolActivityItem['result'], locale: UiLocale): string { if (!result) return ''; diff --git a/packages/ui/src/tool-activity/tool-code-block.tsx b/packages/ui/src/tool-activity/tool-code-block.tsx index 3ff31ffb4a..ade74e932e 100644 --- a/packages/ui/src/tool-activity/tool-code-block.tsx +++ b/packages/ui/src/tool-activity/tool-code-block.tsx @@ -30,6 +30,7 @@ export function ToolCodeBlock(props: { title?: string; maxHeight?: string | number; actionIdentity?: string; + hasCopyButton?: boolean; }) { const copy = getToolActivityCopy(useUiLocale()).copy; const actionIdentity = props.actionIdentity?.trim(); @@ -41,6 +42,7 @@ export function ToolCodeBlock(props: { const codeBlock = ( void) | undefined +>(undefined); +export const ToolResultSessionContext = createContext(undefined); + +export const ToolResultHostProvider = ToolResultHostContext.Provider; +export const SessionToolResultProvider = ToolResultSessionContext.Provider; diff --git a/packages/ui/src/tool-activity/tool-result-preview.tsx b/packages/ui/src/tool-activity/tool-result-preview.tsx index 014f0db9bd..1f63c7137b 100644 --- a/packages/ui/src/tool-activity/tool-result-preview.tsx +++ b/packages/ui/src/tool-activity/tool-result-preview.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useRef, type ReactNode } from 'react'; +import { useContext, type ReactNode } from 'react'; import { isShellOutput, type ShellOutput } from '@maka/core/shell-run'; import { normalizeSearchUrl } from '@maka/core/search'; import { ptyHumanTerminalText } from '@maka/core/pty-output-view'; @@ -26,13 +26,16 @@ import { type ToolResultContent } from '@maka/core/events'; import { Button as UiButton, Link } from '@astryxdesign/core'; import { ICON_SIZE, AlertCircle, Ban, Check, Clock, Copy, GitBranch, Loader2, Plug, ShieldAlert } from '../icons.js'; import { redactSecrets } from '../redact.js'; +import { parseUnifiedDiffRows } from '@maka/core/unified-diff'; +import { ToolResultHostContext, ToolResultSessionContext } from './tool-result-context.js'; import { useClipboardCopyFeedback } from '../clipboard-feedback.js'; import { useUiLocale } from '../locale-context.js'; import { cn } from '../ui.js'; -import { formatQuietJsonValue } from './builtin-preview.js'; +import { formatBoundedQuietJsonValue } from '@maka/core/tool-quiet-preview'; +import { SavedToolOutput, ToolTextPreview, ToolOutputScroller } from './tool-text-preview.js'; import { ToolCodeBlock } from './tool-code-block.js'; import { DiffCodePreview } from './diff-code-preview.js'; -import { TOOL_LINE_CAP, capLines, formatUserVisibleToolText } from './preview-utils.js'; +import { capLines, formatBytes, readResultText, webFetchReference, formatUserVisibleToolText } from './preview-utils.js'; import { getToolActivityCopy } from './copy.js'; import { isSandboxDeniedToolResult } from './sandbox-denial.js'; @@ -139,17 +142,59 @@ export function ToolOutputSurface(props: { ); } +function WebFetchPreview(props: { + content: Extract; + args?: unknown; +}) { + const copy = getToolActivityCopy(useUiLocale()); + const text = props.content.kind === 'text' ? props.content.text : undefined; + const bytes = props.content.kind === 'archived_tool_result' ? props.content.originalBytes : new TextEncoder().encode(props.content.text).byteLength; + const truncated = props.content.kind === 'text' && props.content.truncated; + const reference = webFetchReference(text ?? '', props.args); + return
+ {reference.title} + {reference.href && {reference.location}} +

+ {formatBytes(bytes)}{text !== undefined && ` · ${copy.detail.lines(text.split('\n').length)}`} + {truncated && ` · ${copy.result.outputTruncated}`} +

+
; +} + /** Routes persisted tool results to bounded, kind-specific preview cards. */ export function ToolResultPreview(props: { content: ToolResultContent; toolName?: string; args?: unknown; + failed?: boolean; shellRunSource?: 'owned' | 'unavailable'; fileDiffActions?: ReactNode; actionIdentity?: string; + heading?: string; }) { - const { content } = props; + const readText = props.toolName === 'Read' && !props.failed ? readResultText(props.content) : undefined; + const content = props.content.kind === 'json' && readText !== undefined + ? { kind: 'text' as const, text: readText } + : props.content; const locale = useUiLocale(); + const openOutput = useContext(ToolResultHostContext); + const sessionId = useContext(ToolResultSessionContext); + + if (content.kind === 'archived_tool_result') { + if (props.toolName === 'WebFetch' && !props.failed) { + return ; + } + const copy = getToolActivityCopy(locale).detail; + if (content.status !== 'not_loaded' || (!content.resourceRef && !content.artifactId) || !content.bodySha256 || !openOutput || !sessionId) { + return

{copy.unavailable}

; + } + const identity = { ...(content.resourceRef + ? { resourceRef: content.resourceRef } + : { artifactId: content.artifactId! }), + bodySha256: content.bodySha256, originalBytes: content.originalBytes }; + return ; + } if (content.kind === 'file_diff') { return ( @@ -212,12 +257,17 @@ export function ToolResultPreview(props: { if (content.kind === 'json') { // No language: quiet text must stay contiguous (tokenizer splits words). - const quiet = formatQuietJsonValue(content.value, locale); + const quiet = formatBoundedQuietJsonValue(content.value, locale); return (
-
@@ -225,16 +275,28 @@ export function ToolResultPreview(props: { } if (content.kind === 'text') { - const copy = getToolActivityCopy(locale).result; - const { body, capped } = capLines(formatUserVisibleToolText(redactSecrets(content.text), locale)); - const code = capped > 0 ? `${body}\n\n${copy.hiddenLines(capped)}` : body; + if (props.toolName === 'WebFetch' && !props.failed) { + return ; + } return (
- +
); } + if (content.kind === 'summary') { + return ; + } + // image / summary / unknown — show a compact descriptor so the user knows // what kind landed without dumping binary or storage refs. if (content.kind === 'file_write') { @@ -319,23 +381,42 @@ function FileDiffPreview(props: { actions?: ReactNode; actionIdentity?: string; }) { - const copy = getToolActivityCopy(useUiLocale()).result; + const activityCopy = getToolActivityCopy(useUiLocale()); // Apply UI-level redaction then cap the displayed lines. Both are // @kenji's PR76 review items: never echo a token a tool happened to dump // into a diff (commit body, .env file diff, etc.), and never let a // 10k-line diff create 10k React elements. - const { body, capped } = capLines(redactSecrets(props.diff)); + const safe = redactSecrets(props.diff); + const preview = capLines(safe, { lines: 60, chars: 6_000 }); + let body = preview.body; + if (preview.hiddenChars > 0) { + const rows = parseUnifiedDiffRows(body); + const lastHunk = rows.findLastIndex((row) => row.kind === 'hunk'); + const header = rows[lastHunk]?.text.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/); + if (header && rows.slice(0, lastHunk).some((row) => row.kind === 'hunk')) { + const tail = rows.slice(lastHunk + 1); + const oldCount = tail.filter((row) => row.oldLine !== undefined).length; + const newCount = tail.filter((row) => row.newLine !== undefined).length; + if (oldCount < Number(header[1] ?? 1) || newCount < Number(header[2] ?? 1)) { + body = body.slice(0, body.lastIndexOf(rows[lastHunk]!.text)).trimEnd(); + } + } + } + const hiddenChars = safe.length - body.length; return ( 0 ? props.paths.join(', ') : undefined} - body={body} + body={safe} actions={props.actions} actionIdentity={props.actionIdentity} > - {capped > 0 && ( -

{copy.hiddenLines(capped)}

+ {hiddenChars > 0 && ( + <> +

{activityCopy.detail.previewTruncated}

+ + )}
); @@ -362,7 +443,7 @@ function TerminalPreview(props: { {props.output ? ( @@ -435,7 +516,7 @@ function ShellRunPreview(props: {

@@ -545,50 +626,24 @@ function ShellRunStatus(props: { } } -/** - * The output half of a `ToolOutputSurface` — never the command. The command is - * the surface's header now, so this renders the same `

` well the live
- * stream uses instead of its own bordered CodeBlock card, which used to nest a
- * second border inside the panel and put the command in its title slot.
- */
-/**
- * The plain text a shell body renders, so the surface's copy action can offer
- * the same string the reader is looking at — capped and redacted, with the
- * per-stream "hidden lines" markers the body shows.
- *
- * The body used to be an Astryx CodeBlock, whose own copy button carried this
- * text; the panel replaced that chrome, so the text has to reach the panel's
- * button instead. One function owns it, and the body renders from the same
- * call — a copy that quietly diverged from the pixels would be worse than none.
- */
-function shellOutputText(
-  output: ShellOutput,
-  copy: ReturnType['result'],
-): string {
+/** Copy and the bounded viewport use the same retained, redacted output. */
+function shellOutputText(output: ShellOutput): string {
   if (output.mode === 'pty') return redactSecrets(ptyHumanTerminalText(output));
-  const stdout = capLines(redactSecrets(output.stdout));
-  const stderr = capLines(redactSecrets(output.stderr));
-  const parts: string[] = [];
-  if (stdout.body) {
-    parts.push(stdout.capped > 0
-      ? `${stdout.body}\n\n${copy.streamHidden('stdout', stdout.capped)}`
-      : stdout.body);
-  }
-  if (stderr.body) {
-    parts.push(stderr.capped > 0
-      ? `${stderr.body}\n\n${copy.streamHidden('stderr', stderr.capped)}`
-      : stderr.body);
-  }
-  return parts.join('\n');
+  return [output.stdout, output.stderr]
+    .filter(Boolean)
+    .map(text => redactSecrets(text))
+    .join('\n');
 }
 
 function ShellOutputBody(props: {
   output: ShellOutput;
   failed: boolean;
 }) {
-  const copy = getToolActivityCopy(useUiLocale()).result;
-  if (props.output.mode === 'pty') {
-    const text = shellOutputText(props.output, copy);
+  const { output } = props;
+  const activityCopy = getToolActivityCopy(useUiLocale());
+  const copy = activityCopy.result;
+  if (output.mode === 'pty') {
+    const text = shellOutputText(output);
     return (
       <>
         {text ?  : (
@@ -596,53 +651,37 @@ function ShellOutputBody(props: {
             {props.failed ? copy.noTerminalFrame : copy.noOutputYet}
           

)} - {props.output.truncated &&

{copy.terminalTruncated}

} - {props.output.redacted &&

{copy.terminalRedacted}

} + {output.truncated &&

{copy.terminalTruncated}

} + {output.redacted &&

{copy.terminalRedacted}

} ); } - const stdout = capLines(redactSecrets(props.output.stdout)); - const stderr = capLines(redactSecrets(props.output.stderr)); - const hiddenLines = stdout.capped + stderr.capped; - const runtimeTruncated = props.output.stdoutTruncated || props.output.stderrTruncated; - const hasOutput = props.output.stdout.length > 0 || props.output.stderr.length > 0; - const code = shellOutputText(props.output, copy); + const runtimeTruncated = output.stdoutTruncated || output.stderrTruncated; + const hasOutput = output.stdout.length > 0 || output.stderr.length > 0; + const code = shellOutputText(output); return ( <> {hasOutput - ?
{code}
+ ? {code} :

{copy.noOutput}

} - {(runtimeTruncated || hiddenLines > 0) && ( + {runtimeTruncated && (

- {hiddenLines > 0 ? copy.streamsTruncated(TOOL_LINE_CAP) : copy.outputTruncated} + {copy.outputTruncated}

)} - {props.output.redacted &&

{copy.outputRedacted}

} + {output.redacted &&

{copy.outputRedacted}

} ); } function PtyTerminalSurface(props: { text: string }) { - const ref = useRef(null); - const followTail = useRef(true); - useEffect(() => { - const element = ref.current; - if (element && followTail.current) element.scrollTop = element.scrollHeight; - }, [props.text]); - return ( -
 {
-        const element = event.currentTarget;
-        followTail.current = element.scrollHeight - element.scrollTop - element.clientHeight <= 2;
-      }}
-    >
-      {props.text}
-    
- ); + return {props.text}; } function isCancelledStatus(status: string | undefined): boolean { diff --git a/packages/ui/src/tool-activity/tool-text-preview.tsx b/packages/ui/src/tool-activity/tool-text-preview.tsx new file mode 100644 index 0000000000..4be8bf7fc6 --- /dev/null +++ b/packages/ui/src/tool-activity/tool-text-preview.tsx @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useContext, useEffect, useRef, useSyncExternalStore, type ComponentProps } from 'react'; +import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; +import { Button } from '@astryxdesign/core'; +import { FileText, ICON_SIZE } from '../icons.js'; +import { useUiLocale } from '../locale-context.js'; +import { redactSecrets } from '../redact.js'; +import type { ToolOutputSource } from './tool-result-context.js'; +import { ToolResultHostContext } from './tool-result-context.js'; +import { getToolActivityCopy } from './copy.js'; +import { ToolCodeBlock } from './tool-code-block.js'; +import { capLines } from './preview-utils.js'; + +/** Reading retained output belongs to the host's artifact viewer. */ +export function SavedToolOutput(props: { + source: ToolOutputSource; + toolName?: string; + truncated?: boolean; + actionIdentity?: string; +}) { + const copy = getToolActivityCopy(useUiLocale()).detail; + const host = useContext(ToolResultHostContext); + return
; +} diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 1332908750..ed64d85c2d 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -102,7 +102,7 @@ export interface TranscriptScrollAuthority { getSnapshot(): TranscriptScrollSnapshot; } -export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { +export function createTranscriptScrollAuthority(options: { explicitResume?: boolean } = {}): TranscriptScrollAuthority { let root: HTMLElement | null = null; let pinned = true; let awayFromTail = false; @@ -171,7 +171,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // scrollers (a tool output box, a terminal) never reach here at all: // `scroll` does not bubble, and there is no `wheel` listener to catch // instead. - if (lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) < 1) { + if (lastWrittenTop !== undefined && (options.explicitResume ? target.scrollTop === lastWrittenTop : Math.abs(target.scrollTop - lastWrittenTop) < 1)) { lastScrollHeight = target.scrollHeight; lastClientHeight = target.clientHeight; lastScrollTop = target.scrollTop; @@ -216,7 +216,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { publish(); return; } - pinned = distance <= PIN_THRESHOLD_PX; + pinned = options.explicitResume ? pinned && unexplained >= 0 : distance <= PIN_THRESHOLD_PX; publish(); for (const listener of [...readerListeners]) listener(unexplained < 0 ? 'up' : 'down'); }; diff --git a/packages/ui/stories/tool-activity.stories.tsx b/packages/ui/stories/tool-activity.stories.tsx index 44ddbfa4ab..5f1f0a843f 100644 --- a/packages/ui/stories/tool-activity.stories.tsx +++ b/packages/ui/stories/tool-activity.stories.tsx @@ -282,3 +282,42 @@ export const LongIntentGroupNarrow: Story = { ); }, }; + + +// Real path: completed tool calls in a conversation, with retained bodies disclosed on demand. +export const RetainedToolOutput: Story = { + args: { items: [ + { toolUseId: 'fetch-preview', toolName: 'WebFetch', status: 'completed', + args: { url: 'https://example.com/docs' }, + result: { kind: 'text', text: 'Fetched documentation\n' + 'Documentation paragraph.\n'.repeat(500) } }, + { toolUseId: 'json-preview', toolName: 'Inspect', status: 'completed', args: {}, + result: { kind: 'json', value: { warning: 'Results are partial', + items: Array.from({ length: 100 }, (_, index) => ({ path: `src/module-${index}.ts`, matches: index })) } } }, + { toolUseId: 'shell-preview', toolName: 'Bash', status: 'completed', args: { command: 'npm test' }, + result: { kind: 'terminal', cwd: '/repo', cmd: 'npm test', status: 'completed', exitCode: 0, + output: { mode: 'pipes', stdout: 'Starting tests\n' + 'Test passed\n'.repeat(60) + 'All tests passed', + stderr: '', stdoutTruncated: false, stderrTruncated: false, redacted: false } } }, + ] }, + render: (args) =>
, +}; + +// Real path: a tool group mixes captured output and a completed call with no output. +export const MixedToolDisclosure: Story = { + args: { items: [' M file.ts', ''].map((text, index) => ({ + toolUseId: `mixed-disclosure-${index}`, toolName: 'Bash', status: 'completed', + args: { command: index ? 'git diff --cached --stat' : 'git status' }, + result: { kind: 'text', text }, + })) }, + render: (args) =>
, + play: async ({ canvasElement }) => { + const names = within(canvasElement).getAllByText('Bash', { exact: true }); + expect(names).toHaveLength(2); + expect(names[0].closest('[role="button"]')).not.toBeNull(); + expect(names[1].closest('[role="button"]')).toBeNull(); + const first = getComputedStyle(names[0]); + const second = getComputedStyle(names[1]); + expect(first.fontSize).toBe(second.fontSize); + expect(first.fontFamily).toBe(second.fontFamily); + expect(first.fontWeight).toBe(second.fontWeight); + }, +};