From 07ce25d5c79fd1e1b333fb31eb66de72d9cc9e85 Mon Sep 17 00:00:00 2001 From: "heyang.why" Date: Thu, 20 Aug 2026 14:13:00 +0800 Subject: [PATCH 01/11] feat(transcript): add cross-host document export pipeline Establish a shared transcript model and document-mode projection so Web Shell, VS Code, and HTML export can consume the same stable conversation semantics without changing interactive rendering. - Preserve daemon and ACP segment identity across replay and normalization - Add export-safe previews and a versioned transcript document builder - Add document-mode Web Shell rendering with bounded Mermaid processing - Lock direct-daemon and ACP behavior with contract fixtures - Cover render and export equivalence in integration tests --- .../chat-transcript-contract-prevalidation.md | 8 +- .../chat-transcript-contract.test.ts | 748 +++-- .../chat-transcript-document.test.ts | 865 ++++++ .../v1/capability-matrix.md | 46 +- .../representative/acp-session-updates.jsonl | 6 +- .../cases/representative/daemon-events.jsonl | 6 +- .../cases/representative/expected-export.json | 4 +- .../cases/representative/expected-model.json | 8 +- .../representative/expected-network.json | 10 + .../representative/expected-render-items.json | 14 +- .../v1/cases/representative/manifest.json | 35 +- .../acp-bridge/src/transcript-replay.test.ts | 23 + packages/acp-bridge/src/transcript-replay.ts | 64 +- .../acp-integration/session/Session.test.ts | 57 +- .../src/acp-integration/session/Session.ts | 8 +- .../transcript-update-identity.test.ts | 143 + .../session/transcript-update-identity.ts | 164 ++ .../export/export-transcript-document.test.ts | 1362 +++++++++ .../export/export-transcript-document.ts | 2586 +++++++++++++++++ packages/cli/src/ui/utils/export/index.ts | 12 + packages/sdk-typescript/scripts/build.js | 12 +- packages/sdk-typescript/src/daemon/index.ts | 4 + .../sdk-typescript/src/daemon/ui/index.ts | 8 +- .../src/daemon/ui/normalizer.ts | 26 + .../sdk-typescript/src/daemon/ui/render.ts | 6 + .../src/daemon/ui/toolPreview.ts | 183 +- .../src/daemon/ui/transcript.ts | 77 +- .../sdk-typescript/src/daemon/ui/types.ts | 42 + .../unit/daemon-transcript-projection.test.ts | 68 + .../sdk-typescript/test/unit/daemonUi.test.ts | 233 +- .../chatTranscriptContractProbe.test.ts | 257 ++ .../services/chatTranscriptContractProbe.ts | 184 ++ .../web-shell/client/adapters/messageTypes.ts | 4 + .../client/adapters/parallelAgentGrouping.ts | 133 + .../adapters/transcriptRenderProbe.test.ts | 231 ++ .../client/adapters/transcriptRenderProbe.ts | 297 ++ .../adapters/transcriptToMessages.test.ts | 457 ++- .../client/adapters/transcriptToMessages.ts | 375 ++- .../client/components/MessageList.module.css | 7 + .../client/components/MessageList.tsx | 125 +- .../components/WebShellTranscript.test.tsx | 77 + .../client/components/WebShellTranscript.tsx | 30 +- .../artifacts/turnOutputSelectors.test.ts | 22 + .../artifacts/turnOutputSelectors.ts | 2 +- .../messages/AssistantMessage.test.tsx | 29 +- .../components/messages/AssistantMessage.tsx | 24 +- .../components/messages/GoalStatusMessage.tsx | 2 +- .../messages/Markdown.mermaid.test.ts | 126 + .../components/messages/Markdown.test.ts | 131 + .../client/components/messages/Markdown.tsx | 108 +- .../messages/PlanExecutionView.test.tsx | 26 + .../components/messages/PlanExecutionView.tsx | 3 + .../components/messages/PlanMessage.test.tsx | 20 +- .../components/messages/PlanMessage.tsx | 43 +- .../messages/TasksStatusMessage.test.tsx | 64 +- .../messages/TasksStatusMessage.tsx | 117 +- .../components/messages/ToolGroup.test.tsx | 47 +- .../client/components/messages/ToolGroup.tsx | 61 +- .../components/messages/UserMessage.test.tsx | 16 + .../components/messages/UserMessage.tsx | 6 +- .../messages/UserShellMessage.module.css | 5 + .../messages/tools/DiffView.module.css | 5 + .../tools/ParallelAgentsGroup.test.tsx | 36 + .../messages/tools/ParallelAgentsGroup.tsx | 40 +- .../messages/tools/SubAgentPanel.module.css | 6 + .../messages/tools/SubAgentPanel.test.tsx | 40 +- .../messages/tools/SubAgentPanel.tsx | 45 +- .../messages/tools/ToolChrome.module.css | 5 + .../web-shell/client/hooks/useMessages.ts | 2 + .../web-shell/client/transcriptRenderMode.ts | 2 +- 70 files changed, 9204 insertions(+), 834 deletions(-) create mode 100644 integration-tests/chat-transcript-document.test.ts create mode 100644 integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-network.json create mode 100644 packages/cli/src/acp-integration/session/transcript-update-identity.test.ts create mode 100644 packages/cli/src/acp-integration/session/transcript-update-identity.ts create mode 100644 packages/cli/src/ui/utils/export/export-transcript-document.test.ts create mode 100644 packages/cli/src/ui/utils/export/export-transcript-document.ts create mode 100644 packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.test.ts create mode 100644 packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.ts create mode 100644 packages/web-shell/client/adapters/parallelAgentGrouping.ts create mode 100644 packages/web-shell/client/adapters/transcriptRenderProbe.test.ts create mode 100644 packages/web-shell/client/adapters/transcriptRenderProbe.ts create mode 100644 packages/web-shell/client/components/messages/Markdown.mermaid.test.ts diff --git a/docs/design/web-shell/chat-transcript-contract-prevalidation.md b/docs/design/web-shell/chat-transcript-contract-prevalidation.md index 3587ea8d165..9b7960ad643 100644 --- a/docs/design/web-shell/chat-transcript-contract-prevalidation.md +++ b/docs/design/web-shell/chat-transcript-contract-prevalidation.md @@ -1,8 +1,10 @@ # Web Shell、VS Code、Desktop 与 HTML Export 统一 Chat Transcript 总体设计 -> 文档地位:本方案的唯一规范性设计文档 -> 实施方式:两个 MR 按顺序合入 -> 当前状态:MR1 契约预验证已在当前分支准备;MR2 生产迁移尚未进入当前分支 +> 文档地位:本方案的唯一规范性设计文档 +> +> 实施方式:两个 MR 按顺序合入 +> +> 当前状态:MR1 契约预验证已在当前分支准备;MR2 生产迁移尚未进入当前分支 > 当前门禁:`overall: "fail"`,`selectedVscodePath: null` ## 0. 文档治理 diff --git a/integration-tests/chat-transcript-contract.test.ts b/integration-tests/chat-transcript-contract.test.ts index f6bfc5bf53a..fcb0ba8f00c 100644 --- a/integration-tests/chat-transcript-contract.test.ts +++ b/integration-tests/chat-transcript-contract.test.ts @@ -1,17 +1,22 @@ import { createHash } from 'node:crypto'; -import { readFileSync, readdirSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; import { - createDaemonTranscriptState, DAEMON_ERROR_KINDS, - normalizeDaemonEvent, - reduceDaemonTranscriptEvents, type DaemonEvent, type DaemonTranscriptBlock, } from '@qwen-code/sdk/daemon'; import { projectChatRecordsToDaemonTranscript } from '@qwen-code/sdk/daemon/transcript'; +import { createExportTranscriptDocumentV1 } from '../packages/cli/src/ui/utils/export/export-transcript-document.js'; +import { TranscriptUpdateIdentityProjector } from '../packages/cli/src/acp-integration/session/transcript-update-identity.js'; +import { + probeAcpTranscriptUpdates, + probeDirectDaemonTranscript, +} from '../packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.js'; +import { probeTranscriptRenderIdentity } from '../packages/web-shell/client/adapters/transcriptRenderProbe.js'; import { transcriptBlocksToDaemonMessages } from '../packages/web-shell/client/adapters/transcriptToMessages.js'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -20,270 +25,91 @@ const fixtureRoot = resolve( 'integration-tests/fixtures/chat-transcript-contract/v1', ); const caseRoot = resolve(fixtureRoot, 'cases/representative'); +const context = { scopeKey: 'workspace-a:session-a', generation: 3 } as const; interface FixtureManifest { readonly fixtureVersion: number; readonly name: string; - readonly generatorVersion?: string; - readonly sources: readonly string[]; - readonly consumers: readonly string[]; + readonly generatorVersion: string; readonly capabilities: readonly string[]; - readonly complete: boolean; + readonly consumers: readonly string[]; readonly expectedDiagnostics: readonly string[]; - readonly normalizedFields?: readonly string[]; + readonly normalizedFields: readonly string[]; readonly hashes: Readonly>; } interface ExpectedModel { readonly kinds: readonly string[]; - readonly texts: readonly string[]; readonly sourceRecordIds: readonly (readonly string[])[]; + readonly rawFreeToolResult: string; } interface ExpectedRenderItems { readonly roles: readonly string[]; - readonly expectedTextContent: readonly string[]; - readonly runtimeFields: readonly string[]; - readonly expectedToolArgs: Readonly>; - readonly expectedToolResult: unknown; + readonly requiredCapabilities: readonly string[]; + readonly identityFields: readonly string[]; } -interface ExpectedExportContract { +interface ExpectedExport { readonly schemaVersion: number; readonly forbiddenFields: readonly string[]; readonly frozenErrorKinds: readonly string[]; + readonly expectedToolResult: string; readonly timestamps: number; - readonly implementation: string; -} - -interface IdentityCandidateResult { - readonly status: 'fail'; - readonly stableUnderPartialPrepend: false; - readonly unstableBlockKinds: readonly string[]; - readonly missingNativeTextIdentity: readonly string[]; -} - -interface ExpectedGate { - readonly overall: 'fail'; - readonly selectedVscodePath: null; - readonly candidates: { - readonly directDaemon: IdentityCandidateResult; - readonly acp: IdentityCandidateResult; - }; - readonly blockers: readonly string[]; } function readJson(path: string): T { return JSON.parse(readFileSync(path, 'utf8')) as T; } -function readJsonLines(path: string): T[] { +function readJsonLines(path: string): unknown[] { return readFileSync(path, 'utf8') .trim() .split('\n') - .map((line) => JSON.parse(line) as T); + .map((line) => JSON.parse(line) as unknown); } function sha256(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } -function listFixtureEvidenceFiles( - directory: string, - relativeDirectory = '', -): string[] { - return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { - const relativePath = relativeDirectory - ? `${relativeDirectory}/${entry.name}` - : entry.name; - if (entry.isDirectory()) { - return listFixtureEvidenceFiles( - resolve(directory, entry.name), - relativePath, - ); - } - return relativePath === 'cases/representative/manifest.json' - ? [] - : [relativePath]; - }); -} - -function expectManifestToMatchSchema( - manifest: FixtureManifest, - schema: Record, -): void { - const properties = schema['properties'] as Record< - string, - Record - >; - const required = schema['required']; - expect(properties).toBeTypeOf('object'); - expect(required).toBeInstanceOf(Array); - expect(schema['additionalProperties']).toBe(false); - - const allowedKeys = new Set(Object.keys(properties)); - for (const key of Object.keys(manifest)) { - expect(allowedKeys.has(key), `manifest property ${key}`).toBe(true); - } - for (const key of required as string[]) { - expect(manifest, `required manifest property ${key}`).toHaveProperty(key); - } - - const nameSchema = properties['name']; - expect(manifest.name.length).toBeGreaterThanOrEqual( - nameSchema?.['minLength'] as number, - ); - expect(manifest.name.length).toBeLessThanOrEqual( - nameSchema?.['maxLength'] as number, - ); - const capabilitySchema = properties['capabilities']; - const capabilityItemSchema = capabilitySchema?.['items'] as Record< - string, - unknown - >; - expect(manifest.capabilities.length).toBeGreaterThanOrEqual( - capabilitySchema?.['minItems'] as number, - ); - expect(new Set(manifest.capabilities)).toHaveLength( - manifest.capabilities.length, - ); - for (const capability of manifest.capabilities) { - expect(capability).toBeTypeOf('string'); - expect(capability.length).toBeLessThanOrEqual( - capabilityItemSchema['maxLength'] as number, - ); - } - const hashSchema = properties['hashes']?.['additionalProperties'] as Record< - string, - unknown - >; - const hashPattern = new RegExp(hashSchema['pattern'] as string, 'u'); - for (const [relativePath, hash] of Object.entries(manifest.hashes)) { - expect(relativePath).not.toBe('cases/representative/manifest.json'); - expect(hash, relativePath).toMatch(hashPattern); - } +function removeRawPresentationFields( + blocks: readonly DaemonTranscriptBlock[], +): DaemonTranscriptBlock[] { + const forbidden = new Set([ + 'rawInput', + 'rawOutput', + 'content', + 'toolCall', + 'details', + 'locations', + 'meta', + ]); + return JSON.parse( + JSON.stringify(blocks, (key, value) => + forbidden.has(key) ? undefined : value, + ), + ) as DaemonTranscriptBlock[]; } -function collectDeclaredSchemaProperties( +function collectObjectKeys( value: unknown, - names = new Set(), + keys = new Set(), ): Set { if (Array.isArray(value)) { - for (const item of value) collectDeclaredSchemaProperties(item, names); - return names; + for (const item of value) collectObjectKeys(item, keys); + return keys; } - if (!value || typeof value !== 'object') return names; - + if (!value || typeof value !== 'object') return keys; for (const [key, item] of Object.entries(value)) { - if (key === 'properties' && item && typeof item === 'object') { - for (const propertyName of Object.keys(item)) names.add(propertyName); - } - collectDeclaredSchemaProperties(item, names); - } - return names; -} - -function reduceDaemonEvents( - events: readonly DaemonEvent[], -): readonly DaemonTranscriptBlock[] { - let state = createDaemonTranscriptState({ now: 0 }); - for (const event of events) { - state = reduceDaemonTranscriptEvents(state, normalizeDaemonEvent(event), { - now: 0, - }); - } - return state.blocks; -} - -function reduceAcpUpdates( - updates: readonly unknown[], -): readonly DaemonTranscriptBlock[] { - return reduceDaemonEvents( - updates.map( - (update): DaemonEvent => ({ - v: 1, - type: 'session_update', - data: { update }, - }), - ), - ); -} - -function blockSemanticKey(block: DaemonTranscriptBlock): string { - switch (block.kind) { - case 'user': - case 'assistant': - case 'thought': - return `${block.kind}:${block.text}`; - case 'tool': - return `tool:${block.toolCallId}`; - case 'permission': - return `permission:${block.requestId}`; - default: - throw new Error(`Unsupported identity probe block kind: ${block.kind}`); - } -} - -function indexBlocksBySemanticKey( - blocks: readonly DaemonTranscriptBlock[], - label: 'complete' | 'partial', -): ReadonlyMap { - const indexed = new Map(); - for (const block of blocks) { - const key = blockSemanticKey(block); - if (indexed.has(key)) { - throw new Error(`Ambiguous ${label} identity probe semantic key: ${key}`); - } - indexed.set(key, block); + keys.add(key); + collectObjectKeys(item, keys); } - return indexed; + return keys; } -function probeIdentity( - complete: readonly DaemonTranscriptBlock[], - partial: readonly DaemonTranscriptBlock[], -): IdentityCandidateResult { - const completeBySemanticKey = indexBlocksBySemanticKey(complete, 'complete'); - const partialBySemanticKey = indexBlocksBySemanticKey(partial, 'partial'); - const unstableBlockKinds = [ - ...new Set( - [...partialBySemanticKey].flatMap(([key, block]) => { - const completeBlock = completeBySemanticKey.get(key); - if (!completeBlock) { - throw new Error(`Missing complete identity probe block: ${key}`); - } - return completeBlock.id !== block.id ? [block.kind] : []; - }), - ), - ]; - const missingNativeTextIdentity = [ - ...new Set( - complete.flatMap((block) => { - if ( - block.kind !== 'user' && - block.kind !== 'assistant' && - block.kind !== 'thought' - ) { - return []; - } - return block.sourceRecordIds?.length || block.promptId - ? [] - : [block.kind]; - }), - ), - ]; - - expect(unstableBlockKinds.length).toBeGreaterThan(0); - return { - status: 'fail', - stableUnderPartialPrepend: false, - unstableBlockKinds, - missingNativeTextIdentity, - }; -} - -describe('chat transcript contract prevalidation', () => { - it('locks the evidence fixtures, schemas, and fail-first capability decision', () => { +describe('chat transcript cross-host contract', () => { + it('locks fixture hashes, schemas, consumers, and capability decisions', () => { const manifest = readJson( resolve(caseRoot, 'manifest.json'), ); @@ -293,7 +119,7 @@ describe('chat transcript contract prevalidation', () => { const exportSchema = readJson>( resolve(fixtureRoot, 'schema/export-transcript-document-v1.schema.json'), ); - const expectedExport = readJson( + const expectedExport = readJson( resolve(caseRoot, 'expected-export.json'), ); const matrix = readFileSync( @@ -301,39 +127,23 @@ describe('chat transcript contract prevalidation', () => { 'utf8', ); - expectManifestToMatchSchema(manifest, manifestSchema); - const manifestWithUnknownProperty = { - ...manifest, - unknownProperty: true, - }; - expect(() => - expectManifestToMatchSchema(manifestWithUnknownProperty, manifestSchema), - ).toThrow(/manifest property unknownProperty/u); expect(manifest.fixtureVersion).toBe(1); - expect(manifest.complete).toBe(true); - expect(new Set(manifest.sources)).toEqual( - new Set(['daemon', 'acp', 'chat-records']), - ); - expect(new Set(manifest.consumers)).toEqual( - new Set(['web', 'tauri', 'vscode', 'html']), - ); expect(manifest.name).toBe('representative'); - expect(manifest.generatorVersion).toBe( - 'chat-transcript-prevalidation-evidence-v1', - ); + expect(manifest.generatorVersion).toBe('chat-transcript-prevalidation-v1'); expect(new Set(manifest.capabilities)).toEqual( new Set([ - 'semantic-projection', - 'runtime-raw-compatibility', - 'stable-identity-prepend-probe', - 'export-document-schema', - 'two-mr-migration-gate', + 'text-thinking-usage-images', + 'streaming-replay-prepend', + 'tools-plan-permission', + 'render-action-identity', + 'scope-generation', + 'export-security-network-budgets', ]), ); - expect(manifest.expectedDiagnostics).toEqual([ - 'direct_daemon_unstable_identity', - 'acp_unstable_identity', - ]); + expect(new Set(manifest.consumers)).toEqual( + new Set(['web', 'tauri', 'vscode', 'html']), + ); + expect(manifest.expectedDiagnostics).toEqual([]); expect(manifest.normalizedFields).toEqual([ 'clientReceivedAt', 'createdAt', @@ -341,12 +151,32 @@ describe('chat transcript contract prevalidation', () => { ]); expect(manifestSchema['additionalProperties']).toBe(false); expect(exportSchema['additionalProperties']).toBe(false); - const exportDefinitions = exportSchema['$defs'] as Record; - const blockSchema = exportDefinitions['block'] as { - oneOf: Array<{ $ref: string }>; + const metadataSchema = exportDefinitions['metadata'] as { + properties: Record; }; - expect(blockSchema.oneOf).toHaveLength(10); + expect(metadataSchema.properties).not.toHaveProperty('sessionLabel'); + const toolPreviewSchema = exportDefinitions['toolPreview'] as { + oneOf: Array>; + }; + expect(toolPreviewSchema.oneOf).toHaveLength(14); + expect( + toolPreviewSchema.oneOf + .filter((entry) => !('$ref' in entry)) + .every((entry) => entry['additionalProperties'] === false), + ).toBe(true); + const permissionBlockSchema = exportDefinitions['permissionBlock'] as { + properties: { + resolved: { enum: string[] }; + }; + }; + expect(permissionBlockSchema.properties.resolved.enum).toEqual([ + 'approved', + 'rejected', + 'cancelled', + 'expired', + 'resolved', + ]); for (const definitionName of ['statusBlock', 'errorBlock']) { const definition = exportDefinitions[definitionName] as { properties: { errorKind: { enum: string[] } }; @@ -355,20 +185,7 @@ describe('chat transcript contract prevalidation', () => { expectedExport.frozenErrorKinds, ); } - for (const errorKind of expectedExport.frozenErrorKinds) { - expect( - DAEMON_ERROR_KINDS, - `Export V1 error kind ${errorKind} must remain supported by the SDK`, - ).toContain(errorKind); - } - const declaredExportProperties = - collectDeclaredSchemaProperties(exportSchema); - for (const field of expectedExport.forbiddenFields) { - expect(declaredExportProperties.has(field), field).toBe(false); - } - const permissionOption = exportDefinitions['permissionOption'] as { - properties: { raw: { const: unknown } }; - }; + expect(expectedExport.frozenErrorKinds).toEqual(DAEMON_ERROR_KINDS); const toolBlock = exportDefinitions['toolBlock'] as { properties: Record; }; @@ -381,21 +198,30 @@ describe('chat transcript contract prevalidation', () => { expect(toolBlock.properties).not.toHaveProperty('content'); expect(statusBlock.properties).not.toHaveProperty('data'); expect(errorBlock.properties).not.toHaveProperty('data'); - expect(permissionOption.properties.raw.const).toBeNull(); - expect(expectedExport).toMatchObject({ - schemaVersion: 1, - timestamps: 0, - implementation: 'deferred-to-mr2', - }); - - expect(Object.keys(manifest.hashes).sort()).toEqual( - listFixtureEvidenceFiles(fixtureRoot).sort(), - ); + const blockSchema = exportDefinitions['block'] as { + oneOf: Array<{ $ref: string }>; + }; + expect(blockSchema.oneOf).toHaveLength(10); + for (const { $ref } of blockSchema.oneOf) { + const definitionName = $ref.replace('#/$defs/', ''); + const definition = exportDefinitions[definitionName] as Record< + string, + unknown + >; + expect(definition['additionalProperties']).toBe(false); + const kind = (definition['properties'] as Record)[ + 'kind' + ] as Record; + expect(typeof kind['const']).toBe('string'); + } for (const [relativePath, expectedHash] of Object.entries( manifest.hashes, )) { - expect(sha256(resolve(fixtureRoot, relativePath))).toBe(expectedHash); + expect(sha256(resolve(caseRoot, relativePath))).toBe(expectedHash); } + expect(matrix).toContain('pass; stable under append/prepend/replay'); + expect(matrix).toContain('pass; selected for the later migration phase'); + expect(matrix).not.toMatch(/\b(?:TBD|unknown)\b/i); const exportProperties = exportSchema['properties'] as Record< string, @@ -410,62 +236,57 @@ describe('chat transcript contract prevalidation', () => { '1.2.3-beta.1+build.7', 'a'.repeat(64), ]) { - expect(validVersion, validVersion).toMatch(rendererVersionPattern); + expect(rendererVersionPattern.test(validVersion), validVersion).toBe( + true, + ); } for (const invalidVersion of [ - 'LATEST', 'latest', - '1.0.0 - 2.0.0', - '1.x', - '1.0.0 || 2.0.0', '^1.2.3', - '~1.2.3', - '*', - '>=1.0.0', + '>=1.2.3', + '1.2', + '1.2.3 || 2.0.0', + 'not-a-version', ]) { - expect(invalidVersion, invalidVersion).not.toMatch( - rendererVersionPattern, + expect(rendererVersionPattern.test(invalidVersion), invalidVersion).toBe( + false, ); } - expect(matrix).toContain('FAIL — migration blocked'); - expect(matrix).toContain('No VS Code transport is selected in MR1'); - expect(matrix).not.toMatch(/pass; selected/i); }); - it('preserves current ChatRecord and Web Shell runtime semantics', () => { - const records = readJsonLines( - resolve(caseRoot, 'chat-records.jsonl'), - ); + it('keeps document semantics after all raw renderer fields are removed', () => { + const records = readJsonLines(resolve(caseRoot, 'chat-records.jsonl')); const expected = readJson( resolve(caseRoot, 'expected-model.json'), ); const expectedRender = readJson( resolve(caseRoot, 'expected-render-items.json'), ); + const expectedExport = readJson( + resolve(caseRoot, 'expected-export.json'), + ); const projection = projectChatRecordsToDaemonTranscript(records); - const messages = transcriptBlocksToDaemonMessages(projection.blocks); - const toolBlock = projection.blocks.find((block) => block.kind === 'tool'); - const toolMessage = messages.find( - (message) => message.role === 'tool_group', + const rawFreeBlocks = removeRawPresentationFields(projection.blocks); + const messages = transcriptBlocksToDaemonMessages(rawFreeBlocks, { + safeToolProjection: true, + }); + const renderEvidence = probeTranscriptRenderIdentity(rawFreeBlocks, { + safeToolProjection: true, + }); + const exportDocument = createExportTranscriptDocumentV1( + records, + { startTime: '2026-08-16T00:00:00.000Z' }, + { + rendererVersion: '0.21.11-contract-probe.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, ); + const exportedKeys = collectObjectKeys(exportDocument); expect(projection.complete).toBe(true); - expect(projection.diagnostics).toEqual([]); expect(projection.blocks.map((block) => block.kind)).toEqual( expected.kinds, ); - expect( - projection.blocks.flatMap((block) => { - switch (block.kind) { - case 'user': - case 'assistant': - case 'thought': - return [block.text]; - default: - return []; - } - }), - ).toEqual(expected.texts); expect( projection.blocks.map((block) => block.sourceRecordIds ?? []), ).toEqual(expected.sourceRecordIds); @@ -473,119 +294,216 @@ describe('chat transcript contract prevalidation', () => { expectedRender.roles, ); expect( - messages.flatMap((message) => { - switch (message.role) { - case 'user': - case 'thinking': - case 'assistant': - return [message.content]; - default: - return []; - } - }), - ).toEqual(expectedRender.expectedTextContent); - expect(toolBlock).toMatchObject({ - rawInput: expectedRender.expectedToolArgs, - rawOutput: expectedRender.expectedToolResult, - }); - expect(toolMessage).toMatchObject({ - tools: [ - { - args: expectedRender.expectedToolArgs, - rawOutput: expectedRender.expectedToolResult, - }, - ], + messages.find((message) => message.role === 'tool_group')?.tools[0] + ?.rawOutput, + ).toBe(expected.rawFreeToolResult); + expect( + new Set(renderEvidence.items.flatMap((item) => item.capabilities)), + ).toEqual(new Set(expectedRender.requiredCapabilities)); + expect(renderEvidence.items.every((item) => item.renderedItemId)).toBe( + true, + ); + expect( + renderEvidence.items.every((item) => item.sourceBlockIds.length > 0), + ).toBe(true); + for (const field of expectedRender.identityFields) { + expect( + renderEvidence.items.every((item) => Object.hasOwn(item, field)), + field, + ).toBe(true); + } + expect(JSON.stringify(renderEvidence)).not.toContain( + expected.rawFreeToolResult, + ); + expect(renderEvidence.actions.copyAll.renderedItemIds).toEqual( + renderEvidence.items.map((item) => item.renderedItemId), + ); + expect(renderEvidence.actions.copyLastReply).toBeDefined(); + expect(renderEvidence.actions.editLastUserMessage).toBeDefined(); + expect(renderEvidence.actions.openFiles.length).toBeGreaterThan(0); + expect(exportDocument.schemaVersion).toBe(expectedExport.schemaVersion); + expect( + exportDocument.blocks.find((block) => block.kind === 'tool') + ?.resultPreview, + ).toMatchObject({ + kind: 'text', + text: expectedExport.expectedToolResult, }); - expect(expectedRender.runtimeFields).toEqual(['rawInput', 'rawOutput']); + expect( + exportDocument.blocks.every( + (block) => + block.clientReceivedAt === expectedExport.timestamps && + block.createdAt === expectedExport.timestamps && + block.updatedAt === expectedExport.timestamps, + ), + ).toBe(true); + for (const field of expectedExport.forbiddenFields) { + expect(exportedKeys.has(field), field).toBe(false); + } }); - it('records both VS Code identity candidates as reproducible blockers', () => { - const daemonEvents = readJsonLines( + it('keeps identity stable in both VS Code candidates', () => { + const daemonEvents = readJsonLines( resolve(caseRoot, 'daemon-events.jsonl'), - ); - const acpUpdates = readJsonLines( + ) as DaemonEvent[]; + const acpUpdates = readJsonLines( resolve(caseRoot, 'acp-session-updates.jsonl'), ); - const expectedGate = readJson( - resolve(caseRoot, 'expected-gate.json'), - ); - const observedGate: ExpectedGate = { - overall: 'fail', - selectedVscodePath: null, - candidates: { - directDaemon: probeIdentity( - reduceDaemonEvents(daemonEvents), - reduceDaemonEvents(daemonEvents.slice(1)), - ), - acp: probeIdentity( - reduceAcpUpdates(acpUpdates), - reduceAcpUpdates(acpUpdates.slice(1)), - ), + const direct = probeDirectDaemonTranscript(daemonEvents, context, context); + const directTail = probeDirectDaemonTranscript( + daemonEvents.slice(1), + context, + context, + ); + const acp = probeAcpTranscriptUpdates(acpUpdates, context, context); + const liveIdentity = new TranscriptUpdateIdentityProjector(); + const liveAcpUpdate = liveIdentity.project( + { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'live answer' }, + } as SessionUpdate, + 'session-a########1', + ); + const liveAcp = probeAcpTranscriptUpdates( + [liveAcpUpdate], + context, + context, + ); + const taggedAcpSegments = ['first ', 'second'].map((text, index) => ({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + _meta: { + qwenTranscript: { + segmentId: `record-${index + 1}:0`, + sourceRecordIds: [`record-${index + 1}`], + }, }, - blockers: [ - 'direct-daemon uses reducer ordinal block IDs that change when history is prepended', - 'ACP text updates do not carry a stable source identity and inherit the same ordinal block IDs', - ], - }; - - expect(observedGate).toEqual(expectedGate); - }); - - it('fails closed on ambiguous identity keys and records kind sets', () => { - const assistantBlock = ( - id: string, - text: string, - ): DaemonTranscriptBlock => ({ - id, - kind: 'assistant', - clientReceivedAt: 0, - createdAt: 0, - updatedAt: 0, - text, + })); + const completeTaggedAcp = probeAcpTranscriptUpdates( + taggedAcpSegments, + context, + context, + ); + const tailTaggedAcp = probeAcpTranscriptUpdates( + taggedAcpSegments.slice(1), + context, + context, + ); + const stale = probeDirectDaemonTranscript(daemonEvents, context, { + ...context, + generation: context.generation + 1, }); + const directRender = probeTranscriptRenderIdentity(direct.model.blocks); + const directTailRender = probeTranscriptRenderIdentity( + directTail.model.blocks, + ); + const acpTail = probeAcpTranscriptUpdates( + acpUpdates.slice(1), + context, + context, + ); + const acpRender = probeTranscriptRenderIdentity(acp.model.blocks); + const acpTailRender = probeTranscriptRenderIdentity(acpTail.model.blocks); + const firstDelta = { + id: 60, + v: 1, + type: 'session_update', + promptId: 'prompt-multi-delta', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'first ' }, + _meta: { + qwenTranscript: { segmentId: 'prompt-multi-delta:assistant:0' }, + }, + }, + }, + } satisfies DaemonEvent; + const secondDelta = { + ...firstDelta, + id: 61, + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'second' }, + _meta: { + qwenTranscript: { segmentId: 'prompt-multi-delta:assistant:0' }, + }, + }, + }, + } satisfies DaemonEvent; + const completeDeltaBlock = probeDirectDaemonTranscript( + [firstDelta, secondDelta], + context, + context, + ); + const tailDeltaBlock = probeDirectDaemonTranscript( + [secondDelta], + context, + context, + ); + const completeRender = probeTranscriptRenderIdentity( + completeDeltaBlock.model.blocks, + ); + const tailRender = probeTranscriptRenderIdentity( + tailDeltaBlock.model.blocks, + ); - expect(() => - probeIdentity( - [assistantBlock('complete-1', 'duplicate')], - [ - assistantBlock('partial-1', 'duplicate'), - assistantBlock('partial-2', 'duplicate'), - ], - ), - ).toThrow(/Ambiguous partial identity probe semantic key/u); - + expect(direct.diagnostics).toEqual([]); expect( - probeIdentity( - [ - assistantBlock('complete-1', 'first'), - assistantBlock('complete-2', 'second'), - ], - [ - assistantBlock('partial-1', 'first'), - assistantBlock('partial-2', 'second'), - ], - ), - ).toEqual({ - status: 'fail', - stableUnderPartialPrepend: false, - unstableBlockKinds: ['assistant'], - missingNativeTextIdentity: ['assistant'], + direct.identities.every((item) => item.sourceIdentity.length > 0), + ).toBe(true); + expect(directTail.model.blocks.map((block) => block.id)).toEqual( + direct.model.blocks.slice(1).map((block) => block.id), + ); + expect(directTailRender.items).toEqual(directRender.items.slice(1)); + expect(directTailRender.actions.copyLastReply).toEqual( + directRender.actions.copyLastReply, + ); + expect(directTailRender.actions.openFiles).toEqual( + directRender.actions.openFiles, + ); + expect(acp.diagnostics).toEqual([]); + expect(acp.identities.every((item) => item.sourceIdentity.length > 0)).toBe( + true, + ); + expect(acpTail.diagnostics).toEqual([]); + expect(acpTail.model.blocks.map((block) => block.id)).toEqual( + acp.model.blocks.slice(1).map((block) => block.id), + ); + expect(acpTailRender.items).toEqual(acpRender.items.slice(1)); + expect(acpTailRender.actions.copyLastReply).toEqual( + acpRender.actions.copyLastReply, + ); + expect(acpTailRender.actions.openFiles).toEqual( + acpRender.actions.openFiles, + ); + expect(completeDeltaBlock.model.blocks[0]?.id).toBe( + tailDeltaBlock.model.blocks[0]?.id, + ); + expect(completeDeltaBlock.diagnostics).toEqual([]); + expect(tailDeltaBlock.diagnostics).toEqual([]); + expect(completeRender.items[0]?.renderedItemId).toBe( + tailRender.items[0]?.renderedItemId, + ); + expect(completeRender.actions.copyLastReply?.renderedItemId).toBe( + tailRender.actions.copyLastReply?.renderedItemId, + ); + expect(completeRender.actions.copyLastReply?.semanticHash).not.toBe( + tailRender.actions.copyLastReply?.semanticHash, + ); + expect(completeTaggedAcp.model.blocks[1]?.id).toBe( + tailTaggedAcp.model.blocks[0]?.id, + ); + expect(completeTaggedAcp.identities[1]?.sourceIdentity).toEqual( + tailTaggedAcp.identities[0]?.sourceIdentity, + ); + expect(liveAcp.diagnostics).toEqual([]); + expect(liveAcp.identities[0]?.sourceIdentity[0]).toBe('segmentId'); + expect(stale.model.blocks).toEqual([]); + expect(stale.diagnostics).toContainEqual({ + code: 'stale_scope_generation_ignored', + severity: 'info', }); - - expect(() => - probeIdentity( - [ - { - id: 'status-1', - kind: 'status', - clientReceivedAt: 0, - createdAt: 0, - updatedAt: 0, - text: 'status', - }, - ], - [], - ), - ).toThrow(/Unsupported identity probe block kind: status/u); }); }); diff --git a/integration-tests/chat-transcript-document.test.ts b/integration-tests/chat-transcript-document.test.ts new file mode 100644 index 00000000000..267a2d90f4e --- /dev/null +++ b/integration-tests/chat-transcript-document.test.ts @@ -0,0 +1,865 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { performance as nodePerformance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { build as esbuild } from 'esbuild'; +import { afterEach, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import type { DaemonEvent } from '@qwen-code/sdk/daemon'; +import { TranscriptUpdateIdentityProjector } from '../packages/cli/src/acp-integration/session/transcript-update-identity.js'; +import { + EXPORT_TRANSCRIPT_LIMITS_V1, + assertExportTranscriptDocumentV1, + createExportTranscriptDocumentV1, + type ExportTranscriptBlockV1, + type ExportTranscriptDocumentV1, +} from '../packages/cli/src/ui/utils/export/export-transcript-document.js'; +import { + probeAcpTranscriptUpdates, + probeDirectDaemonTranscript, + type TranscriptAdapterProbeResult, +} from '../packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.js'; +import { probeTranscriptRenderIdentity } from '../packages/web-shell/client/adapters/transcriptRenderProbe.js'; + +const RENDERER_VERSION = '0.21.11-contract-probe.1'; +const EXPORTED_AT = '2026-08-16T01:00:00.000Z'; +const CANARY = 'CHAT_TRANSCRIPT_TEST_SECRET_DO_NOT_EXPORT'; +const MAX_DOCUMENT_DURATION_MS = 60_000; +const MAX_HEAP_DELTA_BYTES = 512 * 1024 * 1024; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const fixtureRoot = resolve( + repoRoot, + 'integration-tests/fixtures/chat-transcript-contract/v1', +); + +interface ExpectedNetwork { + readonly unexpectedRequests: number; + readonly cspViolations: number; + readonly allowedImageSources: readonly string[]; +} + +interface VscodeIdentityGate { + readonly directDaemon: 'pass' | 'fail'; + readonly acp: 'pass' | 'fail'; + readonly selectedPath: 'acp' | 'direct-daemon' | null; + readonly blockers: readonly string[]; +} + +const expectedNetwork = JSON.parse( + readFileSync( + resolve(fixtureRoot, 'cases/representative/expected-network.json'), + 'utf8', + ), +) as ExpectedNetwork; + +function record( + uuid: string, + parentUuid: string | null, + type: 'user' | 'assistant', + text: string, +): Record { + return { + uuid, + parentUuid, + sessionId: 'synthetic-session', + timestamp: '2026-08-16T00:00:00.000Z', + cwd: '/workspace/project', + version: 'test', + type, + message: { + role: type === 'user' ? 'user' : 'model', + parts: [{ text }], + }, + }; +} + +function createMaximumDocument(): ExportTranscriptDocumentV1 { + const records: Record[] = []; + for ( + let index = 0; + index < EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks; + index += 1 + ) { + const uuid = `record-${index}`; + const marker = + index === 0 + ? 'FIRST_SEARCH_NEEDLE' + : index === EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks - 1 + ? 'LAST_SEARCH_NEEDLE' + : `block-${index}`; + records.push( + record( + uuid, + index === 0 ? null : `record-${index - 1}`, + index % 2 === 0 ? 'user' : 'assistant', + `${marker} ${'x'.repeat(7_950)}`, + ), + ); + } + const document = createExportTranscriptDocumentV1( + records, + { + startTime: '2026-08-16T00:00:00.000Z', + metadata: { + sessionId: `hidden-${CANARY}`, + startTime: '2026-08-16T00:00:00.000Z', + exportTime: EXPORTED_AT, + cwd: '/workspace/project', + gitRepo: 'qwen-code', + gitBranch: 'contract-probe', + model: 'synthetic-model', + channel: 'cli', + promptCount: 500, + totalTokens: 1_000, + filesWritten: 0, + linesAdded: 0, + linesRemoved: 0, + uniqueFiles: [`/workspace/${CANARY}.ts`], + }, + }, + { rendererVersion: RENDERER_VERSION, exportedAt: EXPORTED_AT }, + ); + const blocks: ExportTranscriptBlockV1[] = [...document.blocks]; + blocks[10] = { + id: blocks[10]!.id, + kind: 'thought', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: `DOCUMENT_THINKING_DETAIL ${'x'.repeat(7_950)}`, + streaming: false, + }; + blocks[11] = { + id: blocks[11]!.id, + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'tool-call-document', + title: 'Document shell result', + status: 'completed', + toolName: 'shell', + toolKind: 'execute', + preview: { kind: 'command', command: 'printf document' }, + resultPreview: { + kind: 'text', + text: `DOCUMENT_TOOL_DETAIL ${'x'.repeat(7_950)}`, + }, + }; + blocks[12] = { + id: blocks[12]!.id, + kind: 'assistant', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: [ + 'DOCUMENT_RICH_CONTENT', + '```mermaid', + 'graph TD; A[Export] --> B[Document]', + '```', + '```echarts', + '{"title":{"text":"DOCUMENT_CHART_FALLBACK"},"series":[]}', + '```', + 'Inline math: $E=mc^2$', + 'x'.repeat(7_800), + ].join('\n'), + streaming: false, + }; + blocks[13] = { + id: blocks[13]!.id, + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'agent-document-1', + title: 'Review export contract', + status: 'cancelled', + toolName: 'agent', + toolKind: 'think', + preview: { + kind: 'subagent_delegation', + agentName: 'reviewer', + task: 'Review the document contract', + }, + }; + blocks[14] = { + id: blocks[14]!.id, + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'nested-document-tool', + title: 'Read nested evidence', + status: 'completed', + toolName: 'read', + toolKind: 'read', + preview: { kind: 'file_read', path: 'contract.md' }, + resultPreview: { kind: 'text', text: 'DOCUMENT_NESTED_TOOL_DETAIL' }, + parentToolCallId: 'agent-document-1', + parentBlockId: blocks[13]!.id, + }; + blocks[15] = { + id: blocks[15]!.id, + kind: 'assistant', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: 'DOCUMENT_SUBAGENT_STREAM', + streaming: false, + parentToolCallId: 'agent-document-1', + }; + blocks[16] = { + id: blocks[16]!.id, + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'agent-document-2', + title: 'Audit export security', + status: 'completed', + toolName: 'agent', + toolKind: 'think', + preview: { + kind: 'subagent_delegation', + agentName: 'security-reviewer', + task: 'Audit the document security boundary', + }, + resultPreview: { + kind: 'text', + text: ['DOCUMENT_SUBAGENT_RESULT', 'DOCUMENT_PARALLEL_AGENT_RESULT'].join( + '\n', + ), + }, + }; + blocks[17] = { + id: blocks[17]!.id, + kind: 'user_shell', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + command: 'printf user-shell', + cwd: 'project', + text: `DOCUMENT_USER_SHELL_DETAIL ${'x'.repeat(7_900)}`, + }; + blocks[19] = { + id: blocks[19]!.id, + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'diff-document', + title: 'Document diff', + status: 'completed', + toolName: 'edit', + toolKind: 'edit', + preview: { + kind: 'file_diff', + path: 'document.ts', + oldText: Array.from({ length: 180 }, (_, index) => `-old ${index}`).join( + '\n', + ), + newText: [ + 'DOCUMENT_DIFF_DETAIL', + ...Array.from({ length: 180 }, (_, index) => `+new ${index}`), + ].join('\n'), + }, + resultPreview: { kind: 'text', text: 'Document diff completed' }, + }; + return { ...document, blocks }; +} + +async function buildWebShellDocumentBundle(): Promise { + const distEntry = resolve(repoRoot, 'packages/web-shell/dist/index.js'); + const distMtime = statSync(distEntry).mtimeMs; + const clientRoot = resolve(repoRoot, 'packages/web-shell/client'); + const productionSourceMtimes: number[] = []; + const visitSources = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) { + visitSources(path); + } else if ( + /\.(?:css|ts|tsx)$/.test(entry.name) && + !/\.(?:test|spec)\.(?:ts|tsx)$/.test(entry.name) + ) { + productionSourceMtimes.push(statSync(path).mtimeMs); + } + } + }; + visitSources(clientRoot); + if (productionSourceMtimes.some((mtime) => mtime > distMtime)) { + throw new Error( + 'Web Shell dist is stale; run npm run build --workspace=packages/web-shell.', + ); + } + const bundled = await esbuild({ + stdin: { + contents: ` + import React from 'react'; + import { createRoot } from 'react-dom/client'; + import { assertExportTranscriptDocumentV1 } from './packages/cli/src/ui/utils/export/export-transcript-document.ts'; + import { WebShellTranscript } from './packages/web-shell/dist/index.js'; + const envelope = document.getElementById('transcript'); + const rootNode = document.getElementById('app'); + if (!(envelope instanceof HTMLScriptElement) || !rootNode) throw new Error('Transcript document root is missing.'); + const serialized = envelope.textContent ?? ''; + if (new TextEncoder().encode(serialized).byteLength > ${EXPORT_TRANSCRIPT_LIMITS_V1.maxEnvelopeBytes}) throw new Error('Transcript document exceeds the envelope budget.'); + const value = JSON.parse(serialized); + assertExportTranscriptDocumentV1(value); + if (value.rendererVersion !== '${RENDERER_VERSION}') throw new Error('Transcript renderer version is unsupported.'); + createRoot(rootNode).render(React.createElement(WebShellTranscript, { + blocks: value.blocks, + renderMode: 'document', + compactThinking: true, + theme: 'light', + })); + const markComplete = () => { + const text = document.body.innerText; + if (text.includes('FIRST_SEARCH_NEEDLE') && text.includes('LAST_SEARCH_NEEDLE')) { + document.body.dataset.renderComplete = 'true'; + return; + } + requestAnimationFrame(markComplete); + }; + requestAnimationFrame(markComplete); + `, + resolveDir: repoRoot, + sourcefile: 'document-browser-entry.js', + }, + bundle: true, + format: 'iife', + platform: 'browser', + target: 'chrome120', + minify: true, + write: false, + define: { 'process.env.NODE_ENV': '"production"' }, + }); + const code = bundled.outputFiles[0]?.text; + if (!code) throw new Error('Web Shell browser bundle is empty.'); + return code.replace(/<\/script/gi, '<\\/script'); +} + +let webShellDocumentBundle: Promise | undefined; + +function getWebShellDocumentBundle(): Promise { + webShellDocumentBundle ??= buildWebShellDocumentBundle(); + return webShellDocumentBundle; +} + +function buildDocumentProbeHtml( + document: ExportTranscriptDocumentV1, + browserBundle: string, +): string { + assertExportTranscriptDocumentV1(document); + const envelope = JSON.stringify(document).replaceAll('<', '\\u003c'); + return ` + + + + + + + +
+ + + + +`; +} + +function writeGateReport(evidence: { + readonly durationMs: number; + readonly heapDeltaBytes: number; + readonly envelopeBytes: number; + readonly pdfBytes: number; + readonly copiedLength: number; + readonly renderedItemCount: number; + readonly sourceBlockCount: number; + readonly requests: readonly string[]; + readonly cspErrors: readonly string[]; +}): void { + const outputRoot = process.env['INTEGRATION_TEST_FILE_DIR']; + if (!outputRoot) return; + const manifest = JSON.parse( + readFileSync( + resolve(fixtureRoot, 'cases/representative/manifest.json'), + 'utf8', + ), + ) as { hashes: Readonly> }; + const matrix = readFileSync( + resolve(fixtureRoot, 'capability-matrix.md'), + 'utf8', + ); + const vscodeIdentity = evaluateVscodeIdentityGate(); + const identityPassed = + vscodeIdentity.directDaemon === 'pass' && vscodeIdentity.acp === 'pass'; + writeFileSync( + resolve(outputRoot, 'chat-transcript-gate-report.json'), + `${JSON.stringify( + { + schemaVersion: 1, + generatedBy: 'chat-transcript-contract prevalidation tests', + overall: identityPassed ? 'pass' : 'fail', + selectedVscodePath: vscodeIdentity.selectedPath, + vscodeCandidates: { + directDaemon: vscodeIdentity.directDaemon, + acp: vscodeIdentity.acp, + }, + vscodeBlockers: vscodeIdentity.blockers, + gates: { + semantic: { + status: 'pass', + evidence: 'chat-transcript-contract.test.ts', + }, + identityAction: { + status: identityPassed ? 'pass' : 'fail', + evidence: + 'source-stamped live ACP plus direct-daemon/ACP append, partial-prepend, replay, render and action identity fixtures', + }, + exportSecurity: { + status: 'pass', + evidence: 'ExportTranscriptDocument allowlist and canary tests', + }, + resourceNetwork: { + status: 'pass', + evidence: + 'built WebShellTranscript document-mode Chromium open/search/copy/print probe', + }, + }, + thresholds: { + maxDurationMs: MAX_DOCUMENT_DURATION_MS, + maxHeapDeltaBytes: MAX_HEAP_DELTA_BYTES, + maxBlocks: EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks, + maxEnvelopeBytes: EXPORT_TRANSCRIPT_LIMITS_V1.maxEnvelopeBytes, + unexpectedRequests: expectedNetwork.unexpectedRequests, + cspViolations: expectedNetwork.cspViolations, + }, + observed: { + ...evidence, + durationMs: Math.round(evidence.durationMs), + }, + fixtureHashes: manifest.hashes, + capabilityMatrixSha256: createHash('sha256') + .update(matrix) + .digest('hex'), + }, + null, + 2, + )}\n`, + ); +} + +function evaluateVscodeIdentityGate(): VscodeIdentityGate { + const caseRoot = resolve(fixtureRoot, 'cases/representative'); + const daemonEvents = readJsonLines( + resolve(caseRoot, 'daemon-events.jsonl'), + ) as DaemonEvent[]; + const acpUpdates = readJsonLines( + resolve(caseRoot, 'acp-session-updates.jsonl'), + ); + const context = { + scopeKey: 'workspace-a:session-a', + generation: 3, + } as const; + const direct = probeDirectDaemonTranscript(daemonEvents, context, context); + const directTail = probeDirectDaemonTranscript( + daemonEvents.slice(1), + context, + context, + ); + const acp = probeAcpTranscriptUpdates(acpUpdates, context, context); + const acpTail = probeAcpTranscriptUpdates( + acpUpdates.slice(1), + context, + context, + ); + const liveProjector = new TranscriptUpdateIdentityProjector(); + const promptId = 'session-a########1'; + const liveUpdates = ['first ', 'second'].map((text) => + liveProjector.project( + { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + } as SessionUpdate, + promptId, + ), + ); + const live = probeAcpTranscriptUpdates(liveUpdates, context, context); + const liveTail = probeAcpTranscriptUpdates( + liveUpdates.slice(1), + context, + context, + ); + const directPassed = stableTailIdentity(direct, directTail); + const acpPassed = + stableTailIdentity(acp, acpTail) && stableTailIdentity(live, liveTail, 0); + const blockers = [ + ...(directPassed ? [] : ['direct-daemon stable identity matrix failed']), + ...(acpPassed ? [] : ['ACP stable identity matrix failed']), + ]; + return { + directDaemon: directPassed ? 'pass' : 'fail', + acp: acpPassed ? 'pass' : 'fail', + selectedPath: acpPassed ? 'acp' : directPassed ? 'direct-daemon' : null, + blockers, + }; +} + +function stableTailIdentity( + complete: TranscriptAdapterProbeResult, + tail: TranscriptAdapterProbeResult, + completeOffset = 1, +): boolean { + if ( + [...complete.diagnostics, ...tail.diagnostics].some( + (diagnostic) => diagnostic.severity === 'error', + ) + ) { + return false; + } + if ( + JSON.stringify( + complete.model.blocks.slice(completeOffset).map(({ id }) => id), + ) !== JSON.stringify(tail.model.blocks.map(({ id }) => id)) + ) { + return false; + } + const completeRender = probeTranscriptRenderIdentity(complete.model.blocks); + const tailRender = probeTranscriptRenderIdentity(tail.model.blocks); + return ( + JSON.stringify(completeRender.items.slice(completeOffset)) === + JSON.stringify(tailRender.items) && + JSON.stringify(actionIdentity(completeRender.actions.copyLastReply)) === + JSON.stringify(actionIdentity(tailRender.actions.copyLastReply)) && + JSON.stringify(completeRender.actions.openFiles) === + JSON.stringify(tailRender.actions.openFiles) + ); +} + +function actionIdentity( + action: + | { + readonly renderedItemId: string; + readonly sourceBlockIds: readonly string[]; + } + | undefined, +): unknown { + return action + ? { + renderedItemId: action.renderedItemId, + sourceBlockIds: action.sourceBlockIds, + } + : undefined; +} + +function readJsonLines(path: string): unknown[] { + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown); +} + +async function installNetworkAndCspProbe( + page: Page, +): Promise<{ requests: string[]; cspErrors: string[] }> { + const requests: string[] = []; + const cspErrors: string[] = []; + await page.route('**/*', async (route) => { + requests.push(route.request().url()); + await route.abort('blockedbyclient'); + }); + page.on('console', (message) => { + const text = message.text(); + if (/content security policy|refused to/i.test(text)) cspErrors.push(text); + }); + return { requests, cspErrors }; +} + +describe('ExportTranscriptDocument browser gate', () => { + let browser: Browser | undefined; + + afterEach(async () => { + await browser?.close(); + browser = undefined; + }); + + it('keeps machine-readable schema limits aligned with runtime limits', () => { + const schema = JSON.parse( + readFileSync( + resolve( + repoRoot, + 'integration-tests/fixtures/chat-transcript-contract/v1/schema/export-transcript-document-v1.schema.json', + ), + 'utf8', + ), + ) as Record; + const properties = schema['properties'] as Record; + const blocks = properties['blocks'] as Record; + const definitions = schema['$defs'] as Record; + const rasterImage = definitions['rasterImage'] as Record; + const rasterProperties = rasterImage['properties'] as Record< + string, + unknown + >; + const rasterData = rasterProperties['data'] as Record; + const rasterMimeType = rasterProperties['mimeType'] as { + enum: readonly string[]; + }; + const toolPreview = definitions['toolPreview'] as { + oneOf: Array>; + }; + const imageGeneration = toolPreview.oneOf.find( + (entry) => + ( + (entry['properties'] as Record | undefined)?.[ + 'kind' + ] as Record | undefined + )?.['const'] === 'image_generation', + ); + const thumbnailUrl = ( + imageGeneration?.['properties'] as Record + )['thumbnailUrl'] as Record; + + expect(blocks['maxItems']).toBe(EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks); + expect(rasterData['maxLength']).toBe( + Math.ceil(EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes / 3) * 4, + ); + expect(thumbnailUrl['maxLength']).toBe( + Math.ceil(EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes / 3) * 4 + 23, + ); + expect(rasterMimeType.enum.map((mimeType) => `data:${mimeType}`)).toEqual( + expectedNetwork.allowedImageSources, + ); + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!value || typeof value !== 'object') return; + const entry = value as Record; + if (entry['type'] === 'array') { + expect(Number(entry['maxItems'])).toBeLessThanOrEqual( + EXPORT_TRANSCRIPT_LIMITS_V1.maxArrayLength, + ); + } + if (entry['type'] === 'integer') { + expect(entry['maximum']).toBeDefined(); + } + for (const child of Object.values(entry)) visit(child); + }; + visit(schema); + }); + + it('opens, searches, copies, and prints the maximum document with zero network', async () => { + const browserBundle = await getWebShellDocumentBundle(); + const exportDocument = createMaximumDocument(); + const serialized = JSON.stringify(exportDocument); + const renderEvidence = probeTranscriptRenderIdentity( + exportDocument.blocks, + { safeToolProjection: true }, + ); + const renderedSourceBlockIds = new Set( + renderEvidence.items.flatMap((item) => item.sourceBlockIds), + ); + expect(exportDocument.blocks).toHaveLength( + EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks, + ); + expect(renderedSourceBlockIds).toEqual( + new Set(exportDocument.blocks.map((block) => block.id)), + ); + expect(exportDocument.metadata).toMatchObject({ + complete: true, + truncated: false, + }); + const envelopeBytes = new TextEncoder().encode(serialized).byteLength; + expect(envelopeBytes).toBeLessThanOrEqual( + EXPORT_TRANSCRIPT_LIMITS_V1.maxEnvelopeBytes, + ); + expect(serialized).not.toContain(CANARY); + + browser = await chromium.launch({ + headless: true, + args: ['--enable-precise-memory-info'], + }); + const page = await browser.newPage(); + const probe = await installNetworkAndCspProbe(page); + const startedAt = nodePerformance.now(); + const heapBefore = await page.evaluate( + () => + ( + globalThis.performance as Performance & { + memory?: { usedJSHeapSize: number }; + } + ).memory?.usedJSHeapSize ?? 0, + ); + + await page.setContent( + buildDocumentProbeHtml(exportDocument, browserBundle), + { + waitUntil: 'load', + }, + ); + await expect + .poll(() => page.locator('body').getAttribute('data-render-complete')) + .toBe('true'); + await expect + .poll(() => page.locator('div[class*="mermaidInline"] svg').count()) + .toBeGreaterThan(0); + expect(await page.locator('.katex').count()).toBeGreaterThan(0); + expect( + await page.locator('[data-agent-status]').count(), + ).toBeGreaterThanOrEqual(2); + expect(await page.locator('[data-message-row-key]').count()).toBe( + renderEvidence.items.length, + ); + const interaction = await page.evaluate(() => { + const bodyText = globalThis.document.body.innerText; + const range = globalThis.document.createRange(); + range.selectNodeContents(globalThis.document.body); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const copiedLength = selection?.toString().length ?? 0; + selection?.removeAllRanges(); + const clippedByMaxHeight = Array.from( + globalThis.document.querySelectorAll('*'), + ) + .filter((element) => { + const style = getComputedStyle(element); + return ( + style.display !== 'none' && + style.maxHeight !== 'none' && + element.scrollHeight > element.clientHeight + 1 + ); + }) + .map((element) => ({ + tag: element.tagName.toLowerCase(), + className: element.className, + maxHeight: getComputedStyle(element).maxHeight, + })); + return { + firstFound: bodyText.includes('FIRST_SEARCH_NEEDLE'), + lastFound: bodyText.includes('LAST_SEARCH_NEEDLE'), + thinkingFound: bodyText.includes('DOCUMENT_THINKING_DETAIL'), + toolFound: bodyText.includes('DOCUMENT_TOOL_DETAIL'), + richFound: bodyText.includes('DOCUMENT_RICH_CONTENT'), + chartFallbackFound: bodyText.includes('DOCUMENT_CHART_FALLBACK'), + subagentResultFound: bodyText.includes('DOCUMENT_SUBAGENT_RESULT'), + subagentStreamFound: bodyText.includes('DOCUMENT_SUBAGENT_STREAM'), + nestedToolFound: bodyText.includes('DOCUMENT_NESTED_TOOL_DETAIL'), + parallelAgentFound: bodyText.includes('DOCUMENT_PARALLEL_AGENT_RESULT'), + userShellFound: bodyText.includes('DOCUMENT_USER_SHELL_DETAIL'), + diffFound: bodyText.includes('DOCUMENT_DIFF_DETAIL'), + copiedLength, + clippedByMaxHeight, + }; + }); + const pdf = await page.pdf({ printBackground: false }); + const heapAfter = await page.evaluate( + () => + ( + globalThis.performance as Performance & { + memory?: { usedJSHeapSize: number }; + } + ).memory?.usedJSHeapSize ?? 0, + ); + const durationMs = nodePerformance.now() - startedAt; + + expect(interaction).toMatchObject({ + firstFound: true, + lastFound: true, + thinkingFound: true, + toolFound: true, + richFound: true, + chartFallbackFound: true, + subagentResultFound: true, + subagentStreamFound: true, + nestedToolFound: true, + parallelAgentFound: true, + userShellFound: true, + diffFound: true, + clippedByMaxHeight: [], + }); + expect(interaction.copiedLength).toBeGreaterThan(7_000_000); + expect(pdf.byteLength).toBeGreaterThan(1_000); + expect(probe.requests).toHaveLength(expectedNetwork.unexpectedRequests); + expect(probe.cspErrors, probe.cspErrors.join('\n')).toHaveLength( + expectedNetwork.cspViolations, + ); + expect(durationMs).toBeLessThan(MAX_DOCUMENT_DURATION_MS); + const heapDeltaBytes = Math.max(0, heapAfter - heapBefore); + expect(heapDeltaBytes).toBeLessThan(MAX_HEAP_DELTA_BYTES); + writeGateReport({ + durationMs, + heapDeltaBytes, + envelopeBytes, + pdfBytes: pdf.byteLength, + copiedLength: interaction.copiedLength, + renderedItemCount: renderEvidence.items.length, + sourceBlockCount: renderedSourceBlockIds.size, + requests: probe.requests, + cspErrors: probe.cspErrors, + }); + + await page.close(); + await browser.close(); + browser = undefined; + }, 90_000); + + it('removes active Markdown resources before the browser envelope exists', async () => { + const exportDocument = createExportTranscriptDocumentV1( + [ + { + ...record( + 'remote-image', + null, + 'user', + '![tracking](https://example.invalid/track.png)', + ), + rawInput: CANARY, + }, + ], + { + startTime: '2026-08-16T00:00:00.000Z', + metadata: { + sessionId: CANARY, + startTime: '2026-08-16T00:00:00.000Z', + exportTime: EXPORTED_AT, + cwd: '/workspace/project', + gitRepo: 'qwen-code', + gitBranch: 'contract-probe', + model: 'synthetic-model', + channel: 'cli', + promptCount: 1, + totalTokens: 1, + filesWritten: 0, + linesAdded: 0, + linesRemoved: 0, + uniqueFiles: [CANARY], + }, + }, + { rendererVersion: RENDERER_VERSION, exportedAt: EXPORTED_AT }, + ); + const html = buildDocumentProbeHtml( + exportDocument, + await getWebShellDocumentBundle(), + ); + + expect(html).not.toContain('https://example.invalid'); + expect(html).not.toContain(CANARY); + expect(html).toContain("connect-src 'none'"); + expect(html).toContain("object-src 'none'"); + expect(html).toContain("frame-src 'none'"); + expect(html).toContain("media-src 'none'"); + }); +}); diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/capability-matrix.md b/integration-tests/fixtures/chat-transcript-contract/v1/capability-matrix.md index 055800dfbd1..85301541813 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/capability-matrix.md +++ b/integration-tests/fixtures/chat-transcript-contract/v1/capability-matrix.md @@ -1,21 +1,31 @@ -# Chat transcript contract prevalidation matrix +# Chat transcript contract capability matrix -MR1 freezes evidence for the current paths. A green test run means that the -evidence is reproducible; it does not turn a failed migration gate into a pass. +| Capability | Native source | Contract mapping | Render/action mapping | Consumers | Fixture/evidence | Owner | Gate | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------- | ----------------------------------------------------- | ------------------ | -------------------------------------------- | +| user/assistant/thought | prompt-scoped live or persisted segment ID | runtime ordinal ID unchanged; candidate probe projects source-keyed IDs | stable source IDs and semantic-copy hash | Web, Tauri, VS Code, HTML | `representative`, SDK append/prepend matrix | CLI + SDK UI | pass; stable under append/prepend/replay | +| tools and grouping | tool call ID | runtime keeps raw fields; document/export uses typed input and result preview only | group keeps every block/tool call ID; file target is semantic | Web runtime + document | raw compatibility and raw-free document tests | SDK UI + Web Shell | pass | +| plan/todo | plan tool call ID and plan ID | runtime keeps raw fields; document/export uses typed `todo_list` preview/result | standalone deterministic tool item | Web runtime + document | raw compatibility and raw-free todo document tests | SDK UI + Web Shell | pass | +| permission history | request ID and safe tool identity | runtime keeps raw tool call; document/export uses safe identity only | resolved history maps to stable tool target | Web runtime + document | raw compatibility and raw-free permission tests | SDK UI + Web Shell | pass | +| replay/prepend | prompt-scoped live or persisted segment ID | source metadata and persisted record boundaries are preserved | probe compares stable block/item IDs and semantic hash | all | SDK, direct-daemon and ACP append/prepend tests | CLI + SDK UI | pass | +| scope isolation | host scope key + generation | stale input is rejected before reduction | stale action evidence is absent | VS Code | VS Code contract probe | VS Code | pass | +| VS Code direct daemon | daemon event plus source segment ID | unchanged SDK reducer plus read-only stable-ID projection | shared Web Shell render/action probe | VS Code | direct-daemon SDK contract probe | VS Code | pass; retained as a validated alternative | +| VS Code ACP | Qwen ACP live/history segment metadata | thin SDK normalizer/reducer plus read-only stable-ID projection | shared Web Shell render/action probe | VS Code | ACP source + contract probes | VS Code | pass; selected for the later migration phase | +| Tauri distribution | packaged qwen runtime | same daemon blocks and Web Shell build | same renderer artifact | Tauri | existing Desktop runtime smoke outside this matrix | Desktop | deferred; installed artifact not certified | +| export record policy | ChatRecord type/subtype and parent chain | known visible records only | canonical projector output | HTML | export policy unit test | CLI | pass | +| export block safety | projected block | per-kind allowlist, opaque IDs, zeroed timestamps | direct raw-free renderer input | HTML | export schema/canary tests | CLI | pass | +| Markdown/resources | Markdown image URL and structured raster | approved data raster only in document mode | no automatic remote image source | HTML | Markdown document test + browser request interception | Web Shell | pass | +| document budgets | shared V1 constant | block/text/image/envelope/depth/array/object-property/rich-task caps | non-virtualized document probe | HTML | maximum document browser probe | CLI + Web Shell | pass | -| Capability | Current path under test | Evidence | MR1 gate | Follow-up owner | -| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | ---------------------------- | ----------------------- | -| ChatRecord semantic projection | persisted records → SDK transcript projector | representative record fixture and semantic snapshot | PASS | existing SDK path | -| Web Shell runtime compatibility | SDK blocks → default interactive/read-only adapter | roles plus unchanged `rawInput`/`rawOutput` assertions | PASS | existing Web Shell path | -| `write_file` Turn Output | raw tool input → complete file diff | focused Web Shell regression | PASS | existing Web Shell path | -| direct-daemon identity | daemon envelopes → current SDK reducer | full history versus partial-prepend probe | **FAIL — migration blocked** | MR2 | -| ACP identity | ACP session updates → current SDK reducer | full history versus partial-prepend probe | **FAIL — migration blocked** | MR2 | -| Export document contract | frozen V1 schema and security allowlist | schema and hash assertions only | DEFERRED | MR2 | -| document-mode rendering | sanitized export document → Web Shell document mode | no production consumer or browser probe in MR1 | DEFERRED | MR2 | -| VS Code migration | selected transport → shared ChatPanel contract | depends on a passing identity gate | BLOCKED | MR2 | -| Desktop reuse | packaged Web Shell artifact | no installed-artifact behavior probe in MR1 | DEFERRED | existing Desktop path | +This matrix does not certify the installed Desktop artifact from source-text +inspection. The existing Desktop runtime smoke remains the behavioral evidence +for the packaged Web Shell layout. -No VS Code transport is selected in MR1. Both current candidate paths use -reducer-ordinal block IDs, and the ACP text updates also lack a native stable -source identity. MR2 must resolve and verify those facts before selecting a -transport or wiring a production consumer. +Both VS Code candidates pass the stable identity matrix without changing the +default reducer's ordinal runtime IDs. Qwen ACP prompt-bound live text is +stamped at source with a prompt-scoped deterministic segment ID; persisted +replay keeps its record-derived segment ID. The candidate probes project those native identities +to scope-keyed block IDs, while existing persisted record boundaries prevent +unrelated history segments from merging. ACP text that has neither a stable +prompt source nor segment identity still fails closed. ACP remains the selected path for the later migration phase +because it is the current production transport; direct-daemon stays a validated +alternative, not a production migration in Step 0. diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/acp-session-updates.jsonl b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/acp-session-updates.jsonl index 7242280047d..fe593986e9b 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/acp-session-updates.jsonl +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/acp-session-updates.jsonl @@ -1,4 +1,4 @@ -{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Inspect the contract"}} -{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Checking identity"}} -{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The contract is stable."}} +{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Inspect the contract"},"_meta":{"qwenTranscript":{"segmentId":"user-1:0"}}} +{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Checking identity"},"_meta":{"qwenTranscript":{"segmentId":"assistant-1:0"}}} +{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The contract is stable."},"_meta":{"qwenTranscript":{"segmentId":"assistant-1:1"}}} {"sessionUpdate":"tool_call","toolCallId":"read-1","title":"Read file","status":"completed","rawInput":{"path":"src/index.ts"},"_meta":{"toolName":"read"}} diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/daemon-events.jsonl b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/daemon-events.jsonl index 1137600eebe..f4232555683 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/daemon-events.jsonl +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/daemon-events.jsonl @@ -1,5 +1,5 @@ -{"id":10,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Inspect the contract"}}}} -{"id":20,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Checking identity"}}}} -{"id":30,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The contract is stable."}}}} +{"id":10,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"Inspect the contract"},"_meta":{"qwenTranscript":{"segmentId":"prompt-1:user:0"}}}}} +{"id":20,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Checking identity"},"_meta":{"qwenTranscript":{"segmentId":"prompt-1:thought:0"}}}}} +{"id":30,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The contract is stable."},"_meta":{"qwenTranscript":{"segmentId":"prompt-1:assistant:0"}}}}} {"id":40,"v":1,"type":"session_update","data":{"update":{"sessionUpdate":"tool_call","toolCallId":"read-1","title":"Read file","status":"completed","rawInput":{"path":"src/index.ts"},"_meta":{"toolName":"read"}}}} {"id":50,"v":1,"type":"permission_request","data":{"requestId":"permission-1","sessionId":"session-test","title":"Allow read?","options":[{"optionId":"allow","name":"Allow","kind":"allow_once"}],"toolCall":{"toolCallId":"read-2","name":"read","kind":"read","rawInput":{"path":"src/other.ts"}}}} diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-export.json b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-export.json index e4dd6bced1f..2bb3706a260 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-export.json +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-export.json @@ -34,6 +34,6 @@ "model_stream_interrupted", "loop_detected" ], - "timestamps": 0, - "implementation": "deferred-to-mr2" + "expectedToolResult": "Visible summary\nVisible notice", + "timestamps": 0 } diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-model.json b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-model.json index aa2e0f144b2..0cb582d9c63 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-model.json +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-model.json @@ -1,14 +1,10 @@ { "kinds": ["user", "thought", "assistant", "tool"], - "texts": [ - "Inspect the contract", - "Checking identity", - "The contract is stable." - ], "sourceRecordIds": [ ["user-1"], ["assistant-1"], ["assistant-1"], ["tool-start", "tool-result"] - ] + ], + "rawFreeToolResult": "Visible summary\nVisible notice" } diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-network.json b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-network.json new file mode 100644 index 00000000000..ca6171c929c --- /dev/null +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-network.json @@ -0,0 +1,10 @@ +{ + "unexpectedRequests": 0, + "cspViolations": 0, + "allowedImageSources": [ + "data:image/png", + "data:image/jpeg", + "data:image/gif", + "data:image/webp" + ] +} diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-render-items.json b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-render-items.json index 52dc7470cf0..9169ef03c3f 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-render-items.json +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-render-items.json @@ -1,15 +1,5 @@ { "roles": ["user", "thinking", "assistant", "tool_group"], - "expectedTextContent": [ - "Inspect the contract", - "Checking identity", - "The contract is stable." - ], - "runtimeFields": ["rawInput", "rawOutput"], - "expectedToolArgs": { "path": "/workspace/project/src/index.ts" }, - "expectedToolResult": { - "type": "vision_bridge_notice", - "summary": "Visible summary", - "notice": "Visible notice" - } + "requiredCapabilities": ["copy", "edit-user-message", "open-file"], + "identityFields": ["renderedItemId", "sourceBlockIds", "sourceToolCallIds"] } diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/manifest.json b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/manifest.json index 555c9d67a28..93945b6ec1a 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/manifest.json +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/manifest.json @@ -1,32 +1,27 @@ { "fixtureVersion": 1, "name": "representative", - "generatorVersion": "chat-transcript-prevalidation-evidence-v1", + "generatorVersion": "chat-transcript-prevalidation-v1", "sources": ["daemon", "acp", "chat-records"], "consumers": ["web", "tauri", "vscode", "html"], "capabilities": [ - "semantic-projection", - "runtime-raw-compatibility", - "stable-identity-prepend-probe", - "export-document-schema", - "two-mr-migration-gate" + "text-thinking-usage-images", + "streaming-replay-prepend", + "tools-plan-permission", + "render-action-identity", + "scope-generation", + "export-security-network-budgets" ], "complete": true, - "expectedDiagnostics": [ - "direct_daemon_unstable_identity", - "acp_unstable_identity" - ], + "expectedDiagnostics": [], "normalizedFields": ["clientReceivedAt", "createdAt", "updatedAt"], "hashes": { - "capability-matrix.md": "2f8925d7343b47f70ee66df15939ab5d1c1d2ee58dfd291ccaa3360d08a124ca", - "cases/representative/daemon-events.jsonl": "196d6d03c8e71545123a2be340f41f9ad128fe9a53ddb934511858c89e936041", - "cases/representative/acp-session-updates.jsonl": "7c7fc96fcf3768c8595ce21d069cb7e7cabccc2d5359596e2c442ff7df887a34", - "cases/representative/chat-records.jsonl": "b66abea928c3c65cdedc4ca1c455d86b1b0a46b90e2a6a186afefaa89e87db0e", - "cases/representative/expected-model.json": "c0380aac16a7d85e855148fff95959d58f9662ff4589b3a230452ef5ada7410e", - "cases/representative/expected-render-items.json": "d51acc8a0b6282898fec49f1870c0e78c901af5136b9866da3da874722d1db7b", - "cases/representative/expected-export.json": "964a55e8755c458d83d1932b7e5f3e9d8167894ac9f49cf5fdb097ad773e5672", - "cases/representative/expected-gate.json": "d644198a43a35b765672c407966ec99abedadd8c2de00a587e5e5d983bdd9acf", - "schema/export-transcript-document-v1.schema.json": "1c0a48d006d2906d6e527dd131c00ee67ac564028f8ffef7bf04407a9592ae9f", - "schema/manifest.schema.json": "c6c72f87a9fafff94ba62cd031259a6fdf7235277a8638be21aa26cc3366f3fa" + "daemon-events.jsonl": "ea25e535847aea996ee3062b7497272540520a9a213de7223ac705780ccb7ac7", + "acp-session-updates.jsonl": "2f79f50505bd17de22979d47a63e52183cde541d416e4c44eb58cd5351e6a13a", + "chat-records.jsonl": "b66abea928c3c65cdedc4ca1c455d86b1b0a46b90e2a6a186afefaa89e87db0e", + "expected-model.json": "4bbf808871c62219910127b50b3290a5c74b64b4464ec57bf25353faad79a5e5", + "expected-render-items.json": "3ef27f09cc9b8cd2350fff269262dc0a653e0dba8435cce846a07bee8d0fbc73", + "expected-export.json": "23521fd1203ffa6d47b0a93ac3de6481302fc9271e9150e77496ed42abdc063c", + "expected-network.json": "ee14c9469e2f80f1262a23ca8452bc41abb9ce43a625176c1b428608c503ad11" } } diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index 522260986a2..2377c3b3d0f 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createTranscriptReplayMachine, + createTranscriptToolCallResultUpdate, MISSING_TRANSCRIPT_TOOL_RESULT_MESSAGE, type TranscriptReplayStateV1, } from './transcript-replay.js'; @@ -77,6 +78,28 @@ function goalCardRecord( } describe('createTranscriptReplayMachine', () => { + it('keeps raw function responses out of the safe result preview', () => { + const update = createTranscriptToolCallResultUpdate({ + toolName: 'read', + callId: 'read-1', + success: true, + contentPrefix: [ + { + type: 'content', + content: { type: 'text', text: 'Visible prefix' }, + }, + ], + message: [{ text: 'Visible result' }], + }); + + expect(update._meta).toMatchObject({ + qwenTranscript: { + resultPreviewText: 'Visible prefix', + }, + }); + expect(JSON.stringify(update._meta)).not.toContain('Visible result'); + }); + it('does not replay internal Goal runtime prompts as user messages', () => { expect( updates( diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index 3b8553c55a6..4cba3f48dfe 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -31,6 +31,7 @@ import { export const MISSING_TRANSCRIPT_TOOL_RESULT_MESSAGE = 'Tool result missing from saved history; the previous run likely ended ' + 'before this tool completed.'; +const MAX_RESULT_PREVIEW_TEXT_LENGTH = 100_000; export interface TranscriptReplayEmission { readonly sourceRecordId: string; @@ -96,6 +97,7 @@ interface UpdateMetaOptions { readonly sourceRecordIds?: readonly string[]; readonly planToolCallId?: string; readonly todoPlanId?: string; + readonly resultPreviewText?: string; readonly extra?: Readonly>; } @@ -245,6 +247,9 @@ function buildUpdateMeta( ...(options.planToolCallId ? { planToolCallId: options.planToolCallId } : {}), + ...(options.resultPreviewText + ? { resultPreviewText: options.resultPreviewText } + : {}), }; const meta: Record = { ...(options.extra ?? {}), @@ -374,6 +379,7 @@ export function createTranscriptToolCallResultUpdate( content, _meta: buildUpdateMeta({ ...options, + resultPreviewText: getToolContentText(options.contentPrefix), extra: { toolName: options.toolName, provenance: provenance.provenance, @@ -394,6 +400,24 @@ export function createTranscriptToolCallResultUpdate( return update as unknown as SessionUpdate; } +function getToolContentText( + content: readonly ToolCallContent[] | undefined, +): string | undefined { + let text = ''; + for (const entry of content ?? []) { + if (entry.type !== 'content' || entry.content.type !== 'text') continue; + const next = entry.content.text; + if ( + text.length + (text ? 1 : 0) + next.length > + MAX_RESULT_PREVIEW_TEXT_LENGTH + ) { + return undefined; + } + text += `${text ? '\n' : ''}${next}`; + } + return text || undefined; +} + export function createTranscriptPlanUpdate( todos: readonly TranscriptTodoItem[], cumulativeUsage?: TranscriptReplayUsageState, @@ -498,12 +522,18 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { ); } let ordinal = 0; - const emit = (update: SessionUpdate): TranscriptReplayEmission => ({ - sourceRecordId: record.uuid, - ...(record.timestamp ? { sourceTimestamp: record.timestamp } : {}), - emissionOrdinal: ordinal++, - update, - }); + const emit = (update: SessionUpdate): TranscriptReplayEmission => { + const emissionOrdinal = ordinal++; + return { + sourceRecordId: record.uuid, + ...(record.timestamp ? { sourceTimestamp: record.timestamp } : {}), + emissionOrdinal, + update: withTranscriptSegmentId( + update, + `${record.uuid}:${emissionOrdinal}`, + ), + }; + }; const meta = { timestamp: record.timestamp, sourceRecordIds: [record.uuid], @@ -1121,6 +1151,28 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { } } +function withTranscriptSegmentId( + update: SessionUpdate, + segmentId: string, +): SessionUpdate { + const record = update as unknown as Record; + const meta = isObjectRecord(record['_meta']) ? record['_meta'] : undefined; + const transcript = + meta && isObjectRecord(meta['qwenTranscript']) + ? meta['qwenTranscript'] + : undefined; + return { + ...record, + _meta: { + ...(meta ?? {}), + qwenTranscript: { + ...(transcript ?? {}), + segmentId, + }, + }, + } as unknown as SessionUpdate; +} + function parseTranscriptGoalStatus( value: unknown, ): TranscriptGoalStatus | undefined { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 7d030f26dac..b6268546b68 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -199,6 +199,17 @@ function chatRecord(overrides: Record): ChatRecord { } as ChatRecord; } +function expectedLiveTranscriptMeta( + extra: Record = {}, +): Record { + return { + ...extra, + qwenTranscript: { + segmentId: expect.stringMatching(/^live:[0-9a-f]{32}$/), + }, + }; +} + describe('computeInitialTurnFromHistory', () => { it('uses the largest numeric prompt id suffix for the current session', () => { expect( @@ -974,6 +985,33 @@ describe('Session', () => { expect(replayDelivered).toBe(replayUpdate); }); + it('stamps live ACP text deltas with one prompt-scoped segment identity', async () => { + await core.promptIdContext.run('test-session-id########1', async () => { + await session.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'first ' }, + }); + await session.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'second' }, + }); + }); + + const updates = vi + .mocked(mockClient.sessionUpdate) + .mock.calls.map(([params]) => params.update); + const segmentIds = updates.map( + (update) => + ( + update._meta as + | { qwenTranscript?: { segmentId?: string } } + | undefined + )?.qwenTranscript?.segmentId, + ); + expect(segmentIds[0]).toMatch(/^live:[0-9a-f]{32}$/); + expect(segmentIds[1]).toBe(segmentIds[0]); + }); + describe('active work holds', () => { let changes: number; @@ -11003,6 +11041,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: @@ -11033,6 +11072,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: @@ -11142,6 +11182,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: @@ -11417,6 +11458,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: @@ -11429,6 +11471,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: @@ -13518,6 +13561,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: @@ -13736,6 +13780,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: @@ -16852,7 +16897,7 @@ describe('Session', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Already compressed.' }, - _meta: { source: 'slash_command' }, + _meta: expectedLiveTranscriptMeta({ source: 'slash_command' }), }, }); expect( @@ -16896,7 +16941,7 @@ describe('Session', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Review complete.' }, - _meta: { source: 'slash_command' }, + _meta: expectedLiveTranscriptMeta({ source: 'slash_command' }), }, }); expect(finishedSpy).toHaveBeenCalledTimes(1); @@ -16952,7 +16997,7 @@ describe('Session', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Side answer.' }, - _meta: { source: 'slash_command' }, + _meta: expectedLiveTranscriptMeta({ source: 'slash_command' }), }, }); }); @@ -17102,7 +17147,7 @@ describe('Session', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Compressing context...' }, - _meta: { source: 'slash_command' }, + _meta: expectedLiveTranscriptMeta({ source: 'slash_command' }), }, }); expect(mockClient.sessionUpdate).toHaveBeenNthCalledWith(2, { @@ -17110,7 +17155,7 @@ describe('Session', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Context compressed.' }, - _meta: { source: 'slash_command' }, + _meta: expectedLiveTranscriptMeta({ source: 'slash_command' }), }, }); }); @@ -22185,6 +22230,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: 'Stop hook blocked continuation 2 consecutive times; overriding and ending the turn.', @@ -22233,6 +22279,7 @@ describe('Session', () => { sessionId: 'test-session-id', update: { sessionUpdate: 'agent_message_chunk', + _meta: expectedLiveTranscriptMeta(), content: { type: 'text', text: 'Stop hook blocked continuation 1 consecutive time; overriding and ending the turn.', diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 73e1fd40cc3..f7d1ed71d2f 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -269,6 +269,7 @@ import { insertAfterFunctionResponses, normalizePartList, } from '../../utils/nonInteractiveHelpers.js'; +import { TranscriptUpdateIdentityProjector } from './transcript-update-identity.js'; import { prefixMidTurnUserMessageParts } from '../../utils/midTurnUserMessage.js'; import { handleSlashCommand, @@ -1877,6 +1878,8 @@ export class Session implements SessionContext { private readonly toolCallEmitter: ToolCallEmitter; private readonly planEmitter: PlanEmitter; private readonly messageEmitter: MessageEmitter; + private readonly transcriptUpdateIdentity = + new TranscriptUpdateIdentityProjector(); private liveScreenContextTool?: CaptureScreenContextTool; private liveTaskTools: readonly LiveTaskTool[] = []; private liveSpeakToUserTool?: SpeakToUserTool; @@ -6227,7 +6230,10 @@ export class Session implements SessionContext { observeAcpToolResultProjection(update, projectedUpdate, this.sessionId); const params: SessionNotification = { sessionId: this.sessionId, - update: projectedUpdate, + update: this.transcriptUpdateIdentity.project( + projectedUpdate, + promptIdContext.getStore(), + ), }; if (update.sessionUpdate === 'plan') { diff --git a/packages/cli/src/acp-integration/session/transcript-update-identity.test.ts b/packages/cli/src/acp-integration/session/transcript-update-identity.test.ts new file mode 100644 index 00000000000..07b368f361d --- /dev/null +++ b/packages/cli/src/acp-integration/session/transcript-update-identity.test.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import { TranscriptUpdateIdentityProjector } from './transcript-update-identity.js'; + +function textUpdate( + sessionUpdate: + | 'user_message_chunk' + | 'agent_message_chunk' + | 'agent_thought_chunk', + text: string, +): SessionUpdate { + return { + sessionUpdate, + content: { type: 'text', text }, + } as SessionUpdate; +} + +function segmentId(update: SessionUpdate): string | undefined { + return ( + update._meta as { qwenTranscript?: { segmentId?: string } } | undefined + )?.qwenTranscript?.segmentId; +} + +describe('TranscriptUpdateIdentityProjector', () => { + it('reuses one stable identity for streaming deltas in the same lane', () => { + const projector = new TranscriptUpdateIdentityProjector(); + const first = projector.project( + textUpdate('agent_message_chunk', 'first '), + 'session-a########1', + ); + const second = projector.project( + textUpdate('agent_message_chunk', 'second'), + 'session-a########1', + ); + + expect(segmentId(first)).toMatch(/^live:[0-9a-f]{32}$/); + expect(segmentId(second)).toBe(segmentId(first)); + }); + + it('starts a new deterministic segment after a lane or tool boundary', () => { + const run = (): string[] => { + const projector = new TranscriptUpdateIdentityProjector(); + const promptId = 'session-a########1'; + const assistant = projector.project( + textUpdate('agent_message_chunk', 'answer'), + promptId, + ); + const thought = projector.project( + textUpdate('agent_thought_chunk', 'thinking'), + promptId, + ); + projector.project( + { + sessionUpdate: 'tool_call', + toolCallId: 'read-1', + title: 'Read', + status: 'pending', + } as SessionUpdate, + promptId, + ); + const resumed = projector.project( + textUpdate('agent_message_chunk', 'done'), + promptId, + ); + return [assistant, thought, resumed].map((update) => segmentId(update)!); + }; + + const first = run(); + expect(new Set(first)).toHaveLength(3); + expect(run()).toEqual(first); + }); + + it('gives consecutive discrete messages distinct deterministic segments', () => { + const run = (): string[] => { + const projector = new TranscriptUpdateIdentityProjector(); + return ['first', 'second'].map( + (text) => + segmentId( + projector.project( + { + ...textUpdate('agent_message_chunk', text), + _meta: { qwenDiscreteMessage: true }, + } as SessionUpdate, + 'session-a########1', + ), + )!, + ); + }; + + const first = run(); + expect(new Set(first)).toHaveLength(2); + expect(run()).toEqual(first); + }); + + it('preserves persisted replay identity and unrelated metadata', () => { + const projector = new TranscriptUpdateIdentityProjector(); + const update = { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'history' }, + _meta: { + timestamp: 1, + qwenTranscript: { segmentId: 'record-1:0' }, + }, + } as SessionUpdate; + + expect(projector.project(update, undefined)).toBe(update); + expect(projector.project(update, 'session-a########1')).toBe(update); + }); + + it('continues an explicitly identified live segment', () => { + const projector = new TranscriptUpdateIdentityProjector(); + const promptId = 'session-a########1'; + const first = { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'first ' }, + _meta: { qwenTranscript: { segmentId: 'native-segment' } }, + } as SessionUpdate; + + expect(projector.project(first, promptId)).toBe(first); + expect( + segmentId( + projector.project( + textUpdate('agent_message_chunk', 'second'), + promptId, + ), + ), + ).toBe('native-segment'); + }); + + it('does not invent identity without a stable prompt source', () => { + const projector = new TranscriptUpdateIdentityProjector(); + const update = textUpdate('agent_message_chunk', 'unscoped'); + + expect(projector.project(update, undefined)).toBe(update); + expect(segmentId(update)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/acp-integration/session/transcript-update-identity.ts b/packages/cli/src/acp-integration/session/transcript-update-identity.ts new file mode 100644 index 00000000000..a4337b1811d --- /dev/null +++ b/packages/cli/src/acp-integration/session/transcript-update-identity.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; + +export class TranscriptUpdateIdentityProjector { + private promptId?: string; + private activeLane?: string; + private activeSegmentId?: string; + private predecessorId?: string; + + project(update: SessionUpdate, promptId: string | undefined): SessionUpdate { + const existingSegmentId = readSegmentId(update); + if (!promptId) return update; + + if (this.promptId !== promptId) { + this.promptId = promptId; + this.activeLane = undefined; + this.activeSegmentId = undefined; + this.predecessorId = `prompt:${promptId}`; + } + + const lane = readTextLane(update); + if (existingSegmentId) { + if (lane) { + this.activeLane = lane; + this.activeSegmentId = existingSegmentId; + this.predecessorId = existingSegmentId; + } + return update; + } + if (!lane) { + const boundaryId = readBoundaryId(update); + if (boundaryId) { + this.predecessorId = hashIdentity([ + this.predecessorId ?? `prompt:${promptId}`, + boundaryId, + ]); + this.activeLane = undefined; + this.activeSegmentId = undefined; + } + return update; + } + + if ( + this.activeLane !== lane || + !this.activeSegmentId || + isDiscreteMessage(update) + ) { + this.activeSegmentId = `live:${hashIdentity([ + promptId, + this.predecessorId ?? `prompt:${promptId}`, + lane, + ])}`; + this.activeLane = lane; + this.predecessorId = this.activeSegmentId; + } + + return withSegmentId(update, this.activeSegmentId); + } +} + +function isDiscreteMessage(update: SessionUpdate): boolean { + const record = update as unknown as Record; + const meta = isRecord(record['_meta']) ? record['_meta'] : undefined; + return meta?.['qwenDiscreteMessage'] === true; +} + +function readTextLane(update: SessionUpdate): string | undefined { + const record = update as unknown as Record; + const kind = record['sessionUpdate']; + if ( + kind !== 'user_message_chunk' && + kind !== 'agent_message_chunk' && + kind !== 'agent_thought_chunk' + ) { + return undefined; + } + const content = isRecord(record['content']) ? record['content'] : undefined; + if ( + content?.['type'] !== 'text' || + typeof content['text'] !== 'string' || + content['text'].length === 0 + ) { + return undefined; + } + const meta = isRecord(record['_meta']) ? record['_meta'] : undefined; + const parentToolCallId = readString(meta, 'parentToolCallId'); + return `${kind}:${parentToolCallId ?? 'root'}`; +} + +function readBoundaryId(update: SessionUpdate): string | undefined { + const record = update as unknown as Record; + const toolCallId = readString(record, 'toolCallId'); + if (toolCallId) return `tool:${toolCallId}`; + + const meta = isRecord(record['_meta']) ? record['_meta'] : undefined; + const transcript = isRecord(meta?.['qwenTranscript']) + ? meta['qwenTranscript'] + : undefined; + const planToolCallId = readString(transcript, 'planToolCallId'); + if (planToolCallId) return `plan:${planToolCallId}`; + + return undefined; +} + +function readSegmentId(update: SessionUpdate): string | undefined { + const record = update as unknown as Record; + const meta = isRecord(record['_meta']) ? record['_meta'] : undefined; + const transcript = isRecord(meta?.['qwenTranscript']) + ? meta['qwenTranscript'] + : undefined; + return readString(transcript, 'segmentId'); +} + +function withSegmentId( + update: SessionUpdate, + segmentId: string, +): SessionUpdate { + const record = update as unknown as Record; + const meta = isRecord(record['_meta']) ? record['_meta'] : undefined; + const transcript = isRecord(meta?.['qwenTranscript']) + ? meta['qwenTranscript'] + : undefined; + return { + ...record, + _meta: { + ...(meta ?? {}), + qwenTranscript: { + ...(transcript ?? {}), + segmentId, + }, + }, + } as unknown as SessionUpdate; +} + +function hashIdentity(parts: readonly string[]): string { + const hash = createHash('sha256'); + for (const part of parts) { + hash.update(String(Buffer.byteLength(part, 'utf8'))); + hash.update(':'); + hash.update(part); + } + return hash.digest('hex').slice(0, 32); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString( + value: Record | undefined, + key: string, +): string | undefined { + const candidate = value?.[key]; + return typeof candidate === 'string' && candidate.length > 0 + ? candidate + : undefined; +} diff --git a/packages/cli/src/ui/utils/export/export-transcript-document.test.ts b/packages/cli/src/ui/utils/export/export-transcript-document.test.ts new file mode 100644 index 00000000000..fce39e7b4b9 --- /dev/null +++ b/packages/cli/src/ui/utils/export/export-transcript-document.test.ts @@ -0,0 +1,1362 @@ +import { describe, expect, it } from 'vitest'; +import { + EXPORT_TRANSCRIPT_LIMITS_V1, + ExportTranscriptDocumentError, + assertExportTranscriptDocumentV1, + classifyPermissionResolutionForExport, + createExportTranscriptDocumentV1, + exportDocumentToTranscriptBlocks, +} from './export-transcript-document.js'; + +const CANARY = 'CHAT_TRANSCRIPT_TEST_SECRET_DO_NOT_EXPORT'; + +function record( + uuid: string, + parentUuid: string | null, + overrides: Record = {}, +): Record { + return { + uuid, + parentUuid, + sessionId: 'raw-session-id', + timestamp: '2026-08-16T00:00:00.000Z', + cwd: '/Users/tester/project', + version: 'test', + type: 'user', + message: { role: 'user', parts: [{ text: uuid }] }, + ...overrides, + }; +} + +const sessionData = { + startTime: '2026-08-16T00:00:00.000Z', + metadata: { + sessionId: `session-${CANARY}`, + startTime: '2026-08-16T00:00:00.000Z', + exportTime: '2026-08-16T01:00:00.000Z', + cwd: '/Users/tester/project', + gitRepo: 'qwen-code', + gitBranch: 'feat/transcript', + model: 'qwen-test', + channel: 'cli', + promptCount: 1, + totalTokens: 12, + filesWritten: 1, + linesAdded: 2, + linesRemoved: 0, + uniqueFiles: [`/Users/tester/${CANARY}.ts`], + }, +}; + +describe('ExportTranscriptDocumentV1', () => { + it('projects records through an explicit allowlist without raw leakage', () => { + const records = [ + record('user-1', null, { + message: { role: 'user', parts: [{ text: 'Read the file' }] }, + }), + record('tool-start', 'user-1', { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'read-1', + name: 'read_file', + args: { + path: '/Users/tester/visible.ts', + credential: CANARY, + }, + }, + }, + ], + }, + }), + record('tool-result', 'tool-start', { + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'read-1', + name: 'read_file', + response: { output: CANARY }, + }, + }, + ], + }, + toolCallResult: { + callId: 'read-1', + resultDisplay: { + type: 'vision_bridge_notice', + summary: 'Safe visible result at /Users/tester', + notice: 'One page converted from C:\\Users\\tester directory.', + }, + }, + }), + record('internal', 'tool-result', { + type: 'system', + subtype: 'custom_title', + systemPayload: { title: CANARY }, + }), + ]; + + const document = createExportTranscriptDocumentV1(records, sessionData, { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + title: 'Synthetic transcript', + }); + const serialized = JSON.stringify(document); + + expect(serialized).not.toContain(CANARY); + expect(serialized).not.toContain('/Users/tester'); + expect(serialized).not.toContain('raw-session-id'); + expect(serialized).not.toContain('user-1'); + expect(serialized).not.toContain('read-1'); + expect(serialized).not.toMatch(/"(?:rawInput|rawOutput|toolCall)"/); + expect(document.metadata).toMatchObject({ + projectName: 'project', + repository: 'qwen-code', + complete: true, + truncated: false, + }); + expect(document.metadata).not.toHaveProperty('uniqueFiles'); + expect(document.blocks.every((block) => block.createdAt === 0)).toBe(true); + expect( + document.blocks.find((block) => block.kind === 'tool'), + ).toMatchObject({ + preview: { kind: 'file_read', path: 'visible.ts' }, + resultPreview: { + kind: 'text', + text: 'Safe visible result at [home]\nOne page converted from [home] directory.', + }, + }); + expect(document.diagnostics).toContainEqual({ + code: 'record_internal_excluded', + severity: 'info', + count: 1, + }); + expect(exportDocumentToTranscriptBlocks(document)).toBe(document.blocks); + }); + + it('redacts home paths from visible text without corrupting image data', () => { + const document = createExportTranscriptDocumentV1( + [ + record('visible-paths', null, { + message: { + role: 'user', + parts: [ + { + text: [ + 'Unix /Users/alice/private.txt', + 'Windows C:\\Users\\alice\\private.txt', + 'URI file:///Users/alice/private.txt', + 'Windows URI file:///C:/Users/alice/private.txt', + '![safe](data:image/png;base64,/home/AA)', + ].join('\n'), + }, + ], + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).toContain('Unix [home]/private.txt'); + expect(text).toContain('Windows [home]\\private.txt'); + expect(text).toContain('URI file://[home]/private.txt'); + expect(text).toContain('Windows URI file://[home]/private.txt'); + expect(text).toContain('data:image/png;base64,/home/AA'); + expect(text).not.toContain('/Users/alice'); + expect(text).not.toContain('C:\\Users\\alice'); + }); + + it('degrades a completed tool when its safe result preview is unavailable', () => { + const document = createExportTranscriptDocumentV1( + [ + record('tool-start', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'large-result', + name: 'read_file', + args: { path: 'large.txt' }, + }, + }, + ], + }, + }), + record('tool-result', 'tool-start', { + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'large-result', + name: 'read_file', + response: { output: 'x'.repeat(100_001) }, + }, + }, + ], + }, + toolCallResult: { + callId: 'large-result', + resultDisplay: 'x'.repeat(100_001), + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const tool = document.blocks.find((block) => block.kind === 'tool'); + + expect(tool?.resultPreview).toEqual({ + kind: 'text', + text: '[tool result omitted from export]', + }); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toContainEqual({ + code: 'tool_result_presentation_missing', + severity: 'error', + count: 1, + }); + }); + + it('rewrites todo, plan, dependency, and delegation references opaquely', () => { + const nativeTodoId = `todo-${CANARY}`; + const nativeDependencyId = `dependency-${CANARY}`; + const nativePlanId = `plan-${CANARY}`; + const nativeParentDelegationId = `parent-${CANARY}`; + const document = createExportTranscriptDocumentV1( + [ + record('todo-tool', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'todo-call', + name: 'todo_write', + args: { + entries: [ + { + content: 'Safe todo', + status: 'completed', + _meta: { + qwenTodo: { + id: nativeTodoId, + blockedBy: [nativeDependencyId], + }, + }, + }, + ], + plan: { id: nativePlanId, revision: 1 }, + }, + }, + }, + ], + }, + }), + record('todo-result', 'todo-tool', { + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'todo-call', + name: 'todo_write', + response: { output: 'Todo completed' }, + }, + }, + ], + }, + toolCallResult: { + callId: 'todo-call', + resultDisplay: { + type: 'todo_list', + planId: nativePlanId, + todos: [ + { + id: nativeTodoId, + content: 'Safe todo', + status: 'completed', + blockedBy: [nativeDependencyId], + }, + ], + }, + }, + }), + record('delegation-tool', 'todo-result', { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'delegation-call', + name: 'Task', + args: { + agentName: 'reviewer', + task: 'Review safely', + parentDelegationId: nativeParentDelegationId, + }, + }, + }, + ], + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const serialized = JSON.stringify(document); + const todoTool = document.blocks.find( + (block) => + block.kind === 'tool' && block.resultPreview?.kind === 'todo_list', + ); + const delegationTool = document.blocks.find( + (block) => + block.kind === 'tool' && block.preview.kind === 'subagent_delegation', + ); + + expect(serialized).not.toContain(CANARY); + expect(todoTool?.kind).toBe('tool'); + expect(delegationTool?.kind).toBe('tool'); + if (todoTool?.kind !== 'tool' || delegationTool?.kind !== 'tool') { + throw new Error('Expected projected tool blocks.'); + } + expect(todoTool.resultPreview).toMatchObject({ + kind: 'todo_list', + entries: [ + { + id: expect.stringMatching(/^todo-/), + blockedBy: [expect.stringMatching(/^todo-/)], + }, + ], + planId: expect.stringMatching(/^plan-/), + }); + expect(delegationTool.preview).toMatchObject({ + kind: 'subagent_delegation', + parentDelegationId: expect.stringMatching(/^tool-call-/), + }); + }); + + it('exports a truncated todo preview without widening the schema', () => { + const entries = Array.from({ length: 1_001 }, (_, index) => ({ + content: `Task ${index}`, + status: 'pending', + })); + const document = createExportTranscriptDocumentV1( + [ + record('todo-tool', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'todo-call', + name: 'todo_write', + args: { + entries, + }, + }, + }, + ], + }, + }), + record('todo-result', 'todo-tool', { + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'todo-call', + name: 'todo_write', + response: { output: 'Todo list saved' }, + }, + }, + ], + }, + toolCallResult: { + callId: 'todo-call', + resultDisplay: { type: 'todo_list', todos: entries }, + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const tool = document.blocks.find( + (block) => + block.kind === 'tool' && block.resultPreview?.kind === 'todo_list', + ); + if (tool?.kind !== 'tool' || tool.resultPreview?.kind !== 'todo_list') { + throw new Error('Expected a projected todo result.'); + } + + expect(tool.resultPreview).toMatchObject({ + kind: 'todo_list', + truncated: true, + }); + expect(tool.resultPreview.entries).toHaveLength(1_000); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toContainEqual({ + code: 'todo_preview_truncated', + severity: 'warning', + count: 1, + }); + const validationCandidate = { + ...document, + blocks: document.blocks.map((block) => + block === tool ? { ...block, preview: tool.resultPreview } : block, + ), + }; + expect(() => + assertExportTranscriptDocumentV1(validationCandidate), + ).not.toThrow(); + }); + + it('reduces permission outcomes to safe terminal states', () => { + const nativeOptionId = `allow-${CANARY}`; + const options = [ + { + optionId: nativeOptionId, + label: 'Allow once', + raw: { kind: 'allow_once', credential: CANARY }, + }, + ]; + + const approved = classifyPermissionResolutionForExport( + `selected:${nativeOptionId}`, + options, + ); + const unknown = classifyPermissionResolutionForExport( + `selected:missing-${CANARY}`, + options, + ); + + expect(approved).toEqual({ value: 'approved', lossy: false }); + expect(unknown).toEqual({ value: 'resolved', lossy: true }); + expect(JSON.stringify({ approved, unknown })).not.toContain(CANARY); + }); + + it('marks visible text budget degradation before rendering', () => { + const document = createExportTranscriptDocumentV1( + [ + record('user-large', null), + record('large', 'user-large', { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'edit-large', + name: 'edit', + args: { + path: '/Users/tester/large.ts', + oldText: '中'.repeat(150_000), + newText: 'small', + }, + }, + }, + ], + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'text_budget_exceeded' }), + ]), + ); + + const records = Array.from({ length: 100 }, (_, index) => { + const assistant = index % 2 === 1; + return record(`budget-${index}`, index ? `budget-${index - 1}` : null, { + type: assistant ? 'assistant' : 'user', + message: { + role: assistant ? 'model' : 'user', + parts: [{ text: 'x'.repeat(100_000) }], + }, + }); + }); + const globallyBounded = createExportTranscriptDocumentV1( + records, + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + + expect(globallyBounded.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect( + new TextEncoder().encode( + globallyBounded.blocks + .map((block) => ('text' in block ? block.text : '')) + .join(''), + ).byteLength, + ).toBeLessThanOrEqual(EXPORT_TRANSCRIPT_LIMITS_V1.maxVisibleTextBytes); + }); + + it('marks sanitized metadata URLs as incomplete without leaking secrets', () => { + const document = createExportTranscriptDocumentV1( + [record('user-url', null)], + { + ...sessionData, + metadata: { + ...sessionData.metadata, + gitRepo: + 'https://alice:password@example.com/qwen-code?token=secret#fragment', + }, + }, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const serialized = JSON.stringify(document); + + expect(document.metadata).toMatchObject({ + repository: 'https://example.com/qwen-code', + complete: false, + truncated: true, + }); + expect(document.diagnostics).toContainEqual({ + code: 'url_sanitized', + severity: 'warning', + count: 1, + }); + expect(serialized).not.toContain('alice'); + expect(serialized).not.toContain('password'); + expect(serialized).not.toContain('secret'); + }); + + it('marks array truncation before rendering', () => { + const questions = Array.from( + { length: EXPORT_TRANSCRIPT_LIMITS_V1.maxArrayLength + 1 }, + (_, index) => ({ question: `Question ${index}`, options: [] }), + ); + const document = createExportTranscriptDocumentV1( + [ + record('question-tool', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'question-1', + name: 'ask_user_question', + args: { questions }, + }, + }, + ], + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const tool = document.blocks.find((block) => block.kind === 'tool'); + + expect(tool?.preview.kind).toBe('ask_user_question'); + expect( + tool?.preview.kind === 'ask_user_question' + ? tool.preview.questions.length + : 0, + ).toBe(EXPORT_TRANSCRIPT_LIMITS_V1.maxArrayLength); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toContainEqual({ + code: 'array_budget_exceeded', + severity: 'warning', + count: 1, + }); + }); + + it('sanitizes active Markdown links without changing code examples', () => { + const document = createExportTranscriptDocumentV1( + [ + record('markdown-links', null, { + message: { + role: 'user', + parts: [ + { + text: [ + '[safe](https://example.com/path)', + '[credential](https://alice:password@example.com/private?CHAT_TRANSCRIPT_URL_CANARY#fragment)', + '[unsafe](javascript:CHAT_TRANSCRIPT_URL_CANARY)', + '', + 'https://carol:password@example.com/bare?CHAT_TRANSCRIPT_URL_CANARY#fragment', + '`https://dave:password@example.com/inline?CHAT_TRANSCRIPT_URL_CANARY`', + '```text', + 'https://erin:password@example.com/fenced?CHAT_TRANSCRIPT_URL_CANARY', + '```', + ].join('\n'), + }, + ], + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).toContain('[safe](https://example.com/path)'); + expect(text).toContain('[credential](https://example.com/private)'); + expect(text).toContain(''); + expect(text).toContain('https://example.com/bare'); + expect(text).toContain( + '`https://dave:password@example.com/inline?CHAT_TRANSCRIPT_URL_CANARY`', + ); + expect(text).toContain( + 'https://erin:password@example.com/fenced?CHAT_TRANSCRIPT_URL_CANARY', + ); + expect(text).not.toContain('javascript:'); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toEqual( + expect.arrayContaining([ + { code: 'url_rejected', severity: 'warning', count: 1 }, + { code: 'url_sanitized', severity: 'warning', count: 3 }, + ]), + ); + }); + + it('preserves Markdown-like syntax inside structured code fields', () => { + const code = [ + "const endpoint = 'https://example.com/api?mode=test#fragment';", + "const literal = '![not-an-image](https://example.com/image.png)';", + ].join('\n'); + const document = createExportTranscriptDocumentV1( + [ + record('code-tool', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'code-1', + name: 'exec_code', + args: { language: 'typescript', code }, + }, + }, + ], + }, + }), + record('code-result', 'code-tool', { + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'code-1', + name: 'exec_code', + response: { output: 'ok' }, + }, + }, + ], + }, + toolCallResult: { + callId: 'code-1', + resultDisplay: { + type: 'vision_bridge_notice', + summary: 'Execution complete', + notice: 'No output.', + }, + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const tool = document.blocks.find((block) => block.kind === 'tool'); + + expect(tool?.preview).toEqual({ + kind: 'code_block', + language: 'typescript', + code, + }); + expect(document.metadata).toMatchObject({ + complete: true, + truncated: false, + }); + }); + + it('freezes rich rendering after 100 tasks while preserving safe source', () => { + const content = Array.from( + { length: EXPORT_TRANSCRIPT_LIMITS_V1.maxRichRenderTasks + 1 }, + (_, index) => `\`\`\`mermaid\ngraph TD; A${index}-->B${index}\n\`\`\``, + ).join('\n'); + const document = createExportTranscriptDocumentV1( + [ + record('rich-user', null, { + message: { role: 'user', parts: [{ text: content }] }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + + const block = document.blocks[0]; + expect(block?.kind).toBe('user'); + expect(block && 'text' in block ? block.text : '').toContain( + '```text [source fallback: mermaid]', + ); + expect(document.metadata).toMatchObject({ + complete: true, + truncated: false, + }); + expect(document.diagnostics).toContainEqual({ + code: 'rich_render_budget_exceeded', + severity: 'warning', + count: 1, + }); + }); + + it('budgets image-generation thumbnails as raster data, not visible text', () => { + const thumbnailData = 'A'.repeat(600 * 1024); + const thumbnailUrl = `data:IMAGE/PNG;base64,${thumbnailData}`; + const document = createExportTranscriptDocumentV1( + [ + record('image-tool', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'image-1', + name: 'dalle3_generate', + args: { prompt: 'A safe image', thumbnailUrl }, + }, + }, + ], + }, + }), + record('image-result', 'image-tool', { + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'image-1', + name: 'dalle3_generate', + response: { output: 'Generated image' }, + }, + }, + ], + }, + toolCallResult: { + callId: 'image-1', + resultDisplay: 'Generated image', + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const tool = document.blocks.find((block) => block.kind === 'tool'); + + expect(tool?.preview).toMatchObject({ + kind: 'image_generation', + thumbnailUrl: `data:image/png;base64,${thumbnailData}`, + }); + expect(JSON.stringify(document)).not.toContain('data:IMAGE/PNG'); + expect(document.metadata).toMatchObject({ + complete: true, + truncated: false, + }); + expect(document.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'text_budget_exceeded' }), + ]), + ); + }); + + it('rejects home paths in validated visible text without inspecting raster data', () => { + const envelope = { + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }; + + expect(() => + assertExportTranscriptDocumentV1({ + ...envelope, + blocks: [ + { + id: 'user-home-path', + kind: 'user', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: 'Leaked /Users/alice/private.txt', + streaming: false, + }, + ], + }), + ).toThrowError('home_path_forbidden'); + + expect(() => + assertExportTranscriptDocumentV1({ + ...envelope, + blocks: [ + { + id: 'user-raster-data', + kind: 'user', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: 'Safe image', + streaming: false, + images: [{ data: '/home/AA', mimeType: 'image/png' }], + }, + ], + }), + ).not.toThrow(); + }); + + it('rejects schema widening and floating renderer versions', () => { + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: 'latest', + blocks: [], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError(ExportTranscriptDocumentError); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + widened: true, + }), + ).toThrowError('additional_property'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'duplicate', + kind: 'prompt_cancelled', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + }, + { + id: 'duplicate', + kind: 'prompt_cancelled', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('duplicate_block_id'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'permission-safe', + kind: 'permission', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + requestId: 'permission-1', + title: 'Allow read?', + options: [ + { optionId: 'permission-option-1', label: 'Allow', raw: null }, + ], + preview: { kind: 'generic' }, + resolved: `selected:${CANARY}`, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_block'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'tool-safe', + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'read-1', + title: 'Read failed', + status: 'failed', + preview: { kind: 'file_read', path: 'index.ts' }, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_block'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'tool-safe', + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'read-1', + title: 'Read completed', + status: 'completed', + preview: { kind: 'file_read', path: 'index.ts' }, + resultPreview: { kind: 'generic', summary: ' ' }, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_block'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'tool-safe', + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'read-1', + title: 'Read', + status: 'completed', + preview: { + kind: 'file_read', + path: 'index.ts', + credential: CANARY, + }, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('additional_property'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'error-safe', + kind: 'error', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: 'Failed safely', + errorKind: `unknown-${CANARY}`, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_block'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'image-safe', + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'image-1', + title: 'Generate image', + status: 'cancelled', + preview: { + kind: 'image_generation', + prompt: 'A safe image', + thumbnailUrl: 'data:IMAGE/PNG;base64,iVBORw0KGgo=', + }, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_block'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'user-safe', + kind: 'user', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: 'Hello', + usage: { inputTokens: 1, outputTokens: 1 }, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('additional_property'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'user-safe', + kind: 'user', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: '![remote](https://example.invalid/track.png)', + streaming: false, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_markdown_image'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'user-safe', + kind: 'user', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: '[credential](https://alice:password@example.com/path?token=canary)', + streaming: false, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_markdown_url'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + repository: 'https://secret@example.com/qwen-code?token=canary', + }, + }), + ).toThrowError('invalid_metadata'); + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [], + diagnostics: [{ code: 'url_sanitized', severity: 'warning', count: 1 }], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: false, + truncated: false, + }, + }), + ).toThrowError('invalid_metadata_state'); + }); + + it('rejects cyclic envelopes before recursive field validation', () => { + const document = createExportTranscriptDocumentV1([], sessionData, { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }); + const cyclic = structuredClone(document) as unknown as Record< + string, + unknown + >; + cyclic['metadata'] = cyclic; + + expect(() => assertExportTranscriptDocumentV1(cyclic)).toThrowError( + expect.objectContaining({ code: 'cyclic_envelope' }), + ); + }); + + it('rejects object property floods before field validation', () => { + const document = createExportTranscriptDocumentV1([], sessionData, { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }); + const metadata = Object.fromEntries( + Array.from( + { length: EXPORT_TRANSCRIPT_LIMITS_V1.maxObjectProperties + 1 }, + (_, index) => [`extra-${index}`, index], + ), + ); + + expect(() => + assertExportTranscriptDocumentV1({ ...document, metadata }), + ).toThrowError( + expect.objectContaining({ code: 'object_property_budget_exceeded' }), + ); + }); + + it('applies the structured raster policy to Markdown images', () => { + const document = createExportTranscriptDocumentV1( + [ + record('markdown-images', null, { + message: { + role: 'user', + parts: [ + { + text: [ + '![remote](https://example.invalid/track.png)', + '![svg](data:image/svg+xml;base64,PHN2Zy8+)', + '![safe](data:image/png;base64,iVBORw0KGgo=)', + '![animated reference][animated-gif]', + '[animated-gif]: data:image/gif;base64,LAAs', + '![remote reference][tracker]', + '[tracker]: https://example.invalid/reference.png', + '', + '`![inline code](https://example.invalid/inline-code.png)`', + '```md', + '![fenced code](https://example.invalid/fenced-code.png)', + '```', + ' ![indented code](https://example.invalid/indented-code.png)', + '\\![escaped image](https://example.invalid/escaped-image.png)', + '\\\\![even escape](https://example.invalid/even-escape.png)', + '\\\\\\![odd escape](https://example.invalid/odd-escape.png)', + ].join('\n'), + }, + ], + }, + }), + ], + sessionData, + { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).not.toContain('track.png'); + expect(text).not.toContain(' { + const staticGif = + 'R0lGODlhCAAIAPUAAAAAABUAAAAcABoLGwAgAAAxAAA+AB0oGQMbN2UcGEM1AGsnJwBFCQBzBRhNIh9XOmFBNxAATBg5XTUTZGcTT1IsTT56RlVlQk1teGhrZ4I8XVqhUX2Vczl0hklUgmGRkm2Co22uwIiBg5KSkpmljZWEoq2IuYWzqYi/rJm7oJ+1uJ67vbi5u7i8u8Cxl8WSqciitLP2utzFs7WU2NCgwtOZ7vas/73Ow7T32Lf938bRxNHgz8vf69js7gAAAAAAACH5BAAAAAAALAAAAAAIAAgAAAY6wJ0u1+PJaDaW6oaLsWa1UOm0QrleMNEHNDKlSCOIxoPZcDIdSmIxuVgekooiEGE0HIjBoQAgGAQAQQA7'; + + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'user-safe', + kind: 'user', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: 'Static GIF', + streaming: false, + images: [{ data: staticGif, mimeType: 'image/gif' }], + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).not.toThrow(); + }); + + it('freezes every V1 limit in one shared constant', () => { + expect(EXPORT_TRANSCRIPT_LIMITS_V1).toEqual({ + maxBlocks: 1_000, + maxTextBytes: 400 * 1024, + maxVisibleTextBytes: 8 * 1024 * 1024, + maxRasterBytes: 8 * 1024 * 1024, + maxTotalRasterBytes: 16 * 1024 * 1024, + maxEnvelopeBytes: 32 * 1024 * 1024, + maxObjectDepth: 16, + maxObjectProperties: 1_000, + maxArrayLength: 1_000, + maxRichRenderTasks: 100, + }); + }); +}); diff --git a/packages/cli/src/ui/utils/export/export-transcript-document.ts b/packages/cli/src/ui/utils/export/export-transcript-document.ts new file mode 100644 index 00000000000..6adad2fe1ed --- /dev/null +++ b/packages/cli/src/ui/utils/export/export-transcript-document.ts @@ -0,0 +1,2586 @@ +import { + DAEMON_ERROR_KINDS, + type DaemonErrorKind, + type DaemonPermissionTranscriptBlock, + type DaemonShellTranscriptBlock, + type DaemonStatusTranscriptBlock, + type DaemonTextTranscriptBlock, + type DaemonToolPreview, + type DaemonToolResultPreview, + type DaemonToolTranscriptBlock, + type DaemonTodoListPreview, + type DaemonTranscriptBlock, + type DaemonUiPermissionOption, + type DaemonUserShellTranscriptBlock, +} from '@qwen-code/sdk/daemon'; +import { projectChatRecordsToDaemonTranscript } from '@qwen-code/sdk/daemon/transcript'; +import type { ExportSessionData } from './types.js'; + +export const EXPORT_TRANSCRIPT_LIMITS_V1 = Object.freeze({ + maxBlocks: 1_000, + maxTextBytes: 400 * 1024, + maxVisibleTextBytes: 8 * 1024 * 1024, + maxRasterBytes: 8 * 1024 * 1024, + maxTotalRasterBytes: 16 * 1024 * 1024, + maxEnvelopeBytes: 32 * 1024 * 1024, + maxObjectDepth: 16, + maxObjectProperties: 1_000, + maxArrayLength: 1_000, + maxRichRenderTasks: 100, +}); + +export interface ExportTranscriptDiagnosticV1 { + readonly code: string; + readonly severity: 'info' | 'warning' | 'error'; + readonly count: number; +} + +export interface ExportMetadataPresentationV1 { + readonly title?: string; + readonly startedAt?: string; + readonly exportedAt: string; + readonly complete: boolean; + readonly truncated: boolean; + readonly projectName?: string; + readonly repository?: string; + readonly gitBranch?: string; + readonly model?: string; + readonly channel?: string; + readonly promptCount?: number; + readonly contextUsagePercent?: number; + readonly contextWindowSize?: number; + readonly totalTokens?: number; + readonly filesWritten?: number; + readonly linesAdded?: number; + readonly linesRemoved?: number; +} + +type DaemonPromptCancelledTranscriptBlock = Extract< + DaemonTranscriptBlock, + { kind: 'prompt_cancelled' } +>; + +type ExportBlockBaseKeys = + | 'id' + | 'kind' + | 'clientReceivedAt' + | 'createdAt' + | 'updatedAt'; + +type ExportPermissionOptionV1 = Pick< + DaemonUiPermissionOption, + 'optionId' | 'label' | 'description' +> & { raw: null }; + +interface ExportTranscriptQuestionOptionV1 { + label: string; + description?: string; + raw: null; +} + +interface ExportTranscriptQuestionV1 { + header?: string; + question: string; + options: ExportTranscriptQuestionOptionV1[]; + raw: null; +} + +type ToolPreviewOf = Extract< + DaemonToolPreview, + { kind: K } +>; +type ToolPreviewPick< + K extends DaemonToolPreview['kind'], + P extends keyof ToolPreviewOf, +> = Pick, 'kind' | P>; +type ExportTodoListPreviewV1 = ToolPreviewPick< + 'todo_list', + 'entries' | 'truncated' | 'planId' | 'revision' +>; +type ExportToolPreviewV1 = + | { kind: 'ask_user_question'; questions: ExportTranscriptQuestionV1[] } + | ToolPreviewPick<'command', 'command' | 'cwd'> + | ToolPreviewPick<'file_diff', 'path' | 'oldText' | 'newText' | 'patch'> + | ToolPreviewPick<'file_read', 'path' | 'range'> + | ToolPreviewPick<'web_fetch', 'url' | 'method'> + | ToolPreviewPick<'mcp_invocation', 'serverId' | 'toolName' | 'argsSummary'> + | ToolPreviewPick<'code_block', 'language' | 'code' | 'origin'> + | ToolPreviewPick<'search', 'query' | 'resultCount' | 'top'> + | ToolPreviewPick<'tabular', 'columns' | 'rows' | 'totalRows'> + | ToolPreviewPick<'image_generation', 'prompt' | 'thumbnailUrl' | 'model'> + | ToolPreviewPick< + 'subagent_delegation', + 'agentName' | 'task' | 'parentDelegationId' + > + | ToolPreviewPick<'key_value', 'rows'> + | ExportTodoListPreviewV1 + | ToolPreviewPick<'generic', 'summary'>; +type ExportToolResultPreviewV1 = + | ExportTodoListPreviewV1 + | { kind: 'text'; text: string } + | { kind: 'generic'; summary: string }; + +export type ExportPermissionResolutionV1 = + | 'approved' + | 'rejected' + | 'cancelled' + | 'expired' + | 'resolved'; + +type ExportTextTranscriptBlockBaseV1 = Pick< + DaemonTextTranscriptBlock, + | Exclude + | 'text' + | 'images' + | 'collapsed' + | 'parentToolCallId' +> & { streaming?: false }; +type ExportTextTranscriptBlockV1 = + | (ExportTextTranscriptBlockBaseV1 & { kind: 'user' | 'thought' }) + | (ExportTextTranscriptBlockBaseV1 & { + kind: 'assistant'; + usage?: DaemonTextTranscriptBlock['usage']; + }); +type ExportToolTranscriptBlockV1 = Pick< + DaemonToolTranscriptBlock, + | ExportBlockBaseKeys + | 'toolCallId' + | 'title' + | 'toolName' + | 'toolKind' + | 'parentToolCallId' + | 'parentBlockId' + | 'subagentType' +> & { + status: 'completed' | 'failed' | 'cancelled' | 'canceled'; + preview: ExportToolPreviewV1; + resultPreview?: ExportToolResultPreviewV1; +}; +type ExportShellTranscriptBlockV1 = Pick< + DaemonShellTranscriptBlock, + ExportBlockBaseKeys | 'text' | 'stream' +>; +type ExportUserShellTranscriptBlockV1 = Pick< + DaemonUserShellTranscriptBlock, + ExportBlockBaseKeys | 'text' | 'command' | 'cwd' | 'stream' +>; +type ExportPermissionTranscriptBlockV1 = Pick< + DaemonPermissionTranscriptBlock, + | ExportBlockBaseKeys + | 'requestId' + | 'title' + | 'toolCallId' + | 'toolName' + | 'toolKind' +> & { + options: ExportPermissionOptionV1[]; + preview: ExportToolPreviewV1; + resolved?: ExportPermissionResolutionV1; +}; +type ExportStatusTranscriptBlockV1 = Pick< + DaemonStatusTranscriptBlock, + | Exclude + | 'text' + | 'code' + | 'errorKind' + | 'source' +> & { + kind: 'status' | 'error'; +}; +type ExportPromptCancelledTranscriptBlockV1 = Pick< + DaemonPromptCancelledTranscriptBlock, + ExportBlockBaseKeys | 'reason' +>; + +export type ExportTranscriptBlockV1 = + | ExportTextTranscriptBlockV1 + | ExportToolTranscriptBlockV1 + | ExportShellTranscriptBlockV1 + | ExportUserShellTranscriptBlockV1 + | ExportPermissionTranscriptBlockV1 + | ExportStatusTranscriptBlockV1 + | ExportPromptCancelledTranscriptBlockV1; + +export interface ExportTranscriptDocumentV1 { + readonly schemaVersion: 1; + readonly rendererVersion: string; + readonly blocks: readonly ExportTranscriptBlockV1[]; + readonly diagnostics: readonly ExportTranscriptDiagnosticV1[]; + readonly metadata: ExportMetadataPresentationV1; +} + +export interface CreateExportTranscriptDocumentOptions { + readonly rendererVersion: string; + readonly exportedAt: string; + readonly title?: string; +} + +export class ExportTranscriptDocumentError extends Error { + constructor(readonly code: string) { + super(`Cannot create export transcript document: ${code}.`); + this.name = 'ExportTranscriptDocumentError'; + } +} + +export function createExportTranscriptDocumentV1( + records: readonly unknown[], + sessionData: Pick, + options: CreateExportTranscriptDocumentOptions, +): ExportTranscriptDocumentV1 { + if (!isSafeRendererVersion(options.rendererVersion)) { + throw new ExportTranscriptDocumentError('invalid_renderer_version'); + } + if (!isIsoDate(options.exportedAt)) { + throw new ExportTranscriptDocumentError('invalid_exported_at'); + } + + const diagnostics = new DiagnosticCounter(); + const policy = applyRecordExportPolicy(records, diagnostics); + const projection = projectChatRecordsToDaemonTranscript(policy.records, { + maxBlocks: EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks, + }); + for (const item of projection.diagnostics) { + diagnostics.add(item.code, item.severity, 1, item.affectsCompleteness); + } + const budget = new ExportBudget(diagnostics); + const ids = new OpaqueDocumentIds(); + const blocks = projection.blocks.flatMap((block) => { + const safe = sanitizeBlock(block, budget, ids, diagnostics); + return safe ? [safe] : []; + }); + const initialTruncated = projection.truncated || budget.truncated; + const metadataPresentation = createMetadataPresentation( + sessionData, + options, + false, + initialTruncated, + diagnostics, + budget, + ); + const truncated = initialTruncated || budget.truncated; + const degraded = + !policy.complete || + !projection.complete || + budget.truncated || + diagnostics.hasErrors || + diagnostics.hasCompletenessLoss; + const metadata = { + ...metadataPresentation, + complete: !degraded, + truncated, + }; + const document: ExportTranscriptDocumentV1 = { + schemaVersion: 1, + rendererVersion: options.rendererVersion, + blocks, + diagnostics: diagnostics.toArray(), + metadata, + }; + assertExportTranscriptDocumentV1(document); + return document; +} + +export function assertExportTranscriptDocumentV1( + value: unknown, +): asserts value is ExportTranscriptDocumentV1 { + if (!isRecord(value) || value['schemaVersion'] !== 1) { + throw new ExportTranscriptDocumentError('unsupported_schema_version'); + } + assertOnlyKeys(value, [ + 'schemaVersion', + 'rendererVersion', + 'blocks', + 'diagnostics', + 'metadata', + ]); + assertDepthAndArrayBudgets(value); + const bytes = serializedEnvelopeBytes(value); + if (bytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxEnvelopeBytes) { + throw new ExportTranscriptDocumentError('envelope_budget_exceeded'); + } + if (!isSafeRendererVersion(value['rendererVersion'])) { + throw new ExportTranscriptDocumentError('invalid_renderer_version'); + } + if (!Array.isArray(value['blocks'])) { + throw new ExportTranscriptDocumentError('invalid_blocks'); + } + if (value['blocks'].length > EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks) { + throw new ExportTranscriptDocumentError('block_budget_exceeded'); + } + for (const block of value['blocks']) assertExportBlock(block); + if (!Array.isArray(value['diagnostics'])) { + throw new ExportTranscriptDocumentError('invalid_diagnostics'); + } + for (const diagnostic of value['diagnostics']) { + if (!isRecord(diagnostic)) { + throw new ExportTranscriptDocumentError('invalid_diagnostic'); + } + assertOnlyKeys(diagnostic, ['code', 'severity', 'count']); + if ( + !isSafeLabel(diagnostic['code'], 128) || + !['info', 'warning', 'error'].includes(String(diagnostic['severity'])) || + !isSafeCount(diagnostic['count']) + ) { + throw new ExportTranscriptDocumentError('invalid_diagnostic'); + } + } + assertMetadata(value['metadata']); + assertDocumentConsistency(value); + assertNoForbiddenFields(value); + assertResourceBudgets(value); +} + +export function exportDocumentToTranscriptBlocks( + value: unknown, +): readonly DaemonTranscriptBlock[] { + assertExportTranscriptDocumentV1(value); + return value.blocks; +} + +function applyRecordExportPolicy( + records: readonly unknown[], + diagnostics: DiagnosticCounter, +): { records: unknown[]; complete: boolean } { + const accepted: unknown[] = []; + const rejectedIds = new Set(); + let complete = true; + for (const record of records) { + if (!isRecord(record)) { + diagnostics.add('record_invalid', 'error'); + complete = false; + continue; + } + const type = record['type']; + const subtype = record['subtype']; + const acceptedSystemSubtype = + type === 'system' && + typeof subtype === 'string' && + VISIBLE_SYSTEM_RECORD_SUBTYPES.has(subtype); + if ( + type === 'user' || + type === 'assistant' || + type === 'tool_result' || + acceptedSystemSubtype + ) { + accepted.push(record); + continue; + } + const uuid = record['uuid']; + if (typeof uuid === 'string') rejectedIds.add(uuid); + diagnostics.add( + type === 'system' + ? 'record_internal_excluded' + : 'record_unknown_excluded', + type === 'system' ? 'info' : 'error', + ); + if (type !== 'system') complete = false; + } + for (const record of accepted) { + if (!isRecord(record)) continue; + const parentUuid = record['parentUuid']; + if (typeof parentUuid === 'string' && rejectedIds.has(parentUuid)) { + diagnostics.add('causal_record_excluded', 'error'); + complete = false; + } + } + return { records: accepted, complete }; +} + +const VISIBLE_SYSTEM_RECORD_SUBTYPES = new Set([ + 'notification', + 'cron', + 'mid_turn_user_message', + 'realtime_message', + 'goal_state', + 'goal_runtime', +]); + +function sanitizeBlock( + block: DaemonTranscriptBlock, + budget: ExportBudget, + ids: OpaqueDocumentIds, + diagnostics: DiagnosticCounter, +): ExportTranscriptBlockV1 | undefined { + const common = { + id: ids.get('block', block.id), + kind: block.kind, + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + }; + switch (block.kind) { + case 'user': + case 'assistant': + case 'thought': { + const text = budget.text(block.text); + const images = block.images + ? budget.array(block.images).flatMap((image) => { + const safe = budget.image(image); + return safe ? [safe] : []; + }) + : undefined; + return { + ...common, + kind: block.kind, + text, + streaming: false, + ...(block.collapsed ? { collapsed: true } : {}), + ...(block.parentToolCallId + ? { + parentToolCallId: ids.get('tool-call', block.parentToolCallId), + } + : {}), + ...(images && images.length > 0 ? { images } : {}), + ...(block.kind === 'assistant' && block.usage + ? { + usage: { + inputTokens: safeCount(block.usage.inputTokens), + outputTokens: safeCount(block.usage.outputTokens), + ...(block.usage.cachedTokens !== undefined + ? { cachedTokens: safeCount(block.usage.cachedTokens) } + : {}), + }, + } + : {}), + }; + } + case 'tool': { + const status = terminalToolStatus(block.status, diagnostics, budget); + const toolName = budget.optionalLabel(block.toolName, 128); + const toolKind = budget.optionalLabel(block.toolKind, 128); + const subagentType = budget.optionalLabel(block.subagentType, 128); + let resultPreview = block.resultPreview + ? sanitizeResultPreview(block.resultPreview, budget, diagnostics, ids) + : undefined; + if (!resultPreview && (status === 'completed' || status === 'failed')) { + diagnostics.add('tool_result_presentation_missing', 'error'); + budget.markContentLoss(); + resultPreview = { + kind: 'text', + text: budget.plainText('[tool result omitted from export]'), + }; + } + return { + ...common, + kind: 'tool', + toolCallId: ids.get('tool-call', block.toolCallId), + title: budget.plainText(block.title), + status, + preview: sanitizeToolPreview(block.preview, budget, diagnostics, ids), + ...(resultPreview ? { resultPreview } : {}), + ...(toolName ? { toolName } : {}), + ...(toolKind ? { toolKind } : {}), + ...(block.parentToolCallId + ? { + parentToolCallId: ids.get('tool-call', block.parentToolCallId), + } + : {}), + ...(block.parentBlockId + ? { parentBlockId: ids.get('block', block.parentBlockId) } + : {}), + ...(subagentType ? { subagentType } : {}), + }; + } + case 'shell': + return { + ...common, + kind: 'shell', + text: budget.plainText(redactHomePaths(block.text)), + ...(block.stream ? { stream: block.stream } : {}), + }; + case 'user_shell': + return { + ...common, + kind: 'user_shell', + text: budget.plainText(redactHomePaths(block.text)), + command: budget.plainText(redactHomePaths(block.command)), + ...(block.cwd ? { cwd: budget.label(safePath(block.cwd), 400) } : {}), + ...(block.stream ? { stream: block.stream } : {}), + }; + case 'permission': { + const toolName = budget.optionalLabel(block.toolName, 128); + const toolKind = budget.optionalLabel(block.toolKind, 128); + const resolution = block.resolved + ? classifyPermissionResolutionForExport(block.resolved, block.options) + : undefined; + if (resolution?.lossy) { + diagnostics.add('permission_resolution_sanitized', 'warning', 1, true); + budget.markContentLoss(); + } + return { + ...common, + kind: 'permission', + requestId: ids.get('permission', block.requestId), + title: budget.plainText(block.title), + options: budget.array(block.options).map((option) => ({ + optionId: ids.get('permission-option', option.optionId), + label: budget.plainText(option.label), + ...(option.description + ? { description: budget.plainText(option.description) } + : {}), + raw: null, + })), + preview: sanitizeToolPreview(block.preview, budget, diagnostics, ids), + ...(block.toolCallId + ? { toolCallId: ids.get('tool-call', block.toolCallId) } + : {}), + ...(toolName ? { toolName } : {}), + ...(toolKind ? { toolKind } : {}), + ...(resolution ? { resolved: resolution.value } : {}), + }; + } + case 'status': + case 'error': { + const code = budget.optionalLabel(block.code, 128); + const errorKind = safeExportErrorKind(block.errorKind); + const source = budget.optionalLabel(block.source, 128); + return { + ...common, + kind: block.kind, + text: budget.text(block.text), + ...(code ? { code } : {}), + ...(errorKind ? { errorKind } : {}), + ...(source ? { source } : {}), + }; + } + case 'prompt_cancelled': + return { + ...common, + kind: 'prompt_cancelled', + ...(block.reason ? { reason: budget.plainText(block.reason) } : {}), + }; + case 'debug': + diagnostics.add('debug_block_excluded', 'info'); + return undefined; + default: + return assertNever(block); + } +} + +function sanitizeToolPreview( + preview: DaemonToolPreview, + budget: ExportBudget, + diagnostics: DiagnosticCounter, + ids: OpaqueDocumentIds, +): ExportToolPreviewV1 { + switch (preview.kind) { + case 'ask_user_question': + return { + kind: preview.kind, + questions: budget.array(preview.questions).map((question) => ({ + ...(question.header + ? { header: budget.label(question.header, 200) } + : {}), + question: budget.plainText(question.question), + options: budget.array(question.options).map((option) => ({ + label: budget.plainText(option.label), + ...(option.description + ? { description: budget.plainText(option.description) } + : {}), + raw: null, + })), + raw: null, + })), + }; + case 'command': + return { + kind: preview.kind, + command: budget.plainText(redactHomePaths(preview.command)), + ...(preview.cwd + ? { cwd: budget.label(safePath(preview.cwd), 400) } + : {}), + }; + case 'file_diff': + return { + kind: preview.kind, + path: budget.label(safePath(preview.path), 400), + ...(preview.oldText !== undefined + ? { oldText: budget.plainText(preview.oldText) } + : {}), + ...(preview.newText !== undefined + ? { newText: budget.plainText(preview.newText) } + : {}), + ...(preview.patch !== undefined + ? { patch: budget.plainText(preview.patch) } + : {}), + }; + case 'file_read': + return { + kind: preview.kind, + path: budget.label(safePath(preview.path), 400), + ...(preview.range ? { range: preview.range } : {}), + }; + case 'web_fetch': { + const method = budget.optionalLabel(preview.method, 16); + return { + kind: preview.kind, + url: budget.plainText( + safeDisplayUrl(preview.url, diagnostics, () => + budget.markContentLoss(), + ), + ), + ...(method ? { method } : {}), + }; + } + case 'mcp_invocation': + return { + kind: preview.kind, + serverId: budget.label(preview.serverId, 128), + toolName: budget.label(preview.toolName, 128), + ...(preview.argsSummary + ? { argsSummary: budget.plainText(preview.argsSummary) } + : {}), + }; + case 'code_block': { + const language = budget.optionalLabel(preview.language, 64); + return { + kind: preview.kind, + code: budget.plainText(preview.code), + ...(language ? { language } : {}), + ...(preview.origin + ? { origin: budget.label(safePath(preview.origin), 400) } + : {}), + }; + } + case 'search': + return { + kind: preview.kind, + query: budget.plainText(preview.query), + ...(preview.resultCount !== undefined + ? { resultCount: safeCount(preview.resultCount) } + : {}), + ...(preview.top + ? { + top: budget + .array(preview.top) + .map((item) => budget.plainText(redactHomePaths(item))), + } + : {}), + }; + case 'tabular': + return { + kind: preview.kind, + columns: budget + .array(preview.columns) + .map((item) => budget.plainText(item)), + rows: budget + .array(preview.rows) + .map((row) => + budget.array(row).map((item) => budget.plainText(item)), + ), + ...(preview.totalRows !== undefined + ? { totalRows: safeCount(preview.totalRows) } + : {}), + }; + case 'image_generation': { + const model = budget.optionalLabel(preview.model, 128); + const thumbnailUrl = + preview.thumbnailUrl !== undefined + ? budget.dataImageUrl(preview.thumbnailUrl) + : undefined; + return { + kind: preview.kind, + prompt: budget.plainText(preview.prompt), + ...(thumbnailUrl ? { thumbnailUrl } : {}), + ...(model ? { model } : {}), + }; + } + case 'subagent_delegation': + return { + kind: preview.kind, + agentName: budget.label(preview.agentName, 128), + task: budget.plainText(preview.task), + ...(preview.parentDelegationId + ? { + parentDelegationId: ids.get( + 'tool-call', + preview.parentDelegationId, + ), + } + : {}), + }; + case 'key_value': + return { + kind: preview.kind, + rows: budget.array(preview.rows).map((row) => ({ + label: budget.plainText(row.label), + value: budget.plainText(redactHomePaths(row.value)), + })), + }; + case 'todo_list': + return sanitizeTodoPreview(preview, budget, ids); + case 'generic': + return { + kind: preview.kind, + ...(preview.summary + ? { summary: budget.plainText(preview.summary) } + : {}), + }; + default: + return assertNever(preview); + } +} + +function sanitizeResultPreview( + preview: DaemonToolResultPreview, + budget: ExportBudget, + _diagnostics: DiagnosticCounter, + ids: OpaqueDocumentIds, +): ExportToolResultPreviewV1 | undefined { + if (preview.kind === 'todo_list') { + return sanitizeTodoPreview(preview, budget, ids); + } + if (preview.kind === 'text') { + return { kind: 'text', text: budget.text(redactHomePaths(preview.text)) }; + } + if (!preview.summary?.trim()) return undefined; + const summary = budget.text(redactHomePaths(preview.summary)); + return summary.trim() + ? { kind: 'generic', summary } + : { kind: 'text', text: summary }; +} + +function sanitizeTodoPreview( + preview: DaemonTodoListPreview, + budget: ExportBudget, + ids: OpaqueDocumentIds, +): ExportTodoListPreviewV1 { + if (preview.truncated) budget.markTruncated('todo_preview_truncated'); + return { + kind: 'todo_list', + entries: budget.array(preview.entries).map((entry) => ({ + id: ids.get('todo', entry.id), + content: budget.plainText(entry.content), + status: entry.status, + ...(entry.priority ? { priority: entry.priority } : {}), + ...(entry.blockedBy + ? { + blockedBy: budget + .array(entry.blockedBy) + .map((item) => ids.get('todo', item)), + } + : {}), + })), + ...(preview.truncated ? { truncated: true } : {}), + ...(preview.planId ? { planId: ids.get('plan', preview.planId) } : {}), + ...(preview.revision !== undefined + ? { revision: safeCount(preview.revision) } + : {}), + }; +} + +function terminalToolStatus( + status: string, + diagnostics: DiagnosticCounter, + budget: ExportBudget, +): ExportToolTranscriptBlockV1['status'] { + if ( + status === 'completed' || + status === 'failed' || + status === 'cancelled' || + status === 'canceled' + ) { + return status; + } + diagnostics.add('tool_status_frozen', 'warning', 1, true); + budget.markContentLoss(); + return 'cancelled'; +} + +function createMetadataPresentation( + sessionData: Pick, + options: CreateExportTranscriptDocumentOptions, + complete: boolean, + truncated: boolean, + diagnostics: DiagnosticCounter, + budget: ExportBudget, +): ExportMetadataPresentationV1 { + const metadata = sessionData.metadata; + const title = safeMetadataLabel( + options.title, + 200, + 'title', + diagnostics, + budget, + ); + const gitBranch = safeMetadataLabel( + metadata?.gitBranch, + 200, + 'git_branch', + diagnostics, + budget, + ); + const model = safeMetadataLabel( + metadata?.model, + 200, + 'model', + diagnostics, + budget, + ); + const channel = safeMetadataLabel( + metadata?.channel, + 100, + 'channel', + diagnostics, + budget, + ); + const projectName = metadata?.cwd + ? safeMetadataLabel( + safePath(metadata.cwd), + 400, + 'project_name', + diagnostics, + budget, + ) + : undefined; + const repository = metadata?.gitRepo + ? safeRepository(metadata.gitRepo, diagnostics, budget) + : undefined; + return { + ...(title ? { title } : {}), + ...(isIsoDate(sessionData.startTime) + ? { startedAt: sessionData.startTime } + : {}), + exportedAt: options.exportedAt, + complete, + truncated, + ...(projectName ? { projectName } : {}), + ...(repository ? { repository } : {}), + ...(gitBranch ? { gitBranch } : {}), + ...(model ? { model } : {}), + ...(channel ? { channel } : {}), + ...(metadata ? { promptCount: safeCount(metadata.promptCount) } : {}), + ...(metadata?.contextUsagePercent !== undefined + ? { + contextUsagePercent: Math.min( + 100, + safeCount(metadata.contextUsagePercent), + ), + } + : {}), + ...(metadata?.contextWindowSize !== undefined + ? { contextWindowSize: safeCount(metadata.contextWindowSize) } + : {}), + ...(metadata?.totalTokens !== undefined + ? { totalTokens: safeCount(metadata.totalTokens) } + : {}), + ...(metadata?.filesWritten !== undefined + ? { filesWritten: safeCount(metadata.filesWritten) } + : {}), + ...(metadata?.linesAdded !== undefined + ? { linesAdded: safeCount(metadata.linesAdded) } + : {}), + ...(metadata?.linesRemoved !== undefined + ? { linesRemoved: safeCount(metadata.linesRemoved) } + : {}), + }; +} + +class ExportBudget { + visibleTextBytes = 0; + totalRasterBytes = 0; + richRenderTasks = 0; + truncated = false; + + constructor(private readonly diagnostics: DiagnosticCounter) {} + + array(value: readonly T[]): T[] { + if (value.length > EXPORT_TRANSCRIPT_LIMITS_V1.maxArrayLength) { + this.truncated = true; + this.diagnostics.add('array_budget_exceeded', 'warning'); + } + return value.slice(0, EXPORT_TRANSCRIPT_LIMITS_V1.maxArrayLength); + } + + markTruncated(code: string): void { + this.truncated = true; + this.diagnostics.add(code, 'warning'); + } + + markContentLoss(): void { + this.truncated = true; + } + + label(value: unknown, maxLength: number): string { + const safe = safeLabel(value, maxLength); + if (safe !== value) this.markTruncated('label_sanitized'); + return this.plainText(safe); + } + + optionalLabel(value: unknown, maxLength: number): string | undefined { + if (value === undefined || value === '') return undefined; + return this.label(value, maxLength); + } + + plainText(value: string): string { + return this.applyTextBudget(redactHomePaths(value)); + } + + text(value: string): string { + value = redactHomePaths(value); + const definitions = new Map(); + const markdownSegments = splitMarkdownFenceSegments(value); + for (const segment of markdownSegments) { + if (!segment.prose) continue; + transformMarkdownProse(segment.value, (prose) => { + for (const match of prose.matchAll( + /^\s*\[([^\]]+)\]:\s*(?:<([^>]+)>|([^\s]+))(?:\s+.*)?$/gm, + )) { + const label = match[1]?.trim().toLowerCase(); + const source = match[2] ?? match[3]; + if (label && source) definitions.set(label, source); + } + return prose; + }); + } + const replaceImage = (alt: string, source: string | undefined): string => { + const parsed = source ? parseApprovedImageDataUrl(source) : undefined; + if (parsed && this.image(parsed)) { + return `![${alt}](${formatApprovedImageDataUrl(parsed)})`; + } + this.truncated = true; + this.diagnostics.add('markdown_image_rejected', 'warning'); + return `[image omitted${alt ? `: ${alt}` : ''}]`; + }; + const replaceReferenceImage = ( + alt: string, + source: string | undefined, + ): string => + source && parseApprovedImageDataUrl(source) + ? `![${alt}](${source})` + : replaceImage(alt, undefined); + const resourceSafeValue = markdownSegments + .map((segment) => { + if (!segment.prose) return segment.value; + return transformMarkdownProse(segment.value, (prose) => { + let safe = replaceActiveMarkdownSyntax( + prose, + /!\[([^\]]*)\]\[([^\]]*)\]/g, + (match) => + replaceReferenceImage( + match[1] ?? '', + definitions.get( + (match[2] || match[1] || '').trim().toLowerCase(), + ), + ), + ); + safe = replaceActiveMarkdownSyntax( + safe, + /!\[([^\]]+)\](?![([])/g, + (match) => + replaceReferenceImage( + match[1] ?? '', + definitions.get((match[1] ?? '').trim().toLowerCase()), + ), + ); + safe = replaceActiveMarkdownSyntax(safe, /]*>/gi, () => + replaceImage('', undefined), + ); + safe = replaceActiveMarkdownSyntax( + safe, + /!\[([^\]]*)\]\(([^\s)]+)(?:\s+["'][^)]*["'])?\)/g, + (match) => replaceImage(match[1] ?? '', match[2]), + ); + return sanitizeMarkdownNavigableUrls(safe, (code) => { + this.truncated = true; + this.diagnostics.add(code, 'warning', 1, true); + }); + }); + }) + .join(''); + const richTaskSafeValue = resourceSafeValue.replace( + /^(\s*)(```|~~~)([^\s`~]+)(.*)$/gm, + ( + match, + indent: string, + fence: string, + language: string, + rest: string, + ) => { + this.richRenderTasks += 1; + if ( + this.richRenderTasks <= EXPORT_TRANSCRIPT_LIMITS_V1.maxRichRenderTasks + ) { + return match; + } + this.diagnostics.add('rich_render_budget_exceeded', 'warning'); + return `${indent}${fence}text${rest} [source fallback: ${safeLabel(language, 32)}]`; + }, + ); + return this.applyTextBudget(richTaskSafeValue); + } + + private applyTextBudget(value: string): string { + const bytes = utf8Bytes(value); + if ( + bytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxTextBytes || + this.visibleTextBytes + bytes > + EXPORT_TRANSCRIPT_LIMITS_V1.maxVisibleTextBytes + ) { + this.truncated = true; + this.diagnostics.add('text_budget_exceeded', 'warning'); + const fallback = '[content omitted: export text budget exceeded]'; + const fallbackBytes = utf8Bytes(fallback); + if ( + this.visibleTextBytes + fallbackBytes <= + EXPORT_TRANSCRIPT_LIMITS_V1.maxVisibleTextBytes + ) { + this.visibleTextBytes += fallbackBytes; + return fallback; + } + return ''; + } + this.visibleTextBytes += bytes; + return value; + } + + image(image: { + data: string; + mimeType: string; + }): { data: string; mimeType: string } | undefined { + if (!SAFE_RASTER_MIME_TYPES.has(image.mimeType)) { + this.truncated = true; + this.diagnostics.add('image_type_rejected', 'warning'); + return undefined; + } + const bytes = decodedBase64Bytes(image.data); + if ( + bytes === undefined || + bytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes || + this.totalRasterBytes + bytes > + EXPORT_TRANSCRIPT_LIMITS_V1.maxTotalRasterBytes || + (image.mimeType === 'image/gif' && isAnimatedGif(image.data)) + ) { + this.truncated = true; + this.diagnostics.add('image_budget_or_animation_rejected', 'warning'); + return undefined; + } + this.totalRasterBytes += bytes; + return { data: image.data, mimeType: image.mimeType }; + } + + dataImageUrl(value: string): string | undefined { + const image = parseApprovedImageDataUrl(value); + if (!image) { + this.truncated = true; + this.diagnostics.add('image_type_rejected', 'warning'); + return undefined; + } + return this.image(image) ? formatApprovedImageDataUrl(image) : undefined; + } +} + +const SAFE_RASTER_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +]); + +function parseApprovedImageDataUrl( + value: string, +): { data: string; mimeType: string } | undefined { + const match = + /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/]*={0,2})$/i.exec( + value, + ); + if (!match?.[1] || match[2] === undefined) return undefined; + return { mimeType: match[1].toLowerCase(), data: match[2] }; +} + +function formatApprovedImageDataUrl(image: { + data: string; + mimeType: string; +}): string { + return `data:${image.mimeType};base64,${image.data}`; +} + +class DiagnosticCounter { + private readonly entries = new Map< + string, + { severity: 'info' | 'warning' | 'error'; count: number } + >(); + private completenessLost = false; + + add( + code: string, + severity: 'info' | 'warning' | 'error', + count = 1, + affectsCompleteness = false, + ): void { + if (affectsCompleteness) this.completenessLost = true; + const current = this.entries.get(code); + if (current) { + current.count += Math.max(1, count); + if (severityRank(severity) > severityRank(current.severity)) { + current.severity = severity; + } + return; + } + this.entries.set(code, { severity, count: Math.max(1, count) }); + } + + get hasErrors(): boolean { + return [...this.entries.values()].some((item) => item.severity === 'error'); + } + + get hasCompletenessLoss(): boolean { + return this.completenessLost; + } + + toArray(): ExportTranscriptDiagnosticV1[] { + return [...this.entries.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([code, item]) => ({ code, ...item })); + } +} + +class OpaqueDocumentIds { + private readonly ids = new Map(); + private nextOrdinal = 0; + + get(kind: string, nativeId: string): string { + const key = `${kind}\u0000${nativeId}`; + let id = this.ids.get(key); + if (!id) { + id = `${kind}-${this.nextOrdinal}`; + this.nextOrdinal += 1; + this.ids.set(key, id); + } + return id; + } +} + +const APPROVED_PERMISSION_TOKENS = new Set([ + 'accept', + 'accepted', + 'allow', + 'allow_always', + 'allow_once', + 'allowed', + 'approve', + 'approved', + 'confirm', + 'confirmed', + 'proceed', + 'proceed_always_project', + 'proceed_always_user', + 'proceed_once', + 'proceed_once_and_switch_to_default', + 'succeeded', + 'success', +]); + +const REJECTED_PERMISSION_TOKENS = new Set([ + 'deny', + 'denied', + 'reject', + 'reject_always', + 'reject_once', + 'rejected', +]); + +const CANCELLED_PERMISSION_TOKENS = new Set([ + 'cancel', + 'canceled', + 'cancelled', +]); + +const EXPIRED_PERMISSION_TOKENS = new Set([ + 'expired', + 'session_closed', + 'timed_out', + 'timeout', +]); + +export function classifyPermissionResolutionForExport( + resolved: string, + options: readonly DaemonUiPermissionOption[], +): { value: ExportPermissionResolutionV1; lossy: boolean } { + const separator = resolved.indexOf(':'); + const primary = (separator === -1 ? resolved : resolved.slice(0, separator)) + .trim() + .toLowerCase(); + let token = primary; + if (primary === 'selected' && separator !== -1) { + const optionId = resolved.slice(separator + 1).trim(); + const option = options.find((candidate) => candidate.optionId === optionId); + if (!option) return { value: 'resolved', lossy: true }; + const raw = isRecord(option.raw) ? option.raw : undefined; + token = + (typeof raw?.['kind'] === 'string' + ? raw['kind'].trim().toLowerCase() + : '') || option.optionId.trim().toLowerCase(); + } + if (APPROVED_PERMISSION_TOKENS.has(token)) { + return { value: 'approved', lossy: false }; + } + if (REJECTED_PERMISSION_TOKENS.has(token)) { + return { value: 'rejected', lossy: false }; + } + if (CANCELLED_PERMISSION_TOKENS.has(token)) { + return { value: 'cancelled', lossy: false }; + } + if (EXPIRED_PERMISSION_TOKENS.has(token)) { + return { value: 'expired', lossy: false }; + } + if (token === 'resolved') return { value: 'resolved', lossy: false }; + return { value: 'resolved', lossy: true }; +} + +function assertExportBlock(value: unknown): void { + if (!isRecord(value) || !isSafeLabel(value['id'], 200)) { + throw new ExportTranscriptDocumentError('invalid_block'); + } + const kind = value['kind']; + const common = ['id', 'kind', 'clientReceivedAt', 'createdAt', 'updatedAt']; + const keysByKind: Record = { + user: [ + ...common, + 'text', + 'streaming', + 'collapsed', + 'parentToolCallId', + 'images', + ], + assistant: [ + ...common, + 'text', + 'streaming', + 'collapsed', + 'parentToolCallId', + 'images', + 'usage', + ], + thought: [ + ...common, + 'text', + 'streaming', + 'collapsed', + 'parentToolCallId', + 'images', + ], + tool: [ + ...common, + 'toolCallId', + 'title', + 'status', + 'toolName', + 'toolKind', + 'preview', + 'resultPreview', + 'parentToolCallId', + 'parentBlockId', + 'subagentType', + ], + shell: [...common, 'text', 'stream'], + user_shell: [...common, 'text', 'command', 'cwd', 'stream'], + permission: [ + ...common, + 'requestId', + 'title', + 'options', + 'preview', + 'toolCallId', + 'toolName', + 'toolKind', + 'resolved', + ], + status: [...common, 'text', 'code', 'errorKind', 'source'], + error: [...common, 'text', 'code', 'errorKind', 'source'], + prompt_cancelled: [...common, 'reason'], + }; + if (typeof kind !== 'string' || !keysByKind[kind]) { + throw new ExportTranscriptDocumentError('unsupported_block_kind'); + } + assertOnlyKeys(value, keysByKind[kind]); + if ( + value['clientReceivedAt'] !== 0 || + value['createdAt'] !== 0 || + value['updatedAt'] !== 0 + ) { + throw new ExportTranscriptDocumentError('nonzero_block_timestamp'); + } + switch (kind) { + case 'user': + case 'assistant': + case 'thought': + assertText(value['text']); + if (value['streaming'] !== undefined && value['streaming'] !== false) { + invalidBlock(); + } + assertOptionalBoolean(value['collapsed']); + assertOptionalLabel(value['parentToolCallId'], 200); + if (value['images'] !== undefined) { + if (!Array.isArray(value['images'])) invalidBlock(); + for (const image of value['images']) assertRasterImage(image); + } + if (value['usage'] !== undefined) { + if (kind !== 'assistant') invalidBlock(); + assertUsage(value['usage']); + } + return; + case 'tool': + assertLabel(value['toolCallId'], 200); + assertText(value['title']); + if ( + !['completed', 'failed', 'cancelled', 'canceled'].includes( + String(value['status']), + ) + ) { + invalidBlock(); + } + assertToolPreview(value['preview']); + if (value['resultPreview'] !== undefined) { + assertToolResultPreview(value['resultPreview']); + } + if ( + (value['status'] === 'completed' || value['status'] === 'failed') && + value['resultPreview'] === undefined + ) { + invalidBlock(); + } + for (const key of [ + 'toolName', + 'toolKind', + 'parentToolCallId', + 'parentBlockId', + 'subagentType', + ]) { + assertOptionalLabel(value[key], 200); + } + return; + case 'shell': + assertText(value['text']); + assertOptionalStream(value['stream']); + return; + case 'user_shell': + assertText(value['text']); + assertText(value['command']); + assertOptionalPresentationLabel(value['cwd'], 400); + if (value['cwd'] !== undefined && !isSafeExportPath(value['cwd'])) { + invalidBlock(); + } + assertOptionalStream(value['stream']); + return; + case 'permission': + assertLabel(value['requestId'], 200); + assertText(value['title']); + if (!Array.isArray(value['options'])) invalidBlock(); + for (const option of value['options']) assertPermissionOption(option); + assertToolPreview(value['preview']); + assertOptionalLabel(value['toolCallId'], 200); + assertOptionalLabel(value['toolName'], 200); + assertOptionalLabel(value['toolKind'], 200); + if ( + value['resolved'] !== undefined && + !['approved', 'rejected', 'cancelled', 'expired', 'resolved'].includes( + String(value['resolved']), + ) + ) { + invalidBlock(); + } + return; + case 'status': + case 'error': + assertText(value['text']); + assertOptionalLabel(value['code'], 128); + if ( + value['errorKind'] !== undefined && + !SAFE_EXPORT_ERROR_KINDS.has(String(value['errorKind'])) + ) { + invalidBlock(); + } + assertOptionalLabel(value['source'], 128); + return; + case 'prompt_cancelled': + if (value['reason'] !== undefined) assertText(value['reason']); + return; + default: + invalidBlock(); + } +} + +const SAFE_EXPORT_ERROR_KINDS = new Set(DAEMON_ERROR_KINDS); + +function safeExportErrorKind( + value: DaemonErrorKind | undefined, +): DaemonErrorKind | undefined { + return value && SAFE_EXPORT_ERROR_KINDS.has(value) ? value : undefined; +} + +function invalidBlock(): never { + throw new ExportTranscriptDocumentError('invalid_block'); +} + +function assertText(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + utf8Bytes(value) > EXPORT_TRANSCRIPT_LIMITS_V1.maxTextBytes + ) { + invalidBlock(); + } +} + +function assertLabel( + value: unknown, + maxLength: number, +): asserts value is string { + if (!isSafeLabel(value, maxLength)) invalidBlock(); +} + +function assertOptionalLabel(value: unknown, maxLength: number): void { + if (value !== undefined) assertLabel(value, maxLength); +} + +function assertPresentationLabel(value: unknown, maxLength: number): void { + if (!isSafePresentationLabel(value, maxLength)) invalidBlock(); +} + +function assertOptionalPresentationLabel( + value: unknown, + maxLength: number, +): void { + if (value !== undefined) assertPresentationLabel(value, maxLength); +} + +function assertOptionalBoolean(value: unknown): void { + if (value !== undefined && typeof value !== 'boolean') invalidBlock(); +} + +function assertOptionalStream(value: unknown): void { + if (value !== undefined && value !== 'stdout' && value !== 'stderr') { + invalidBlock(); + } +} + +function assertRasterImage(value: unknown): void { + if (!isRecord(value)) invalidBlock(); + assertOnlyKeys(value, ['data', 'mimeType']); + if ( + typeof value['data'] !== 'string' || + typeof value['mimeType'] !== 'string' || + !SAFE_RASTER_MIME_TYPES.has(value['mimeType']) || + decodedBase64Bytes(value['data']) === undefined || + (value['mimeType'] === 'image/gif' && isAnimatedGif(value['data'])) + ) { + invalidBlock(); + } +} + +function assertUsage(value: unknown): void { + if (!isRecord(value)) invalidBlock(); + assertOnlyKeys(value, ['inputTokens', 'outputTokens', 'cachedTokens']); + if ( + !isSafeCount(value['inputTokens']) || + !isSafeCount(value['outputTokens']) || + (value['cachedTokens'] !== undefined && !isSafeCount(value['cachedTokens'])) + ) { + invalidBlock(); + } +} + +function assertPermissionOption(value: unknown): void { + if (!isRecord(value)) invalidBlock(); + assertOnlyKeys(value, ['optionId', 'label', 'description', 'raw']); + assertLabel(value['optionId'], 200); + assertText(value['label']); + if (value['description'] !== undefined) assertText(value['description']); + if (value['raw'] !== null) invalidBlock(); +} + +function assertToolPreview(value: unknown): void { + if (!isRecord(value) || typeof value['kind'] !== 'string') invalidBlock(); + const kind = value['kind']; + const keysByKind: Record = { + ask_user_question: ['kind', 'questions'], + command: ['kind', 'command', 'cwd'], + file_diff: ['kind', 'path', 'oldText', 'newText', 'patch'], + file_read: ['kind', 'path', 'range'], + web_fetch: ['kind', 'url', 'method'], + mcp_invocation: ['kind', 'serverId', 'toolName', 'argsSummary'], + code_block: ['kind', 'language', 'code', 'origin'], + search: ['kind', 'query', 'resultCount', 'top'], + tabular: ['kind', 'columns', 'rows', 'totalRows'], + image_generation: ['kind', 'prompt', 'thumbnailUrl', 'model'], + subagent_delegation: ['kind', 'agentName', 'task', 'parentDelegationId'], + key_value: ['kind', 'rows'], + todo_list: ['kind', 'entries', 'truncated', 'planId', 'revision'], + generic: ['kind', 'summary'], + }; + const keys = keysByKind[kind]; + if (!keys) invalidBlock(); + assertOnlyKeys(value, keys); + switch (kind) { + case 'ask_user_question': + if (!Array.isArray(value['questions'])) invalidBlock(); + for (const question of value['questions']) assertQuestion(question); + return; + case 'command': + assertText(value['command']); + assertOptionalPresentationLabel(value['cwd'], 400); + if (value['cwd'] !== undefined && !isSafeExportPath(value['cwd'])) { + invalidBlock(); + } + return; + case 'file_diff': + assertPresentationLabel(value['path'], 400); + if (!isSafeExportPath(value['path'])) invalidBlock(); + for (const key of ['oldText', 'newText', 'patch']) { + if (value[key] !== undefined) assertText(value[key]); + } + return; + case 'file_read': + assertPresentationLabel(value['path'], 400); + if (!isSafeExportPath(value['path'])) invalidBlock(); + if (value['range'] !== undefined) { + if ( + !Array.isArray(value['range']) || + value['range'].length !== 2 || + !value['range'].every(isSafeCount) + ) { + invalidBlock(); + } + } + return; + case 'web_fetch': + assertText(value['url']); + if (!isSafeDisplayUrl(value['url'])) invalidBlock(); + assertOptionalLabel(value['method'], 16); + return; + case 'mcp_invocation': + assertPresentationLabel(value['serverId'], 128); + assertPresentationLabel(value['toolName'], 128); + if (value['argsSummary'] !== undefined) assertText(value['argsSummary']); + return; + case 'code_block': + assertText(value['code']); + assertOptionalLabel(value['language'], 64); + assertOptionalLabel(value['origin'], 400); + if (value['origin'] !== undefined && !isSafeExportPath(value['origin'])) { + invalidBlock(); + } + return; + case 'search': + assertText(value['query']); + if ( + value['resultCount'] !== undefined && + !isSafeCount(value['resultCount']) + ) { + invalidBlock(); + } + assertOptionalTextArray(value['top']); + return; + case 'tabular': + assertTextArray(value['columns']); + if (!Array.isArray(value['rows'])) invalidBlock(); + for (const row of value['rows']) assertTextArray(row); + if ( + value['totalRows'] !== undefined && + !isSafeCount(value['totalRows']) + ) { + invalidBlock(); + } + return; + case 'image_generation': + assertText(value['prompt']); + if (value['thumbnailUrl'] !== undefined) { + if (typeof value['thumbnailUrl'] !== 'string') invalidBlock(); + const image = parseApprovedImageDataUrl(value['thumbnailUrl']); + const bytes = image ? decodedBase64Bytes(image.data) : undefined; + const canonical = image ? formatApprovedImageDataUrl(image) : undefined; + if ( + bytes === undefined || + bytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes || + value['thumbnailUrl'] !== canonical || + (image?.mimeType === 'image/gif' && isAnimatedGif(image.data)) + ) { + invalidBlock(); + } + } + assertOptionalLabel(value['model'], 128); + return; + case 'subagent_delegation': + assertPresentationLabel(value['agentName'], 128); + assertText(value['task']); + assertOptionalLabel(value['parentDelegationId'], 128); + return; + case 'key_value': + if (!Array.isArray(value['rows'])) invalidBlock(); + for (const row of value['rows']) { + if (!isRecord(row)) invalidBlock(); + assertOnlyKeys(row, ['label', 'value']); + assertText(row['label']); + assertText(row['value']); + } + return; + case 'todo_list': + assertTodoPreview(value); + return; + case 'generic': + if (value['summary'] !== undefined) assertText(value['summary']); + return; + default: + invalidBlock(); + } +} + +function assertToolResultPreview(value: unknown): void { + if (!isRecord(value)) invalidBlock(); + if (value['kind'] === 'todo_list') { + assertTodoPreview(value); + return; + } + if (value['kind'] === 'text') { + assertOnlyKeys(value, ['kind', 'text']); + assertText(value['text']); + return; + } + if (value['kind'] === 'generic') { + assertOnlyKeys(value, ['kind', 'summary']); + assertText(value['summary']); + if (value['summary'].trim().length === 0) invalidBlock(); + return; + } + invalidBlock(); +} + +function assertQuestion(value: unknown): void { + if (!isRecord(value)) invalidBlock(); + assertOnlyKeys(value, ['header', 'question', 'options', 'raw']); + assertOptionalLabel(value['header'], 200); + assertText(value['question']); + if (!Array.isArray(value['options'])) invalidBlock(); + for (const option of value['options']) { + if (!isRecord(option)) invalidBlock(); + assertOnlyKeys(option, ['label', 'description', 'raw']); + assertText(option['label']); + if (option['description'] !== undefined) assertText(option['description']); + if (option['raw'] !== null) invalidBlock(); + } + if (value['raw'] !== null) invalidBlock(); +} + +function assertTodoPreview(value: Record): void { + assertOnlyKeys(value, ['kind', 'entries', 'truncated', 'planId', 'revision']); + if (!Array.isArray(value['entries'])) invalidBlock(); + for (const entry of value['entries']) { + if (!isRecord(entry)) invalidBlock(); + assertOnlyKeys(entry, ['id', 'content', 'status', 'priority', 'blockedBy']); + assertLabel(entry['id'], 128); + assertText(entry['content']); + if ( + !['pending', 'in_progress', 'completed'].includes(String(entry['status'])) + ) { + invalidBlock(); + } + if ( + entry['priority'] !== undefined && + !['high', 'medium', 'low'].includes(String(entry['priority'])) + ) { + invalidBlock(); + } + if (entry['blockedBy'] !== undefined) { + if (!Array.isArray(entry['blockedBy'])) invalidBlock(); + for (const dependency of entry['blockedBy']) assertLabel(dependency, 128); + } + } + assertOptionalBoolean(value['truncated']); + assertOptionalLabel(value['planId'], 128); + if (value['revision'] !== undefined && !isSafeCount(value['revision'])) { + invalidBlock(); + } +} + +function assertTextArray(value: unknown): void { + if (!Array.isArray(value)) invalidBlock(); + for (const item of value) assertText(item); +} + +function assertOptionalTextArray(value: unknown): void { + if (value !== undefined) assertTextArray(value); +} + +function assertMetadata(value: unknown): void { + if (!isRecord(value)) { + throw new ExportTranscriptDocumentError('invalid_metadata'); + } + assertOnlyKeys(value, [ + 'title', + 'startedAt', + 'exportedAt', + 'complete', + 'truncated', + 'projectName', + 'repository', + 'gitBranch', + 'model', + 'channel', + 'promptCount', + 'contextUsagePercent', + 'contextWindowSize', + 'totalTokens', + 'filesWritten', + 'linesAdded', + 'linesRemoved', + ]); + if ( + !isIsoDate(value['exportedAt']) || + typeof value['complete'] !== 'boolean' || + typeof value['truncated'] !== 'boolean' + ) { + throw new ExportTranscriptDocumentError('invalid_metadata'); + } + assertOptionalLabel(value['title'], 200); + if (value['startedAt'] !== undefined && !isIsoDate(value['startedAt'])) { + throw new ExportTranscriptDocumentError('invalid_metadata'); + } + assertOptionalLabel(value['projectName'], 400); + if ( + value['projectName'] !== undefined && + !isSafeExportPath(value['projectName']) + ) { + throw new ExportTranscriptDocumentError('invalid_metadata'); + } + if ( + value['repository'] !== undefined && + !isSafeRepository(value['repository']) + ) { + throw new ExportTranscriptDocumentError('invalid_metadata'); + } + assertOptionalLabel(value['gitBranch'], 200); + assertOptionalLabel(value['model'], 200); + assertOptionalLabel(value['channel'], 100); + for (const key of [ + 'promptCount', + 'contextWindowSize', + 'totalTokens', + 'filesWritten', + 'linesAdded', + 'linesRemoved', + ]) { + if (value[key] !== undefined && !isSafeCount(value[key])) { + throw new ExportTranscriptDocumentError('invalid_metadata'); + } + } + if ( + value['contextUsagePercent'] !== undefined && + (!isSafeCount(value['contextUsagePercent']) || + Number(value['contextUsagePercent']) > 100) + ) { + throw new ExportTranscriptDocumentError('invalid_metadata'); + } +} + +function assertDocumentConsistency(value: Record): void { + const metadata = value['metadata'] as ExportMetadataPresentationV1; + const diagnostics = value['diagnostics'] as ExportTranscriptDiagnosticV1[]; + const blocks = value['blocks'] as ExportTranscriptBlockV1[]; + const blockIds = new Set(blocks.map((block) => block.id)); + if (blockIds.size !== blocks.length) { + throw new ExportTranscriptDocumentError('duplicate_block_id'); + } + for (const block of blocks) { + if ( + block.kind === 'tool' && + block.parentBlockId !== undefined && + !blockIds.has(block.parentBlockId) + ) { + throw new ExportTranscriptDocumentError('invalid_block_reference'); + } + } + if (metadata.complete && metadata.truncated) { + throw new ExportTranscriptDocumentError('invalid_metadata_state'); + } + const hasError = diagnostics.some( + (diagnostic) => diagnostic.severity === 'error', + ); + const hasCompletenessDiagnostic = diagnostics.some( + (diagnostic) => + diagnostic.severity === 'error' || + (diagnostic.severity === 'warning' && + diagnostic.code !== 'rich_render_budget_exceeded'), + ); + const hasTruncationDiagnostic = diagnostics.some((diagnostic) => + TRUNCATION_DIAGNOSTIC_CODES.has(diagnostic.code), + ); + const hasExplicitContentLoss = blocks.some((block) => { + if ( + block.kind === 'tool' && + (block.status === 'completed' || block.status === 'failed') && + block.resultPreview === undefined + ) { + return true; + } + if (block.kind === 'tool') { + return ( + (block.preview.kind === 'todo_list' && + block.preview.truncated === true) || + (block.resultPreview?.kind === 'todo_list' && + block.resultPreview.truncated === true) + ); + } + if (block.kind === 'permission') { + return ( + block.preview.kind === 'todo_list' && block.preview.truncated === true + ); + } + return false; + }); + if ( + (hasError || hasCompletenessDiagnostic || hasExplicitContentLoss) && + metadata.complete + ) { + throw new ExportTranscriptDocumentError('invalid_metadata_state'); + } + if ( + (hasExplicitContentLoss || hasTruncationDiagnostic) && + !metadata.truncated + ) { + throw new ExportTranscriptDocumentError('invalid_metadata_state'); + } +} + +const TRUNCATION_DIAGNOSTIC_CODES = new Set([ + 'array_budget_exceeded', + 'todo_preview_truncated', + 'markdown_image_rejected', + 'text_budget_exceeded', + 'image_type_rejected', + 'image_budget_or_animation_rejected', + 'tool_status_frozen', + 'url_sanitized', + 'url_rejected', + 'repository_url_rejected', + 'repository_rejected', + 'title_rejected', + 'git_branch_rejected', + 'model_rejected', + 'channel_rejected', + 'project_name_rejected', + 'permission_resolution_sanitized', + 'tool_result_presentation_missing', + 'label_sanitized', +]); + +function assertNoForbiddenFields(value: unknown): void { + const forbidden = new Set([ + 'rawInput', + 'rawOutput', + 'toolCall', + 'details', + 'locations', + 'meta', + 'sessionId', + 'sourceRecordIds', + 'eventId', + 'serverTimestamp', + 'promptId', + 'branchRecordId', + 'debugReason', + ]); + const visit = (entry: unknown, key?: string): void => { + if (typeof entry === 'string') { + if (key !== 'data' && redactHomePaths(entry) !== entry) { + throw new ExportTranscriptDocumentError('home_path_forbidden'); + } + return; + } + if (Array.isArray(entry)) { + for (const item of entry) visit(item, key); + return; + } + if (!isRecord(entry)) return; + for (const [key, item] of Object.entries(entry)) { + if (forbidden.has(key)) { + throw new ExportTranscriptDocumentError('forbidden_field'); + } + visit(item, key); + } + }; + visit(value); +} + +function assertDepthAndArrayBudgets( + value: unknown, + depth = 0, + ancestors = new WeakSet(), +): void { + if (depth > EXPORT_TRANSCRIPT_LIMITS_V1.maxObjectDepth) { + throw new ExportTranscriptDocumentError('object_depth_exceeded'); + } + if (Array.isArray(value)) { + if (ancestors.has(value)) { + throw new ExportTranscriptDocumentError('cyclic_envelope'); + } + if (value.length > EXPORT_TRANSCRIPT_LIMITS_V1.maxArrayLength) { + throw new ExportTranscriptDocumentError('array_budget_exceeded'); + } + ancestors.add(value); + for (const item of value) { + assertDepthAndArrayBudgets(item, depth + 1, ancestors); + } + ancestors.delete(value); + return; + } + if (!isRecord(value)) return; + if (ancestors.has(value)) { + throw new ExportTranscriptDocumentError('cyclic_envelope'); + } + const entries = Object.values(value); + if (entries.length > EXPORT_TRANSCRIPT_LIMITS_V1.maxObjectProperties) { + throw new ExportTranscriptDocumentError('object_property_budget_exceeded'); + } + ancestors.add(value); + for (const item of entries) { + assertDepthAndArrayBudgets(item, depth + 1, ancestors); + } + ancestors.delete(value); +} + +function serializedEnvelopeBytes(value: unknown): number { + try { + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new ExportTranscriptDocumentError('invalid_envelope'); + } + return utf8Bytes(serialized); + } catch (error) { + if (error instanceof ExportTranscriptDocumentError) throw error; + throw new ExportTranscriptDocumentError('invalid_envelope'); + } +} + +function assertResourceBudgets(value: unknown): void { + let visibleTextBytes = 0; + let totalRasterBytes = 0; + let richRenderTasks = 0; + const visit = ( + entry: unknown, + key?: string, + parent?: Record, + path: readonly string[] = [], + ): void => { + if (typeof entry === 'string') { + if (key === 'data') return; + if (key === 'thumbnailUrl') { + const image = parseApprovedImageDataUrl(entry); + const bytes = image ? decodedBase64Bytes(image.data) : undefined; + const canonical = image ? formatApprovedImageDataUrl(image) : undefined; + if ( + bytes === undefined || + bytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes || + entry !== canonical || + (image?.mimeType === 'image/gif' && isAnimatedGif(image.data)) + ) { + throw new ExportTranscriptDocumentError('invalid_thumbnail_image'); + } + totalRasterBytes += bytes; + return; + } + const bytes = utf8Bytes(entry); + if (bytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxTextBytes) { + throw new ExportTranscriptDocumentError('text_budget_exceeded'); + } + const markdownText = isMarkdownExportText(key, parent, path); + if (key && VISIBLE_EXPORT_TEXT_FIELDS.has(key)) { + visibleTextBytes += bytes; + if (markdownText) { + richRenderTasks += [ + ...entry.matchAll(/^(\s*)(```|~~~)([^\s`~]+)/gm), + ].filter( + (match) => !['text', 'plain', 'plaintext'].includes(match[3] ?? ''), + ).length; + } + } + if (markdownText) { + for (const segment of splitMarkdownFenceSegments(entry)) { + if (!segment.prose) continue; + transformMarkdownProse(segment.value, (prose) => { + if (sanitizeMarkdownNavigableUrls(prose, () => {}) !== prose) { + throw new ExportTranscriptDocumentError('invalid_markdown_url'); + } + for (const pattern of [ + /!\[[^\]]*\]\[[^\]]*\]/g, + /!\[[^\]]+\](?![([])/g, + /]*>/gi, + ]) { + replaceActiveMarkdownSyntax(prose, pattern, () => { + throw new ExportTranscriptDocumentError( + 'invalid_markdown_image', + ); + }); + } + replaceActiveMarkdownSyntax( + prose, + /!\[[^\]]*\]\(([^\s)]+)(?:\s+["'][^)]*["'])?\)/g, + (match) => { + const source = match[1]; + const image = source + ? parseApprovedImageDataUrl(source) + : undefined; + const imageBytes = image + ? decodedBase64Bytes(image.data) + : undefined; + if ( + imageBytes === undefined || + imageBytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes || + (image?.mimeType === 'image/gif' && isAnimatedGif(image.data)) + ) { + throw new ExportTranscriptDocumentError( + 'invalid_markdown_image', + ); + } + totalRasterBytes += imageBytes; + return match[0]; + }, + ); + return prose; + }); + } + } + return; + } + if (Array.isArray(entry)) { + for (const item of entry) visit(item, key, parent, path); + return; + } + if (!isRecord(entry)) return; + if ( + typeof entry['data'] === 'string' && + typeof entry['mimeType'] === 'string' + ) { + const bytes = decodedBase64Bytes(entry['data']); + if ( + bytes === undefined || + bytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes || + !SAFE_RASTER_MIME_TYPES.has(entry['mimeType']) || + (entry['mimeType'] === 'image/gif' && isAnimatedGif(entry['data'])) + ) { + throw new ExportTranscriptDocumentError('raster_budget_exceeded'); + } + totalRasterBytes += bytes; + } + for (const [childKey, item] of Object.entries(entry)) { + visit(item, childKey, entry, [...path, childKey]); + } + }; + visit(value); + if (visibleTextBytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxVisibleTextBytes) { + throw new ExportTranscriptDocumentError('visible_text_budget_exceeded'); + } + if (totalRasterBytes > EXPORT_TRANSCRIPT_LIMITS_V1.maxTotalRasterBytes) { + throw new ExportTranscriptDocumentError('total_raster_budget_exceeded'); + } + if (richRenderTasks > EXPORT_TRANSCRIPT_LIMITS_V1.maxRichRenderTasks) { + throw new ExportTranscriptDocumentError('rich_render_budget_exceeded'); + } +} + +function isMarkdownExportText( + key: string | undefined, + parent: Record | undefined, + path: readonly string[], +): boolean { + if (path.at(-2) === 'resultPreview') { + return key === 'text' || key === 'summary'; + } + return ( + key === 'text' && + ['user', 'assistant', 'thought', 'status', 'error'].includes( + String(parent?.['kind']), + ) + ); +} + +function splitMarkdownFenceSegments( + value: string, +): Array<{ value: string; prose: boolean }> { + const segments: Array<{ value: string; prose: boolean }> = []; + let fence: { character: string; length: number } | undefined; + for (const line of value.match(/[^\n]*(?:\n|$)/g) ?? []) { + if (line === '') continue; + const marker = /^ {0,3}(`{3,}|~{3,})(?:[^\n]*)/.exec(line)?.[1]; + const isClosing = + fence !== undefined && + marker?.[0] === fence.character && + marker.length >= fence.length && + /^ {0,3}(?:`{3,}|~{3,})\s*$/.test(line.replace(/\n$/, '')); + const indentedCode = + fence === undefined && marker === undefined && /^(?: {4}|\t)/.test(line); + const prose = fence === undefined && marker === undefined && !indentedCode; + const previous = segments.at(-1); + if (previous?.prose === prose) previous.value += line; + else segments.push({ value: line, prose }); + if (fence === undefined && marker) { + fence = { character: marker[0] ?? '', length: marker.length }; + } else if (isClosing) { + fence = undefined; + } + } + return segments; +} + +function transformMarkdownProse( + value: string, + transform: (value: string) => string, +): string { + let result = ''; + let cursor = 0; + for (const match of value.matchAll(/(`+)[\s\S]*?\1/g)) { + const index = match.index; + result += transform(value.slice(cursor, index)); + result += match[0]; + cursor = index + match[0].length; + } + return result + transform(value.slice(cursor)); +} + +function replaceActiveMarkdownSyntax( + value: string, + pattern: RegExp, + replace: (match: RegExpMatchArray) => string, +): string { + let result = ''; + let cursor = 0; + for (const match of value.matchAll(pattern)) { + const index = match.index; + if (isEscapedMarkdownSyntax(value, index)) continue; + result += value.slice(cursor, index); + result += replace(match); + cursor = index + match[0].length; + } + return result + value.slice(cursor); +} + +function isEscapedMarkdownSyntax(value: string, index: number): boolean { + let backslashes = 0; + for ( + let cursor = index - 1; + cursor >= 0 && value[cursor] === '\\'; + cursor -= 1 + ) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + +function sanitizeMarkdownNavigableUrls( + value: string, + onChange: (code: 'url_rejected' | 'url_sanitized') => void, +): string { + const replaceDestination = ( + source: string, + render: (safe: string) => string, + fallback: string, + original: string, + ): string => { + const safe = normalizeNavigableUrl(source); + if (safe === source) return original; + if (safe === undefined) { + onChange('url_rejected'); + return fallback; + } + onChange('url_sanitized'); + return render(safe); + }; + let safe = replaceActiveMarkdownSyntax( + value, + /(?]+)>|([^\s)]+))(\s+["'][^)]*["'])?\)/g, + (match) => { + const label = match[1] ?? ''; + const source = match[2] ?? match[3] ?? ''; + const title = match[4] ?? ''; + return replaceDestination( + source, + (destination) => `[${label}](${destination}${title})`, + label, + match[0], + ); + }, + ); + safe = replaceActiveMarkdownSyntax( + safe, + /^(\s*\[[^\]]+\]:\s*)(?:<([^>]+)>|([^\s]+))(\s+.*)?$/gm, + (match) => { + const prefix = match[1] ?? ''; + const source = match[2] ?? match[3] ?? ''; + const suffix = match[4] ?? ''; + return replaceDestination( + source, + (destination) => `${prefix}${destination}${suffix}`, + '', + match[0], + ); + }, + ); + safe = replaceActiveMarkdownSyntax( + safe, + /<((?:https?|mailto):[^>\s]+)>/gi, + (match) => { + const source = match[1] ?? ''; + return replaceDestination( + source, + (destination) => `<${destination}>`, + '[link omitted]', + match[0], + ); + }, + ); + return replaceActiveMarkdownSyntax( + safe, + /https?:\/\/[^\s<>"'`)\]]+/gi, + (match) => { + const source = match[0]; + return replaceDestination( + source, + (destination) => destination, + '[link omitted]', + source, + ); + }, + ); +} + +function normalizeNavigableUrl(value: string): string | undefined { + if (value.startsWith('#')) return value; + if (value.startsWith('/') && !value.startsWith('//')) { + const queryIndex = value.search(/[?#]/); + return queryIndex === -1 ? value : value.slice(0, queryIndex); + } + try { + const url = new URL(value); + if ( + url.protocol !== 'http:' && + url.protocol !== 'https:' && + url.protocol !== 'mailto:' + ) { + return undefined; + } + if ( + url.username === '' && + url.password === '' && + url.search === '' && + url.hash === '' + ) { + return value; + } + url.username = ''; + url.password = ''; + url.search = ''; + url.hash = ''; + return url.toString(); + } catch { + return undefined; + } +} + +const VISIBLE_EXPORT_TEXT_FIELDS = new Set([ + 'text', + 'title', + 'label', + 'description', + 'command', + 'cwd', + 'question', + 'path', + 'oldText', + 'newText', + 'patch', + 'url', + 'argsSummary', + 'code', + 'origin', + 'query', + 'top', + 'columns', + 'rows', + 'prompt', + 'task', + 'value', + 'content', + 'summary', + 'reason', + 'projectName', + 'repository', + 'gitBranch', + 'model', + 'channel', +]); + +function assertOnlyKeys( + value: Record, + allowed: readonly string[], +): void { + const allowedKeys = new Set(allowed); + if (Object.keys(value).some((key) => !allowedKeys.has(key))) { + throw new ExportTranscriptDocumentError('additional_property'); + } +} + +function safeDisplayUrl( + raw: string, + diagnostics: DiagnosticCounter, + onContentLoss?: () => void, +): string { + try { + const safe = normalizeNavigableUrl(raw); + if (!safe) throw new Error(); + const url = new URL(safe); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(); + } + if (utf8Bytes(safe) > EXPORT_TRANSCRIPT_LIMITS_V1.maxTextBytes) { + throw new Error(); + } + if (safe !== raw) { + diagnostics.add('url_sanitized', 'warning', 1, true); + onContentLoss?.(); + } + return safe; + } catch { + diagnostics.add('url_rejected', 'warning', 1, true); + onContentLoss?.(); + return '[link omitted]'; + } +} + +function safeRepository( + raw: string, + diagnostics: DiagnosticCounter, + budget: ExportBudget, +): string { + if (/^https?:/i.test(raw)) { + const safe = safeDisplayUrl(raw, diagnostics, () => + budget.markContentLoss(), + ); + if (safe.length <= 200) return budget.plainText(safe); + diagnostics.add('repository_url_rejected', 'warning', 1, true); + budget.markContentLoss(); + return budget.plainText('[link omitted]'); + } + const safe = safePath(raw).replace(/\.git$/i, ''); + if (isSafeLabel(safe, 200)) return budget.plainText(safe); + diagnostics.add('repository_rejected', 'warning', 1, true); + budget.markContentLoss(); + return budget.plainText('[link omitted]'); +} + +function safeMetadataLabel( + value: unknown, + maxLength: number, + field: string, + diagnostics: DiagnosticCounter, + budget: ExportBudget, +): string | undefined { + if (value === undefined || value === '') return undefined; + if (isSafeLabel(value, maxLength)) return budget.plainText(value); + diagnostics.add(`${field}_rejected`, 'warning', 1, true); + budget.markContentLoss(); + return undefined; +} + +function isSafeDisplayUrl(value: unknown): value is string { + if ( + value === '[link omitted]' || + value === '[content omitted: export text budget exceeded]' + ) { + return true; + } + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && + normalizeNavigableUrl(value) === value + ); + } catch { + return false; + } +} + +function isSafeRepository(value: unknown): value is string { + return ( + (typeof value === 'string' && + !/^https?:/i.test(value) && + isSafeLabel(value, 200) && + !/[\\/]/.test(value)) || + isSafeDisplayUrl(value) + ); +} + +function safePath(value: string): string { + const normalized = value.replaceAll('\\', '/').replace(/\/+$/, ''); + return normalized.split('/').filter(Boolean).at(-1) ?? '[path]'; +} + +function isSafeExportPath(value: unknown): value is string { + return ( + isSafePresentationLabel(value, 400) && + !/[\\/]/.test(String(value)) && + !/^[A-Za-z]:$/.test(String(value)) + ); +} + +function redactHomePaths(value: string): string { + return value + .replace( + /file:\/\/\/(?:[A-Za-z]:\/)?(?:Users|home)\/[^\s/]+(?=\/|\s|$)/gi, + 'file://[home]', + ) + .replace( + /(? maxLength) break; + safe += character; + } + return safe; +} + +function isSafeLabel(value: unknown, maxLength: number): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= maxLength && + ![...value].some(isControlCharacter) + ); +} + +function isSafePresentationLabel( + value: unknown, + maxLength: number, +): value is string { + return ( + typeof value === 'string' && + value.length <= maxLength && + ![...value].some(isControlCharacter) + ); +} + +function isControlCharacter(value: string): boolean { + const code = value.charCodeAt(0); + return code <= 31 || code === 127; +} + +function assertNever(value: never): never { + void value; + throw new ExportTranscriptDocumentError('unsupported_value'); +} + +function safeCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(Number.MAX_SAFE_INTEGER, Math.trunc(value))) + : 0; +} + +function isSafeCount(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function isIsoDate(value: unknown): value is string { + if (typeof value !== 'string') return false; + const time = Date.parse(value); + return Number.isFinite(time) && new Date(time).toISOString() === value; +} + +function isSafeRendererVersion(value: unknown): value is string { + return ( + isSafeLabel(value, 128) && + !String(value).includes('latest') && + !/[~^*><=]/.test(String(value)) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function utf8Bytes(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function decodedBase64Bytes(value: string): number | undefined { + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 !== 0) { + return undefined; + } + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + return (value.length / 4) * 3 - padding; +} + +function isAnimatedGif(value: string): boolean { + try { + const binary = atob(value); + if ( + binary.length < 13 || + (binary.slice(0, 6) !== 'GIF87a' && binary.slice(0, 6) !== 'GIF89a') + ) { + return true; + } + const logicalScreenPacked = binary.charCodeAt(10); + let offset = 13; + if ((logicalScreenPacked & 0x80) !== 0) { + offset += 3 * 2 ** ((logicalScreenPacked & 0x07) + 1); + } + let frames = 0; + while (offset < binary.length) { + const marker = binary.charCodeAt(offset); + offset += 1; + if (marker === 0x3b) return frames !== 1; + if (marker === 0x21) { + if (offset >= binary.length) return true; + offset += 1; + const nextOffset = skipGifSubBlocks(binary, offset); + if (nextOffset === undefined) return true; + offset = nextOffset; + continue; + } + if (marker !== 0x2c || offset + 9 > binary.length) return true; + frames += 1; + if (frames > 1) return true; + const imagePacked = binary.charCodeAt(offset + 8); + offset += 9; + if ((imagePacked & 0x80) !== 0) { + offset += 3 * 2 ** ((imagePacked & 0x07) + 1); + } + if (offset >= binary.length) return true; + offset += 1; + const nextOffset = skipGifSubBlocks(binary, offset); + if (nextOffset === undefined) return true; + offset = nextOffset; + } + return true; + } catch { + return true; + } +} + +function skipGifSubBlocks( + binary: string, + startOffset: number, +): number | undefined { + let offset = startOffset; + while (offset < binary.length) { + const size = binary.charCodeAt(offset); + offset += 1; + if (size === 0) return offset; + if (offset + size > binary.length) return undefined; + offset += size; + } + return undefined; +} + +function severityRank(value: 'info' | 'warning' | 'error'): number { + return value === 'error' ? 2 : value === 'warning' ? 1 : 0; +} diff --git a/packages/cli/src/ui/utils/export/index.ts b/packages/cli/src/ui/utils/export/index.ts index 2e032ad7184..8975300d48f 100644 --- a/packages/cli/src/ui/utils/export/index.ts +++ b/packages/cli/src/ui/utils/export/index.ts @@ -20,3 +20,15 @@ export { export { toJson } from './formatters/json.js'; export { toJsonl } from './formatters/jsonl.js'; export { generateExportFilename } from './utils.js'; +export { + EXPORT_TRANSCRIPT_LIMITS_V1, + ExportTranscriptDocumentError, + assertExportTranscriptDocumentV1, + createExportTranscriptDocumentV1, + exportDocumentToTranscriptBlocks, + type CreateExportTranscriptDocumentOptions, + type ExportMetadataPresentationV1, + type ExportTranscriptBlockV1, + type ExportTranscriptDiagnosticV1, + type ExportTranscriptDocumentV1, +} from './export-transcript-document.js'; diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 535c4932054..46f3c297605 100644 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -92,14 +92,10 @@ const rootDir = join(__dirname, '..'); // metadata (#9180). // Bumped from 195KB to 196KB for transient-vs-gone media hydration errors and // the reference-only replay placeholder. -// Bumped from 196KB to 197KB for the workspace session live-state daemon -// surface (catalog version + live snapshot accessors) and immutable, -// identity-stable transcript block indexes used by browser renderers. -// Bumped from 197KB to 198KB for the unrecognized-diagnostic sidechannel -// (`unrecognizedDiagnostics` routing + selector, #8823). -// Bumped from 198KB to 199KB for persistent session attachment read/remove and -// binary resource hydration. -const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 199 * 1024; +// Bumped from 196KB to 203KB after combining workspace live state, persistent +// attachment hydration, unrecognized diagnostics, transcript segment identity, +// and export-safe presentation. +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 203 * 1024; // The opt-in `daemon/transports` browser bundle legitimately ships the concrete // ACP transports (AcpHttpTransport/AcpWsTransport/AutoReconnect + negotiate), so // it's larger than the default barrel — but still budgeted so a future PR can't diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 56899b4a79a..4343e9fc5bd 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -89,6 +89,7 @@ export { parseSseStream, SseFramingError } from './sse.js'; export { appendLocalUserTranscriptMessage, createDaemonToolPreview, + createDaemonToolResultPreview, createDaemonTranscriptState, createDaemonTranscriptStore, DAEMON_GOAL_STATUS_SENTINEL_PREFIX, @@ -150,6 +151,9 @@ export type { DaemonTextTranscriptBlock, DaemonTextDeltaMeta, DaemonToolPreview, + DaemonToolResultPreview, + DaemonTodoListPreview, + DaemonTranscriptTodoItem, DaemonToolTranscriptBlock, DaemonTranscriptBlock, DaemonTranscriptBlockKind, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index f02c81b81c2..10375cc890c 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -9,7 +9,10 @@ export { normalizeDaemonEvent, getSessionUpdatePayload, } from './normalizer.js'; -export { createDaemonToolPreview } from './toolPreview.js'; +export { + createDaemonToolPreview, + createDaemonToolResultPreview, +} from './toolPreview.js'; export { appendLocalUserTranscriptMessage, createDaemonTranscriptState, @@ -79,6 +82,9 @@ export type { DaemonTextTranscriptBlock, DaemonTextDeltaMeta, DaemonToolPreview, + DaemonToolResultPreview, + DaemonTodoListPreview, + DaemonTranscriptTodoItem, DaemonToolTranscriptBlock, DaemonTranscriptBlock, DaemonTranscriptBlockKind, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index f417dc95880..7accbc151a1 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -21,6 +21,7 @@ import type { NormalizeDaemonEventOptions, } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; +import { createDaemonToolResultTextPreview } from './toolPreview.js'; import { getFirstString, getOutputText, @@ -43,6 +44,7 @@ type NormalizedEventBase = Pick< | 'eventId' | 'serverTimestamp' | 'sourceRecordIds' + | 'segmentId' | 'promptId' | 'branchRecordId' | 'originatorClientId' @@ -648,11 +650,13 @@ function createBase( ): NormalizedEventBase { const serverTimestamp = extractServerTimestamp(event); const sourceRecordIds = extractSourceRecordIds(event); + const segmentId = extractTranscriptSegmentId(event); const branchRecordId = extractBranchRecordId(event); return { ...(event.id !== undefined ? { eventId: event.id } : {}), ...(serverTimestamp !== undefined ? { serverTimestamp } : {}), ...(sourceRecordIds ? { sourceRecordIds } : {}), + ...(segmentId ? { segmentId } : {}), ...(event.promptId ? { promptId: event.promptId } : {}), ...(branchRecordId ? { branchRecordId } : {}), ...(event.originatorClientId @@ -664,6 +668,19 @@ function createBase( }; } +function extractTranscriptSegmentId(event: DaemonEvent): string | undefined { + if (!isRecord(event.data)) return undefined; + const update = getSessionUpdatePayload(event.data); + const meta = + update && isRecord(update['_meta']) ? update['_meta'] : undefined; + const transcript = + meta && isRecord(meta['qwenTranscript']) + ? meta['qwenTranscript'] + : undefined; + const segmentId = transcript ? getString(transcript, 'segmentId') : undefined; + return segmentId && segmentId.length <= 512 ? segmentId : undefined; +} + function extractBranchRecordId(event: DaemonEvent): string | undefined { if (!isRecord(event.data)) return undefined; const update = getSessionUpdatePayload(event.data); @@ -1054,6 +1071,14 @@ function normalizeToolUpdate( base: NormalizedEventBase, ): DaemonUiEvent { const metadata = isRecord(update['_meta']) ? update['_meta'] : undefined; + const transcript = + metadata && isRecord(metadata['qwenTranscript']) + ? metadata['qwenTranscript'] + : undefined; + const resultPreviewText = getString(transcript, 'resultPreviewText'); + const resultPreview = resultPreviewText + ? createDaemonToolResultTextPreview(resultPreviewText) + : undefined; const toolName = getString(update, 'toolName') ?? getString(update, 'name') ?? @@ -1137,6 +1162,7 @@ function normalizeToolUpdate( ...(subagentType ? { subagentType } : {}), ...(rawInput !== undefined ? { rawInput } : {}), ...(rawOutput !== undefined ? { rawOutput } : {}), + ...(resultPreview ? { resultPreview } : {}), ...(rawInput !== undefined ? { details: capDetails(stringifyRedactedJson(rawInput)) } : rawOutput !== undefined diff --git a/packages/sdk-typescript/src/daemon/ui/render.ts b/packages/sdk-typescript/src/daemon/ui/render.ts index 5f993a5278f..33de6048b5b 100644 --- a/packages/sdk-typescript/src/daemon/ui/render.ts +++ b/packages/sdk-typescript/src/daemon/ui/render.ts @@ -322,6 +322,10 @@ export function daemonToolPreviewToMarkdown( )}`, ) .join('\n'); + case 'todo_list': + return preview.summary + ? `_${escapeMarkdownText(preview.summary, opts)}_` + : ''; case 'generic': return preview.summary ? `_${escapeMarkdownText(preview.summary, opts)}_` @@ -535,6 +539,8 @@ function daemonToolPreviewToPlainText( return preview.rows .map((r) => `${cap(r.label)}: ${cap(r.value)}`) .join('\n'); + case 'todo_list': + return preview.summary ? cap(preview.summary) : ''; case 'generic': return preview.summary ? cap(preview.summary) : ''; default: diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index 64004f89561..6eab9b0e8d4 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -6,8 +6,10 @@ import type { DaemonToolPreview, + DaemonToolResultPreview, DaemonTranscriptQuestion, DaemonTranscriptQuestionOption, + DaemonTranscriptTodoItem, } from './types.js'; import { getFirstString, @@ -18,6 +20,9 @@ import { } from './utils.js'; const MAX_TOOL_PREVIEW_DEPTH = 8; +const MAX_TODO_PREVIEW_ENTRIES = 1_000; +const MAX_TOOL_RESULT_PREVIEW_LENGTH = 100_000; +const MAX_TODO_ID_LENGTH = 512; export function createDaemonToolPreview( input: unknown, @@ -51,6 +56,9 @@ export function createDaemonToolPreview( return { kind: 'ask_user_question', questions: askUserQuestions }; } + const todoList = detectTodoList(input, opts, true); + if (todoList) return todoList; + // PR-C / PR-F: try specific tool-shape detectors before falling back to // generic command / key_value detection. Detector order matters — // most specific wins. @@ -105,6 +113,175 @@ export function createDaemonToolPreview( return { kind: 'generic', ...(summary ? { summary } : {}) }; } +export function createDaemonToolResultPreview( + output: unknown, + content?: unknown, + opts: { toolName?: string; toolKind?: string } = {}, +): DaemonToolResultPreview | undefined { + const todoList = detectTodoList(output, opts, false); + if (todoList) return todoList; + + const text = extractDisplayContentText(content); + return text ? createDaemonToolResultTextPreview(text) : undefined; +} + +export function createDaemonToolResultTextPreview( + text: string, +): Extract | undefined { + return text.length > 0 && text.length <= MAX_TOOL_RESULT_PREVIEW_LENGTH + ? { kind: 'text', text } + : undefined; +} + +function detectTodoList( + input: unknown, + opts: { title?: string; toolName?: string; toolKind?: string }, + includeLegacySummary: boolean, +): Extract | undefined { + if (!isRecord(input)) return undefined; + const toolName = opts.toolName?.toLowerCase(); + const entries = Array.isArray(input['entries']) + ? input['entries'] + : Array.isArray(input['todos']) + ? input['todos'] + : undefined; + if (!entries || (toolName !== 'todo_write' && toolName !== 'todowrite')) { + return undefined; + } + + const normalized: DaemonTranscriptTodoItem[] = []; + let truncated = entries.length > MAX_TODO_PREVIEW_ENTRIES; + let remainingText = MAX_TOOL_RESULT_PREVIEW_LENGTH; + let remainingDependencyInputs = MAX_TODO_PREVIEW_ENTRIES; + entries.slice(0, MAX_TODO_PREVIEW_ENTRIES).forEach((entry, index) => { + if (!isRecord(entry)) { + truncated = true; + return; + } + const rawContent = getFirstString(entry, ['content', 'text', 'title']); + if (!rawContent) { + truncated = true; + return; + } + if (remainingText === 0) { + truncated = true; + return; + } + const content = rawContent.slice(0, remainingText); + if (content.length < rawContent.length) truncated = true; + remainingText -= content.length; + const meta = isRecord(entry['_meta']) ? entry['_meta'] : undefined; + const qwenTodo = + meta && isRecord(meta['qwenTodo']) ? meta['qwenTodo'] : undefined; + const rawId = + getFirstString(qwenTodo, ['id']) ?? + getFirstString(entry, ['id']) ?? + `plan-${index}`; + const id = rawId.length <= MAX_TODO_ID_LENGTH ? rawId : `plan-${index}`; + if (id !== rawId) truncated = true; + const rawStatus = getFirstString(entry, ['status']); + const status = + rawStatus === 'completed' || rawStatus === 'in_progress' + ? rawStatus + : 'pending'; + if ( + rawStatus !== undefined && + rawStatus !== 'pending' && + rawStatus !== 'in_progress' && + rawStatus !== 'completed' + ) { + truncated = true; + } + const rawPriority = getFirstString(entry, ['priority']); + const priority = + rawPriority === 'high' || + rawPriority === 'medium' || + rawPriority === 'low' + ? rawPriority + : undefined; + if (entry['priority'] !== undefined && priority === undefined) { + truncated = true; + } + const blockedBySource = qwenTodo?.['blockedBy'] ?? entry['blockedBy']; + const rawBlockedBy = Array.isArray(blockedBySource) ? blockedBySource : []; + if (blockedBySource !== undefined && !Array.isArray(blockedBySource)) { + truncated = true; + } + const blockedBy: string[] = []; + for (const dependency of rawBlockedBy) { + if (remainingDependencyInputs === 0) { + truncated = true; + break; + } + remainingDependencyInputs -= 1; + if ( + typeof dependency !== 'string' || + dependency.length === 0 || + dependency.length > MAX_TODO_ID_LENGTH + ) { + truncated = true; + continue; + } + blockedBy.push(dependency); + } + normalized.push({ + id, + content, + status, + ...(priority ? { priority } : {}), + ...(blockedBy.length > 0 ? { blockedBy } : {}), + }); + }); + const meta = isRecord(input['_meta']) ? input['_meta'] : undefined; + const todoPlan = + meta && isRecord(meta['qwenTodoPlan']) ? meta['qwenTodoPlan'] : undefined; + const plan = isRecord(input['plan']) ? input['plan'] : undefined; + const rawPlanId = + getFirstString(todoPlan, ['id']) ?? + getFirstString(plan, ['id']) ?? + getFirstString(input, ['planId']); + const planId = + rawPlanId && rawPlanId.length <= MAX_TODO_ID_LENGTH ? rawPlanId : undefined; + if (rawPlanId && !planId) truncated = true; + const rawRevision = + todoPlan?.['revision'] ?? plan?.['revision'] ?? input['revision']; + const revision = + typeof rawRevision === 'number' && + Number.isSafeInteger(rawRevision) && + rawRevision >= 0 + ? rawRevision + : undefined; + if (rawRevision !== undefined && revision === undefined) truncated = true; + const summary = opts.title ?? opts.toolName ?? opts.toolKind; + return { + kind: 'todo_list', + entries: normalized, + ...(includeLegacySummary && summary ? { summary } : {}), + ...(truncated ? { truncated: true } : {}), + ...(planId ? { planId } : {}), + ...(revision !== undefined ? { revision } : {}), + }; +} + +function extractDisplayContentText(content: unknown): string | undefined { + if (!Array.isArray(content)) return undefined; + let text = ''; + for (const entry of content) { + if (!isRecord(entry) || entry['type'] !== 'content') continue; + const body = isRecord(entry['content']) ? entry['content'] : undefined; + const next = typeof body?.['text'] === 'string' ? body['text'] : undefined; + if (!next) continue; + if ( + text.length + (text ? 1 : 0) + next.length > + MAX_TOOL_RESULT_PREVIEW_LENGTH + ) { + return undefined; + } + text += `${text ? '\n' : ''}${next}`; + } + return text || undefined; +} + /** * Detect file-edit tool calls by signature. Matches: * @@ -270,7 +447,11 @@ function detectMcpInvocation( .filter(([key]) => key !== 'name' && key !== 'toolName') .slice(0, 1) .map(([key, value]) => { - const v = typeof value === 'string' ? value : JSON.stringify(value); + const v = isSensitiveKey(key) + ? '[redacted]' + : typeof value === 'string' + ? value + : stringifyRedactedJson(value); const trimmed = v.length > 60 ? `${v.slice(0, 60)}…` : v; return `${key}=${trimmed}`; })[0]; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 66b5e62611d..7561ce6b33e 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -25,7 +25,10 @@ import { DAEMON_PLAN_TOOL_CALL_ID, isUnrecognizedDiagnosticReason, } from './types.js'; -import { createDaemonToolPreview } from './toolPreview.js'; +import { + createDaemonToolPreview, + createDaemonToolResultPreview, +} from './toolPreview.js'; import { isRecord } from './utils.js'; const DEFAULT_MAX_BLOCKS = 1_000; @@ -135,6 +138,9 @@ export function appendLocalUserTranscriptMessage( undefined, undefined, opts.meta, + undefined, + undefined, + undefined, ); if (opts.images && opts.images.length > 0) { (block as DaemonTextTranscriptBlock).images = [...opts.images]; @@ -228,6 +234,7 @@ function userBlockForAttachment( event.meta, event.sourceRecordIds, event.promptId, + event.segmentId, ) as DaemonTextTranscriptBlock; appendBlock(next, block); next.activeUserBlockId = block.id; @@ -737,6 +744,7 @@ function appendTextDelta( 'meta' in event ? event.meta : undefined, event.sourceRecordIds, event.promptId, + event.segmentId, ); if (kind === 'assistant' && event.branchRecordId) { block.branchRecordId = event.branchRecordId; @@ -921,6 +929,24 @@ function upsertToolBlock( } existing.rawOutput = rawOutput; } + const resultPreview = + event.resultPreview ?? + createDaemonToolResultPreview( + rawOutput ?? existing.rawOutput, + event.content ?? existing.content, + { + toolName: event.toolName ?? existing.toolName, + toolKind: event.toolKind ?? existing.toolKind, + }, + ); + if (resultPreview) existing.resultPreview = resultPreview; + else if ( + event.resultPreview !== undefined || + rawOutput !== undefined || + event.content !== undefined + ) { + delete existing.resultPreview; + } existing.sourceRecordIds = unionStrings( existing.sourceRecordIds, event.sourceRecordIds, @@ -961,6 +987,12 @@ function upsertToolBlock( state.toolBlockByCallId[event.parentToolCallId] !== TRIMMED_TOOL_BLOCK_ID ? state.toolBlockByCallId[event.parentToolCallId] : undefined; + const resultPreview = + event.resultPreview ?? + createDaemonToolResultPreview(rawOutput, event.content, { + toolName: event.toolName, + toolKind: event.toolKind, + }); const block: DaemonToolTranscriptBlock = { id: allocateBlockId(state, 'tool'), kind: 'tool', @@ -972,6 +1004,7 @@ function upsertToolBlock( toolName: event.toolName, toolKind: event.toolKind, }), + ...(resultPreview ? { resultPreview } : {}), clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, @@ -982,6 +1015,7 @@ function upsertToolBlock( ...(event.sourceRecordIds ? { sourceRecordIds: [...event.sourceRecordIds] } : {}), + ...(event.segmentId ? { segmentId: event.segmentId } : {}), ...(event.details ? { details: event.details } : {}), ...(!compactTaskOutput && event.content !== undefined ? { content: event.content } @@ -1160,6 +1194,7 @@ function appendShellBlock( ...(event.serverTimestamp !== undefined ? { serverTimestamp: event.serverTimestamp } : {}), + ...(event.segmentId ? { segmentId: event.segmentId } : {}), ...(event.stream ? { stream: event.stream } : {}), }; appendBlock(state, block); @@ -1204,6 +1239,7 @@ function appendUserShellBlock( ...(event.serverTimestamp !== undefined ? { serverTimestamp: event.serverTimestamp } : {}), + ...(event.segmentId ? { segmentId: event.segmentId } : {}), ...(event.stream ? { stream: event.stream } : {}), }; state.pendingUserShellCommand = undefined; @@ -1221,11 +1257,15 @@ function upsertPermissionBlock( const preview = createDaemonToolPreview(event.toolCall, { title: event.title, }); + const toolIdentity = getPermissionToolIdentity(event.toolCall); if (existing?.kind === 'permission') { existing.title = event.title; existing.options = event.options.map((option) => ({ ...option })); existing.toolCall = event.toolCall; existing.preview = preview; + if (toolIdentity.toolCallId) existing.toolCallId = toolIdentity.toolCallId; + if (toolIdentity.toolName) existing.toolName = toolIdentity.toolName; + if (toolIdentity.toolKind) existing.toolKind = toolIdentity.toolKind; existing.updatedAt = state.now; if (event.eventId !== undefined) existing.eventId = event.eventId; return; @@ -1238,6 +1278,7 @@ function upsertPermissionBlock( title: event.title, options: event.options.map((option) => ({ ...option })), preview, + ...toolIdentity, clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, @@ -1245,6 +1286,7 @@ function upsertPermissionBlock( ...(event.serverTimestamp !== undefined ? { serverTimestamp: event.serverTimestamp } : {}), + ...(event.segmentId ? { segmentId: event.segmentId } : {}), ...(event.sessionId ? { sessionId: event.sessionId } : {}), ...(event.toolCall !== undefined ? { toolCall: event.toolCall } : {}), }; @@ -1300,6 +1342,7 @@ function resolvePermissionBlock( ...(event.serverTimestamp !== undefined ? { serverTimestamp: event.serverTimestamp } : {}), + ...(event.segmentId ? { segmentId: event.segmentId } : {}), }; appendBlock(state, block); state.permissionBlockByRequestId[event.requestId] = block.id; @@ -1394,6 +1437,7 @@ function appendStatusBlock( ...(event?.serverTimestamp !== undefined ? { serverTimestamp: event.serverTimestamp } : {}), + ...(event?.segmentId ? { segmentId: event.segmentId } : {}), ...(event?.type === 'error' && event.code ? { code: event.code } : {}), ...(event?.type === 'error' && event.promptId ? { promptId: event.promptId } @@ -1449,6 +1493,7 @@ function appendPromptCancelledBlock( ...(event.serverTimestamp !== undefined ? { serverTimestamp: event.serverTimestamp } : {}), + ...(event.segmentId ? { segmentId: event.segmentId } : {}), }; appendBlock(state, block); clearActiveText(state); @@ -1463,6 +1508,7 @@ function createTextBlock( meta?: Record, sourceRecordIds?: readonly string[], promptId?: string, + segmentId?: string, ): DaemonTextTranscriptBlock { const blockId = allocateBlockId(state, kind); return { @@ -1475,6 +1521,7 @@ function createTextBlock( ...(eventId !== undefined ? { eventId } : {}), ...(serverTimestamp !== undefined ? { serverTimestamp } : {}), ...(sourceRecordIds ? { sourceRecordIds: [...sourceRecordIds] } : {}), + ...(segmentId ? { segmentId } : {}), ...(promptId ? { promptId } : {}), ...(meta ? { meta: { ...meta } } : {}), }; @@ -1771,6 +1818,7 @@ function cloneBlockForWrite( return { ...block, preview: cloneJsonLike(block.preview), + resultPreview: cloneJsonLike(block.resultPreview), content: cloneJsonLike(block.content), locations: cloneJsonLike(block.locations), rawInput: cloneJsonLike(block.rawInput), @@ -1786,6 +1834,33 @@ function allocateBlockId(state: DaemonTranscriptState, prefix: string): string { return id; } +function getPermissionToolIdentity(toolCall: unknown): { + toolCallId?: string; + toolName?: string; + toolKind?: string; +} { + if (!isRecord(toolCall)) return {}; + const meta = isRecord(toolCall['_meta']) ? toolCall['_meta'] : undefined; + const read = ( + record: Record | undefined, + ...keys: string[] + ): string | undefined => { + for (const key of keys) { + const value = record?.[key]; + if (typeof value === 'string' && value.length > 0) return value; + } + return undefined; + }; + const toolCallId = read(toolCall, 'toolCallId', 'id'); + const toolName = read(meta, 'toolName') ?? read(toolCall, 'toolName', 'name'); + const toolKind = read(toolCall, 'kind'); + return { + ...(toolCallId ? { toolCallId } : {}), + ...(toolName ? { toolName } : {}), + ...(toolKind ? { toolKind } : {}), + }; +} + function clearActiveText( state: DaemonTranscriptState, parentToolCallId?: string, diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index f6e66e48d35..d1bf12712e2 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -90,6 +90,8 @@ export interface DaemonUiEventBase { serverTimestamp?: number; /** Ordered persisted ChatRecord identities that contributed to this event. */ sourceRecordIds?: readonly string[]; + /** Stable identity for one projected segment within a persisted record. */ + segmentId?: string; /** Admitted prompt identifier for events belonging to one turn. */ promptId?: string; /** Durable checkpoint UUID for branching from this Assistant response. */ @@ -238,6 +240,8 @@ export interface DaemonUiToolUpdateEvent extends DaemonUiEventBase { details?: string; rawInput?: unknown; rawOutput?: unknown; + /** Typed, redacted output presentation supplied by a trusted projector. */ + resultPreview?: DaemonToolResultPreview; } export interface DaemonUiShellOutputEvent extends DaemonUiEventBase { @@ -769,6 +773,24 @@ export interface DaemonTranscriptQuestion { raw: unknown; } +export interface DaemonTranscriptTodoItem { + id: string; + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority?: 'high' | 'medium' | 'low'; + blockedBy?: readonly string[]; +} + +export interface DaemonTodoListPreview { + kind: 'todo_list'; + entries: readonly DaemonTranscriptTodoItem[]; + /** Legacy runtime-renderer summary; document/export uses typed entries. */ + summary?: string; + truncated?: boolean; + planId?: string; + revision?: number; +} + export type DaemonToolPreview = | { kind: 'ask_user_question'; @@ -864,6 +886,18 @@ export type DaemonToolPreview = kind: 'key_value'; rows: Array<{ label: string; value: string }>; } + | DaemonTodoListPreview + | { + kind: 'generic'; + summary?: string; + }; + +export type DaemonToolResultPreview = + | DaemonTodoListPreview + | { + kind: 'text'; + text: string; + } | { kind: 'generic'; summary?: string; @@ -904,6 +938,8 @@ export interface DaemonTranscriptBlockBase { serverTimestamp?: number; /** Ordered persisted ChatRecord identities that contributed to this block. */ sourceRecordIds?: readonly string[]; + /** Stable projected segment identity when no daemon event cursor exists. */ + segmentId?: string; /** Admitted prompt identifier for content belonging to one turn. */ promptId?: string; /** Durable checkpoint UUID for branching from this Assistant response. */ @@ -965,6 +1001,8 @@ export interface DaemonToolTranscriptBlock extends DaemonTranscriptBlockBase { toolName?: string; toolKind?: string; preview: DaemonToolPreview; + /** Typed, redacted result data for explicit document/export projection. */ + resultPreview?: DaemonToolResultPreview; content?: unknown; locations?: unknown; details?: string; @@ -1017,6 +1055,10 @@ export interface DaemonPermissionTranscriptBlock options: DaemonUiPermissionOption[]; toolCall?: unknown; preview: DaemonToolPreview; + /** Safe tool identity retained after the raw tool call is removed. */ + toolCallId?: string; + toolName?: string; + toolKind?: string; resolved?: string; } diff --git a/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts b/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts index 0fd333a21d5..e6dc16dd5b7 100644 --- a/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-transcript-projection.test.ts @@ -69,6 +69,33 @@ describe('projectChatRecordsToDaemonTranscript', () => { ).toBe(true); }); + it('keeps persisted source identity when earlier history is prepended', () => { + const tail = [ + record('root', null), + record('answer', 'root', { + type: 'assistant', + message: { role: 'model', parts: [{ text: 'stable answer' }] }, + }), + ]; + const prepended = [ + record('earlier', null), + record('root', 'earlier'), + tail[1], + ]; + + const tailAnswer = projectChatRecordsToDaemonTranscript(tail).blocks.find( + (block) => block.kind === 'assistant', + ); + const prependedAnswer = projectChatRecordsToDaemonTranscript( + prepended, + ).blocks.find((block) => block.kind === 'assistant'); + + expect(tailAnswer?.sourceRecordIds).toEqual(['answer']); + expect(prependedAnswer?.sourceRecordIds).toEqual(['answer']); + expect(tailAnswer?.segmentId).toBe('answer:0'); + expect(prependedAnswer?.segmentId).toBe('answer:0'); + }); + it('finalizes earlier assistant blocks when record boundaries prevent merging', () => { const projection = projectChatRecordsToDaemonTranscript([ record('root', null), @@ -380,6 +407,47 @@ describe('projectChatRecordsToDaemonTranscript', () => { ['plan-2'], ]); expect(new Set(plans.map((block) => block.toolCallId)).size).toBe(2); + expect(plans[0]).toMatchObject({ + resultPreview: { + kind: 'todo_list', + entries: [{ content: 'A', status: 'in_progress' }], + }, + }); + }); + + it('marks a bounded todo preview as truncated', () => { + const projection = projectChatRecordsToDaemonTranscript([ + record('large-plan', null, { + type: 'tool_result', + message: { + role: 'user', + parts: [{ functionResponse: { name: 'todo_write', response: {} } }], + }, + toolCallResult: { + callId: 'todo-large', + resultDisplay: { + type: 'todo_list', + todos: Array.from({ length: 1_001 }, (_, index) => ({ + content: `Task ${index}`, + status: 'pending', + })), + }, + }, + }), + ]); + const plan = projection.blocks.find( + (block) => block.kind === 'tool' && block.toolName === 'todo_write', + ); + + expect(plan?.resultPreview).toMatchObject({ + kind: 'todo_list', + truncated: true, + }); + expect( + plan?.resultPreview?.kind === 'todo_list' + ? plan.resultPreview.entries.length + : 0, + ).toBe(1_000); }); it('does not merge a todo plan into a persisted daemon-plan tool id', () => { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 32732714ea9..81d2ff364ab 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -8,8 +8,11 @@ import { describe, expect, it, vi } from 'vitest'; import { appendLocalUserTranscriptMessage, createDaemonToolPreview, + createDaemonToolResultPreview, createDaemonTranscriptState, createDaemonTranscriptStore, + daemonBlockToMarkdown, + daemonBlockToPlainText, daemonUiEventToTerminalText, getOutputText, isDaemonUiSensitiveKey, @@ -115,6 +118,36 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('carries source segment identity without changing legacy ordinal block IDs', () => { + const project = (eventId: number) => + reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + normalizeDaemonEvent({ + id: eventId, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'stable' }, + _meta: { + qwenTranscript: { segmentId: 'record-1:0' }, + }, + }, + }, + }), + { now: 2 }, + ); + + const first = project(10); + const replayed = project(999); + + expect(first.blocks[0]?.segmentId).toBe('record-1:0'); + expect(replayed.blocks[0]?.segmentId).toBe('record-1:0'); + expect(first.blocks[0]?.id).toMatch(/^assistant-\d+$/); + expect(replayed.blocks[0]?.id).toBe(first.blocks[0]?.id); + }); + it('drops silent-shell heartbeat tool updates instead of rewriting the tool block', () => { const events = normalizeDaemonEvent({ id: 1, @@ -581,7 +614,8 @@ describe('daemon UI normalizer and transcript reducer', () => { { now: 2 }, ); - expect(state.activeAssistantBlockId).toBe('assistant-1'); + expect(state.activeAssistantBlockId).toBe(state.blocks[0]?.id); + expect(state.activeAssistantBlockId).toMatch(/^assistant-/); expect(state.blocks).toMatchObject([ { kind: 'assistant', text: 'done', streaming: true }, ]); @@ -1123,7 +1157,9 @@ describe('daemon UI normalizer and transcript reducer', () => { rawOutput: 'ok', }, ]); - expect(state.blockIndexById).toEqual({ 'tool-1': 0 }); + const toolBlockId = state.blocks[0]?.id; + expect(toolBlockId).toMatch(/^tool-/); + expect(state.blockIndexById).toEqual({ [String(toolBlockId)]: 0 }); state = reduceDaemonTranscriptEvents( state, @@ -4361,6 +4397,199 @@ describe('daemon UI tool preview taxonomy (PR-C)', () => { ); }); + it('redacts sensitive MCP arguments from the typed preview', () => { + const preview = createDaemonToolPreview( + { arguments: { apiKey: 'CHAT_TRANSCRIPT_TEST_SECRET_DO_NOT_EXPORT' } }, + { toolName: 'mcp__github__create_issue' }, + ); + + expect(preview).toMatchObject({ + kind: 'mcp_invocation', + argsSummary: 'apiKey=[redacted]', + }); + expect(JSON.stringify(preview)).not.toContain( + 'CHAT_TRANSCRIPT_TEST_SECRET_DO_NOT_EXPORT', + ); + }); + + it('bounds typed tool result text before duplicating renderer content', () => { + const content = (text: string) => [ + { type: 'content', content: { type: 'text', text } }, + ]; + + expect( + createDaemonToolResultPreview(undefined, content('visible')), + ).toEqual({ kind: 'text', text: 'visible' }); + expect( + createDaemonToolResultPreview(undefined, content('x'.repeat(100_001))), + ).toBeUndefined(); + }); + + it('does not retain a stale result preview when a later result is unsafe to preview', () => { + let state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { + type: 'tool.update', + toolCallId: 'preview-replaced', + status: 'running', + resultPreview: { kind: 'text', text: 'partial result' }, + }, + ], + { now: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + [ + { + type: 'tool.update', + toolCallId: 'preview-replaced', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: 'x'.repeat(100_001) }, + }, + ], + }, + ], + { now: 3 }, + ); + + expect(state.blocks[0]).not.toHaveProperty('resultPreview'); + }); + + it('drops negative todo revisions from the typed preview', () => { + const preview = createDaemonToolPreview( + { + entries: [{ id: 'todo-1', content: 'Implement', status: 'pending' }], + plan: { id: 'plan-1', revision: -1 }, + }, + { toolName: 'todo_write' }, + ); + + expect(preview).toMatchObject({ + kind: 'todo_list', + planId: 'plan-1', + }); + expect(preview).not.toHaveProperty('revision'); + }); + + it('keeps the legacy runtime summary for typed todo previews', () => { + const preview = createDaemonToolPreview( + { + entries: [{ content: 'Implement', status: 'pending' }], + plan: { id: 'plan-1', revision: 1 }, + }, + { toolName: 'todo_write' }, + ); + const block = { + id: 'tool-1', + kind: 'tool', + toolCallId: 'todo-1', + title: 'Update plan', + status: 'completed', + preview, + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + } satisfies DaemonTranscriptBlock; + + expect(daemonBlockToMarkdown(block)).toContain(String.raw`_todo\_write_`); + expect(daemonBlockToPlainText(block)).toContain('todo_write'); + }); + + it('bounds cumulative todo preview text', () => { + const preview = createDaemonToolPreview( + { + entries: [ + { content: 'a'.repeat(60_000), status: 'pending' }, + { content: 'b'.repeat(60_000), status: 'pending' }, + { content: 'not retained', status: 'pending' }, + ], + }, + { toolName: 'todo_write' }, + ); + + expect(preview).toMatchObject({ + kind: 'todo_list', + truncated: true, + }); + expect( + preview.kind === 'todo_list' + ? preview.entries.reduce( + (total, entry) => total + entry.content.length, + 0, + ) + : 0, + ).toBe(100_000); + }); + + it('bounds cumulative todo dependency inputs', () => { + const preview = createDaemonToolPreview( + { + entries: [ + { + content: 'Implement', + status: 'pending', + _meta: { + qwenTodo: { + id: 'todo-1', + blockedBy: Array.from( + { length: 1_001 }, + (_, index) => `todo-${index + 2}`, + ), + }, + }, + }, + ], + }, + { toolName: 'todo_write' }, + ); + + expect(preview).toMatchObject({ + kind: 'todo_list', + truncated: true, + }); + expect( + preview.kind === 'todo_list' ? preview.entries[0]?.blockedBy?.length : 0, + ).toBe(1_000); + }); + + it('preserves top-level todo result dependencies and revision', () => { + const preview = createDaemonToolResultPreview( + { + todos: [ + { + id: 'todo-2', + content: 'Implement', + status: 'in_progress', + blockedBy: ['todo-1'], + }, + ], + planId: 'plan-1', + revision: 3, + }, + undefined, + { toolName: 'todo_write' }, + ); + + expect(preview).toEqual({ + kind: 'todo_list', + entries: [ + { + id: 'todo-2', + content: 'Implement', + status: 'in_progress', + blockedBy: ['todo-1'], + }, + ], + planId: 'plan-1', + revision: 3, + }); + }); + it('mcp_invocation takes priority over file_diff (more specific)', () => { // Even if the input shape happens to match file_diff (e.g., an MCP // tool that edits files), MCP provenance wins. diff --git a/packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.test.ts b/packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.test.ts new file mode 100644 index 00000000000..fd851d49ab2 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import type { DaemonEvent } from '@qwen-code/sdk/daemon'; +import { + probeAcpTranscriptUpdates, + probeDirectDaemonTranscript, +} from './chatTranscriptContractProbe.js'; + +const context = { scopeKey: 'session-a:main', generation: 2 } as const; + +function textEvent( + id: number, + sessionUpdate: 'user_message_chunk' | 'agent_message_chunk', + text: string, + segmentId = `event-${id}:0`, +): DaemonEvent { + return { + id, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate, + content: { type: 'text', text }, + _meta: { qwenTranscript: { segmentId } }, + }, + }, + }; +} + +describe('chat transcript contract probes', () => { + it('keeps direct-daemon identity stable under prepend', () => { + const tail = probeDirectDaemonTranscript( + [textEvent(20, 'agent_message_chunk', 'answer')], + context, + context, + ); + const prepended = probeDirectDaemonTranscript( + [ + textEvent(10, 'user_message_chunk', 'request'), + textEvent(20, 'agent_message_chunk', 'answer'), + ], + context, + context, + ); + + expect(tail.diagnostics).toEqual([]); + expect(prepended.diagnostics).toEqual([]); + expect(prepended.model.blocks[1]?.id).toBe(tail.model.blocks[0]?.id); + expect(prepended.identities[1]?.sourceIdentity).toEqual([ + 'segmentId', + 'event-20:0', + ]); + }); + + it('keeps one direct-daemon segment stable across append and partial replay', () => { + const first = { + ...textEvent(20, 'agent_message_chunk', 'first ', 'prompt-1:assistant:0'), + promptId: 'prompt-1', + }; + const second = { + ...textEvent(21, 'agent_message_chunk', 'second', 'prompt-1:assistant:0'), + promptId: 'prompt-1', + }; + const firstOnly = probeDirectDaemonTranscript([first], context, context); + const complete = probeDirectDaemonTranscript( + [first, second], + context, + context, + ); + const tailOnly = probeDirectDaemonTranscript([second], context, context); + + expect(complete.model.blocks).toHaveLength(1); + expect(tailOnly.model.blocks).toHaveLength(1); + expect(complete.model.blocks[0]?.id).toBe(tailOnly.model.blocks[0]?.id); + expect(complete.model.blocks[0]?.id).toBe(firstOnly.model.blocks[0]?.id); + expect(complete.diagnostics).toEqual([]); + expect(tailOnly.diagnostics).toEqual([]); + }); + + it('uses the SDK normalizer/reducer as the ACP thin conversion', () => { + const result = probeAcpTranscriptUpdates( + [ + { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'answer' }, + _meta: { + qwenTranscript: { segmentId: 'record-1:0' }, + }, + }, + { + sessionUpdate: 'tool_call', + toolCallId: 'read-1', + title: 'Read', + status: 'completed', + rawInput: { path: 'src/index.ts' }, + _meta: { toolName: 'read' }, + }, + ], + context, + context, + ); + + expect(result.model.blocks.map((block) => block.kind)).toEqual([ + 'assistant', + 'tool', + ]); + expect(result.diagnostics).toEqual([]); + expect(result.identities).toMatchObject([ + { sourceIdentity: ['segmentId', 'record-1:0'] }, + { sourceIdentity: ['toolCallId', 'read-1'] }, + ]); + }); + + it('makes untagged ACP live text a blocking identity diagnostic', () => { + const result = probeAcpTranscriptUpdates( + [ + { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'untagged answer' }, + }, + ], + context, + context, + ); + + expect(result.diagnostics).toContainEqual({ + code: 'stable_native_identity_missing', + severity: 'error', + }); + }); + + it('keeps distinct tagged ACP segments stable under prepend', () => { + const updates = ['first ', 'second'].map((text, index) => ({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + _meta: { + qwenTranscript: { + segmentId: `record-${index + 1}:0`, + sourceRecordIds: [`record-${index + 1}`], + }, + }, + })); + const complete = probeAcpTranscriptUpdates(updates, context, context); + const tailOnly = probeAcpTranscriptUpdates( + updates.slice(1), + context, + context, + ); + + expect(complete.diagnostics).toEqual([]); + expect(tailOnly.diagnostics).toEqual([]); + expect(complete.model.blocks).toHaveLength(2); + expect(tailOnly.model.blocks).toHaveLength(1); + expect(complete.model.blocks[1]?.id).toBe(tailOnly.model.blocks[0]?.id); + expect(complete.identities[1]?.sourceIdentity).toEqual( + tailOnly.identities[0]?.sourceIdentity, + ); + }); + + it('prefers semantic segment identity over a replay-local event cursor', () => { + const result = probeDirectDaemonTranscript( + [ + { + ...textEvent(20, 'agent_message_chunk', 'answer'), + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'answer' }, + _meta: { qwenTranscript: { segmentId: 'record-1:0' } }, + }, + }, + }, + ], + context, + context, + ); + + expect(result.identities[0]?.sourceIdentity).toEqual([ + 'segmentId', + 'record-1:0', + ]); + expect(result.diagnostics).toEqual([]); + }); + + it('uses the native permission request identity', () => { + const result = probeDirectDaemonTranscript( + [ + { + id: 30, + v: 1, + type: 'permission_request', + data: { + requestId: 'permission-1', + sessionId: 'session-a', + toolCall: { + toolCallId: 'read-1', + name: 'read', + rawInput: { path: 'src/index.ts' }, + }, + options: [{ optionId: 'allow' }], + }, + }, + ], + context, + context, + ); + + expect(result.diagnostics).toEqual([]); + expect(result.model.blocks).toHaveLength(1); + expect(result.identities[0]?.sourceIdentity).toEqual([ + 'requestId', + 'permission-1', + ]); + }); + + it('fails closed when one source segment is reused for distinct blocks', () => { + const result = probeDirectDaemonTranscript( + [ + textEvent(20, 'agent_message_chunk', 'before', 'reused-segment'), + { + id: 21, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'read-1', + title: 'Read', + status: 'completed', + }, + }, + }, + textEvent(22, 'agent_message_chunk', 'after', 'reused-segment'), + ], + context, + context, + ); + + expect(result.diagnostics).toContainEqual({ + code: 'duplicate_stable_block_identity', + severity: 'error', + }); + }); + + it('drops events from a stale scope generation', () => { + const result = probeDirectDaemonTranscript( + [textEvent(20, 'agent_message_chunk', 'late answer')], + context, + { ...context, generation: 3 }, + ); + + expect(result.model.blocks).toEqual([]); + expect(result.diagnostics).toEqual([ + { code: 'stale_scope_generation_ignored', severity: 'info' }, + ]); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.ts b/packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.ts new file mode 100644 index 00000000000..2eb4215e864 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/chatTranscriptContractProbe.ts @@ -0,0 +1,184 @@ +import { + createDaemonTranscriptState, + normalizeDaemonEvent, + reduceDaemonTranscriptEvents, + type DaemonEvent, + type DaemonTranscriptBlock, +} from '@qwen-code/sdk/daemon'; + +export interface TranscriptAdapterContext { + readonly scopeKey: string; + readonly generation: number; +} + +export interface TranscriptDiagnostic { + readonly code: string; + readonly severity: 'info' | 'warning' | 'error'; + readonly sourceIndex?: number; +} + +export interface TranscriptIdentityEvidence { + readonly blockId: string; + readonly sourceKind: string; + readonly sourceIdentity: readonly string[]; +} + +export interface TranscriptAdapterProbeResult { + readonly model: { readonly blocks: readonly DaemonTranscriptBlock[] }; + readonly diagnostics: readonly TranscriptDiagnostic[]; + readonly identities: readonly TranscriptIdentityEvidence[]; +} + +export function probeDirectDaemonTranscript( + events: readonly DaemonEvent[], + context: TranscriptAdapterContext, + activeContext: TranscriptAdapterContext, +): TranscriptAdapterProbeResult { + return probeEvents(events, context, activeContext); +} + +export function probeAcpTranscriptUpdates( + updates: readonly unknown[], + context: TranscriptAdapterContext, + activeContext: TranscriptAdapterContext, +): TranscriptAdapterProbeResult { + const events = updates.map( + (update): DaemonEvent => ({ + v: 1, + type: 'session_update', + data: { update }, + }), + ); + return probeEvents(events, context, activeContext); +} + +function probeEvents( + events: readonly DaemonEvent[], + context: TranscriptAdapterContext, + activeContext: TranscriptAdapterContext, +): TranscriptAdapterProbeResult { + if ( + context.scopeKey !== activeContext.scopeKey || + context.generation !== activeContext.generation + ) { + return { + model: { blocks: [] }, + diagnostics: [ + { code: 'stale_scope_generation_ignored', severity: 'info' }, + ], + identities: [], + }; + } + + let state = createDaemonTranscriptState({ now: 0 }); + const diagnostics: TranscriptDiagnostic[] = []; + events.forEach((event, sourceIndex) => { + const normalized = normalizeDaemonEvent(event); + if (normalized.some((item) => item.type === 'debug')) { + diagnostics.push({ + code: 'normalizer_debug_output', + severity: 'warning', + sourceIndex, + }); + } + state = reduceDaemonTranscriptEvents(state, normalized, { now: 0 }); + }); + const projected = projectStableBlockIds(state.blocks, context.scopeKey); + const seenBlockIds = new Set(); + const identities = projected.blocks.map((block) => { + const sourceIdentity = getBlockIdentity(block); + if (sourceIdentity.length === 0) { + diagnostics.push({ + code: 'stable_native_identity_missing', + severity: 'error', + }); + } else if (seenBlockIds.has(block.id)) { + diagnostics.push({ + code: 'duplicate_stable_block_identity', + severity: 'error', + }); + } + seenBlockIds.add(block.id); + return { + blockId: block.id, + sourceKind: block.kind, + sourceIdentity, + }; + }); + return { + model: { blocks: projected.blocks }, + diagnostics, + identities, + }; +} + +function getBlockIdentity(block: DaemonTranscriptBlock): string[] { + if (block.kind === 'tool') { + return ['toolCallId', block.toolCallId]; + } + if (block.kind === 'permission') { + return ['requestId', block.requestId]; + } + if (block.segmentId) { + return ['segmentId', block.segmentId]; + } + if ( + block.kind === 'user' || + block.kind === 'assistant' || + block.kind === 'thought' + ) { + return []; + } + if (block.eventId !== undefined) { + return ['eventId', String(block.eventId)]; + } + return []; +} + +function projectStableBlockIds( + blocks: readonly DaemonTranscriptBlock[], + scopeKey: string, +): { readonly blocks: readonly DaemonTranscriptBlock[] } { + const stableIdByRuntimeId = new Map(); + for (const block of blocks) { + const sourceIdentity = getBlockIdentity(block); + if (sourceIdentity.length === 0) continue; + stableIdByRuntimeId.set( + block.id, + `${block.kind}-${hashIdentity([ + scopeKey, + block.kind, + ...sourceIdentity, + ])}`, + ); + } + return { + blocks: blocks.map((block) => { + const id = stableIdByRuntimeId.get(block.id) ?? block.id; + if (block.kind !== 'tool' || !block.parentBlockId) { + return id === block.id ? block : { ...block, id }; + } + return { + ...block, + id, + parentBlockId: + stableIdByRuntimeId.get(block.parentBlockId) ?? block.parentBlockId, + }; + }), + }; +} + +function hashIdentity(parts: readonly string[]): string { + const value = parts.join('\u0000'); + let first = 0x811c9dc5; + let second = 0x9e3779b9; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + first = Math.imul(first ^ code, 0x01000193); + second = Math.imul(second ^ code, 0x85ebca6b); + second ^= second >>> 13; + } + return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0) + .toString(16) + .padStart(8, '0')}`; +} diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index e04342af955..4b570af8106 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -62,6 +62,8 @@ export interface DaemonMessageToolCall { endTime?: number; subContent?: string; subTools?: DaemonMessageToolCall[]; + /** Transcript blocks folded into this tool presentation. */ + sourceBlockIds?: string[]; } export interface DaemonMessageTodoItem { @@ -84,6 +86,8 @@ export interface DaemonMessageMeta { * that have no backing block. */ timestamp?: number; + /** Stable transcript blocks folded into this rendered message. */ + sourceBlockIds?: string[]; } export interface DaemonUserMessage extends DaemonMessageMeta { diff --git a/packages/web-shell/client/adapters/parallelAgentGrouping.ts b/packages/web-shell/client/adapters/parallelAgentGrouping.ts new file mode 100644 index 00000000000..5268e910780 --- /dev/null +++ b/packages/web-shell/client/adapters/parallelAgentGrouping.ts @@ -0,0 +1,133 @@ +import type { ACPToolCall, Message } from './types.js'; +import { + isBackgroundSubAgentToolCall, + isSubAgentToolCall, +} from './toolClassification.js'; + +export type ParallelAgentDisplayItem = + | { + type: 'message'; + key: string; + message: Message; + } + | { + type: 'parallel_agents'; + key: string; + turnId: string; + agents: ACPToolCall[]; + /** + * Wall-clock time of the first grouped launch, carried so the grouped + * box reveals its time on hover exactly like a standalone message row. + */ + timestamp?: number; + }; + +function isAgentOnlyToolGroup(message: Message): boolean { + return ( + message.role === 'tool_group' && + message.tools.length === 1 && + isSubAgentToolCall(message.tools[0]) + ); +} + +function isBackgroundAgentOnlyToolGroup(message: Message): boolean { + return ( + message.role === 'tool_group' && + message.tools.length === 1 && + isBackgroundSubAgentToolCall(message.tools[0]) + ); +} + +function isBackgroundLaunchNarration(message: Message): boolean { + // The daemon often streams short main-agent thought text between background + // launches, e.g. "agent A is running, now starting agent B". The CLI treats + // those as internal launch narration and shows a single Parallel agents box. + // Only skip thought-only messages here; any user-facing assistant content + // still breaks the group and remains visible. + return message.role === 'thinking'; +} + +export function groupParallelAgents( + messages: Message[], +): ParallelAgentDisplayItem[] { + const items: ParallelAgentDisplayItem[] = []; + let index = 0; + while (index < messages.length) { + if (isBackgroundAgentOnlyToolGroup(messages[index])) { + const grouped: Message[] = []; + let nextIndex = index; + while (nextIndex < messages.length) { + const current = messages[nextIndex]; + if (isBackgroundAgentOnlyToolGroup(current)) { + grouped.push(current); + nextIndex += 1; + continue; + } + if (isBackgroundLaunchNarration(current)) { + let nextAgentIndex = nextIndex + 1; + while ( + nextAgentIndex < messages.length && + isBackgroundLaunchNarration(messages[nextAgentIndex]) + ) { + nextAgentIndex += 1; + } + if ( + nextAgentIndex < messages.length && + isBackgroundAgentOnlyToolGroup(messages[nextAgentIndex]) + ) { + nextIndex = nextAgentIndex; + continue; + } + } + break; + } + + if (grouped.length >= 2) { + items.push({ + type: 'parallel_agents', + key: `par-${grouped[0].id}`, + turnId: grouped[0].id, + agents: grouped.map( + (message) => (message as { tools: ACPToolCall[] }).tools[0], + ), + timestamp: grouped[0].timestamp, + }); + index = nextIndex; + continue; + } + } + + if (isAgentOnlyToolGroup(messages[index])) { + const start = index; + while (index < messages.length && isAgentOnlyToolGroup(messages[index])) { + index += 1; + } + if (index - start >= 2) { + const grouped = messages.slice(start, index); + items.push({ + type: 'parallel_agents', + key: `par-${grouped[0].id}`, + turnId: grouped[0].id, + agents: grouped.map( + (message) => (message as { tools: ACPToolCall[] }).tools[0], + ), + timestamp: grouped[0].timestamp, + }); + } else { + items.push({ + type: 'message', + key: messages[start].id, + message: messages[start], + }); + } + } else { + items.push({ + type: 'message', + key: messages[index].id, + message: messages[index], + }); + index += 1; + } + } + return items; +} diff --git a/packages/web-shell/client/adapters/transcriptRenderProbe.test.ts b/packages/web-shell/client/adapters/transcriptRenderProbe.test.ts new file mode 100644 index 00000000000..e64d43eb6d2 --- /dev/null +++ b/packages/web-shell/client/adapters/transcriptRenderProbe.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +import { probeTranscriptRenderIdentity } from './transcriptRenderProbe'; + +function block( + value: Omit< + DaemonTranscriptBlock, + 'clientReceivedAt' | 'createdAt' | 'updatedAt' + >, +): DaemonTranscriptBlock { + return { + ...value, + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + } as DaemonTranscriptBlock; +} + +describe('probeTranscriptRenderIdentity', () => { + it('is stable across folding and preserves every action source', () => { + const blocks = [ + block({ id: 'user-1', kind: 'user', text: 'request' }), + block({ id: 'assistant-1', kind: 'assistant', text: 'first ' }), + block({ id: 'assistant-2', kind: 'assistant', text: 'second' }), + block({ + id: 'tool-1', + kind: 'tool', + toolCallId: 'read-1', + title: 'Read file', + status: 'completed', + toolName: 'read', + toolKind: 'read', + preview: { kind: 'file_read', path: 'src/index.ts' }, + resultPreview: { kind: 'text', text: 'contents' }, + }), + block({ + id: 'tool-2', + kind: 'tool', + toolCallId: 'search-1', + title: 'Search', + status: 'completed', + toolName: 'search', + toolKind: 'search', + preview: { kind: 'search', query: 'needle' }, + }), + ]; + + const first = probeTranscriptRenderIdentity(blocks); + const second = probeTranscriptRenderIdentity(blocks); + + expect(second).toEqual(first); + expect(first.items).toMatchObject([ + { + sourceBlockIds: ['user-1'], + capabilities: ['copy', 'edit-user-message'], + }, + { + sourceBlockIds: ['assistant-1', 'assistant-2'], + capabilities: ['copy'], + }, + { + sourceBlockIds: ['tool-1', 'tool-2'], + sourceToolCallIds: ['read-1', 'search-1'], + capabilities: ['copy', 'open-file'], + }, + ]); + expect(Object.keys(first.semanticCopyHashes)).toHaveLength(3); + expect(first.actions.copyAll.renderedItemIds).toEqual( + first.items.map((item) => item.renderedItemId), + ); + expect(first.actions.copyLastReply?.renderedItemId).toBe( + first.items[1]?.renderedItemId, + ); + expect(first.actions.editLastUserMessage?.renderedItemId).toBe( + first.items[0]?.renderedItemId, + ); + expect(first.actions.openFiles).toEqual([ + { + renderedItemId: first.items[2]?.renderedItemId, + sourceToolCallId: 'read-1', + }, + ]); + }); + + it('changes semantic hash without leaking copied content into evidence', () => { + const first = probeTranscriptRenderIdentity([ + block({ id: 'assistant-1', kind: 'assistant', text: 'secret one' }), + ]); + const second = probeTranscriptRenderIdentity([ + block({ id: 'assistant-1', kind: 'assistant', text: 'secret two' }), + ]); + + expect(first.items).toEqual(second.items); + expect(first.semanticCopyHashes).not.toEqual(second.semanticCopyHashes); + expect(JSON.stringify(first)).not.toContain('secret one'); + }); + + it('includes nested subagent content in semantic copy evidence', () => { + const transcript = (nestedResult: string): DaemonTranscriptBlock[] => [ + block({ + id: 'agent-start', + kind: 'tool', + toolCallId: 'agent-1', + title: 'Delegate', + status: 'in_progress', + toolName: 'agent', + preview: { + kind: 'subagent_delegation', + agentName: 'reviewer', + task: 'Review', + }, + }), + block({ + id: 'nested-tool', + kind: 'tool', + toolCallId: 'read-1', + title: 'Read file', + status: 'completed', + toolName: 'read', + preview: { kind: 'file_read', path: 'src/index.ts' }, + resultPreview: { kind: 'text', text: nestedResult }, + parentToolCallId: 'agent-1', + }), + block({ + id: 'agent-end', + kind: 'tool', + toolCallId: 'agent-1', + title: 'Delegate', + status: 'completed', + toolName: 'agent', + preview: { + kind: 'subagent_delegation', + agentName: 'reviewer', + task: 'Review', + }, + resultPreview: { kind: 'text', text: 'Done' }, + }), + ]; + + const first = probeTranscriptRenderIdentity(transcript('nested one'), { + safeToolProjection: true, + }); + const second = probeTranscriptRenderIdentity(transcript('nested two'), { + safeToolProjection: true, + }); + + expect(first.items).toEqual(second.items); + expect(first.semanticCopyHashes).not.toEqual(second.semanticCopyHashes); + expect(JSON.stringify(first)).not.toContain('nested one'); + }); + + it('uses the renderer parallel-agent grouping for item identity', () => { + const transcript: DaemonTranscriptBlock[] = [ + block({ + id: 'agent-1-block', + kind: 'tool', + toolCallId: 'agent-1', + title: 'First agent', + status: 'completed', + toolName: 'agent', + preview: { + kind: 'subagent_delegation', + agentName: 'reviewer', + task: 'Review', + }, + }), + block({ + id: 'nested-tool-block', + kind: 'tool', + toolCallId: 'read-1', + title: 'Read evidence', + status: 'completed', + toolName: 'read', + preview: { kind: 'file_read', path: 'contract.md' }, + parentToolCallId: 'agent-1', + }), + block({ + id: 'agent-2-block', + kind: 'tool', + toolCallId: 'agent-2', + title: 'Second agent', + status: 'completed', + toolName: 'agent', + preview: { + kind: 'subagent_delegation', + agentName: 'tester', + task: 'Test', + }, + }), + ]; + + const first = probeTranscriptRenderIdentity(transcript); + const prepended = probeTranscriptRenderIdentity([ + block({ id: 'older-user', kind: 'user', text: 'older' }), + ...transcript, + ]); + + expect(first.items).toHaveLength(1); + expect(first.items[0]).toMatchObject({ + sourceBlockIds: ['agent-1-block', 'nested-tool-block', 'agent-2-block'], + sourceToolCallIds: ['agent-1', 'read-1', 'agent-2'], + capabilities: ['copy', 'open-file'], + }); + expect(prepended.items[1]?.renderedItemId).toBe( + first.items[0]?.renderedItemId, + ); + }); + + it('assigns unique stable identities to split items from one source block', () => { + const insight = block({ + id: 'insight-1', + kind: 'assistant', + text: 'before {"insight_ready":{"path":"/tmp/report.md"}} after', + }); + const first = probeTranscriptRenderIdentity([insight]); + const withPrependedHistory = probeTranscriptRenderIdentity([ + block({ id: 'older-user', kind: 'user', text: 'older' }), + insight, + ]); + const firstIds = first.items.map((item) => item.renderedItemId); + const replayedIds = withPrependedHistory.items + .filter((item) => item.sourceBlockIds.includes('insight-1')) + .map((item) => item.renderedItemId); + + expect(new Set(firstIds).size).toBe(3); + expect(replayedIds).toEqual(firstIds); + expect(withPrependedHistory.actions.copyLastReply).toEqual( + first.actions.copyLastReply, + ); + }); +}); diff --git a/packages/web-shell/client/adapters/transcriptRenderProbe.ts b/packages/web-shell/client/adapters/transcriptRenderProbe.ts new file mode 100644 index 00000000000..74152e41968 --- /dev/null +++ b/packages/web-shell/client/adapters/transcriptRenderProbe.ts @@ -0,0 +1,297 @@ +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +import type { DaemonMessage, DaemonMessageToolCall } from './messageTypes.js'; +import { groupParallelAgents } from './parallelAgentGrouping.js'; +import { transcriptBlocksToDaemonMessages } from './transcriptToMessages.js'; + +export interface TranscriptRenderedItemEvidence { + readonly renderedItemId: string; + readonly sourceBlockIds: readonly string[]; + readonly sourceToolCallIds: readonly string[]; + readonly capabilities: readonly ( + | 'copy' + | 'open-file' + | 'edit-user-message' + )[]; +} + +export interface TranscriptRenderProbeResult { + readonly items: readonly TranscriptRenderedItemEvidence[]; + readonly semanticCopyHashes: Readonly>; + readonly actions: { + readonly copyAll: { + readonly renderedItemIds: readonly string[]; + readonly semanticHash: string; + }; + readonly copyLastReply?: TranscriptActionTargetEvidence; + readonly editLastUserMessage?: TranscriptActionTargetEvidence; + readonly openFiles: readonly { + readonly renderedItemId: string; + readonly sourceToolCallId: string; + }[]; + }; +} + +export interface TranscriptActionTargetEvidence { + readonly renderedItemId: string; + readonly sourceBlockIds: readonly string[]; + readonly semanticHash: string; +} + +interface RenderProbeItem { + readonly id: string; + readonly role: DaemonMessage['role'] | 'parallel_agents'; + readonly sourceBlockIds: readonly string[]; + readonly sourceToolCallIds: readonly string[]; + readonly fileToolCallIds: readonly string[]; + readonly semanticText: string; +} + +export function probeTranscriptRenderIdentity( + blocks: readonly DaemonTranscriptBlock[], + options: { safeToolProjection?: boolean } = {}, +): TranscriptRenderProbeResult { + const messages = transcriptBlocksToDaemonMessages(blocks, { + includeSourceIdentity: true, + safeToolProjection: options.safeToolProjection, + }); + const renderItems = groupParallelAgents(messages).map( + (item): RenderProbeItem => { + if (item.type === 'message') { + const { message } = item; + return { + id: message.id, + role: message.role, + sourceBlockIds: [...new Set(message.sourceBlockIds ?? [message.id])], + sourceToolCallIds: collectToolCallIds(message), + fileToolCallIds: collectFileTargetToolCallIds(message), + semanticText: semanticCopyText(message), + }; + } + return { + id: item.key, + role: 'parallel_agents', + sourceBlockIds: [ + ...new Set( + item.agents.flatMap((agent) => agent.sourceBlockIds ?? []), + ), + ], + sourceToolCallIds: collectToolCallIdsFromTools(item.agents), + fileToolCallIds: collectFileTargetToolCallIdsFromTools(item.agents), + semanticText: item.agents.map(semanticToolText).join('\n'), + }; + }, + ); + const semanticCopyHashes: Record = Object.create(null); + const semanticTexts = renderItems.map((item) => item.semanticText); + const fileToolCallIdsByItem: string[][] = []; + const items = renderItems.map((renderItem, index) => { + const renderedItemId = `item-${encodeIdentity([ + renderItem.role, + renderItem.id, + String(renderItem.sourceBlockIds.length), + ...renderItem.sourceBlockIds, + String(renderItem.sourceToolCallIds.length), + ...renderItem.sourceToolCallIds, + ])}`; + semanticCopyHashes[renderedItemId] = hashIdentity([semanticTexts[index]!]); + const fileToolCallIds = [...renderItem.fileToolCallIds]; + fileToolCallIdsByItem.push(fileToolCallIds); + const capabilities: TranscriptRenderedItemEvidence['capabilities'] = [ + 'copy', + ...(renderItem.role === 'user' ? (['edit-user-message'] as const) : []), + ...(fileToolCallIds.length > 0 ? (['open-file'] as const) : []), + ]; + return { + renderedItemId, + sourceBlockIds: renderItem.sourceBlockIds, + sourceToolCallIds: renderItem.sourceToolCallIds, + capabilities, + }; + }); + const lastAssistantIndex = findLastMessageIndex( + renderItems, + (item) => item.role === 'assistant', + ); + const lastUserIndex = findLastMessageIndex( + renderItems, + (item) => item.role === 'user', + ); + const actionTarget = ( + index: number, + ): TranscriptActionTargetEvidence | undefined => { + const item = items[index]; + if (!item) return undefined; + return { + renderedItemId: item.renderedItemId, + sourceBlockIds: item.sourceBlockIds, + semanticHash: semanticCopyHashes[item.renderedItemId]!, + }; + }; + return { + items, + semanticCopyHashes, + actions: { + copyAll: { + renderedItemIds: items.map((item) => item.renderedItemId), + semanticHash: hashIdentity([JSON.stringify(semanticTexts)]), + }, + ...(lastAssistantIndex >= 0 + ? { copyLastReply: actionTarget(lastAssistantIndex) } + : {}), + ...(lastUserIndex >= 0 + ? { editLastUserMessage: actionTarget(lastUserIndex) } + : {}), + openFiles: items.flatMap((item, index) => + fileToolCallIdsByItem[index]!.map((sourceToolCallId) => ({ + renderedItemId: item.renderedItemId, + sourceToolCallId, + })), + ), + }, + }; +} + +function collectToolCallIds(message: DaemonMessage): string[] { + if (message.role !== 'tool_group') return []; + return collectToolCallIdsFromTools(message.tools); +} + +function collectToolCallIdsFromTools( + tools: readonly DaemonMessageToolCall[], +): string[] { + const ids: string[] = []; + const visit = (tools: readonly DaemonMessageToolCall[]): void => { + for (const tool of tools) { + ids.push(tool.callId); + if (tool.subTools) visit(tool.subTools); + } + }; + visit(tools); + return ids; +} + +function collectFileTargetToolCallIds(message: DaemonMessage): string[] { + if (message.role !== 'tool_group') return []; + return collectFileTargetToolCallIdsFromTools(message.tools); +} + +function collectFileTargetToolCallIdsFromTools( + tools: readonly DaemonMessageToolCall[], +): string[] { + const ids: string[] = []; + const visit = (tools: readonly DaemonMessageToolCall[]): void => { + for (const tool of tools) { + if (isFileTargetTool(tool)) ids.push(tool.callId); + if (tool.subTools) visit(tool.subTools); + } + }; + visit(tools); + return ids; +} + +function isFileTargetTool(tool: DaemonMessageToolCall): boolean { + if ( + tool.kind === 'read' || + tool.kind === 'edit' || + tool.kind === 'delete' || + tool.kind === 'move' + ) { + return true; + } + if ( + tool.args && + typeof tool.args === 'object' && + !Array.isArray(tool.args) && + (typeof tool.args['path'] === 'string' || + typeof tool.args['file_path'] === 'string' || + typeof tool.args['absolute_path'] === 'string') + ) { + return true; + } + return Boolean(tool.content?.some((item) => item.path)); +} + +function findLastMessageIndex( + messages: readonly T[], + predicate: (message: T) => boolean, +): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (predicate(messages[index]!)) return index; + } + return -1; +} + +function semanticCopyText(message: DaemonMessage): string { + switch (message.role) { + case 'user': + case 'assistant': + case 'thinking': + case 'system': + return message.content; + case 'tool_group': + return message.tools.map(semanticToolText).join('\n'); + case 'plan': + return message.todos + .map((todo) => `${todo.status}: ${todo.content}`) + .join('\n'); + case 'user_shell': + return [message.command, message.output].filter(Boolean).join('\n'); + case 'btw': + return [message.question, message.answer].filter(Boolean).join('\n'); + case 'insight_progress': + return [message.stage, message.detail].filter(Boolean).join('\n'); + case 'insight_ready': + return message.path; + case 'insight_error': + return message.error; + } +} + +function semanticToolText(tool: DaemonMessageToolCall): string { + return [ + tool.title ?? tool.toolName, + safeStringify(tool.content), + safeStringify(tool.rawOutput), + tool.subContent, + ...(tool.subTools?.map(semanticToolText) ?? []), + ] + .filter(Boolean) + .join('\n'); +} + +function safeStringify(value: unknown): string { + if (typeof value === 'string') return value; + if (value === undefined) return ''; + try { + return JSON.stringify(value) ?? ''; + } catch { + return ''; + } +} + +function hashIdentity(parts: readonly string[]): string { + const value = parts.join('\u0000'); + let first = 0x811c9dc5; + let second = 0x9e3779b9; + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + first = Math.imul(first ^ code, 0x01000193); + second = Math.imul(second ^ code, 0x85ebca6b); + second ^= second >>> 13; + } + return `${(first >>> 0).toString(16).padStart(8, '0')}${(second >>> 0) + .toString(16) + .padStart(8, '0')}`; +} + +function encodeIdentity(parts: readonly string[]): string { + return parts + .map((part) => { + let encoded = ''; + for (let index = 0; index < part.length; index += 1) { + encoded += part.charCodeAt(index).toString(16).padStart(4, '0'); + } + return encoded; + }) + .join('-'); +} diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index a2b05b5e95c..2a166e5b8aa 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -174,6 +174,7 @@ function toolBlock( toolName: overrides.toolName ?? 'Read', toolKind: overrides.toolKind, preview: overrides.preview ?? { kind: 'generic' }, + resultPreview: overrides.resultPreview, rawInput: overrides.rawInput, rawOutput: overrides.rawOutput, content: overrides.content, @@ -1111,6 +1112,26 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); + it('uses source-local insight text IDs for identity probes', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + textBlock( + 'insight-1', + 'assistant', + 'before {"insight_ready":{"path":"/tmp/report.md"}} middle {"insight_error":{"error":"boom"}} after', + 1, + ), + ], + { includeSourceIdentity: true }, + ); + + expect( + messages + .filter((message) => message.role === 'assistant') + .map((message) => message.id), + ).toEqual(['insight-1-t-0', 'insight-1-t-1', 'insight-1-t-2']); + }); + it('keeps malformed insight JSON as assistant text', () => { const content = 'before {"insight_ready": bad} after'; const messages = transcriptBlocksToDaemonMessages([ @@ -1875,21 +1896,26 @@ describe('transcriptBlocksToDaemonMessages', () => { }); it('appends shell output to preceding tool_group', () => { - const messages = transcriptBlocksToDaemonMessages([ - toolBlock('t1', 'tc1', 'completed', 1, { - toolName: 'bash', - toolKind: 'execute', - }), - shellBlock('sh1', 'output text', 2), - ]); + const messages = transcriptBlocksToDaemonMessages( + [ + toolBlock('t1', 'tc1', 'completed', 1, { + toolName: 'bash', + toolKind: 'execute', + }), + shellBlock('sh1', 'output text', 2), + ], + { includeSourceIdentity: true }, + ); expect(messages).toHaveLength(1); expect(messages[0]).toMatchObject({ role: 'tool_group', + sourceBlockIds: ['t1', 'sh1'], tools: [ { callId: 'tc1', rawOutput: 'output text', + sourceBlockIds: ['t1', 'sh1'], }, ], }); @@ -2192,6 +2218,423 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + it('renders typed todo results after raw fields are removed', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + toolBlock('todo-typed', 'todo-call', 'completed', 1, { + toolName: 'todo_write', + preview: { + kind: 'todo_list', + entries: [ + { + id: 'implement', + content: 'Implement contract', + status: 'completed', + blockedBy: ['audit'], + }, + ], + planId: 'plan-1', + revision: 2, + }, + resultPreview: { + kind: 'todo_list', + entries: [ + { + id: 'implement', + content: 'Implement contract', + status: 'completed', + blockedBy: ['audit'], + }, + ], + planId: 'plan-1', + revision: 2, + }, + rawInput: undefined, + rawOutput: undefined, + content: undefined, + }), + ], + { includeSourceIdentity: true, safeToolProjection: true }, + ); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + sourceBlockIds: ['todo-typed'], + tools: [ + { + callId: 'todo-call', + sourceBlockIds: ['todo-typed'], + rawOutput: { + entries: [ + { + content: 'Implement contract', + status: 'completed', + }, + ], + plan: { id: 'plan-1', revision: 2 }, + }, + }, + ], + }, + ]); + }); + + it('preserves typed file diff previews after raw fields are removed', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + toolBlock('diff-typed', 'diff-call', 'completed', 1, { + toolName: 'edit', + preview: { + kind: 'file_diff', + path: 'document.ts', + oldText: 'old content', + newText: 'DOCUMENT_DIFF_DETAIL', + }, + resultPreview: { kind: 'text', text: 'Diff completed' }, + rawInput: undefined, + rawOutput: undefined, + content: undefined, + }), + ], + { safeToolProjection: true }, + ); + + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool).toMatchObject({ + args: { + path: 'document.ts', + oldText: 'old content', + newText: 'DOCUMENT_DIFF_DETAIL', + }, + rawOutput: 'Diff completed', + }); + }); + + it('keeps raw tool data authoritative in the default projection', () => { + const rawInput = { + file_path: 'src/generated.ts', + content: 'RAW_INPUT_CONTENT\nSECOND_LINE\n', + }; + const rawOutput = { + returnDisplay: 'RAW_RESULT', + audit: 'KEEP_ME', + }; + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('write-raw', 'write-call', 'completed', 1, { + toolName: 'write_file', + rawInput, + rawOutput, + preview: { + kind: 'file_diff', + path: 'src/generated.ts', + newText: 'PREVIEW_NEW_TEXT\n', + }, + resultPreview: { kind: 'text', text: 'SAFE_RESULT' }, + }), + ]); + + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.args).toEqual(rawInput); + expect(tool?.rawOutput).toEqual(rawOutput); + }); + + it('does not inspect typed projections in the default projection', () => { + const malformedTypedBlock = { + ...toolBlock('write-malformed', 'write-call', 'completed', 1, { + toolName: 'write_file', + rawInput: { content: 'RAW_INPUT' }, + rawOutput: 'RAW_OUTPUT', + }), + preview: { kind: 'todo_list' }, + resultPreview: { kind: 'todo_list' }, + } as unknown as DaemonToolTranscriptBlock; + + const messages = transcriptBlocksToDaemonMessages([malformedTypedBlock]); + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.args).toEqual({ content: 'RAW_INPUT' }); + expect(tool?.rawOutput).toBe('RAW_OUTPUT'); + }); + + it('does not consume typed tool projections in the default projection', () => { + const messages = transcriptBlocksToDaemonMessages([ + toolBlock('write-preview-only', 'write-call', 'completed', 1, { + toolName: 'write_file', + preview: { + kind: 'file_diff', + path: 'src/generated.ts', + newText: 'SAFE_CONTENT\n', + }, + resultPreview: { kind: 'text', text: 'SAFE_RESULT' }, + }), + ]); + + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.args).toBeUndefined(); + expect(tool?.rawOutput).toBeUndefined(); + }); + + it('does not replace raw tool data with completion previews', () => { + const rawInput = { + file_path: 'src/generated.ts', + content: 'RAW_START_CONTENT\n', + }; + const rawOutput = { audit: 'RAW_START_RESULT' }; + const blocks = [ + toolBlock('write-start', 'write-call', 'in_progress', 1, { + toolName: 'write_file', + rawInput, + rawOutput, + }), + toolBlock('write-complete', 'write-call', 'completed', 2, { + toolName: 'write_file', + preview: { + kind: 'file_diff', + path: 'src/generated.ts', + newText: 'SAFE_COMPLETION_CONTENT\n', + }, + resultPreview: { kind: 'text', text: 'SAFE_COMPLETION_RESULT' }, + }), + ]; + + const messages = transcriptBlocksToDaemonMessages(blocks); + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.args).toEqual(rawInput); + expect(tool?.rawOutput).toEqual(rawOutput); + }); + + it('uses completion previews when multi-block safe projection is explicit', () => { + const blocks = [ + toolBlock('write-start', 'write-call', 'in_progress', 1, { + toolName: 'write_file', + rawInput: { + file_path: 'src/generated.ts', + content: 'RAW_START_CONTENT\n', + }, + rawOutput: { audit: 'RAW_START_RESULT' }, + }), + toolBlock('write-complete', 'write-call', 'completed', 2, { + toolName: 'write_file', + preview: { + kind: 'file_diff', + path: 'src/generated.ts', + newText: 'SAFE_COMPLETION_CONTENT\n', + }, + resultPreview: { kind: 'text', text: 'SAFE_COMPLETION_RESULT' }, + }), + ]; + + const messages = transcriptBlocksToDaemonMessages(blocks, { + safeToolProjection: true, + }); + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.args).toEqual({ + path: 'src/generated.ts', + newText: 'SAFE_COMPLETION_CONTENT\n', + }); + expect(tool?.rawOutput).toBe('SAFE_COMPLETION_RESULT'); + }); + + it('uses typed tool projections when safe projection is explicit', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + toolBlock('write-safe', 'write-call', 'completed', 1, { + toolName: 'write_file', + rawInput: { + file_path: 'src/generated.ts', + content: 'RAW_INPUT_CONTENT\n', + }, + rawOutput: { audit: 'DO_NOT_EXPORT' }, + preview: { + kind: 'file_diff', + path: 'src/generated.ts', + newText: 'SAFE_CONTENT\n', + }, + resultPreview: { kind: 'text', text: 'SAFE_RESULT' }, + }), + ], + { safeToolProjection: true }, + ); + + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.args).toEqual({ + path: 'src/generated.ts', + newText: 'SAFE_CONTENT\n', + }); + expect(tool?.rawOutput).toBe('SAFE_RESULT'); + }); + + it('never falls back to raw tool data in safe projection', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + toolBlock('raw-only', 'raw-only-call', 'completed', 1, { + toolName: 'custom_tool', + rawInput: { secret: 'RAW_INPUT' }, + rawOutput: { secret: 'RAW_OUTPUT' }, + content: [ + { + type: 'content', + content: { type: 'text', text: 'RAW_CONTENT' }, + }, + ], + preview: { kind: 'generic' }, + resultPreview: undefined, + }), + ], + { safeToolProjection: true }, + ); + + const tool = + messages[0]?.role === 'tool_group' ? messages[0].tools[0] : undefined; + expect(tool?.args).toBeUndefined(); + expect(tool?.rawOutput).toBeUndefined(); + expect(tool?.content).toBeUndefined(); + }); + + it('renders resolved permission history from safe identity fields', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + { + id: 'permission-safe', + kind: 'permission', + requestId: 'request-safe', + title: 'Allow file read?', + options: [{ optionId: 'reject', label: 'Reject', raw: null }], + preview: { kind: 'file_read', path: 'src/index.ts' }, + toolCallId: 'read-safe', + toolName: 'read', + toolKind: 'read', + resolved: 'rejected', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + ], + { includeSourceIdentity: true, safeToolProjection: true }, + ); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + sourceBlockIds: ['permission-safe'], + tools: [ + { + callId: 'read-safe', + toolName: 'read', + status: 'failed', + args: { path: 'src/index.ts' }, + }, + ], + }, + ]); + + const neutral = transcriptBlocksToDaemonMessages( + [ + { + id: 'permission-neutral', + kind: 'permission', + requestId: 'request-neutral', + title: 'Permission resolved', + options: [], + preview: { kind: 'file_read', path: 'src/index.ts' }, + toolCallId: 'read-neutral', + toolName: 'read', + resolved: 'resolved', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + ], + { safeToolProjection: true }, + ); + expect(neutral).toMatchObject([ + { + role: 'tool_group', + tools: [{ callId: 'read-neutral', status: 'completed' }], + }, + ]); + + const runtimeNeutral = transcriptBlocksToDaemonMessages([ + { + id: 'permission-neutral-runtime', + kind: 'permission', + requestId: 'request-neutral-runtime', + title: 'Permission resolved', + options: [], + preview: { kind: 'file_read', path: 'src/index.ts' }, + toolCallId: 'read-neutral-runtime', + toolName: 'read', + resolved: 'resolved', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 2, + }, + ]); + expect(runtimeNeutral).toEqual([]); + }); + + it('retains every source block when assistant blocks merge', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + textBlock('assistant-a', 'assistant', 'A', 1), + textBlock('assistant-b', 'assistant', 'B', 2), + ], + { includeSourceIdentity: true }, + ); + + expect(messages).toMatchObject([ + { + role: 'assistant', + content: 'AB', + sourceBlockIds: ['assistant-a', 'assistant-b'], + }, + ]); + }); + + it('retains nested subagent sources on the top-level rendered item', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + toolBlock('agent-block', 'agent-call', 'completed', 1, { + toolName: 'agent', + }), + textBlock('agent-text', 'assistant', 'Subagent result', 2, false, { + parentToolCallId: 'agent-call', + }), + toolBlock('agent-child-tool', 'child-call', 'completed', 3, { + toolName: 'read', + parentToolCallId: 'agent-call', + }), + ], + { includeSourceIdentity: true }, + ); + + expect(messages).toMatchObject([ + { + role: 'tool_group', + sourceBlockIds: ['agent-block', 'agent-text', 'agent-child-tool'], + tools: [ + { + callId: 'agent-call', + sourceBlockIds: ['agent-block', 'agent-text', 'agent-child-tool'], + subTools: [ + { + callId: 'child-call', + sourceBlockIds: ['agent-child-tool'], + }, + ], + }, + ], + }, + ]); + }); + it('does not synthesize a generic tool card for AskUserQuestion permissions', () => { const messages = transcriptBlocksToDaemonMessages([ textBlock('a1', 'assistant', 'I will ask for student info.', 1), diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 2976dec4bdf..fb19f98256f 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -50,6 +50,8 @@ interface TranscriptMessageLabels { interface TranscriptMessageOptions { labels?: TranscriptMessageLabels; + includeSourceIdentity?: boolean; + safeToolProjection?: boolean; } interface BackgroundAgentTaskUpdate { @@ -380,6 +382,8 @@ export function transcriptBlocksToDaemonMessages( options: TranscriptMessageOptions = {}, ): DaemonMessage[] { const messages: DaemonMessage[] = []; + const includeSourceIdentity = options.includeSourceIdentity === true; + const safeToolProjection = options.safeToolProjection === true; const promptCancelledText = options.labels?.promptCancelled ?? 'Request cancelled.'; // Replay can contain thousands of blocks. Keep tool calls indexed by callId @@ -420,6 +424,7 @@ export function transcriptBlocksToDaemonMessages( source: 'background_notification', data: getBackgroundNotificationData(textBlock), timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }); break; } @@ -455,6 +460,7 @@ export function transcriptBlocksToDaemonMessages( role: 'user', content: textBlock.text, timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), ...(source ? { source } : {}), ...(inputAnnotations ? { inputAnnotations } : {}), }; @@ -489,6 +495,7 @@ export function transcriptBlocksToDaemonMessages( source: 'background_notification', data: getBackgroundNotificationData(textBlock), timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }); break; } @@ -498,7 +505,12 @@ export function transcriptBlocksToDaemonMessages( ? toolsByCallId.get(textBlock.parentToolCallId) : undefined; if (parentSubAgent) { - appendSubContent(parentSubAgent, textBlock.text); + appendSubContent( + parentSubAgent, + textBlock.text, + block.id, + includeSourceIdentity, + ); break; } @@ -508,6 +520,7 @@ export function transcriptBlocksToDaemonMessages( let hasTerminal = false; let readyCount = 0; let errorCount = 0; + let textCount = 0; let lastAssistantSegmentIndex: number | null = null; for (const seg of insightSegments) { if (seg.kind === 'insight') { @@ -520,6 +533,9 @@ export function transcriptBlocksToDaemonMessages( role: 'insight_ready', path: seg.data.path, timestamp: blockTime, + ...(includeSourceIdentity + ? { sourceBlockIds: [block.id] } + : {}), }); } else if (seg.data.type === 'insight_error') { hasTerminal = true; @@ -528,14 +544,22 @@ export function transcriptBlocksToDaemonMessages( role: 'insight_error', error: seg.data.error, timestamp: blockTime, + ...(includeSourceIdentity + ? { sourceBlockIds: [block.id] } + : {}), }); } } else { messages.push({ - id: `${block.id}-t-${messages.length}`, + id: options.includeSourceIdentity + ? `${block.id}-t-${textCount++}` + : `${block.id}-t-${messages.length}`, role: 'assistant', content: seg.text, timestamp: blockTime, + ...(includeSourceIdentity + ? { sourceBlockIds: [block.id] } + : {}), }); currentAssistantIdx = messages.length - 1; lastAssistantSegmentIndex = currentAssistantIdx; @@ -559,6 +583,7 @@ export function transcriptBlocksToDaemonMessages( progress: lastProgress.progress, detail: lastProgress.detail, timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }); } needsNewContentMessage = true; @@ -580,6 +605,14 @@ export function transcriptBlocksToDaemonMessages( ...target, content: target.content + textBlock.text, isStreaming: textBlock.streaming, + ...(includeSourceIdentity + ? { + sourceBlockIds: unionMessageIds( + target.sourceBlockIds, + block.id, + ), + } + : {}), ...(textBlock.branchRecordId ? { branchRecordId: textBlock.branchRecordId } : {}), @@ -594,6 +627,7 @@ export function transcriptBlocksToDaemonMessages( content: textBlock.text, isStreaming: textBlock.streaming, timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), ...(textBlock.branchRecordId ? { branchRecordId: textBlock.branchRecordId } : {}), @@ -621,7 +655,12 @@ export function transcriptBlocksToDaemonMessages( ? toolsByCallId.get(textBlock.parentToolCallId) : undefined; if (parentSubAgent) { - appendSubContent(parentSubAgent, textBlock.text); + appendSubContent( + parentSubAgent, + textBlock.text, + block.id, + includeSourceIdentity, + ); break; } const target = @@ -633,6 +672,14 @@ export function transcriptBlocksToDaemonMessages( ...target, content: target.content + textBlock.text, isStreaming: textBlock.streaming, + ...(includeSourceIdentity + ? { + sourceBlockIds: unionMessageIds( + target.sourceBlockIds, + block.id, + ), + } + : {}), }; needsNewContentMessage = false; } else { @@ -642,6 +689,7 @@ export function transcriptBlocksToDaemonMessages( content: textBlock.text, isStreaming: textBlock.streaming, timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }); currentThinkingIdx = messages.length - 1; needsNewContentMessage = false; @@ -652,7 +700,11 @@ export function transcriptBlocksToDaemonMessages( case 'tool': { const toolBlock = block as DaemonToolTranscriptBlock; - const toolCall = daemonToolBlockToToolCall(toolBlock); + const toolCall = daemonToolBlockToToolCall( + toolBlock, + safeToolProjection, + includeSourceIdentity, + ); applyBackgroundAgentTaskUpdate( toolCall, backgroundAgentTaskUpdates.get(toolCall.callId), @@ -670,17 +722,33 @@ export function transcriptBlocksToDaemonMessages( const existingTool = toolsByCallId.get(toolCall.callId); if (existingTool) { - mergeToolCall(existingTool, toolCall); + mergeToolCall(existingTool, toolCall, { + replaceArgs: + safeToolProjection || + toolBlock.rawInput !== undefined || + existingTool.args === undefined, + replaceRawOutput: + safeToolProjection || + getRuntimeToolRawOutput(toolBlock) !== undefined || + existingTool.rawOutput === undefined, + collectSourceIdentity: includeSourceIdentity, + }); break; } if (parentSubAgent) { - appendSubTool(parentSubAgent, toolCall); + appendSubTool(parentSubAgent, toolCall, includeSourceIdentity); toolsByCallId.set(toolCall.callId, toolCall); break; } - appendToolCallMessage(messages, block.id, toolCall, blockTime); + appendToolCallMessage( + messages, + block.id, + toolCall, + blockTime, + includeSourceIdentity, + ); toolsByCallId.set(toolCall.callId, toolCall); currentAssistantIdx = null; currentThinkingIdx = null; @@ -702,9 +770,25 @@ export function transcriptBlocksToDaemonMessages( const nextTool = { ...targetTool, rawOutput: previousOutput + shellBlock.text, + ...(includeSourceIdentity + ? { + sourceBlockIds: unionMessageIds( + targetTool.sourceBlockIds, + block.id, + ), + } + : {}), }; messages[messages.length - 1] = { ...lastMsg, + ...(includeSourceIdentity + ? { + sourceBlockIds: unionMessageIds( + lastMsg.sourceBlockIds, + block.id, + ), + } + : {}), tools: [ ...lastMsg.tools.slice(0, targetIdx), nextTool, @@ -719,6 +803,7 @@ export function transcriptBlocksToDaemonMessages( messages.push({ id: block.id, role: 'tool_group', + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), tools: [ { callId: block.id, @@ -726,6 +811,9 @@ export function transcriptBlocksToDaemonMessages( status: 'completed', kind: 'execute', rawOutput: shellBlock.text, + ...(includeSourceIdentity + ? { sourceBlockIds: [block.id] } + : {}), }, ], timestamp: blockTime, @@ -740,6 +828,7 @@ export function transcriptBlocksToDaemonMessages( messages.push({ id: block.id, role: 'user_shell', + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), command: shellBlock.command, output: shellBlock.text, ...(shellBlock.cwd ? { cwd: shellBlock.cwd } : {}), @@ -751,8 +840,16 @@ export function transcriptBlocksToDaemonMessages( case 'permission': { const permBlock = block as DaemonPermissionTranscriptBlock; - rememberPermissionToolInfo(permBlock, permissionToolInfoByCallId); - const permissionToolCall = permissionBlockToToolCall(permBlock); + rememberPermissionToolInfo( + permBlock, + permissionToolInfoByCallId, + safeToolProjection, + ); + const permissionToolCall = permissionBlockToToolCall( + permBlock, + safeToolProjection, + includeSourceIdentity, + ); if (!permissionToolCall) break; const isSubAgentPermission = isSubAgentToolCall(permissionToolCall); // Pending permissions are rendered by the dedicated permission UI. @@ -771,11 +868,17 @@ export function transcriptBlocksToDaemonMessages( ? permissionToolCall.status : 'in_progress'; } else { - permissionToolCall.status = 'failed'; + permissionToolCall.status = + safeToolProjection && + isNeutralPermissionResolution(permBlock.resolved) + ? 'completed' + : 'failed'; permissionToolCall.endTime = permBlock.updatedAt; } } - mergeToolCall(existingPermission, permissionToolCall); + mergeToolCall(existingPermission, permissionToolCall, { + collectSourceIdentity: includeSourceIdentity, + }); if ( isTerminalToolStatus(previousStatus) || (permBlock.resolved && @@ -805,17 +908,23 @@ export function transcriptBlocksToDaemonMessages( block.id, permissionToolCall, blockTime, + includeSourceIdentity, ); toolsByCallId.set(permissionToolCall.callId, permissionToolCall); needsNewContentMessage = true; } else { - permissionToolCall.status = 'failed'; + permissionToolCall.status = + safeToolProjection && + isNeutralPermissionResolution(permBlock.resolved) + ? 'completed' + : 'failed'; permissionToolCall.endTime = permBlock.updatedAt; appendToolCallMessage( messages, block.id, permissionToolCall, blockTime, + includeSourceIdentity, ); toolsByCallId.set(permissionToolCall.callId, permissionToolCall); needsNewContentMessage = true; @@ -873,6 +982,7 @@ export function transcriptBlocksToDaemonMessages( role: 'plan', todos, timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }); needsNewContentMessage = true; break; @@ -888,6 +998,7 @@ export function transcriptBlocksToDaemonMessages( content: text, variant: 'info', timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), ...(statusBlock.source ? { source: statusBlock.source } : {}), ...(statusBlock.data !== undefined ? { data: statusBlock.data } : {}), }); @@ -907,6 +1018,7 @@ export function transcriptBlocksToDaemonMessages( errorBlock.source === 'turn_error' && isRetryableTurnErrorKind(errorKind), timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), ...(errorBlock.source ? { source: errorBlock.source } : {}), ...getErrorMessageData(errorBlock.data, errorKind), }); @@ -922,6 +1034,7 @@ export function transcriptBlocksToDaemonMessages( variant: 'info', source: 'prompt_cancelled', timestamp: blockTime, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }); needsNewContentMessage = true; break; @@ -931,19 +1044,60 @@ export function transcriptBlocksToDaemonMessages( } } + if (includeSourceIdentity) synchronizeToolGroupSourceIdentity(messages); return messages; } +function synchronizeToolGroupSourceIdentity(messages: DaemonMessage[]): void { + const collect = (tools: readonly DaemonMessageToolCall[]): string[] => { + const ids: string[] = []; + for (const tool of tools) { + ids.push(...(tool.sourceBlockIds ?? [])); + if (tool.subTools) ids.push(...collect(tool.subTools)); + } + return ids; + }; + for (const message of messages) { + if (message.role !== 'tool_group') continue; + message.sourceBlockIds = unionMessageIds( + message.sourceBlockIds, + ...collect(message.tools), + ); + } +} + function appendSubTool( parent: DaemonMessageToolCall, toolCall: DaemonMessageToolCall, + includeSourceIdentity: boolean, ): void { parent.subTools ||= []; parent.subTools.push(toolCall); + if (includeSourceIdentity) { + parent.sourceBlockIds = unionMessageIds( + parent.sourceBlockIds, + ...(toolCall.sourceBlockIds ?? []), + ); + } +} + +function unionMessageIds( + current: readonly string[] | undefined, + ...incoming: string[] +): string[] { + return [...new Set([...(current ?? []), ...incoming])]; } -function appendSubContent(parent: DaemonMessageToolCall, text: string): void { +function appendSubContent( + parent: DaemonMessageToolCall, + text: string, + blockId: string, + includeSourceIdentity: boolean, +): void { parent.subContent = (parent.subContent || '') + text; + if (includeSourceIdentity) { + parent.sourceBlockIds = unionMessageIds(parent.sourceBlockIds, blockId); + } } function appendToolCallMessage( @@ -951,6 +1105,7 @@ function appendToolCallMessage( blockId: string, toolCall: DaemonMessageToolCall, timestamp?: number, + includeSourceIdentity = false, ): void { // Native CLI groups every tool call of one scheduler batch into a single // bordered tool_group (mapToDisplay in useReactToolScheduler). The daemon @@ -978,6 +1133,9 @@ function appendToolCallMessage( !last.tools.some(isStandalone) ) { last.tools.push(toolCall); + if (includeSourceIdentity) { + last.sourceBlockIds = unionMessageIds(last.sourceBlockIds, blockId); + } return; } messages.push({ @@ -985,6 +1143,7 @@ function appendToolCallMessage( role: 'tool_group', tools: [toolCall], timestamp, + ...(includeSourceIdentity ? { sourceBlockIds: [blockId] } : {}), }); } @@ -1019,15 +1178,30 @@ function findShellOutputTargetIndex( function mergeToolCall( target: DaemonMessageToolCall, source: DaemonMessageToolCall, + options: { + replaceArgs?: boolean; + replaceRawOutput?: boolean; + collectSourceIdentity?: boolean; + } = {}, ): void { target.status = source.status ?? target.status; target.title = source.title ?? target.title; target.toolName = source.toolName ?? target.toolName; target.kind = source.kind ?? target.kind; + if (options.collectSourceIdentity) { + target.sourceBlockIds = unionMessageIds( + target.sourceBlockIds, + ...(source.sourceBlockIds ?? []), + ); + } target.content = source.content ?? target.content; target.endTime = source.endTime ?? target.endTime; - target.rawOutput = source.rawOutput ?? target.rawOutput; - target.args = source.args ?? target.args; + if (options.replaceRawOutput !== false) { + target.rawOutput = source.rawOutput ?? target.rawOutput; + } + if (options.replaceArgs !== false) { + target.args = source.args ?? target.args; + } target.locations = source.locations ?? target.locations; } @@ -1107,10 +1281,12 @@ function getTodoPriority( function daemonToolBlockToToolCall( block: DaemonToolTranscriptBlock, + safeToolProjection: boolean, + includeSourceIdentity: boolean, ): DaemonMessageToolCall { - const rawOutput = getToolRawOutput(block); + const rawOutput = getToolRawOutput(block, safeToolProjection); const isBackgroundAgent = isBackgroundAgentBlock(block, rawOutput); - const content = normalizeToolContent(block); + const content = safeToolProjection ? undefined : normalizeToolContent(block); const statusMap: Record = { running: 'in_progress', pending: 'pending', @@ -1138,47 +1314,71 @@ function daemonToolBlockToToolCall( 'in_progress', kind: inferToolKind(block.toolName, block.toolKind), rawOutput, - args: block.rawInput as Record | undefined, + args: getToolArgs(block, safeToolProjection), parentToolCallId: block.parentToolCallId, startTime: block.createdAt, endTime: isComplete && !isBackgroundAgent ? block.updatedAt : undefined, ...(content ? { content } : {}), + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }; } +function getToolArgs( + block: DaemonToolTranscriptBlock, + safeToolProjection: boolean, +): Record | undefined { + if (!safeToolProjection) { + return block.rawInput as Record | undefined; + } + return daemonToolPreviewToArgs(block.preview); +} + function permissionBlockToToolCall( block: DaemonPermissionTranscriptBlock, + safeToolProjection: boolean, + includeSourceIdentity: boolean, ): DaemonMessageToolCall | undefined { const toolCall = getRecord(block.toolCall); - if (!toolCall) return undefined; - - const rawInput = getToolCallRawInput(toolCall); + if (!safeToolProjection && !toolCall) return undefined; + const rawInput = safeToolProjection + ? daemonToolPreviewToArgs(block.preview) + : toolCall + ? getToolCallRawInput(toolCall) + : undefined; // AskUserQuestion permissions are rendered by the shell as a dedicated // interactive form from the pending permission itself. Emitting a synthetic // generic tool card here would show the same permission twice, especially // when older daemon events only expose it as kind: "think". if (Array.isArray(rawInput?.['questions'])) return undefined; - const meta = getRecord(toolCall['_meta']); - const kind = getString(toolCall, 'kind'); + const meta = toolCall ? getRecord(toolCall['_meta']) : undefined; + const kind = safeToolProjection + ? block.toolKind + : getString(toolCall, 'kind'); const toolName = - getString(meta, 'toolName') ?? - getString(toolCall, 'toolName') ?? - getString(toolCall, 'name') ?? + (safeToolProjection + ? block.toolName + : (getString(meta, 'toolName') ?? + getString(toolCall, 'toolName') ?? + getString(toolCall, 'name'))) ?? (rawInput?.['subagent_type'] ? 'agent' : undefined) ?? (kind === 'fetch' ? 'web_fetch' : kind); - const toolCallId = - getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id'); + const toolCallId = safeToolProjection + ? block.toolCallId + : (getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id')); if (!toolCallId || !toolName) return undefined; const syntheticTool: DaemonMessageToolCall = { callId: toolCallId, toolName, - title: getString(toolCall, 'title') ?? block.title, + title: safeToolProjection + ? block.title + : (getString(toolCall, 'title') ?? block.title), status: 'pending', kind: inferToolKind(toolName, kind), args: rawInput, startTime: block.createdAt, + ...(includeSourceIdentity ? { sourceBlockIds: [block.id] } : {}), }; return syntheticTool; @@ -1187,13 +1387,21 @@ function permissionBlockToToolCall( function rememberPermissionToolInfo( block: DaemonPermissionTranscriptBlock, infoByCallId: Map, + safeToolProjection: boolean, ): void { const toolCall = getRecord(block.toolCall); - const toolCallId = - getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id'); + const toolCallId = safeToolProjection + ? block.toolCallId + : (getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id')); if (!toolCallId) return; - const title = getString(toolCall, 'title') ?? block.title; - const rawInput = toolCall ? getToolCallRawInput(toolCall) : undefined; + const title = safeToolProjection + ? block.title + : (getString(toolCall, 'title') ?? block.title); + const rawInput = safeToolProjection + ? daemonToolPreviewToArgs(block.preview) + : toolCall + ? getToolCallRawInput(toolCall) + : undefined; if (!Array.isArray(rawInput?.['questions'])) return; infoByCallId.set(toolCallId, { ...(title ? { title } : {}), @@ -1208,6 +1416,10 @@ function isApprovedPermissionResolution(resolved: string): boolean { return isApprovalToken(detail.trim()); } +function isNeutralPermissionResolution(resolved: string): boolean { + return resolved.trim().toLowerCase() === 'resolved'; +} + function isApprovalToken(token: string): boolean { return ( token === 'allow' || @@ -1250,7 +1462,15 @@ function isBackgroundAgentBlock( return raw?.['status'] === 'background'; } -function getToolRawOutput(block: DaemonToolTranscriptBlock): unknown { +function getToolRawOutput( + block: DaemonToolTranscriptBlock, + safeToolProjection: boolean, +): unknown { + if (!safeToolProjection) return getRuntimeToolRawOutput(block); + return daemonToolResultPreviewToOutput(block.resultPreview); +} + +function getRuntimeToolRawOutput(block: DaemonToolTranscriptBlock): unknown { if (isAskUserQuestionBlock(block) && block.status === 'failed') { return getToolContentText(block) ?? block.details ?? block.rawOutput; } @@ -1281,6 +1501,93 @@ function getToolRawOutput(block: DaemonToolTranscriptBlock): unknown { }; } +function daemonToolResultPreviewToOutput( + preview: DaemonToolTranscriptBlock['resultPreview'], +): unknown { + if (!preview) return undefined; + if (preview.kind === 'text') return preview.text; + if (preview.kind === 'generic') return preview.summary; + return { + entries: preview.entries.map((entry) => ({ + content: entry.content, + status: entry.status, + ...(entry.priority ? { priority: entry.priority } : {}), + _meta: { + qwenTodo: { + id: entry.id, + ...(entry.blockedBy ? { blockedBy: [...entry.blockedBy] } : {}), + }, + }, + })), + ...(preview.planId || preview.revision !== undefined + ? { + plan: { + ...(preview.planId ? { id: preview.planId } : {}), + ...(preview.revision !== undefined + ? { revision: preview.revision } + : {}), + }, + } + : {}), + }; +} + +function daemonToolPreviewToArgs( + preview: DaemonToolTranscriptBlock['preview'] | undefined, +): Record | undefined { + if (!preview) return undefined; + switch (preview.kind) { + case 'ask_user_question': + return { + questions: preview.questions.map((question) => ({ + ...(question.header ? { header: question.header } : {}), + question: question.question, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + })), + }; + case 'todo_list': + return daemonToolResultPreviewToOutput(preview) as Record< + string, + unknown + >; + case 'command': + return { + command: preview.command, + ...(preview.cwd ? { cwd: preview.cwd } : {}), + }; + case 'file_diff': + return { + path: preview.path, + ...(preview.oldText !== undefined ? { oldText: preview.oldText } : {}), + ...(preview.newText !== undefined ? { newText: preview.newText } : {}), + ...(preview.patch !== undefined ? { patch: preview.patch } : {}), + }; + case 'file_read': + return { + path: preview.path, + ...(preview.range ? { range: [...preview.range] } : {}), + }; + case 'web_fetch': + return { + url: preview.url, + ...(preview.method ? { method: preview.method } : {}), + }; + case 'subagent_delegation': + return { + subagent_type: preview.agentName, + prompt: preview.task, + ...(preview.parentDelegationId + ? { parentDelegationId: preview.parentDelegationId } + : {}), + }; + default: + return undefined; + } +} + // The transcript store uses copy-on-write: an unchanged tool keeps its block // identity across frames. Keying by the block, rather than its content array, // also handles callers that replace a block while reusing its content array. diff --git a/packages/web-shell/client/components/MessageList.module.css b/packages/web-shell/client/components/MessageList.module.css index e1f391e58fe..d773ed2a835 100644 --- a/packages/web-shell/client/components/MessageList.module.css +++ b/packages/web-shell/client/components/MessageList.module.css @@ -245,6 +245,13 @@ ); } +:global([data-transcript-render-mode='document']) .sessionTimelineViewport { + max-height: none; + overflow-y: visible; + -webkit-mask-image: none; + mask-image: none; +} + .sessionTimelineViewport.sessionTimelineViewport::-webkit-scrollbar { display: none !important; width: 0 !important; diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 584cfc6680d..8901425b9e0 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -26,6 +26,10 @@ import type { TurnCollapseHead, } from '../adapters/types'; import type { PermissionRequest } from '../adapters/types'; +import { + groupParallelAgents, + type ParallelAgentDisplayItem, +} from '../adapters/parallelAgentGrouping'; import { isBackgroundSubAgentToolCall, isSubAgentToolCall, @@ -217,29 +221,16 @@ function getLastTurnStartMessageId(messages: Message[]): string | null { } export type DisplayItem = - | { - type: 'message'; - key: string; - message: Message; + | (Extract & { /** Metrics info for the final answer assistant message. */ turnCollapse?: TurnCollapseHead; - } + }) + | Extract | { type: 'turn_collapse'; key: string; turnCollapse: TurnCollapseHead; } - | { - type: 'parallel_agents'; - key: string; - turnId: string; - agents: ACPToolCall[]; - /** - * Wall-clock time of the first grouped launch, carried so the grouped - * box reveals its time on hover exactly like a standalone message row. - */ - timestamp?: number; - } | { type: 'turn_outputs'; key: string; @@ -284,31 +275,6 @@ export interface SessionTimelineRange { currentIndex: number; } -function isAgentOnlyToolGroup(msg: Message): boolean { - return ( - msg.role === 'tool_group' && - msg.tools.length === 1 && - isSubAgentToolCall(msg.tools[0]) - ); -} - -function isBackgroundAgentOnlyToolGroup(msg: Message): boolean { - return ( - msg.role === 'tool_group' && - msg.tools.length === 1 && - isBackgroundSubAgentToolCall(msg.tools[0]) - ); -} - -function isBackgroundLaunchNarration(msg: Message): boolean { - // The daemon often streams short main-agent thought text between background - // launches, e.g. "agent A is running, now starting agent B". The CLI treats - // those as internal launch narration and shows a single Parallel agents box. - // Only skip thought-only messages here; any user-facing assistant content - // still breaks the group and remains visible. - return msg.role === 'thinking'; -} - function isForceExpandGroup( msg: Message, pendingApproval: PermissionRequest | null, @@ -425,82 +391,7 @@ function mergeCompactToolGroups( return result; } -export function groupParallelAgents(messages: Message[]): DisplayItem[] { - const items: DisplayItem[] = []; - let i = 0; - while (i < messages.length) { - if (isBackgroundAgentOnlyToolGroup(messages[i])) { - const grouped: Message[] = []; - let j = i; - while (j < messages.length) { - const current = messages[j]; - if (isBackgroundAgentOnlyToolGroup(current)) { - grouped.push(current); - j++; - continue; - } - if (isBackgroundLaunchNarration(current)) { - let nextAgentIdx = j + 1; - while ( - nextAgentIdx < messages.length && - isBackgroundLaunchNarration(messages[nextAgentIdx]) - ) { - nextAgentIdx++; - } - if ( - nextAgentIdx < messages.length && - isBackgroundAgentOnlyToolGroup(messages[nextAgentIdx]) - ) { - j = nextAgentIdx; - continue; - } - } - break; - } - - if (grouped.length >= 2) { - items.push({ - type: 'parallel_agents', - key: `par-${grouped[0].id}`, - turnId: grouped[0].id, - agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]), - timestamp: grouped[0].timestamp, - }); - i = j; - continue; - } - } - - if (isAgentOnlyToolGroup(messages[i])) { - const start = i; - while (i < messages.length && isAgentOnlyToolGroup(messages[i])) i++; - if (i - start >= 2) { - const grouped = messages.slice(start, i); - items.push({ - type: 'parallel_agents', - key: `par-${grouped[0].id}`, - turnId: grouped[0].id, - agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]), - timestamp: grouped[0].timestamp, - }); - } else { - items.push({ - type: 'message', - key: messages[start].id, - message: messages[start], - }); - } - } else { - items.push({ - type: 'message', - key: messages[i].id, - message: messages[i], - }); - i++; - } - } - return items; -} +export { groupParallelAgents }; export function getDisplayItemVirtualKey(item: DisplayItem): string { if (item.type === 'parallel_agents') return `group:${item.key}`; diff --git a/packages/web-shell/client/components/WebShellTranscript.test.tsx b/packages/web-shell/client/components/WebShellTranscript.test.tsx index 7f55e093009..41d99665ad6 100644 --- a/packages/web-shell/client/components/WebShellTranscript.test.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.test.tsx @@ -185,6 +185,83 @@ describe('WebShellTranscript contract', () => { expect(root?.style.getPropertyValue('--chat-content-width')).toBe('720px'); }); + it('uses the non-virtualized, expanded document boundary', () => { + mount( + , + ); + + const observation = latestObservation(); + expect(observation.renderMode).toBe('document'); + expect(observation.props.virtualScrollThreshold).toBe( + Number.MAX_SAFE_INTEGER, + ); + expect(observation.props.hideSessionTimeline).toBe(true); + expect(observation.customization.collapseCompletedTurns).toBe(false); + expect(observation.customization.markdownTableMode).toBe('basic'); + expect( + document + .querySelector('[data-web-shell-root]') + ?.getAttribute('data-transcript-render-mode'), + ).toBe('document'); + }); + + it('uses safe tool projections only in document mode', () => { + const blocks: DaemonTranscriptBlock[] = [ + { + id: 'write-block', + kind: 'tool', + toolCallId: 'write-call', + toolName: 'write_file', + title: 'Write file', + status: 'completed', + rawInput: { + file_path: 'src/generated.ts', + content: 'RAW_CONTENT\n', + }, + rawOutput: { audit: 'RAW_RESULT' }, + preview: { + kind: 'file_diff', + path: 'src/generated.ts', + newText: 'SAFE_CONTENT\n', + }, + resultPreview: { kind: 'text', text: 'SAFE_RESULT' }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]; + const view = mount(); + + const readonlyMessage = latestObservation().props.messages[0]; + const readonlyTool = + readonlyMessage?.role === 'tool_group' + ? readonlyMessage.tools[0] + : undefined; + expect(readonlyTool?.args).toEqual({ + file_path: 'src/generated.ts', + content: 'RAW_CONTENT\n', + }); + expect(readonlyTool?.rawOutput).toEqual({ audit: 'RAW_RESULT' }); + + view.render(); + const documentMessage = latestObservation().props.messages[0]; + const documentTool = + documentMessage?.role === 'tool_group' + ? documentMessage.tools[0] + : undefined; + expect(documentTool?.args).toEqual({ + path: 'src/generated.ts', + newText: 'SAFE_CONTENT\n', + }); + expect(documentTool?.rawOutput).toBe('SAFE_RESULT'); + }); + it('reconverts on language or block changes and supports an empty list', () => { const blocks = [block({ id: 'cancelled', kind: 'prompt_cancelled' })]; const view = mount(); diff --git a/packages/web-shell/client/components/WebShellTranscript.tsx b/packages/web-shell/client/components/WebShellTranscript.tsx index 9638af0d992..6e01bf9f184 100644 --- a/packages/web-shell/client/components/WebShellTranscript.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.tsx @@ -49,6 +49,7 @@ const CHAT_SHELL_HORIZONTAL_PADDING = 40; export interface WebShellTranscriptProps { blocks: readonly DaemonTranscriptBlock[]; + renderMode?: 'readonly' | 'document'; theme?: WebShellTheme; language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn'; className?: string; @@ -99,6 +100,7 @@ function getChatWidthStyle(chatMaxWidth: number | undefined): CSSProperties { function WebShellTranscriptContent({ blocks, + renderMode = 'readonly', theme = WebShellThemeId.Dark, language, className, @@ -106,7 +108,7 @@ function WebShellTranscriptContent({ chatMaxWidth, workspaceCwd = '', compactThinking = false, - collapseCompletedTurns = true, + collapseCompletedTurns, markdownTableMode = 'basic', virtualScrollThreshold, markdown, @@ -118,11 +120,15 @@ function WebShellTranscriptContent({ renderComposerTagTooltip, renderAssistantTurnFooter, }: WebShellTranscriptProps): ReactElement { + const documentMode = renderMode === 'document'; + const effectiveCollapseCompletedTurns = + !documentMode && (collapseCompletedTurns ?? true); + const effectiveMarkdownTableMode = documentMode ? 'basic' : markdownTableMode; const resolvedLanguage = resolveLanguage(language); const t = useMemo(() => getTranslator(resolvedLanguage), [resolvedLanguage]); const messages = useMemo( - () => transcriptBlocksToLocalizedMessages(blocks, t), - [blocks, t], + () => transcriptBlocksToLocalizedMessages(blocks, t, documentMode), + [blocks, documentMode, t], ); const todoDetails = useMemo(() => computeTodoDetails(messages), [messages]); const todoTimeline = useMemo(() => computeTodoTimeline(messages), [messages]); @@ -136,16 +142,16 @@ function WebShellTranscriptContent({ renderComposerTagTooltip, renderAssistantTurnFooter, compactThinking, - collapseCompletedTurns, - markdownTableMode, + collapseCompletedTurns: effectiveCollapseCompletedTurns, + markdownTableMode: effectiveMarkdownTableMode, markdown, }), [ - collapseCompletedTurns, + effectiveCollapseCompletedTurns, compactThinking, composerTagIcons, markdown, - markdownTableMode, + effectiveMarkdownTableMode, parseUserMessageContent, renderAssistantTurnFooter, renderComposerTag, @@ -234,7 +240,7 @@ function WebShellTranscriptContent({ - + @@ -245,6 +251,7 @@ function WebShellTranscriptContent({ style={rootStyle} data-web-shell-root data-web-shell-shadcn + data-transcript-render-mode={renderMode} lang={resolvedLanguage} >
diff --git a/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts b/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts index eead2943309..c6608316cb0 100644 --- a/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts +++ b/packages/web-shell/client/components/artifacts/turnOutputSelectors.test.ts @@ -565,6 +565,28 @@ describe('turnOutputSelectors', () => { ]); }); + it('uses normalized newText only when write_file content is unavailable', () => { + const messages = [ + userMessage('u1', 'write from preview'), + toolGroup('tg1', [ + { + callId: 'write-preview', + toolName: 'write_file', + status: 'completed', + args: { + path: 'src/preview.ts', + newText: 'preview only\n', + }, + }, + ]), + ]; + + const change = getFileChangesByTurn(messages, new Map()).get('u1')?.[0]; + expect(change?.diffs).toEqual([ + { oldText: '', newText: 'preview only\n', fullContent: true }, + ]); + }); + it('keeps empty and whitespace-only file contents', () => { const messages = [ userMessage('u1', 'write blank files'), diff --git a/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts b/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts index ce8defb0429..b2747edc617 100644 --- a/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts +++ b/packages/web-shell/client/components/artifacts/turnOutputSelectors.ts @@ -356,7 +356,7 @@ function getFileChangeDiffs(tool: ACPToolCall): TurnOutputFileChange['diffs'] { } if (diffs.length > 0) return diffs; if (tool.toolName.toLowerCase() === 'write_file') { - const newText = getStringContentField(tool.args, 'content'); + const newText = getStringContentField(tool.args, 'content', 'newText'); return newText !== undefined ? [{ oldText: '', newText, fullContent: true }] : []; diff --git a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx index 79f1ca2e761..7ab7e9515bb 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.test.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.test.tsx @@ -4,6 +4,10 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { WebShellCustomizationProvider } from '../../customization'; import { I18nProvider } from '../../i18n'; +import { + TranscriptRenderModeProvider, + type TranscriptRenderMode, +} from '../../transcriptRenderMode'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); @@ -32,12 +36,22 @@ afterEach(() => { vi.restoreAllMocks(); }); -function render(node: ReactNode, language: 'en' | 'zh-CN' = 'en'): HTMLElement { +function render( + node: ReactNode, + language: 'en' | 'zh-CN' = 'en', + renderMode: TranscriptRenderMode = 'interactive', +): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); act(() => { - root.render({node}); + root.render( + + + {node} + + , + ); }); mounted.push({ root, container }); return container; @@ -101,6 +115,17 @@ describe('AssistantMessage thinking logic', () => { expect(container.textContent).not.toContain('Thought for'); }); + it('keeps thinking content expanded and inert in document mode', () => { + const container = render( + , + 'en', + 'document', + ); + + expect(container.textContent).toContain('document thinking detail'); + expect(container.querySelector('[aria-expanded]')).toBeNull(); + }); + it.each([ [999, 'Thought briefly'], [1000, 'Thought for 1s'], diff --git a/packages/web-shell/client/components/messages/AssistantMessage.tsx b/packages/web-shell/client/components/messages/AssistantMessage.tsx index f1e2c9459ff..e1c66dc7e4b 100644 --- a/packages/web-shell/client/components/messages/AssistantMessage.tsx +++ b/packages/web-shell/client/components/messages/AssistantMessage.tsx @@ -5,6 +5,7 @@ import { type WebShellAssistantTurnFooterRenderInfo, } from '../../customization'; import { useI18n } from '../../i18n'; +import { useTranscriptRenderMode } from '../../transcriptRenderMode'; import { formatTimestamp } from '../MessageTimestamp'; import type { DaemonSessionGenerationEvent } from '@qwen-code/sdk/daemon'; import { Button } from '../ui/button'; @@ -231,7 +232,10 @@ export const ThinkingMessage = memo(function ThinkingMessage({ generateContent, }: ThinkingMessageProps) { const { language, t } = useI18n(); + const transcriptRenderMode = useTranscriptRenderMode(); + const documentMode = transcriptRenderMode === 'document'; const [thinkingExpanded, setThinkingExpanded] = useState(false); + const showThinking = documentMode || thinkingExpanded; const thinkingActive = isStreaming === true; const startTimeRef = useRef(timestamp ?? Date.now()); const sawActiveRef = useRef(thinkingActive); @@ -275,8 +279,8 @@ export const ThinkingMessage = memo(function ThinkingMessage({ : ''; const handleToggle = useCallback(() => { - setThinkingExpanded((v) => !v); - }, []); + if (!documentMode) setThinkingExpanded((v) => !v); + }, [documentMode]); return (
{ if (event.currentTarget.contains(event.target as Node)) { @@ -300,11 +304,13 @@ export const ThinkingMessage = memo(function ThinkingMessage({
- {thinkingExpanded && ( + {showThinking && (
diff --git a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx index 37cf6a58593..21d35a2df55 100644 --- a/packages/web-shell/client/components/messages/GoalStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/GoalStatusMessage.tsx @@ -151,7 +151,7 @@ export function GoalStatusMessage({ const renderMode = useTranscriptRenderMode(); useEffect(() => { - if (!activateFooter || renderMode === 'readonly') return; + if (!activateFooter || renderMode !== 'interactive') return; const active = status.kind === 'set' || status.kind === 'checking'; window.dispatchEvent( new CustomEvent(GOAL_STATUS_ACTIVE_EVENT, { diff --git a/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts b/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts new file mode 100644 index 00000000000..48b268a14b3 --- /dev/null +++ b/packages/web-shell/client/components/messages/Markdown.mermaid.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TranscriptRenderModeProvider } from '../../transcriptRenderMode'; + +const mermaidMock = vi.hoisted(() => ({ + initialize: vi.fn(), + render: vi.fn(() => Promise.resolve({ svg: 'diagram' })), +})); + +vi.mock('mermaid', () => ({ default: mermaidMock })); + +const { Markdown } = await import('./Markdown'); + +const mounted: Array<{ root: Root; container: HTMLElement }> = []; + +function mountMermaid(renderMode: 'interactive' | 'readonly' | 'document') { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + const render = (mode: 'interactive' | 'readonly' | 'document') => + act(() => { + root.render( + createElement( + TranscriptRenderModeProvider, + { value: mode }, + createElement(Markdown, { + content: '```mermaid\ngraph TD\nA --> B\n```', + }), + ), + ); + }); + render(renderMode); + return { container, render }; +} + +async function startMermaidRender(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + mermaidMock.initialize.mockClear(); + mermaidMock.render.mockReset(); + mermaidMock.render.mockResolvedValue({ svg: 'diagram' }); +}); + +afterEach(() => { + for (const { root, container } of mounted.splice(0)) { + act(() => root.unmount()); + container.remove(); + } + vi.useRealTimers(); +}); + +describe('Markdown Mermaid render modes', () => { + it('applies resource limits only in document mode', async () => { + let resolveFirstRender: ((value: { svg: string }) => void) | undefined; + mermaidMock.render.mockImplementationOnce( + () => + new Promise<{ svg: string }>((resolve) => { + resolveFirstRender = resolve; + }), + ); + const view = mountMermaid('interactive'); + await startMermaidRender(); + + expect(mermaidMock.initialize).toHaveBeenCalledTimes(1); + expect(mermaidMock.initialize.mock.calls[0]?.[0]).not.toHaveProperty( + 'maxTextSize', + ); + expect(mermaidMock.initialize.mock.calls[0]?.[0]).not.toHaveProperty( + 'maxEdges', + ); + + view.render('document'); + await startMermaidRender(); + expect(mermaidMock.initialize).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirstRender?.({ svg: 'interactive' }); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mermaidMock.initialize).toHaveBeenCalledTimes(2); + expect(mermaidMock.initialize.mock.calls[1]?.[0]).toMatchObject({ + maxTextSize: 50_000, + maxEdges: 500, + }); + + view.render('readonly'); + await startMermaidRender(); + expect(mermaidMock.initialize).toHaveBeenCalledTimes(3); + expect(mermaidMock.initialize.mock.calls[2]?.[0]).not.toHaveProperty( + 'maxTextSize', + ); + expect(mermaidMock.initialize.mock.calls[2]?.[0]).not.toHaveProperty( + 'maxEdges', + ); + }); + + it('times out only in document mode', async () => { + mermaidMock.render.mockReturnValue(new Promise(() => {})); + const view = mountMermaid('interactive'); + await startMermaidRender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(view.container.querySelector('pre code')).toBeNull(); + + view.render('document'); + await startMermaidRender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(view.container.querySelector('pre code')?.textContent).toContain( + 'graph TD', + ); + }); +}); diff --git a/packages/web-shell/client/components/messages/Markdown.test.ts b/packages/web-shell/client/components/messages/Markdown.test.ts index 1d8ae4ecb43..ba3002bd8b2 100644 --- a/packages/web-shell/client/components/messages/Markdown.test.ts +++ b/packages/web-shell/client/components/messages/Markdown.test.ts @@ -125,6 +125,16 @@ describe('isSafeImageSrc', () => { it('allows relative paths', () => { expect(isSafeImageSrc('/images/logo.png')).toBe(true); }); + + it('allows only approved data images in document mode', () => { + expect(isSafeImageSrc('data:image/png;base64,iVBOR', true)).toBe(true); + expect(isSafeImageSrc('https://example.com/img.png', true)).toBe(false); + expect(isSafeImageSrc('/images/logo.png', true)).toBe(false); + expect(isSafeImageSrc('data:image/bmp;base64,Qk0=', true)).toBe(false); + expect( + isSafeImageSrc('data:image/png;base64,iVBOR" onerror=alert(1)', true), + ).toBe(false); + }); }); describe('markdownUrlTransform', () => { @@ -234,6 +244,57 @@ describe('qwen-session:// links', () => { }); }); +describe('document image policy', () => { + it('does not put remote Markdown image URLs into the DOM', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + createElement( + TranscriptRenderModeProvider, + { value: 'document' }, + createElement(Markdown, { + content: '![remote](https://example.com/secret.png)', + }), + ), + ); + }); + + expect(container.querySelector('img')?.getAttribute('src')).toBeNull(); + expect(container.innerHTML).not.toContain('https://example.com'); + + act(() => root.unmount()); + container.remove(); + }); + + it('renders chart fences as static code in document mode', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + createElement( + TranscriptRenderModeProvider, + { value: 'document' }, + createElement(Markdown, { + content: '```echarts\n{"series":[]}\n```', + source: 'assistant', + }), + ), + ); + }); + + expect(container.querySelector('pre code')?.textContent).toContain( + '{"series":[]}', + ); + expect(container.textContent).not.toContain('Show chart'); + + act(() => root.unmount()); + container.remove(); + }); +}); + describe('Markdown enhanced tables', () => { it('uses enhanced table rendering when configured', () => { const container = document.createElement('div'); @@ -1363,6 +1424,76 @@ describe('Markdown custom code block rendering', () => { }); describe('Markdown code highlighting while streaming', () => { + it('keeps code plain in document mode without loading the highlighter', async () => { + __resetForTesting(); + await getCodeHighlighter('json'); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + createElement( + TranscriptRenderModeProvider, + { value: 'document' }, + createElement(Markdown, { + content: '```json\n{ "safe": true }\n```', + isStreaming: false, + }), + ), + ); + }); + + expect(container.querySelector('.shiki')).toBeNull(); + expect(container.querySelector('pre code')?.textContent).toContain( + '"safe": true', + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it('drops a warmed highlight when switching to document mode', async () => { + __resetForTesting(); + await getCodeHighlighter('json'); + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const content = '```json\n{ "safe": true }\n```'; + + await act(async () => { + root.render( + createElement( + TranscriptRenderModeProvider, + { value: 'interactive' }, + createElement(Markdown, { content, isStreaming: false }), + ), + ); + }); + expect(container.querySelector('.shiki')).not.toBeNull(); + + await act(async () => { + root.render( + createElement( + TranscriptRenderModeProvider, + { value: 'document' }, + createElement(Markdown, { content, isStreaming: false }), + ), + ); + }); + expect(container.querySelector('.shiki')).toBeNull(); + expect(container.querySelector('pre code')?.textContent).toContain( + '"safe": true', + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + it('keeps streamed code content visible while streaming', async () => { const container = document.createElement('div'); document.body.appendChild(container); diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx index ed89432cd9d..a8362559395 100644 --- a/packages/web-shell/client/components/messages/Markdown.tsx +++ b/packages/web-shell/client/components/messages/Markdown.tsx @@ -155,6 +155,8 @@ export function resolveFenceLanguage( const SAFE_HREF_SCHEMES = /^(https?:|mailto:)/i; const SAFE_IMAGE_DATA_URI = /^data:image\/(png|jpeg|gif|webp|bmp);base64,/i; +const SAFE_DOCUMENT_IMAGE_DATA_URI = + /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]*={0,2}$/i; export function isSafeHref(url: string | undefined): boolean { if (!url) return false; @@ -165,10 +167,14 @@ export function isSafeHref(url: string | undefined): boolean { return SAFE_HREF_SCHEMES.test(trimmed); } -export function isSafeImageSrc(url: string | undefined): boolean { +export function isSafeImageSrc( + url: string | undefined, + documentMode = false, +): boolean { if (!url) return false; const trimmed = url.trim(); if (!trimmed) return false; + if (documentMode) return SAFE_DOCUMENT_IMAGE_DATA_URI.test(trimmed); if (trimmed.startsWith('#')) return true; if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return true; if (SAFE_IMAGE_DATA_URI.test(trimmed)) return true; @@ -178,12 +184,17 @@ export function isSafeImageSrc(url: string | undefined): boolean { // Track last initialized theme to avoid redundant mermaid.initialize() calls. // mermaid.initialize() is idempotent but runs per-block; with N diagrams in a // transcript this saves N-1 redundant calls per render cycle. -let lastMermaidTheme: string | undefined; +let lastMermaidConfigKey: string | undefined; +let mermaidRenderQueue: Promise = Promise.resolve(); let mermaidRenderId = 0; +const MAX_MERMAID_TEXT_CHARS = 50_000; +const MAX_MERMAID_EDGES = 500; +const MERMAID_RENDER_TIMEOUT_MS = 10_000; function MermaidBlock({ code }: { code: string }) { const { t } = useI18n(); const appTheme = useTheme(); + const documentMode = useTranscriptRenderMode() === 'document'; const [svg, setSvg] = useState(null); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState<'diagram' | 'code'>('diagram'); @@ -271,47 +282,73 @@ function MermaidBlock({ code }: { code: string }) { useEffect(() => { let cancelled = false; + let timedOut = false; setSvg(null); setError(null); + const renderTimeout = documentMode + ? setTimeout(() => { + timedOut = true; + if (!cancelled) setError('Mermaid render timed out'); + }, MERMAID_RENDER_TIMEOUT_MS) + : undefined; const timer = setTimeout(() => { - import('mermaid').then(async (mod) => { - if (cancelled) return; - const mermaid = mod.default; - if (lastMermaidTheme !== mermaidTheme) { - mermaid.initialize({ - startOnLoad: false, - theme: mermaidTheme, - securityLevel: 'strict', - suppressErrorRendering: true, - flowchart: { - wrappingWidth: 300, - useMaxWidth: false, - }, + import('mermaid') + .then(async (mod) => { + if (cancelled || timedOut) return; + const mermaid = mod.default; + const configKey = `${mermaidTheme}:${documentMode ? 'document' : 'runtime'}`; + const render = mermaidRenderQueue.then(async () => { + if (cancelled || timedOut) + throw new Error('Mermaid render skipped'); + if (lastMermaidConfigKey !== configKey) { + mermaid.initialize({ + startOnLoad: false, + theme: mermaidTheme, + securityLevel: 'strict', + suppressErrorRendering: true, + ...(documentMode + ? { + maxTextSize: MAX_MERMAID_TEXT_CHARS, + maxEdges: MAX_MERMAID_EDGES, + } + : {}), + flowchart: { + wrappingWidth: 300, + useMaxWidth: false, + }, + }); + lastMermaidConfigKey = configKey; + } + const id = `mermaid-${++mermaidRenderId}`; + return mermaid.render(id, code.trim()); }); - lastMermaidTheme = mermaidTheme; - } - try { - const id = `mermaid-${++mermaidRenderId}`; - const { svg } = await mermaid.render(id, code.trim()); + mermaidRenderQueue = render.then( + () => undefined, + () => undefined, + ); + const { svg } = await render; // No additional sanitization needed: securityLevel:'strict' uses // DOMPurify internally to sanitize SVG output. - if (!cancelled) { + if (!cancelled && !timedOut) { + if (renderTimeout !== undefined) clearTimeout(renderTimeout); setSvg(svg); } - } catch (error: unknown) { - if (!cancelled) { + }) + .catch((error: unknown) => { + if (!cancelled && !timedOut) { + if (renderTimeout !== undefined) clearTimeout(renderTimeout); setError( error instanceof Error ? error.message : 'Mermaid render failed', ); } - } - }); + }); }, 150); return () => { cancelled = true; clearTimeout(timer); + if (renderTimeout !== undefined) clearTimeout(renderTimeout); }; - }, [code, mermaidTheme]); + }, [code, documentMode, mermaidTheme]); const handleCopy = () => { navigator.clipboard.writeText(code).then( @@ -427,6 +464,7 @@ function CodeBlock({ }) { const { t } = useI18n(); const appTheme = useTheme(); + const documentMode = useTranscriptRenderMode() === 'document'; const [html, setHtml] = useState(null); const [copied, setCopied] = useState(false); @@ -442,6 +480,7 @@ function CodeBlock({ // repeatedly tokenizes its entire contents and can dominate rendering for // long responses; the settled render below highlights the final text once. if ( + documentMode || isStreaming || lang === 'mermaid' || resolvedLang === 'text' || @@ -492,7 +531,7 @@ function CodeBlock({ return () => { cancelled = true; }; - }, [code, lang, resolvedLang, shikiTheme, isStreaming]); + }, [code, documentMode, lang, resolvedLang, shikiTheme, isStreaming]); const handleCopy = () => { navigator.clipboard.writeText(code).then( @@ -516,7 +555,7 @@ function CodeBlock({ {copied ? t('code.copied') : t('code.copy')}
- {!isStreaming && html !== null ? ( + {!documentMode && !isStreaming && html !== null ? (
{children}; } const sessionId = href.trim().replace(QWEN_SESSION_SCHEME, ''); @@ -751,7 +790,10 @@ function MarkdownLink({ } function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { - const safeSrc = isSafeImageSrc(src) ? src : undefined; + const renderMode = useTranscriptRenderMode(); + const safeSrc = isSafeImageSrc(src, renderMode === 'document') + ? src + : undefined; return {alt; } @@ -895,6 +937,7 @@ export const Markdown = memo(function Markdown({ }: MarkdownProps) { const { markdown, markdownTableMode } = useWebShellCustomization(); const theme = useTheme(); + const documentMode = useTranscriptRenderMode() === 'document'; const sourceMarkdown = source ? markdown : undefined; const throttledContent = useThrottledValue(content ?? '', isStreaming); @@ -931,7 +974,10 @@ export const Markdown = memo(function Markdown({ }; }, [components, effectiveTableMode, sourceComponents]); const chart = - source === 'assistant' && !sourceComponents?.code && !sourceComponents?.pre + !documentMode && + source === 'assistant' && + !sourceComponents?.code && + !sourceComponents?.pre ? (sourceMarkdown?.chart ?? (sourceMarkdown?.renderCodeBlock ? undefined diff --git a/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx b/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx index f546e4c22b1..59c71cc48f9 100644 --- a/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx +++ b/packages/web-shell/client/components/messages/PlanExecutionView.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { DaemonSessionAgentTaskStatus } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { I18nProvider } from '../../i18n'; +import { TranscriptRenderModeProvider } from '../../transcriptRenderMode'; import { getPlanNodeState, layerPlanTodos, @@ -82,6 +83,31 @@ function task( } describe('PlanExecutionView', () => { + it('disables plan selection in document mode', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + + + , + ); + }); + + const planNodes = container.querySelectorAll( + '[data-plan-node-id]', + ); + expect(planNodes).toHaveLength(todos.length); + expect([...planNodes].every((button) => button.disabled)).toBe(true); + expect(container.querySelector('[data-plan-step-details]')).toBeNull(); + + act(() => root.unmount()); + container.remove(); + }); + it('layers dependent todos in topological order', () => { expect( layerPlanTodos(todos).map((layer) => layer.map((todo) => todo.id)), diff --git a/packages/web-shell/client/components/messages/PlanExecutionView.tsx b/packages/web-shell/client/components/messages/PlanExecutionView.tsx index 922c5e9f936..6ac92790012 100644 --- a/packages/web-shell/client/components/messages/PlanExecutionView.tsx +++ b/packages/web-shell/client/components/messages/PlanExecutionView.tsx @@ -13,6 +13,7 @@ import type { import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { isSubAgentToolCall } from '../../adapters/toolClassification'; import { useI18n } from '../../i18n'; +import { useTranscriptRenderMode } from '../../transcriptRenderMode'; import { getAgentDisplayStatus, isAgentCancelled } from './toolFormatting'; import styles from './PlanExecutionView.module.css'; @@ -309,6 +310,7 @@ export function PlanExecutionView({ onOpenSubagent?: (tool: ACPToolCall) => void; }) { const { t } = useI18n(); + const documentMode = useTranscriptRenderMode() === 'document'; const taskIndex = useMemo(() => createTaskExecutionIndex(tasks), [tasks]); const knownIds = new Set(todos.map((todo) => todo.id)); @@ -650,6 +652,7 @@ export function PlanExecutionView({ current === todo.id ? undefined : todo.id, ) } + disabled={documentMode} >
{todo.id} diff --git a/packages/web-shell/client/components/messages/PlanMessage.test.tsx b/packages/web-shell/client/components/messages/PlanMessage.test.tsx index 11fcbf14d9b..574f72f099a 100644 --- a/packages/web-shell/client/components/messages/PlanMessage.test.tsx +++ b/packages/web-shell/client/components/messages/PlanMessage.test.tsx @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nProvider } from '../../i18n'; +import { TranscriptRenderModeProvider } from '../../transcriptRenderMode'; import type { TodoItem } from '../../adapters/types'; // PlanMessage's expanded list reads TodoTimelineContext and (via TodoFullList) @@ -44,6 +45,7 @@ function renderPlan( id: string, todos: TodoItem[], timeline?: Map, + documentMode = false, ): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); @@ -51,9 +53,13 @@ function renderPlan( act(() => { root.render( - - - + + + + + , ); }); @@ -94,6 +100,14 @@ describe('PlanMessage', () => { expect(container.textContent).toContain('▾'); }); + it('renders the complete plan without controls in document mode', () => { + const container = renderPlan('p1', TODOS, undefined, true); + expect(container.textContent).toContain('First task'); + expect(container.textContent).toContain('Second task'); + expect(container.textContent).toContain('Third task'); + expect(container.querySelector('button')).toBeNull(); + }); + it('shows the plan-keyed diff when a timeline is present', () => { const timeline = new Map([ [ diff --git a/packages/web-shell/client/components/messages/PlanMessage.tsx b/packages/web-shell/client/components/messages/PlanMessage.tsx index da0b839b27a..d7d6d56cea9 100644 --- a/packages/web-shell/client/components/messages/PlanMessage.tsx +++ b/packages/web-shell/client/components/messages/PlanMessage.tsx @@ -3,6 +3,7 @@ import type { TodoItem } from '../../adapters/types'; import { TodoTimelineContext } from '../../App'; import { TodoEventSummary, TodoFullList } from './TodoView'; import { useI18n } from '../../i18n'; +import { useTranscriptRenderMode } from '../../transcriptRenderMode'; import flashStyles from '../MessageLocateFlash.module.css'; import styles from './PlanMessage.module.css'; @@ -27,6 +28,7 @@ export const PlanMessage = memo(function PlanMessage({ isLocateFlashing = false, }: PlanMessageProps) { const { t } = useI18n(); + const documentMode = useTranscriptRenderMode() === 'document'; const [expanded, setExpanded] = useState(false); if (todos.length === 0) return null; @@ -39,22 +41,31 @@ export const PlanMessage = memo(function PlanMessage({ isLocateFlashing ? ` ${flashStyles.flash}` : '' }`} > - - {expanded ? ( + {documentMode ? ( +
+ {t('plan.title')} + + {completed}/{total} + +
+ ) : ( + + )} + {documentMode || expanded ? ( ) : ( diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx index 681981f1f19..1c75253588d 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.test.tsx @@ -10,6 +10,10 @@ import type { } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { I18nProvider } from '../../i18n'; +import { + TranscriptRenderModeProvider, + type TranscriptRenderMode, +} from '../../transcriptRenderMode'; // The panel only needs getTasks/cancelTask from the daemon SDK; mock the // hook so the unit test doesn't pull the whole connection graph. Hoisted @@ -41,6 +45,7 @@ afterEach(() => { mounted.length = 0; getTasksMock.mockReset(); cancelTaskMock.mockReset(); + vi.useRealTimers(); }); function agentTask( @@ -88,6 +93,7 @@ function renderPanel( agentTools?: readonly ACPToolCall[]; onOpenSubagent?: (tool: ACPToolCall) => void; onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void; + renderMode?: TranscriptRenderMode; } = {}, ): HTMLElement { const snapshot: DaemonSessionTasksStatus = { @@ -103,15 +109,19 @@ function renderPanel( act(() => { root.render( - + + + , ); }); @@ -119,6 +129,42 @@ function renderPanel( } describe('TasksStatusMessage monitor details', () => { + it('renders a complete inert snapshot without polling in document mode', () => { + vi.useFakeTimers(); + const tasks = Array.from({ length: 10 }, (_, index) => + agentTask(`task-${index}`, { + prompt: + index === 9 + ? Array.from( + { length: 6 }, + (_value, line) => `prompt-line-${line}`, + ).join('\n') + : undefined, + recentActivities: + index === 9 + ? Array.from({ length: 8 }, (_value, activity) => ({ + name: 'read_file', + description: `activity-${activity}.ts`, + at: activity, + })) + : undefined, + }), + ); + const container = renderPanel(tasks, { renderMode: 'document' }); + + act(() => vi.advanceTimersByTime(6_000)); + + expect(getTasksMock).not.toHaveBeenCalled(); + expect(cancelTaskMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain('label-task-0'); + expect(container.textContent).toContain('label-task-9'); + expect(container.textContent).toContain('activity-0.ts'); + expect(container.textContent).toContain('activity-7.ts'); + expect(container.textContent).toContain('prompt-line-0'); + expect(container.textContent).toContain('prompt-line-5'); + expect(container.querySelectorAll('button')).toHaveLength(0); + }); + it('opens an embedded monitor in the right-panel callback', () => { const onOpenMonitor = vi.fn(); const task = monitorTask(); diff --git a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx index d99f28d35f1..cedcd82fd7b 100644 --- a/packages/web-shell/client/components/messages/TasksStatusMessage.tsx +++ b/packages/web-shell/client/components/messages/TasksStatusMessage.tsx @@ -23,6 +23,7 @@ import { formatRuntime } from '../../utils/formatRuntime'; import { formatContextTokens } from '../../utils/formatTokenCount'; import { createSentinelSerializer } from '../../utils/sentinelMessage'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; +import { useTranscriptRenderMode } from '../../transcriptRenderMode'; import { PlanExecutionView } from './PlanExecutionView'; import { localizeAgentTypeName, @@ -259,6 +260,7 @@ export function TasksStatusMessage({ onOpenMonitor?: (task: DaemonSessionMonitorTaskStatus) => void; }) { const { t } = useI18n(); + const documentMode = useTranscriptRenderMode() === 'document'; const actions = useActions(); const [tasks, setTasks] = useState(() => arrangeTasks(message.snapshot.tasks), @@ -288,7 +290,7 @@ export function TasksStatusMessage({ const blockingIds = useMemo(() => computeUserBlockingIds(tasks), [tasks]); useEffect(() => { - if (!isOpen) return; + if (documentMode || !isOpen) return; const refresh = () => { if (refreshInFlightRef.current) return; refreshInFlightRef.current = true; @@ -312,7 +314,7 @@ export function TasksStatusMessage({ }; const id = setInterval(refresh, REFRESH_INTERVAL_MS); return () => clearInterval(id); - }, [isOpen, actions]); + }, [documentMode, isOpen, actions]); useEffect(() => { if (tasks.length === 0 && selectedIndex !== 0) { @@ -351,14 +353,14 @@ export function TasksStatusMessage({ }, [isOpen, step, selectedTask]); useEffect(() => { - if (!manageActiveEvent) return undefined; + if (documentMode || !manageActiveEvent) return undefined; const id = panelIdRef.current; dispatchActive(id, isOpen); return () => dispatchActive(id, false); - }, [isOpen, manageActiveEvent]); + }, [documentMode, isOpen, manageActiveEvent]); useEffect(() => { - if (!manageActiveEvent) return undefined; + if (documentMode || !manageActiveEvent) return undefined; const onActiveChange = (event: Event) => { const detail = (event as CustomEvent<{ id?: string; active?: boolean }>) .detail; @@ -368,15 +370,15 @@ export function TasksStatusMessage({ }; window.addEventListener(ACTIVE_EVENT, onActiveChange); return () => window.removeEventListener(ACTIVE_EVENT, onActiveChange); - }, [manageActiveEvent]); + }, [documentMode, manageActiveEvent]); useEffect(() => { - if (!isOpen) onClose?.(); - }, [isOpen, onClose]); + if (!documentMode && !isOpen) onClose?.(); + }, [documentMode, isOpen, onClose]); const handleCancel = useCallback( async (task: DaemonSessionTaskStatus) => { - if (busy) return; + if (documentMode || busy) return; const isRunning = task.status === 'running'; const isAbandonable = task.kind === 'agent' && task.status === 'paused'; if (!isRunning && !isAbandonable) return; @@ -409,12 +411,12 @@ export function TasksStatusMessage({ setBusy(false); } }, - [actions, busy, blockingIds, pendingCancelId, t], + [actions, busy, blockingIds, documentMode, pendingCancelId, t], ); useDelayedGlobalKeyDown( (event: KeyboardEvent) => { - if (!isOpen) return; + if (documentMode || !isOpen) return; if ( event.key !== 'Escape' && @@ -499,6 +501,7 @@ export function TasksStatusMessage({ }, [ embedded, + documentMode, isOpen, step, tasks.length, @@ -509,7 +512,7 @@ export function TasksStatusMessage({ ], ); - if (!isOpen) return null; + if (!documentMode && !isOpen) return null; const showCancelConfirm = pendingCancelId !== null && @@ -579,7 +582,7 @@ export function TasksStatusMessage({
{t('tasks.empty')}
- {!embedded && ( + {!documentMode && !embedded && (
{t('tasks.shortcut.close')}
)}
@@ -590,8 +593,8 @@ export function TasksStatusMessage({ tasks, clampedSelectedIndex, ); - const listTasks = embedded ? tasks : visible; - const listOffset = embedded ? 0 : windowStart; + const listTasks = embedded || documentMode ? tasks : visible; + const listOffset = embedded || documentMode ? 0 : windowStart; return (
({tasks.length})
)} - {!embedded && hiddenAbove > 0 && ( + {!documentMode && !embedded && hiddenAbove > 0 && (
{t('tasks.moreAbove', { count: hiddenAbove })}
)} {listTasks.map((task, visibleIndex) => { const index = listOffset + visibleIndex; - const selected = index === clampedSelectedIndex; + const selected = !documentMode && index === clampedSelectedIndex; const stClass = statusClassName(task.status); const taskStatusLabel = statusLabel(task.status, t); - const expanded = embedded && selected && step === 'detail'; + const expanded = + documentMode || (embedded && selected && step === 'detail'); const showSelected = embedded ? expanded : selected; const tree: AgentTreeInfo | undefined = task.kind === 'agent' ? treeInfo.get(task.id) : undefined; @@ -671,17 +675,29 @@ export function TasksStatusMessage({ ? `${styles.row} ${styles.selected}` : styles.row } - onClick={() => { - setSelectedIndex(index); - if (embedded && task.kind === 'monitor' && onOpenMonitor) { - onOpenMonitor(task); - } else { - setStep(embedded && expanded ? 'list' : 'detail'); - } - }} - onMouseEnter={() => { - if (!embedded) setSelectedIndex(index); - }} + onClick={ + documentMode + ? undefined + : () => { + setSelectedIndex(index); + if ( + embedded && + task.kind === 'monitor' && + onOpenMonitor + ) { + onOpenMonitor(task); + } else { + setStep(embedded && expanded ? 'list' : 'detail'); + } + } + } + onMouseEnter={ + documentMode + ? undefined + : () => { + if (!embedded) setSelectedIndex(index); + } + } > {showSelected ? '❯' : ''} @@ -724,16 +740,24 @@ export function TasksStatusMessage({ t={t} hideHeader busy={busy} - showCancelConfirm={pendingCancelId === task.id} - onCancel={() => void handleCancel(task)} - onCancelConfirmDismiss={() => setPendingCancelId(null)} + showCancelConfirm={ + !documentMode && pendingCancelId === task.id + } + onCancel={ + documentMode ? undefined : () => void handleCancel(task) + } + onCancelConfirmDismiss={ + documentMode + ? undefined + : () => setPendingCancelId(null) + } />
)}
); })} - {!embedded && hiddenBelow > 0 && ( + {!documentMode && !embedded && hiddenBelow > 0 && (
{t('tasks.moreBelow', { count: hiddenBelow })}
@@ -741,7 +765,7 @@ export function TasksStatusMessage({
)} - {!embedded && step === 'detail' && selectedTask && ( + {!documentMode && !embedded && step === 'detail' && selectedTask && ( <> {actionError &&
{actionError}
} )} - {!embedded && ( + {!documentMode && !embedded && (
void; onCancelConfirmDismiss?: () => void; }) { + const documentMode = useTranscriptRenderMode() === 'document'; const terminalIcon = terminalStatusIcon(task.status); const stClass = statusClassName(task.status); const isAbandonable = task.kind === 'agent' && task.status === 'paused'; @@ -1174,7 +1199,7 @@ function TaskDetail({ const promptLines = task.kind === 'agent' && task.prompt ? task.prompt.split('\n') : []; const actionControls = - canCancel && onCancel ? ( + !documentMode && canCancel && onCancel ? (
{showCancelConfirm ? ( <> @@ -1294,7 +1319,7 @@ function TaskDetail({
{task.recentActivities - .slice(-MAX_DISPLAYED_ACTIVITIES) + .slice(documentMode ? 0 : -MAX_DISPLAYED_ACTIVITIES) .map((a, i, arr) => { const isLast = i === arr.length - 1; const desc = formatActivityLabel(a.name, a.description, t); @@ -1320,13 +1345,17 @@ function TaskDetail({ {t('tasks.detail.prompt')}
- {promptLines.slice(0, 5).map((line, i, arr) => ( -
- {i === arr.length - 1 && promptLines.length > 5 - ? `${line}…` - : line || ' '} -
- ))} + {promptLines + .slice(0, documentMode ? undefined : 5) + .map((line, i, arr) => ( +
+ {!documentMode && + i === arr.length - 1 && + promptLines.length > 5 + ? `${line}…` + : line || ' '} +
+ ))}
)} diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 5634992f14a..4b25888d08c 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -86,6 +86,7 @@ function renderToolGroup( isStreaming?: boolean; beforeToolCallId?: string; }>, + renderMode: 'interactive' | 'readonly' | 'document' = 'interactive', ): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); @@ -93,9 +94,11 @@ function renderToolGroup( act(() => { root.render( - - - + + + + + , ); }); @@ -616,6 +619,29 @@ describe('tool kind logic', () => { }); describe('tool row rendering', () => { + it('keeps grouped tools and thoughts fully expanded in document mode', () => { + const container = renderToolGroup( + [ + makeTool({ + callId: 'shell-1', + rawOutput: 'first document output', + }), + makeTool({ + callId: 'shell-2', + rawOutput: 'second document output', + }), + ], + {}, + [{ content: 'document thought' }], + 'document', + ); + + expect(container.textContent).toContain('first document output'); + expect(container.textContent).toContain('second document output'); + expect(container.textContent).toContain('document thought'); + expect(container.querySelector('[aria-expanded="false"]')).toBeNull(); + }); + it('renders the aggregate summary for a multi-tool group', () => { const container = renderToolGroup([ makeTool({ @@ -1783,4 +1809,19 @@ describe('tool output logic', () => { ' same\n-old\n+new', ); }); + + it('uses a typed file-diff preview without raw output', () => { + expect( + extractDiff( + makeTool({ + toolName: 'edit', + args: { + path: 'document.ts', + oldText: 'old content', + newText: 'DOCUMENT_DIFF_DETAIL', + }, + }), + ), + ).toContain('DOCUMENT_DIFF_DETAIL'); + }); }); diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index e83a59ae1ea..383d4ee1016 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -62,7 +62,10 @@ import { toolContainsCallId, } from './toolFormatting'; import { useI18n } from '../../i18n'; -import { useTranscriptRenderMode } from '../../transcriptRenderMode'; +import { + useTranscriptRenderMode, + type TranscriptRenderMode, +} from '../../transcriptRenderMode'; import { TodoTimelineContext } from '../../App'; import { type ToolHeaderExtraRenderInfo, @@ -180,6 +183,17 @@ export function extractDiff(tool: ACPToolCall): string { } } + const previewPatch = tool.args?.patch; + if (typeof previewPatch === 'string' && previewPatch) return previewPatch; + const previewNewText = tool.args?.newText; + if (typeof previewNewText === 'string') { + const previewOldText = tool.args?.oldText; + return buildUnifiedDiff( + typeof previewOldText === 'string' ? previewOldText : '', + previewNewText, + ); + } + return ''; } @@ -1045,7 +1059,7 @@ const SESSION_LINK_RE = /\[([^\]]+)\]\(qwen-session:\/\/([^)]+)\)/g; function renderWithSessionLinks( text: string, - renderMode: 'interactive' | 'readonly', + renderMode: TranscriptRenderMode, ): ReactNode { if (!text || !text.includes('qwen-session://')) return text; const parts: ReactNode[] = []; @@ -1058,7 +1072,7 @@ function renderWithSessionLinks( } const sessionId = match[2]; parts.push( - renderMode === 'readonly' ? ( + renderMode !== 'interactive' ? ( {match[1]} @@ -1536,18 +1550,24 @@ function ThoughtLine({ generateContent?: SessionContentGenerator; }) { const { language, t } = useI18n(); + const transcriptRenderMode = useTranscriptRenderMode(); + const documentMode = transcriptRenderMode === 'document'; const [expanded, setExpanded] = useState(false); + const showContent = documentMode || expanded; return (
setExpanded((value) => !value)} + role={documentMode ? undefined : 'button'} + tabIndex={documentMode ? undefined : 0} + aria-expanded={documentMode ? undefined : expanded} + onClick={() => { + if (!documentMode) setExpanded((value) => !value); + }} onKeyDown={(event) => { + if (documentMode) return; // Only the container itself toggles; keys pressed inside nested // controls (the translate button) keep their own behavior. if (event.target !== event.currentTarget) return; @@ -1575,14 +1595,14 @@ function ThoughtLine({ )}
- {expanded && ( + {showContent && (
@@ -1600,12 +1620,15 @@ export const ToolGroup = memo(function ToolGroup({ generateContent, }: ToolGroupProps) { const { t } = useI18n(); + const transcriptRenderMode = useTranscriptRenderMode(); + const documentMode = transcriptRenderMode === 'document'; const subagentDetails = useSubagentDetails(); const monitorDetails = useMonitorDetails(); const monitorDetailsAvailable = monitorDetails !== undefined; const [monitorDetailsUnavailable, setMonitorDetailsUnavailable] = useState(false); const [chatExpanded, setChatExpanded] = useState(false); + const showGroupContent = documentMode || chatExpanded; const monitorDetailsRequestRef = useRef(null); const hasRunningTool = hasActiveAgents(tools); const activeTool = @@ -1667,6 +1690,7 @@ export const ToolGroup = memo(function ToolGroup({ type="button" className={styles.chatSummary} onClick={() => { + if (documentMode) return; if (singleSubagent && subagentDetails) { subagentDetails.onOpen(singleSubagent); return; @@ -1677,11 +1701,13 @@ export const ToolGroup = memo(function ToolGroup({ } setChatExpanded((value) => !value); }} - aria-expanded={opensToolDetails ? undefined : chatExpanded} + aria-expanded={ + documentMode || opensToolDetails ? undefined : chatExpanded + } title={ opensToolDetails ? undefined - : chatExpanded + : showGroupContent ? t('tool.collapseHint') : t('tool.expand') } @@ -1715,14 +1741,16 @@ export const ToolGroup = memo(function ToolGroup({