diff --git a/.gitattributes b/.gitattributes index 3c21e997137..0f36e10394c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ *.sh eol=lf *.bash eol=lf Makefile eol=lf +packages/vscode-ide-companion/NOTICES.txt linguist-generated=true # Windows cmd.exe expects batch installers to be checked out with CRLF. scripts/installation/install-qwen-standalone.bat text eol=crlf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5d5f74dc1c..fefac3a5dcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -867,12 +867,22 @@ jobs: - name: 'Install Playwright Chromium (hosted)' if: "${{ runner.environment == 'github-hosted' }}" - run: 'npx playwright install --with-deps chromium' + run: |- + node node_modules/playwright/cli.js install --with-deps chromium + nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js' + if [ -f "${nested_cli}" ]; then + node "${nested_cli}" install chromium + fi - name: 'Install Playwright Chromium (self-hosted)' if: "${{ runner.environment == 'self-hosted' }}" # Self-hosted ECS runners already include system deps; --with-deps can race apt locks. - run: 'npx playwright install chromium' + run: |- + node node_modules/playwright/cli.js install chromium + nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js' + if [ -f "${nested_cli}" ]; then + node "${nested_cli}" install chromium + fi - name: 'Choose web-shell Playwright port' run: |- @@ -880,6 +890,9 @@ jobs: echo "PLAYWRIGHT_PORT=${port}" >> "${GITHUB_ENV}" echo "Using web-shell Playwright port ${port}" + - name: 'Run transcript document browser gate' + run: 'npx vitest run --root ./integration-tests ./chat-transcript-document.test.ts --retry=0' + - name: 'Run web-shell browser smoke' run: 'npm run test:e2e:smoke --workspace=packages/web-shell' diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 68ae1ac101f..7468ac6a45d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -233,9 +233,9 @@ jobs: # test:integration:sandbox:docker: that script would rebuild the image # the step above just built. if [[ "${{ matrix.sandbox }}" == "sandbox:docker" ]]; then - npx cross-env QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --shard='${{ matrix.shard }}' + npx cross-env QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --exclude '**/chat-transcript-document.test.ts' --shard='${{ matrix.shard }}' else - npm run test:integration:sandbox:none -- --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --shard='${{ matrix.shard }}' + npm run test:integration:sandbox:none -- --exclude '**/interactive/cron-interactive.test.ts' --exclude '**/channel-plugin.test.ts' --exclude '**/chat-transcript-document.test.ts' --shard='${{ matrix.shard }}' fi # The sandbox build retags the same image name every run, so on a @@ -304,7 +304,7 @@ jobs: OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}' - run: 'npx cross-env VERBOSE=true KEEP_OUTPUT=true QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude "**/interactive/cron-interactive.test.ts" --exclude "**/channel-plugin.test.ts" --shard="${{ matrix.shard }}"' + run: 'npx cross-env VERBOSE=true KEEP_OUTPUT=true QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude "**/interactive/cron-interactive.test.ts" --exclude "**/channel-plugin.test.ts" --exclude "**/chat-transcript-document.test.ts" --shard="${{ matrix.shard }}"' isolated-nightly: name: '${{ matrix.label }} (nightly)' @@ -393,7 +393,15 @@ jobs: npm ci --prefer-offline --no-audit --progress=false - name: 'Install Playwright Chromium' - run: 'npx playwright install --with-deps chromium' + run: |- + node node_modules/playwright/cli.js install --with-deps chromium + nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js' + if [ -f "${nested_cli}" ]; then + node "${nested_cli}" install chromium + fi + + - name: 'Run transcript document browser gate' + run: 'npx vitest run --root ./integration-tests ./chat-transcript-document.test.ts --retry=0' - name: 'Run web-shell browser regression' run: 'npm run test:e2e --workspace=packages/web-shell' diff --git a/docs/design/web-shell/chat-transcript-contract-prevalidation.md b/docs/design/web-shell/chat-transcript-contract-prevalidation.md index 3587ea8d165..28d6198ddbd 100644 --- a/docs/design/web-shell/chat-transcript-contract-prevalidation.md +++ b/docs/design/web-shell/chat-transcript-contract-prevalidation.md @@ -1,17 +1,20 @@ # Web Shell、VS Code、Desktop 与 HTML Export 统一 Chat Transcript 总体设计 -> 文档地位:本方案的唯一规范性设计文档 -> 实施方式:两个 MR 按顺序合入 -> 当前状态:MR1 契约预验证已在当前分支准备;MR2 生产迁移尚未进入当前分支 -> 当前门禁:`overall: "fail"`,`selectedVscodePath: null` +> 文档地位:本方案的唯一规范性设计文档 +> +> 实施方式:MR1、MR2A、MR2B 按顺序合入 +> +> 当前状态:MR1 契约预验证已完成;当前分支实施 MR2A 的 HTML Export 产品路径,VS Code live transcript 迁移留给 MR2B +> +> 当前门禁:direct-daemon/ACP candidate identity 和产品 HTML browser gate 已通过;`selectedVscodePath: null`、`overall: "fail"`,直到 MR2B 完成产品选型、scope/generation、VSIX/宿主动作和 packaging 门禁 ## 0. 文档治理 -本文档同时定义最终目标架构、公共契约、安全约束、两个 MR 的实施边界和退出门禁。代码虽然拆成两个 MR,但不会为 MR2 新建另一份设计文档。 +本文档同时定义最终目标架构、公共契约、安全约束、三个 MR 的实施边界和退出门禁。代码虽然拆成 MR1、MR2A、MR2B,但不会新建另一份设计文档。 后续规则如下: -1. MR1 和 MR2 的设计变更都回写本文档; +1. MR1、MR2A 和 MR2B 的设计变更都回写本文档; 2. fixture schema、Export JSON Schema、capability matrix 和测试报告是本文档的契约附件,不是第二份设计文档; 3. 实施计划、E2E 记录和发布报告可以单独保存,但不能在其中重新定义本方案的模型、identity 或安全语义; 4. 若代码与本文档冲突,以未完成设计评审处理,不能通过修改 snapshot 将冲突掩盖; @@ -46,22 +49,23 @@ ChatTranscriptModel - typed `preview`/`resultPreview` 的安全消费只在 document/export 路径启用; - Mermaid 的额外预算、超时和降级规则只在 document mode 启用。 -整个工作按顺序拆成两个 MR: +整个工作按顺序拆成三个 MR: 1. **MR1:契约预验证 MR**——只落地可重复证据,允许门禁如实 FAIL; -2. **MR2:VS Code 迁移 + HTML Export MR**——由真实消费者驱动生产改动,并在全部门禁通过后把结果翻转为 PASS。 +2. **MR2A:HTML Export MR**——落地安全 document pipeline、document mode 和 CLI/Web API/VS Code `/export html` 消费者;VS Code 时间线继续使用 legacy `MessageList`; +3. **MR2B:VS Code live transcript 迁移 MR**——选择产品路径并接入真实 adapter、scope/generation、shared renderer、host actions、VSIX 与 packaging 门禁。 ## 2. 当前仓库事实与实施状态 ### 2.1 当前生产边界 -| 消费端 | 当前事实 | 本方案处理 | -| ----------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| Web/Qwen Server | daemon state 经 SDK reducer 产生 `DaemonTranscriptBlock[]`,完整 WebShell 渲染 | 保持生产路径不变;作为语义和兼容基线 | -| Qwen Tauri Desktop | 构建并复制同一 WebShell 产物 | 不增加 Desktop adapter;MR1 不认证安装产物行为 | -| VS Code | `QwenAgentManager` 仍以 ACP、自有消息状态和现有 Webview 时间线为主;仓库存在 daemon connection spike | MR2 选择 direct-daemon 或 ACP 薄转换,只替换时间线 | -| HTML Export | 产品和 integration runner 仍有独立 HTML/ChatViewer 路径 | MR2 收敛到版本绑定的 `WebShellTranscript` document mode | -| OpenWork/Craft Electron | 独立聊天实现 | 本方案范围外 | +| 消费端 | 当前事实 | 本方案处理 | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Web/Qwen Server | daemon state 经 SDK reducer 产生 `DaemonTranscriptBlock[]`,完整 WebShell 渲染 | 保持生产路径不变;作为语义和兼容基线 | +| Qwen Tauri Desktop | 构建并复制同一 WebShell 产物 | 不增加 Desktop adapter;MR1 不认证安装产物行为 | +| VS Code | MR2A 继续由现有 legacy `MessageList` 渲染;不注册 transcript update、不引入 `@qwen-code/web-shell`、不发布死 feature flag | 该 render site 是 MR2B 的设计接入 seam,不新增空生产 adapter | +| HTML Export | CLI、Web API 和 VS Code 导出均把原始 records 交给 document projector,并使用版本绑定的产品模板 | 产品路径已收敛;无 records 的公共调用保留 legacy 兼容 | +| OpenWork/Craft Electron | 独立聊天实现 | 本方案范围外 | 当前 `WebShellTranscript`: @@ -69,22 +73,23 @@ ChatTranscriptModel - 固定运行在 `readonly` render mode; - 不连接 daemon、不提供 composer、不响应权限、不修改 session; - 默认 adapter 仍从 runtime block 的 raw 字段恢复完整工具展示和 Turn Output 语义; -- 尚无 `document` render mode。 +- 已提供独立 `document` render mode;interactive/readonly 的 raw adapter 语义保持不变。 -### 2.2 两个 MR 的状态 +### 2.2 三个 MR 的状态 -| 范围 | 当前状态 | 结论 | -| ------------------------------------------------ | -------------------------- | ----------------------- | -| MR1 fixtures/schema/hash/capability matrix | 当前分支已准备 | PASS | -| ChatRecord → SDK → Web Shell 默认 adapter 等价性 | 当前分支已准备 | PASS | -| `write_file` → Turn Output 完整 diff 回归 | 当前分支已准备 | PASS | -| direct-daemon stable identity | partial-prepend 可重复失败 | FAIL | -| ACP stable identity | partial-prepend 可重复失败 | FAIL | -| VS Code 路径选择 | 前置 identity 未通过 | BLOCKED | -| Export document schema | V1 目标 schema 已冻结 | DEFERRED implementation | -| Export builder/document mode/HTML wiring | 当前分支无生产代码 | DEFERRED to MR2 | +| 范围 | 当前状态 | 结论 | +| ------------------------------------------------ | ---------------------------------------------------------------------- | ------------------ | +| MR1 fixtures/schema/hash/capability matrix | 已完成 | PASS | +| ChatRecord → SDK → Web Shell 默认 adapter 等价性 | 默认 raw 路径回归通过 | PASS | +| `write_file` → Turn Output 完整 diff 回归 | 继续读取完整 runtime raw content | PASS | +| direct-daemon stable identity | SDK reducer + integration candidate projection 通过 partial-prepend | PASS(候选证据) | +| ACP stable identity | integration candidate projection 通过 live/history/partial-prepend | PASS(候选证据) | +| VS Code 时间线 | MR2A 保持 legacy `MessageList`,没有新 transport/adapter/renderer 接线 | DEFERRED TO MR2B | +| Export document schema/builder | canonical schema、严格结构校验和语义安全校验进入产品路径 | PASS | +| document mode/HTML wiring | CLI、Web API、VS Code export 使用同一版本绑定产品模板 | IMPLEMENTED | +| Browser/Scope/VSIX/Packaging | 产品 HTML browser gate 已通过;scope/reconnect、VSIX/安装产物尚未认证 | PARTIAL / DEFERRED | -“MR1 测试通过”表示当前事实和 FAIL blocker 能稳定复现,不表示迁移门禁已经通过。 +`overall` 仍为 FAIL 不是 HTML renderer 失败,而是 MR2A 有意不选择 VS Code 产品路径,required 的 live timeline、宿主与发布级行为证据留给 MR2B;不能把 candidate probe 的 PASS 当作产品接线完成。 ## 3. 目标与非目标 @@ -97,7 +102,7 @@ ChatTranscriptModel 5. 让 HTML Export 使用版本化、安全、资源有界且不主动联网的文档输入; 6. 保证 Web Shell interactive/readonly 和 Tauri Desktop 不发生功能回归; 7. 通过 fixture、hash、capability matrix 和自动化门禁使每个架构结论可重复验证; -8. 允许 VS Code 与 HTML 两条消费路径在 MR2 内分别灰度、观察和回滚。 +8. 允许 VS Code 与 HTML 两条消费路径通过 MR2A/MR2B 独立评审、灰度、观察和回滚。 ### 3.2 非目标 @@ -125,7 +130,7 @@ flowchart LR MODEL --> WEB["Web/Qwen full WebShell"] MODEL --> DESKTOP["Tauri packaged WebShell"] - MODEL --> VST["VS Code WebShellTranscript timeline"] + MODEL -. MR2B .-> VST["VS Code WebShellTranscript timeline"] MODEL --> EP["document/export allowlist projector"] EP --> EDOC["ExportTranscriptDocumentV1"] @@ -138,14 +143,14 @@ flowchart LR ### 4.1 所有权边界 -| 层级 | 负责 | 不负责 | -| -------------------- | ------------------------------------------------------------- | ---------------------------------------------- | -| source adapter | 协议归一化、source provenance、scope/generation admission | UI、宿主副作用 | -| ChatTranscriptModel | 有序只读消息语义、稳定 block identity、展示所需层级 | composer、活动权限响应、传输、session mutation | -| WebShellTranscript | Markdown、thinking、工具、计划、图片和只读时间线展示 | daemon 连接、持久化、权限 API | -| VS Code host adapter | 连接路径、scope/generation、callbacks、feature flag、原生操作 | 复制聊天 renderer | -| export projector | record policy、逐字段 allowlist、ID 重写、预算与 diagnostic | live side-channel、raw payload 透传 | -| HTML shell | schema/version 校验、CSP、document mode、主题/打印 | 工具执行、远程 runtime 下载 | +| 层级 | 负责 | 不负责 | +| -------------------- | -------------------------------------------------------------------- | ---------------------------------------------- | +| source adapter | 协议归一化、source provenance、scope/generation admission | UI、宿主副作用 | +| ChatTranscriptModel | 有序只读消息语义、稳定 block identity、展示所需层级 | composer、活动权限响应、传输、session mutation | +| WebShellTranscript | Markdown、thinking、工具、计划、图片和只读时间线展示 | daemon 连接、持久化、权限 API | +| VS Code host adapter | MR2B 的连接路径、scope/generation、callbacks、feature flag、原生操作 | 复制聊天 renderer;MR2A 不发布空 adapter | +| export projector | record policy、逐字段 allowlist、ID 重写、预算与 diagnostic | live side-channel、raw payload 透传 | +| HTML shell | schema/version 校验、CSP、document mode、主题/打印 | 工具执行、远程 runtime 下载 | 宿主始终是传输、session 和副作用的事实来源。共享 renderer 不得通过 DOM 反向恢复业务状态。 @@ -233,7 +238,7 @@ interface TranscriptAdapterContext { 5. interactive/readonly 的 Markdown、Mermaid、工具卡、折叠、虚拟化和动作行为保持现状; 6. document mode 的限制通过独立 context/option 启用,配置缓存必须按 mode 隔离。 -如果 `ExportTranscriptBlockV1` 不能类型安全地直接交给 `WebShellTranscript`,MR2 只允许增加一个纯函数 document adapter。该 adapter 只能把安全 DTO 映射为 renderer input,不能恢复 raw payload、复制 reducer 或演变成第二套消息模型。 +如果 `ExportTranscriptBlockV1` 不能类型安全地直接交给 `WebShellTranscript`,MR2A 只允许增加一个纯函数 document adapter。该 adapter 只能把安全 DTO 映射为 renderer input,不能恢复 raw payload、复制 reducer 或演变成第二套消息模型。 ## 7. 稳定 identity 设计 @@ -276,19 +281,19 @@ event cursor 只表示传输顺序。对由多个 delta 合并的文本 block, ### 7.4 segment identity 规则 -MR2 在 source producer/admission 边界建立 segment identity: +MR2A 只为 persisted replay/document projection 保留 record-derived segment identity;live producer/admission identity 由 MR2B 的真实 VS Code consumer 驱动。最终规则是: 1. 同一 streaming segment 的多个 delta 复用同一 `segmentId`; 2. user、assistant、thought 和 sub-agent lane 分开; 3. tool/permission/离散消息边界结束当前 text segment; 4. persisted replay 保留 record-derived segment identity,不能在 replay adapter 中重新编号; -5. direct-daemon envelope 和 ACP update 都必须把 source identity 带到 normalizer; +5. MR2B 的 direct-daemon envelope 和 ACP live update 都必须把 source identity 带到 normalizer; 6. 不同 `segmentId` 的相邻文本不能仅因当前窗口相邻而合并成同一 identity block; 7. 缺少 stable prompt/record/segment 来源时输出阻断 diagnostic,不猜测补齐。 -稳定 block ID 由版本化确定性函数从 `{scopeKey, blockKind, nativeSourceIdentity}` 派生。父子 block 引用必须同步重写。默认 Web/Tauri reducer 的 ordinal runtime ID 可保持兼容;稳定投影只进入明确需要它的 VS Code adapter/probe,除非后续单独证明全局替换无回归。 +稳定 block ID 由版本化确定性函数从 `{scopeKey, blockKind, nativeSourceIdentity}` 派生。父子 block 引用必须同步重写。默认 Web/Tauri reducer 的 ordinal runtime ID 可保持兼容;MR2A 只在 integration helper 中保留 candidate evidence,稳定投影进入 VS Code 生产 adapter 必须由 MR2B 的真实消费者驱动。 -### 7.5 当前 FAIL 证据 +### 7.5 MR1 历史 FAIL 与 MR2A candidate 证据 MR1 的 read-only probe 使用当前 `normalizeDaemonEvent` 和 `reduceDaemonTranscriptEvents`: @@ -297,14 +302,16 @@ MR1 的 read-only probe 使用当前 `normalizeDaemonEvent` 和 `reduceDaemonTra 3. 通过语义 key 对齐同一 block; 4. 比较当前 block ID,并记录 source provenance 是否存在。 -当前结果: +MR1 当时的结果: | Candidate | partial-prepend | 原生文本 identity | MR1 gate | | ------------- | --------------------- | --------------------------- | -------- | | direct-daemon | ordinal block ID 漂移 | user/thought/assistant 缺失 | FAIL | | ACP | ordinal block ID 漂移 | user/thought/assistant 缺失 | FAIL | -MR2 合入前,两条候选都必须运行完整 identity matrix 并通过;若要永久放弃其中一条,必须先在本文档中记录范围变更与理由,不能只从测试中删除失败候选。 +MR2B 合入前,两条候选都必须运行完整 identity matrix 并通过;若要永久放弃其中一条,必须先在本文档中记录范围变更与理由,不能只从测试中删除失败候选。 + +MR2A 不保留只供门禁调用的 VS Code 生产 adapter。门禁调用实际 SDK reducer、integration-only candidate projection 与实际 Web Shell message projector;两条候选通过 append/partial-prepend/replay matrix,但结果只证明契约可行性。ACP 是否成为产品路径由 MR2B 结合 scope/generation、host actions、VSIX 和三平台证据重新确认;MR2A 的 `selectedVscodePath` 保持 `null`。 ## 8. Renderer item 与宿主动作 identity @@ -338,7 +345,7 @@ interface TranscriptRenderedItemEvidence { - streaming 文本增长可以改变 semantic copy hash,但不能改变同一 segment 的 item identity; - React key、DOM 顺序、数组下标和可见窗口不能成为业务 identity。 -MR2 只增加由失败 fixture 证明必要的最小 callback/handle,不能借此创建通用宿主框架。 +MR2B 只增加由失败 fixture 证明必要的最小 callback/handle,不能借此创建通用宿主框架;MR2A 不增加宿主 callback/handle。 ## 9. 四端适配设计 @@ -389,7 +396,7 @@ ACP adapter 只做协议归一化和 provenance 传递: ### 9.5 VS Code 选型规则 -两条候选先使用相同 fixture、identity matrix 和 render/action probe。MR2 只能选择满足以下条件的路径: +两条候选先使用相同 fixture 和 identity matrix。MR2B 只能选择满足以下条件的路径: 1. stable identity 全部通过; 2. 现有 composer、permission、session 和 host action 边界保持不变,或范围变更已单独评审; @@ -398,6 +405,8 @@ ACP adapter 只做协议归一化和 provenance 传递: ACP 是当前生产基线,因此在两条路径同等可行时优先 ACP 薄转换;这不是 MR1 的预选结果。最终选择及舍弃理由写入 capability matrix 和本文档状态表。 +MR2A 不做产品选型:VS Code 不旁路转发 raw `session/update`,Webview 不实例化 transcript reducer,不声明 `qwen-code.experimental.webShellTranscript`,并始终渲染现有 legacy `MessageList`。该 render site 是 MR2B 的目标接入 seam,但不是需要额外占位代码的生产抽象。ACP 作为当前 transport 仍是优先候选,只有 MR2B 完成宿主动作 parity、VSIX 和三平台门禁后才能写入 `selectedVscodePath`。 + ### 9.6 HTML Export ```text @@ -616,10 +625,11 @@ integration-tests/fixtures/chat-transcript-contract/v1/ │ ├── expected-export.json │ └── expected-gate.json └── schema/ - ├── manifest.schema.json - └── export-transcript-document-v1.schema.json + └── manifest.schema.json ``` +Export schema 的唯一生产副本位于 `packages/cli/src/ui/utils/export/export-transcript-document-v1.schema.json`。integration gate 直接读取该文件;fixture 不再复制第二份容易漂移的 schema。 + 规则: - 只使用确定性合成数据,不采集真实用户会话; @@ -651,7 +661,7 @@ MR1 的紧凑矩阵每项记录 Capability、当前 source/path、Fixture/Eviden required 项不能以 `unknown`、`TBD`、人工截图或“测试能运行”通过。PASS、FAIL、BLOCKED、DEFERRED 必须分别使用,不能把预计后续修复写成当前 PASS。 -## 12. 两个 MR 的实施边界 +## 12. 三个 MR 的实施边界 ### 12.1 MR1:契约预验证 MR @@ -677,22 +687,41 @@ required 项不能以 `unknown`、`TBD`、人工截图或“测试能运行” MR1 验收:测试通过,同时 `expected-gate.json` 保持 `overall: "fail"`、两候选 FAIL、`selectedVscodePath: null`。 -### 12.2 MR2:VS Code 迁移 + HTML Export MR +### 12.2 MR2A:HTML Export MR + +MR2A 中每项生产代码必须有 HTML 产品消费者。实施顺序: + +1. **safe tool projection**:实现 document-only typed preview/result,保持 runtime raw 兼容; +2. **export builder**:实现 record policy、canonical projection、allowlist、opaque ID、metadata、budget 和 diagnostics; +3. **document mode**:实现非虚拟化/只读/无动作 renderer,Mermaid 限制仅在此 mode; +4. **HTML wiring**:CLI、Web API、VS Code `/export html` 和 integration runner 复用同一产品模板及版本绑定 renderer; +5. **browser/security gates**:CSP、零网络、canary、最大预算和版本失败测试; +6. **candidate evidence**:direct-daemon/ACP identity 只保留在 integration helper,不创建 VS Code 生产 adapter; +7. **gate state**:HTML capability 可标 PASS,但 `selectedVscodePath: null`、`overall: "fail"` 保持不变。 -MR2 中每项生产代码必须有真实消费者。实施顺序: +MR2A 明确不包含: -1. **identity source**:在 producer/admission 边界建立并持久化 direct-daemon/ACP segment provenance; -2. **stable projection**:实现 scope-keyed block 与 parent reference 投影,完成两候选 identity matrix; -3. **render/action seam**:补齐稳定 item/source mapping 和最小 VS Code callbacks; -4. **VS Code timeline**:按选型接入 `WebShellTranscript`,保留现有 composer/permission/session/host actions; -5. **safe tool projection**:实现 document-only typed preview/result,保持 runtime raw 兼容; -6. **export builder**:实现 record policy、canonical projection、allowlist、opaque ID、metadata、budget 和 diagnostics; -7. **document mode**:实现非虚拟化/只读/无动作 renderer,Mermaid 限制仅在此 mode; -8. **HTML wiring**:CLI 和 integration runner 复用同一产品模板及版本绑定 renderer; -9. **browser/security gates**:CSP、零网络、canary、最大预算和版本失败测试; -10. **gate flip**:真实消费者和全部测试通过后,更新 expected gate 与 capability matrix。 +- `@qwen-code/web-shell` 作为 VS Code 直接依赖; +- VS Code transcript feature flag、raw update 转发、ACP adapter、hook、theme bridge 或 shared timeline render branch; +- VS Code host-action/copy/edit/open-file seam; +- 仅为未来迁移存在的空组件、空 callback、死协议字段或 production probe; +- `NOTICES.txt` 与 notices generator 的 transcript 依赖增量。 -MR2 不能只修改 `expected-gate.json` 或恢复拆分前整包代码。应按上述消费者顺序选择性迁移备份实现,并重新对照当前 `main`。 +VS Code 保留的唯一新行为是 `/export html` 把原始 records 传给 document pipeline;它不接管 live timeline。现有 legacy `MessageList` render site 作为文档定义的 MR2B 接入位置,不新增运行时占位抽象。 + +### 12.3 MR2B:VS Code live transcript 迁移 MR + +MR2B 从 MR2A 之后开始,并由真实 VS Code consumer 驱动: + +1. 重新运行 direct-daemon/ACP 完整 identity matrix 并选择产品路径; +2. 建立 scope/generation admission、stable block/item identity 和 parent reference 投影; +3. 接入真实 transcript adapter、hook、feature flag 与 `WebShellTranscript` readonly timeline; +4. 保留现有 composer、permission、session、legacy timeline 和 host actions; +5. 完成 reconnect、late update/action rejection、copy/edit/open-file parity; +6. 增加所需直接依赖并生成准确的 NOTICES; +7. 完成 VSIX、三平台和 packaged artifact 门禁后才能选择路径或翻转 overall gate。 + +MR2A 先独立收敛产品 HTML Export。MR2B 再接入 VS Code live timeline,避免 renderer、transport、host actions、VSIX 与许可证变更挤入同一评审。JSON Schema 无法表达的 credential URL、脱敏 path、总字节和资源预算继续由小型语义安全层负责,不恢复重复的逐字段结构 validator。 ## 13. 验证架构与测试矩阵 @@ -700,10 +729,10 @@ MR2 不能只修改 `expected-gate.json` 或恢复拆分前整包代码。应按 flowchart TD INPUTS["daemon / ACP / ChatRecord fixtures"] --> SEM["semantic projection"] INPUTS --> IDS["block identity matrix"] - SEM --> RENDER["renderer item/action probe"] + SEM --> RENDER["actual Web Shell message projector"] SEM --> EXPORT["export allowlist projector"] EXPORT --> SCHEMA["schema + budget + canary"] - SCHEMA --> BROWSER["document browser probe"] + SCHEMA --> BROWSER["product HTML browser gate"] RENDER --> HOSTS["Web / Tauri / VS Code"] BROWSER --> HTML["HTML Export"] IDS --> GATE{"overall gate"} @@ -724,22 +753,24 @@ cd ../packages/web-shell && npx vitest run client/components/artifacts/turnOutpu MR1 证明:fixture/hash/schema 可重复、默认 raw runtime 兼容、Turn Output 完整 diff 不回归,以及两条 identity blocker 可重复。 -MR1 不以源码文本断言认证 Desktop 打包行为。Web/Tauri 的现有构建检查继续作为回归信号;安装产物中 Web Shell 文件布局与可加载性的行为 smoke 属于 MR2 Packaging gate,在当前矩阵中保持 DEFERRED。 +MR1 不以源码文本断言认证 Desktop 打包行为。Web/Tauri 的现有构建检查继续作为回归信号;安装产物中 Web Shell 文件布局与可加载性的行为 smoke 属于 MR2B Packaging gate,在当前矩阵中保持 DEFERRED。 -### 13.2 MR2 必须验证 +### 13.2 MR2A 与 MR2B 必须验证 -| 范围 | 必须覆盖 | -| -------------- | ------------------------------------------------------------------------------------- | -| SDK/source | segment provenance、append/prepend/replay、parent refs、默认 ordinal 兼容 | -| ACP | live/history 同 identity、缺失 provenance fail closed、迟到 update | -| VS Code | direct/ACP probes、选定路径、scope/generation、callbacks、feature flag、legacy parity | -| Web Shell | interactive/readonly raw 兼容、document safe-only、render/action identity | -| Export builder | record policy、per-kind allowlist、opaque IDs、metadata、diagnostic、version | -| Browser | schema failure、zero network、CSP、canary、find/copy/print、最大预算 | -| Packaging | Web/Tauri regression、VSIX 三平台、CLI renderer 版本绑定、integration runner 收敛 | +| 范围 | 必须覆盖 | +| -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| SDK/source | segment provenance、append/prepend/replay、parent refs、默认 ordinal 兼容 | +| ACP | live/history 同 identity、缺失 provenance fail closed、迟到 update | +| VS Code | MR2A 验证 legacy timeline 与 `/export html`;MR2B 验证选定路径、scope/generation、callbacks、feature flag、legacy parity | +| Web Shell | interactive/readonly raw 兼容、document safe-only、render/action identity | +| Export builder | record policy、per-kind allowlist、opaque IDs、metadata、diagnostic、version | +| Browser | schema failure、zero network、CSP、canary、find/copy/print、最大预算 | +| Packaging | Web/Tauri regression、VSIX 三平台、CLI renderer 版本绑定、integration runner 收敛 | Passing test 也必须反向审计:测试是否断言了正确语义、是否加载当前构建产物、是否真的覆盖真实消费者,不能用静态 source assertion 替代浏览器或 VSIX 行为验证。 +当前 MR2A 验证结果:SDK、Core、CLI、Web Shell、VS Code `/export html` 聚焦测试和 direct-daemon/ACP integration candidate gate 已通过;产品 HTML 已完成构建、Node 侧安全断言和真实 Chromium browser gate,concurrent runner 也复用同一产品收集、归一化和 formatter。browser gate 已覆盖最大文档、真实产品入口、零网络、主动 CSP 违规、canary、搜索、复制、打印、远程资源降级和 epoch 时间戳排除。VS Code live timeline、scope/generation/reconnect、宿主动作、VSIX 与 packaged artifact 证据全部属于 MR2B。 + ## 14. 门禁 ### 14.1 共享语义门禁 @@ -769,9 +800,9 @@ Passing test 也必须反向审计:测试是否断言了正确语义、是否 - 正常 Markdown、code、diff、LaTeX、Mermaid、tool/plan/permission 与 metadata 不被过度删除; - schema/renderer 不兼容安全失败。 -### 14.4 最终结论 +### 14.4 当前与最终结论 -MR1 的正确结论是 FAIL evidence: +MR1 的历史结论是 FAIL evidence。MR2A 的两候选 identity 与产品 HTML browser gate 通过,但不选择 VS Code 产品路径;总体门禁保持 FAIL: ```json { @@ -780,13 +811,14 @@ MR1 的正确结论是 FAIL evidence: } ``` -MR2 只有在上述三组门禁和真实消费者验证全部通过后才能改为 PASS。任何 required 组失败都阻断 MR2 合入;不能人工豁免,也不能先 assert false、合入生产代码后在同一证据缺失状态下只把期望改成 true。 +MR2B 只有在上述三组门禁和真实消费者验证全部通过后才能选择路径或改为 PASS。任何 required 组失败都阻断 MR2B 合入;不能人工豁免,也不能先 assert false、合入生产代码后在同一证据缺失状态下只把期望改成 true。 ## 15. 发布、观察与回滚 ### 15.1 VS Code -- 新时间线受独立 feature flag 控制; +- MR2A 不发布新时间线或 feature flag,始终使用 legacy timeline; +- MR2B 的新时间线受独立 feature flag 控制; - legacy timeline 在 pre-release 和观察期内保留; - 比较相同录制会话的状态、动作、截图、性能和错误; - flag 关闭必须完整回退 legacy,不改变 session 数据; @@ -802,25 +834,25 @@ MR2 只有在上述三组门禁和真实消费者验证全部通过后才能改 ### 15.3 Web 与 Desktop -Web/Qwen 和 Tauri 不迁移。若 MR2 对共享组件的改动导致默认模式回归,应回滚 MR2,而不是为两端增加兼容 adapter。 +Web/Qwen 和 Tauri 不迁移。若 MR2A/MR2B 对共享组件的改动导致默认模式回归,应回滚对应 MR,而不是为两端增加兼容 adapter。 ## 16. 风险与控制 -| 风险 | 控制 | -| ------------------------------------ | ------------------------------------------------------- | -| 证据 MR 膨胀成生产实现 | MR1 文件范围白名单;生产变更全部留给 MR2 真实消费者 | -| 测试模型变成第二套 model | `ChatTranscriptModel` 只命名现有 blocks;不发布 wrapper | -| ordinal ID 在简单 replay 中假稳定 | 强制 multi-delta、partial-prepend、overlap replay | -| block ID 稳定但 render/action 不稳定 | 单独 item/source/action probe | -| ACP 与 direct 只验证一条 | 两候选共用 matrix;删除候选必须更新本文档 | -| document projection 污染 runtime | mode 隔离;默认 raw compatibility tests | -| preview 截断破坏 Turn Output | `write_file` 完整 raw content 回归 | -| raw/metadata 泄漏 | 两层 allowlist、closed schema、canary 和字节扫描 | -| Markdown/图片绕过网络策略 | 统一资源 policy、CSP 和浏览器全请求拦截 | -| Mermaid 全局配置污染 | 仅 document context 启用限制,缓存按 mode 隔离 | -| document 无虚拟化导致资源耗尽 | builder/browser 双预算、源码 fallback、最大文档测试 | -| snapshot update 掩盖 blocker | hash、显式 fixture diff、gate 由测试生成 | -| 备份实现与最新 main 漂移 | MR2 选择性迁移并重新审计,不整包恢复 | +| 风险 | 控制 | +| ------------------------------------ | --------------------------------------------------------------------- | +| 证据 MR 膨胀成生产实现 | MR1 文件范围白名单;HTML 与 VS Code 生产变更分属 MR2A/MR2B 真实消费者 | +| 测试模型变成第二套 model | `ChatTranscriptModel` 只命名现有 blocks;不发布 wrapper | +| ordinal ID 在简单 replay 中假稳定 | 强制 multi-delta、partial-prepend、overlap replay | +| block ID 稳定但 render/action 不稳定 | 单独 item/source/action probe | +| ACP 与 direct 只验证一条 | 两候选共用 matrix;删除候选必须更新本文档 | +| document projection 污染 runtime | mode 隔离;默认 raw compatibility tests | +| preview 截断破坏 Turn Output | `write_file` 完整 raw content 回归 | +| raw/metadata 泄漏 | 两层 allowlist、closed schema、canary 和字节扫描 | +| Markdown/图片绕过网络策略 | 统一资源 policy、CSP 和浏览器全请求拦截 | +| Mermaid 全局配置污染 | 仅 document context 启用限制,缓存按 mode 隔离 | +| document 无虚拟化导致资源耗尽 | builder/browser 双预算、源码 fallback、最大文档测试 | +| snapshot update 掩盖 blocker | hash、显式 fixture diff、gate 由测试生成 | +| 备份实现与最新 main 漂移 | MR2A/MR2B 选择性迁移并重新审计,不整包恢复 | ## 17. 完成定义 @@ -828,7 +860,8 @@ Web/Qwen 和 Tauri 不迁移。若 MR2 对共享组件的改动导致默认模 - MR1 已合入且稳定保存 PASS/FAIL/DEFERRED 证据; - direct-daemon 与 ACP identity 验证达到本文档门禁; -- VS Code 已选择并实现一条路径,其时间线使用 WebShell UI,现有 composer、permission、session 和 host actions 保持边界; +- MR2A 的 VS Code `/export html` 使用新 document pipeline,同时 live timeline 保持 legacy; +- MR2B 已选择并实现一条 VS Code live transcript 路径,其时间线使用 WebShell UI,现有 composer、permission、session 和 host actions 保持边界; - HTML Export 使用 canonical projector、`ExportTranscriptDocumentV1`、版本绑定 renderer 和 document mode; - HTML 产品路径与 integration runner 不再维护第二套 renderer; - Web/Qwen Server 和 Tauri Desktop 默认行为无回归; diff --git a/eslint.config.js b/eslint.config.js index 77c2320af98..fbd3018af82 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,6 +42,7 @@ export default tseslint.config( ignores: [ 'node_modules/*', 'packages/**/dist/**', + 'packages/web-templates/src/generated/**', 'integrations/**/dist/**', 'bundle/**', 'package/bundle/**', diff --git a/integration-tests/chat-transcript-contract.test.ts b/integration-tests/chat-transcript-contract.test.ts index f6bfc5bf53a..a316cdf5ca3 100644 --- a/integration-tests/chat-transcript-contract.test.ts +++ b/integration-tests/chat-transcript-contract.test.ts @@ -1,36 +1,46 @@ import { createHash } from 'node:crypto'; import { readFileSync, readdirSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; +import { dirname, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { - createDaemonTranscriptState, - DAEMON_ERROR_KINDS, - normalizeDaemonEvent, - reduceDaemonTranscriptEvents, - type DaemonEvent, - type DaemonTranscriptBlock, -} from '@qwen-code/sdk/daemon'; +import { SchemaValidator } from '@qwen-code/qwen-code-core'; +import { DAEMON_ERROR_KINDS, type DaemonEvent } 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 { transcriptBlocksToDaemonMessages } from '../packages/web-shell/client/adapters/transcriptToMessages.js'; +import { + adaptAcpTranscriptUpdates, + adaptDirectDaemonEvents, + projectStableTranscriptBlockIds, + readJsonLines, + stableTailIdentity, +} from './helpers/chat-transcript-contract.js'; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const fixtureRoot = resolve( repoRoot, 'integration-tests/fixtures/chat-transcript-contract/v1', ); +const sharedFixtureHashes = { + 'capability-matrix.md': + 'f726f64d41152f0a31636d14d40f34d9d9acab0636143719d60f31df477928cc', + 'schema/manifest.schema.json': + 'c6c72f87a9fafff94ba62cd031259a6fdf7235277a8638be21aa26cc3366f3fa', +} as const; +const casesRoot = resolve(fixtureRoot, 'cases'); const caseRoot = resolve(fixtureRoot, 'cases/representative'); +const scopeKey = 'workspace-a:session-a'; interface FixtureManifest { readonly fixtureVersion: number; readonly name: string; - readonly generatorVersion?: string; + readonly generatorVersion: string; readonly sources: readonly string[]; - readonly consumers: readonly 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 complete: boolean; readonly hashes: Readonly>; } @@ -38,6 +48,7 @@ interface ExpectedModel { readonly kinds: readonly string[]; readonly texts: readonly string[]; readonly sourceRecordIds: readonly (readonly string[])[]; + readonly rawFreeToolResult: string; } interface ExpectedRenderItems { @@ -48,28 +59,20 @@ interface ExpectedRenderItems { readonly expectedToolResult: unknown; } -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 overall: 'pass' | 'fail'; + readonly selectedVscodePath: 'acp' | 'direct-daemon' | null; + readonly candidates: Readonly< + Record<'directDaemon' | 'acp', { readonly status: 'pass' | 'fail' }> + >; readonly blockers: readonly string[]; } @@ -77,34 +80,16 @@ function readJson(path: string): T { return JSON.parse(readFileSync(path, 'utf8')) as T; } -function readJsonLines(path: string): T[] { - return readFileSync(path, 'utf8') - .trim() - .split('\n') - .map((line) => JSON.parse(line) as T); -} - function sha256(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } -function listFixtureEvidenceFiles( - directory: string, - relativeDirectory = '', -): string[] { +function listFixtureFiles(directory: string, root = directory): 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]; + const entryPath = resolve(directory, entry.name); + if (entry.isDirectory()) return listFixtureFiles(entryPath, root); + if (!entry.isFile()) return []; + return [relative(root, entryPath).split(sep).join('/')]; }); } @@ -112,56 +97,27 @@ 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(SchemaValidator.validateStrict(schema, manifest)).toBeNull(); + const properties = schema['properties'] as Record; + const required = schema['required'] as string[]; 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, + expect(Object.keys(manifest).every((key) => key in properties)).toBe(true); + for (const key of required) expect(manifest).toHaveProperty(key); + const hashSchema = ( + properties['hashes'] as { additionalProperties: { pattern: string } } + ).additionalProperties; + for (const key of ['sources', 'consumers', 'capabilities']) { + expect((properties[key] as { uniqueItems?: boolean }).uniqueItems).toBe( + true, ); } - 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); - } + expect((properties['capabilities'] as { minItems?: number }).minItems).toBe( + 1, + ); + expect(hashSchema.pattern).toBe('^[a-f0-9]{64}$'); + const pattern = new RegExp(hashSchema.pattern, 'u'); + for (const hash of Object.values(manifest.hashes)) + expect(hash).toMatch(pattern); } function collectDeclaredSchemaProperties( @@ -173,7 +129,6 @@ function collectDeclaredSchemaProperties( return names; } if (!value || typeof value !== 'object') return names; - 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); @@ -183,107 +138,24 @@ function collectDeclaredSchemaProperties( 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 collectObjectKeys( + value: unknown, + keys = new Set(), +): Set { + if (Array.isArray(value)) { + for (const item of value) collectObjectKeys(item, keys); + return keys; } -} - -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); + if (!value || typeof value !== 'object') return keys; + for (const [key, item] of Object.entries(value)) { + keys.add(key); + collectObjectKeys(item, keys); } - return indexed; -} - -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, - }; + return keys; } -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'), ); @@ -291,49 +163,44 @@ describe('chat transcript contract prevalidation', () => { resolve(fixtureRoot, 'schema/manifest.schema.json'), ); const exportSchema = readJson>( - resolve(fixtureRoot, 'schema/export-transcript-document-v1.schema.json'), + resolve( + repoRoot, + 'packages/cli/src/ui/utils/export/export-transcript-document-v1.schema.json', + ), ); - const expectedExport = readJson( + const expectedExport = readJson( resolve(caseRoot, 'expected-export.json'), ); + const expectedGate = readJson( + resolve(caseRoot, 'expected-gate.json'), + ); const matrix = readFileSync( resolve(fixtureRoot, 'capability-matrix.md'), 'utf8', ); - expectManifestToMatchSchema(manifest, manifestSchema); - const manifestWithUnknownProperty = { - ...manifest, - unknownProperty: true, - }; - expect(() => - expectManifestToMatchSchema(manifestWithUnknownProperty, manifestSchema), - ).toThrow(/manifest property unknownProperty/u); expect(manifest.fixtureVersion).toBe(1); + expectManifestToMatchSchema(manifest, manifestSchema); 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 +208,38 @@ 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); + for (const definition of Object.values(exportDefinitions)) { + const entry = definition as Record; + if (entry['type'] === 'object') { + expect(entry['additionalProperties']).toBe(false); + } + } + 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 +248,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 +261,74 @@ describe('chat transcript contract prevalidation', () => { expect(toolBlock.properties).not.toHaveProperty('content'); expect(statusBlock.properties).not.toHaveProperty('data'); expect(errorBlock.properties).not.toHaveProperty('data'); + const permissionOption = exportDefinitions['permissionOption'] as { + properties: { raw: { const: unknown } }; + }; 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 declaredExportProperties = + collectDeclaredSchemaProperties(exportSchema); + for (const field of expectedExport.forbiddenFields) { + expect(declaredExportProperties.has(field), field).toBe(false); + } + 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'); + } + const declaredCaseFiles = readdirSync(casesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => { + const fixtureCaseRoot = resolve(casesRoot, entry.name); + const fixtureCaseManifest = readJson( + resolve(fixtureCaseRoot, 'manifest.json'), + ); + expectManifestToMatchSchema(fixtureCaseManifest, manifestSchema); + const evidenceFiles = Object.keys(fixtureCaseManifest.hashes); + expect(listFixtureFiles(fixtureCaseRoot).sort()).toEqual( + ['manifest.json', ...evidenceFiles].sort(), + ); + for (const [relativePath, expectedHash] of Object.entries( + fixtureCaseManifest.hashes, + )) { + expect(sha256(resolve(fixtureCaseRoot, relativePath))).toBe( + expectedHash, + ); + } + return ['manifest.json', ...evidenceFiles].map( + (path) => `cases/${entry.name}/${path}`, + ); + }); + expect(listFixtureFiles(fixtureRoot).sort()).toEqual( + [...Object.keys(sharedFixtureHashes), ...declaredCaseFiles].sort(), ); for (const [relativePath, expectedHash] of Object.entries( - manifest.hashes, + sharedFixtureHashes, )) { expect(sha256(resolve(fixtureRoot, relativePath))).toBe(expectedHash); } + expect(matrix).toContain('pass; stable under append/prepend/replay'); + expect(matrix).toContain('deferred; product selection moves to MR2B'); + expect(matrix).not.toMatch(/\b(?:TBD|unknown)\b/i); + expect(expectedGate).toMatchObject({ + overall: 'fail', + selectedVscodePath: null, + candidates: { + directDaemon: { status: 'pass' }, + acp: { status: 'pass' }, + }, + }); + expect(expectedGate.blockers).not.toHaveLength(0); const exportProperties = exportSchema['properties'] as Record< string, @@ -410,85 +343,86 @@ 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 runtimeMessages = transcriptBlocksToDaemonMessages(projection.blocks); + 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 messages = transcriptBlocksToDaemonMessages(exportDocument.blocks, { + safeToolProjection: true, + }); + 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); + expect( + projection.blocks.flatMap((block) => + 'text' in block && typeof block.text === 'string' ? [block.text] : [], + ), + ).toEqual(expected.texts); expect(messages.map((message) => message.role)).toEqual( expectedRender.roles, ); expect( - messages.flatMap((message) => { - switch (message.role) { - case 'user': - case 'thinking': - case 'assistant': - return [message.content]; - default: - return []; - } - }), + messages.flatMap((message) => + 'content' in message && typeof message.content === 'string' + ? [message.content] + : [], + ), ).toEqual(expectedRender.expectedTextContent); - expect(toolBlock).toMatchObject({ + expect( + messages.find((message) => message.role === 'tool_group')?.tools[0] + ?.rawOutput, + ).toBe(expected.rawFreeToolResult); + expect( + projection.blocks.find((block) => block.kind === 'tool'), + ).toMatchObject({ rawInput: expectedRender.expectedToolArgs, rawOutput: expectedRender.expectedToolResult, }); - expect(toolMessage).toMatchObject({ + expect( + runtimeMessages.find((message) => message.role === 'tool_group'), + ).toMatchObject({ tools: [ { args: expectedRender.expectedToolArgs, @@ -497,95 +431,103 @@ describe('chat transcript contract prevalidation', () => { ], }); expect(expectedRender.runtimeFields).toEqual(['rawInput', 'rawOutput']); + expect(messages.every((message) => message.id.length > 0)).toBe(true); + expect(exportDocument.schemaVersion).toBe(expectedExport.schemaVersion); + expect( + exportDocument.blocks.find((block) => block.kind === 'tool') + ?.resultPreview, + ).toMatchObject({ + kind: 'text', + text: expectedExport.expectedToolResult, + }); + 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 direct = adaptDirectDaemonEvents(daemonEvents, scopeKey); + const directTail = adaptDirectDaemonEvents(daemonEvents.slice(1), scopeKey); + const acp = adaptAcpTranscriptUpdates(acpUpdates, scopeKey); + const acpTail = adaptAcpTranscriptUpdates(acpUpdates.slice(1), scopeKey); + + 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}`], + }, + }, + })); + const completeTaggedAcp = adaptAcpTranscriptUpdates( + taggedAcpSegments, + scopeKey, ); - 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 tailTaggedAcp = adaptAcpTranscriptUpdates( + taggedAcpSegments.slice(1), + scopeKey, + ); + const deltaUpdates = ['first ', 'second'].map((text) => ({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text }, + _meta: { + qwenTranscript: { + segmentId: 'prompt-multi-delta:assistant:0', + }, }, - 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 completeDelta = adaptAcpTranscriptUpdates(deltaUpdates, scopeKey); + const tailDelta = adaptAcpTranscriptUpdates( + deltaUpdates.slice(1), + scopeKey, + ); - expect(() => - probeIdentity( - [assistantBlock('complete-1', 'duplicate')], - [ - assistantBlock('partial-1', 'duplicate'), - assistantBlock('partial-2', 'duplicate'), - ], - ), - ).toThrow(/Ambiguous partial identity probe semantic key/u); + expect(stableTailIdentity(direct, directTail)).toBe(true); + expect(stableTailIdentity(acp, acpTail)).toBe(true); + expect(stableTailIdentity(completeTaggedAcp, tailTaggedAcp)).toBe(true); + expect(stableTailIdentity(completeDelta, tailDelta, 0)).toBe(true); - 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'], - }); + const taggedBlock = completeTaggedAcp.blocks.find( + (block) => block.kind === 'assistant', + ); + expect(taggedBlock).toBeDefined(); + const duplicateIdentity = projectStableTranscriptBlockIds( + [taggedBlock!, { ...taggedBlock!, id: 'duplicate-runtime-id' }], + scopeKey, + ); + const missingIdentity = projectStableTranscriptBlockIds( + [ + { + ...taggedBlock!, + id: 'missing-runtime-id', + segmentId: undefined, + sourceRecordIds: undefined, + }, + ], + scopeKey, + ); - expect(() => - probeIdentity( - [ - { - id: 'status-1', - kind: 'status', - clientReceivedAt: 0, - createdAt: 0, - updatedAt: 0, - text: 'status', - }, - ], - [], - ), - ).toThrow(/Unsupported identity probe block kind: status/u); + expect(duplicateIdentity.compatible).toBe(false); + expect(stableTailIdentity(duplicateIdentity, duplicateIdentity, 0)).toBe( + false, + ); + expect(missingIdentity.compatible).toBe(false); + expect(stableTailIdentity(missingIdentity, missingIdentity, 0)).toBe(false); }); }); diff --git a/integration-tests/chat-transcript-document.test.ts b/integration-tests/chat-transcript-document.test.ts new file mode 100644 index 00000000000..6664d510fd6 --- /dev/null +++ b/integration-tests/chat-transcript-document.test.ts @@ -0,0 +1,652 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { performance as nodePerformance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { + EXPORT_TRANSCRIPT_RENDERER_LIMITS, + EXPORT_TRANSCRIPT_RENDERER_VERSION, +} from '@qwen-code/web-templates'; +import { + EXPORT_TRANSCRIPT_LIMITS_V1, + createExportTranscriptDocumentV1, + type ExportTranscriptBlockV1, + type ExportTranscriptDocumentV1, +} from '../packages/cli/src/ui/utils/export/export-transcript-document.js'; +import { + renderExportTranscriptDocumentToHtml, + toHtml, +} from '../packages/cli/src/ui/utils/export/formatters/html.js'; +import { escapeJsonForHtmlScriptData } from '../packages/cli/src/ui/utils/export/html-script-data.js'; + +const RENDERER_VERSION = EXPORT_TRANSCRIPT_RENDERER_VERSION; +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[]; +} + +const expectedNetwork = JSON.parse( + readFileSync( + resolve(fixtureRoot, 'cases/representative/expected-network.json'), + 'utf8', + ), +) as ExpectedNetwork; + +function replaceDocumentEnvelope(html: string, value: unknown): string { + const idIndex = html.indexOf('id="transcript-document"'); + const openTagEnd = html.indexOf('>', idIndex); + const closeTagStart = html.indexOf('', openTagEnd); + if (idIndex === -1 || openTagEnd === -1 || closeTagStart === -1) { + throw new Error('Transcript document envelope is missing from test HTML.'); + } + return `${html.slice(0, openTagEnd + 1)}${escapeJsonForHtmlScriptData( + JSON.stringify(value), + )}${html.slice(closeTagStart)}`; +} + +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 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 }; +} + +async function expectConnectSrcCspEnforced(page: Page): Promise { + const directive = await page.evaluate( + () => + new Promise((resolve, reject) => { + const timeout = window.setTimeout( + () => reject(new Error('CSP violation was not observed.')), + 2_000, + ); + window.addEventListener( + 'securitypolicyviolation', + (event) => { + window.clearTimeout(timeout); + resolve(event.violatedDirective); + }, + { once: true }, + ); + void fetch('https://qwen-csp-probe.invalid/connect').catch(() => {}); + }), + ); + expect(directive).toBe('connect-src'); +} + +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, + 'packages/cli/src/ui/utils/export/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(EXPORT_TRANSCRIPT_RENDERER_LIMITS).toEqual({ + maxBlocks: EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks, + maxEnvelopeBytes: EXPORT_TRANSCRIPT_LIMITS_V1.maxEnvelopeBytes, + }); + 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(); + } + if (entry['type'] === 'string') { + expect(Number(entry['maxLength'])).toBeGreaterThan(0); + expect(Number(entry['maxLength'])).toBeLessThanOrEqual( + EXPORT_TRANSCRIPT_LIMITS_V1.maxEnvelopeBytes, + ); + } + 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 exportDocument = createMaximumDocument(); + const serialized = JSON.stringify(exportDocument); + const html = renderExportTranscriptDocumentToHtml(exportDocument); + expect(exportDocument.blocks).toHaveLength( + EXPORT_TRANSCRIPT_LIMITS_V1.maxBlocks, + ); + expect(exportDocument.metadata).toMatchObject({ + complete: true, + truncated: false, + }); + const envelopeBytes = new TextEncoder().encode( + escapeJsonForHtmlScriptData(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(html, { 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); + const renderedItemCount = await page + .locator('[data-message-row-key]') + .count(); + expect(renderedItemCount).toBeGreaterThan(0); + 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(await page.locator('body').innerText()).not.toMatch( + /(?:1969-12-31|1970-01-01)/, + ); + await expectConnectSrcCspEnforced(page); + expect(durationMs).toBeLessThan(MAX_DOCUMENT_DURATION_MS); + const heapDeltaBytes = Math.max(0, heapAfter - heapBefore); + expect(heapDeltaBytes).toBeLessThan(MAX_HEAP_DELTA_BYTES); + await page.close(); + await browser.close(); + browser = undefined; + }, 90_000); + + it('renders a stable error page for incompatible document envelopes', async () => { + const document = createExportTranscriptDocumentV1( + [record('error-probe', null, 'user', 'Error probe')], + { + startTime: '2026-08-16T00:00:00.000Z', + metadata: { + sessionId: 'error-probe', + startTime: '2026-08-16T00:00:00.000Z', + exportTime: EXPORTED_AT, + cwd: '/workspace/project', + promptCount: 1, + uniqueFiles: [], + }, + }, + { rendererVersion: RENDERER_VERSION, exportedAt: EXPORTED_AT }, + ); + const html = renderExportTranscriptDocumentToHtml(document); + const invalidDocuments = [ + { ...document, rendererVersion: 'incompatible-renderer' }, + { + ...document, + metadata: { ...document.metadata, title: { invalid: true } }, + }, + ]; + + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + for (const invalidDocument of invalidDocuments) { + await page.setContent(replaceDocumentEnvelope(html, invalidDocument), { + waitUntil: 'load', + }); + await expect + .poll(() => page.locator('body').getAttribute('data-render-complete')) + .toBe('error'); + expect(await page.getByRole('alert').textContent()).toContain( + 'Unable to open this chat export', + ); + } + }); + + it('runs the real HTML export entry point with zero network', async () => { + const records = [ + { + ...record( + 'remote-image', + null, + 'assistant', + [ + '![tracking](https://example.invalid/track.png)', + '[![nested-tracking](https://example.invalid/nested-track.png?u=victim)](https://example.com)', + '![inline-safe](data:image/png;base64,iVBORw0KGgo=)', + 'Literal closing tag: ', + ].join('\n'), + ), + rawInput: CANARY, + }, + ]; + const sessionData = { + sessionId: CANARY, + startTime: '2026-08-16T00:00:00.000Z', + messages: [], + 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], + }, + }; + const html = toHtml(sessionData, records); + + 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'"); + + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const probe = await installNetworkAndCspProbe(page); + await page.setContent(html, { waitUntil: 'load' }); + await expect + .poll(() => page.locator('body').getAttribute('data-render-complete')) + .toBe('true'); + expect(await page.locator('body').innerText()).toContain( + '[image omitted: tracking]', + ); + expect(await page.locator('body').innerText()).toContain( + '[image omitted: nested-tracking]', + ); + expect(await page.locator('body').innerText()).toContain( + 'Literal closing tag: ', + ); + expect( + await page.locator('img[alt="inline-safe"]').getAttribute('src'), + ).toBe('data:image/png;base64,iVBORw0KGgo='); + expect(probe.requests).toHaveLength(expectedNetwork.unexpectedRequests); + expect(probe.cspErrors, probe.cspErrors.join('\n')).toHaveLength( + expectedNetwork.cspViolations, + ); + expect(await page.locator('body').innerText()).not.toMatch( + /(?:1969-12-31|1970-01-01)/, + ); + await expectConnectSrcCspEnforced(page); + await page.close(); + await browser.close(); + browser = undefined; + }); +}); diff --git a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js index 3cadfebad4f..641107dec46 100644 --- a/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js +++ b/integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js @@ -9,475 +9,79 @@ import fs from 'node:fs'; import fsp from 'node:fs/promises'; import path from 'node:path'; import readline from 'node:readline'; +import { collectSessionMetadata, toHtml } from '@qwen-code/qwen-code/export'; -const FAVICON_SVG = - ''; - -const HTML_TEMPLATE = ` - - - - - - - Qwen Code Chat Export - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
${FAVICON_SVG}
- -
-
-
- Session Id - - -
-
- Export Time - - -
-
-
- -
-
- - - - - - - -`; - -function escapeJsonForHtml(json) { - return json - .replace(/&/g, '\\u0026') - .replace(//g, '\\u003e'); -} - -function injectDataIntoHtmlTemplate(template, data) { - const jsonData = JSON.stringify(data, null, 2); - const escapedJsonData = escapeJsonForHtml(jsonData); - return template.replace( - /`, - ); -} - -function toHtml(sessionData) { - return injectDataIntoHtmlTemplate(HTML_TEMPLATE, sessionData); -} +const exportConfig = {}; function printUsage(exitCode) { - const msg = ` + const message = ` Usage: - node scripts/export-html-from-chatrecord-jsonl.js [--out ] - node scripts/export-html-from-chatrecord-jsonl.js - [--out ] + node export-html-from-chatrecord-jsonl.js [--out ] + node export-html-from-chatrecord-jsonl.js - [--out ] -Notes: - - Input JSONL is expected to be "one ChatRecord per line". - - For convenience, this also supports JSONL generated by the existing "toJsonl" formatter - (first line is { type: "session_metadata", ... } then one ExportMessage per line). +Notes: + - Input JSONL is expected to contain one ChatRecord per line. + - The legacy exported JSONL shape is also accepted when source ChatRecords + are unavailable. `; - console.error(msg.trimEnd()); + console.error(message.trimEnd()); process.exit(exitCode); } function parseArgs(argv) { - const out = { - input: null, - output: null, - }; - const args = argv.slice(2); - if (args.length === 0) return out; - - out.input = args[0] ?? null; - for (let i = 1; i < args.length; i += 1) { - const a = args[i]; - if (a === '--out' || a === '-o') { - out.output = args[i + 1] ?? null; - i += 1; - continue; - } - if (a === '--help' || a === '-h') { - printUsage(0); - } - } - return out; -} + if (args.includes('--help') || args.includes('-h')) printUsage(0); + if (args.length === 0) return { input: null, output: null }; -function safeJsonParse(line) { - try { - return JSON.parse(line); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - throw new Error( - `Invalid JSONL line: ${message}\nLine: ${line.slice(0, 200)}`, - ); + let output = null; + for (let index = 1; index < args.length; index += 1) { + if (args[index] !== '--out' && args[index] !== '-o') continue; + output = args[index + 1] ?? null; + index += 1; } + return { input: args[0] ?? null, output }; } async function readJsonlObjects(inputPath) { - const objects = []; - - const inputStream = + const input = inputPath === '-' ? process.stdin : fs.createReadStream(inputPath, { encoding: 'utf8' }); - - const rl = readline.createInterface({ - input: inputStream, - crlfDelay: Infinity, - }); - - for await (const rawLine of rl) { + const lines = readline.createInterface({ input, crlfDelay: Infinity }); + const objects = []; + for await (const rawLine of lines) { const line = String(rawLine).trim(); if (!line) continue; - objects.push(safeJsonParse(line)); + try { + objects.push(JSON.parse(line)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Invalid JSONL line: ${message}\nLine: ${line.slice(0, 200)}`, + ); + } } - return objects; } -function looksLikeChatRecord(obj) { - if (!obj || typeof obj !== 'object') return false; - const r = obj; +function looksLikeChatRecord(value) { return ( - typeof r.uuid === 'string' && - 'parentUuid' in r && - typeof r.sessionId === 'string' && - typeof r.timestamp === 'string' && - typeof r.type === 'string' && - typeof r.cwd === 'string' && - typeof r.version === 'string' + value !== null && + typeof value === 'object' && + typeof value.uuid === 'string' && + 'parentUuid' in value && + typeof value.sessionId === 'string' && + typeof value.timestamp === 'string' && + typeof value.type === 'string' && + typeof value.cwd === 'string' && + typeof value.version === 'string' ); } function looksLikeExportJsonl(objects) { - if (!Array.isArray(objects) || objects.length === 0) return false; const first = objects[0]; return ( - !!first && + first !== null && typeof first === 'object' && first.type === 'session_metadata' && typeof first.sessionId === 'string' && @@ -485,483 +89,76 @@ function looksLikeExportJsonl(objects) { ); } -function computeStartTimeFromRecords(records) { - let min = Number.POSITIVE_INFINITY; - for (const r of records) { - const t = Date.parse(r.timestamp); - if (Number.isFinite(t)) min = Math.min(min, t); - } - if (!Number.isFinite(min)) { - return new Date().toISOString(); - } - return new Date(min).toISOString(); -} - -function extractToolNameFromRecord(record) { - const parts = record?.message?.parts; - if (!Array.isArray(parts)) return ''; - for (const part of parts) { - if (part && typeof part === 'object' && 'functionResponse' in part) { - const fr = part.functionResponse; - if (fr && typeof fr === 'object' && typeof fr.name === 'string') { - return fr.name; - } - } - } - return ''; -} - -const TOOL_NAME_MIGRATION = { - search_file_content: 'grep_search', - replace: 'edit', -}; - -const TOOL_DISPLAY_NAME_BY_NAME = { - edit: 'Edit', - write_file: 'WriteFile', - read_file: 'ReadFile', - read_many_files: 'ReadManyFiles', - grep_search: 'Grep', - glob: 'Glob', - run_shell_command: 'Shell', - todo_write: 'TodoList', - save_memory: 'SaveMemory', - task: 'Task', - skill: 'Skill', - exit_plan_mode: 'ExitPlanMode', - web_fetch: 'WebFetch', - list_directory: 'ListFiles', -}; - -const TOOL_KIND_BY_NAME = { - read_file: 'read', - read_many_files: 'read', - skill: 'read', - edit: 'edit', - write_file: 'edit', - write: 'edit', - delete: 'delete', - move: 'move', - rename: 'move', - grep_search: 'search', - glob: 'search', - list_directory: 'search', - run_shell_command: 'execute', - bash: 'execute', - web_fetch: 'fetch', - todo_write: 'think', - save_memory: 'think', - plan: 'think', - exit_plan_mode: 'switch_mode', - task: 'other', -}; - -function normalizeToolName(toolName) { - if (!toolName) return ''; - return TOOL_NAME_MIGRATION[toolName] ?? toolName; -} - -function resolveToolKind(toolName) { - const normalizedName = normalizeToolName(toolName); - return TOOL_KIND_BY_NAME[normalizedName] ?? 'other'; -} - -function resolveToolTitle(toolName) { - const normalizedName = normalizeToolName(toolName); - return ( - TOOL_DISPLAY_NAME_BY_NAME[normalizedName] ?? normalizedName ?? 'tool_call' - ); -} - -function normalizeRawInput(value) { - if (typeof value === 'string') return value; - if (typeof value === 'object' && value !== null) return value; - return undefined; -} - -/** - * Extract locations from rawInput or toolCallResult for file-related tool calls. - * This ensures the exported data matches ACP format, enabling file links in UI. - * - * @param {object|undefined} rawInput - The raw input arguments of the tool call - * @param {object|undefined} toolCallResult - The tool call result object - * @returns {Array<{path: string, line?: number}>|undefined} - Locations array or undefined - */ -function extractLocations(rawInput, toolCallResult) { - const locations = []; - - // Extract from rawInput - common path field names used by various tools - if (rawInput && typeof rawInput === 'object') { - // read_file, write_file, edit tool use file_path - if (typeof rawInput.file_path === 'string' && rawInput.file_path) { - locations.push({ path: rawInput.file_path }); - } - // some tools use just 'path' - else if (typeof rawInput.path === 'string' && rawInput.path) { - locations.push({ path: rawInput.path }); - } - // glob/grep tools use 'pattern' with optional 'path' as search root - else if (typeof rawInput.pattern === 'string' && rawInput.pattern) { - // For search tools, the pattern itself isn't a file path, skip - } - // run_shell_command might have 'command' but no file path - } - - // Extract from toolCallResult.resultDisplay if available - if (toolCallResult && typeof toolCallResult === 'object') { - const display = toolCallResult.resultDisplay; - if (display && typeof display === 'object') { - if (typeof display.fileName === 'string' && display.fileName) { - // Avoid duplicates - if (!locations.some((loc) => loc.path === display.fileName)) { - locations.push({ path: display.fileName }); - } - } - } - } - - return locations.length > 0 ? locations : undefined; -} - -function extractDiffContent(resultDisplay) { - if (!resultDisplay || typeof resultDisplay !== 'object') return null; - const display = resultDisplay; - if ('fileName' in display && 'newContent' in display) { - return [ - { - type: 'diff', - path: display.fileName, - oldText: display.originalContent ?? '', - newText: display.newContent, - }, - ]; - } - return null; -} - -function transformPartsToToolCallContent(parts) { - const content = []; - for (const part of parts ?? []) { - if (part && typeof part === 'object' && 'text' in part && part.text) { - content.push({ - type: 'content', - content: { type: 'text', text: part.text }, - }); - continue; - } - - if ( - part && - typeof part === 'object' && - 'functionResponse' in part && - part.functionResponse - ) { - const fr = part.functionResponse; - const response = - fr.response && typeof fr.response === 'object' ? fr.response : {}; - const outputField = response.output; - const errorField = response.error; - const responseText = - typeof outputField === 'string' - ? outputField - : typeof errorField === 'string' - ? errorField - : JSON.stringify(response); - content.push({ - type: 'content', - content: { type: 'text', text: responseText }, - }); - } - } - return content; -} - -function mergeToolCallData(existing, incoming) { - if (!existing.content || existing.content.length === 0) { - existing.content = incoming.content; - } - if (existing.status === 'pending' || existing.status === 'in_progress') { - existing.status = incoming.status; - } - if (!existing.rawInput && incoming.rawInput) { - existing.rawInput = incoming.rawInput; - } - if ((!existing.title || existing.title === '') && incoming.title) { - existing.title = incoming.title; - } - if ((!existing.kind || existing.kind === 'other') && incoming.kind) { - existing.kind = incoming.kind; - } - if ( - (!existing.locations || existing.locations.length === 0) && - incoming.locations?.length - ) { - existing.locations = incoming.locations; - } - if (!existing.timestamp && incoming.timestamp) { - existing.timestamp = incoming.timestamp; - } -} - -function convertChatRecordsToSessionData(records) { - if (!Array.isArray(records) || records.length === 0) { - return { - sessionId: 'unknown-session', - startTime: new Date().toISOString(), - messages: [], - }; - } - - const sessionId = records[0]?.sessionId ?? 'unknown-session'; - const startTime = computeStartTimeFromRecords(records); - - const messages = []; - const toolCallIndexById = new Map(); - - let currentMessage = null; - function flushCurrentMessage() { - if (!currentMessage) return; - messages.push({ - uuid: currentMessage.uuid, - parentUuid: currentMessage.parentUuid, - sessionId: currentMessage.sessionId, - timestamp: currentMessage.timestamp, - type: currentMessage.type, - message: { - role: currentMessage.role, - parts: currentMessage.parts, - }, - model: currentMessage.model, - }); - currentMessage = null; - } - - function handleMessageChunk( - record, - roleType, - content, - messageRole = roleType, - ) { - if (!content || content.type !== 'text' || !content.text) return; - if ( - currentMessage && - (currentMessage.type !== roleType || currentMessage.role !== messageRole) - ) { - flushCurrentMessage(); - } - - if ( - currentMessage && - currentMessage.type === roleType && - currentMessage.role === messageRole - ) { - currentMessage.parts.push({ text: content.text }); - return; - } - - currentMessage = { - uuid: record.uuid, - parentUuid: record.parentUuid, - sessionId: record.sessionId, - timestamp: record.timestamp, - type: roleType, - role: messageRole, - parts: [{ text: content.text }], - model: record.model, - }; - } - - function addOrMergeToolCallMessage(toolCallMessage) { - const id = toolCallMessage?.toolCall?.toolCallId; - if (!id) { - messages.push(toolCallMessage); - return; - } - - const existingIndex = toolCallIndexById.get(id); - if (existingIndex === undefined) { - toolCallIndexById.set(id, messages.length); - messages.push(toolCallMessage); - return; - } - - const existing = messages[existingIndex]; - if (!existing || existing.type !== 'tool_call' || !existing.toolCall) { - return; - } - mergeToolCallData(existing.toolCall, toolCallMessage.toolCall); - } - +function startTimeFor(records) { + let earliest = Number.POSITIVE_INFINITY; for (const record of records) { - if (!record || typeof record !== 'object') continue; - switch (record.type) { - case 'user': { - for (const part of record.message?.parts ?? []) { - if (part && typeof part === 'object' && 'text' in part && part.text) { - handleMessageChunk( - record, - 'user', - { type: 'text', text: part.text }, - 'user', - ); - } - } - break; - } - - case 'assistant': { - for (const part of record.message?.parts ?? []) { - if (part && typeof part === 'object' && 'text' in part && part.text) { - const isThought = (part.thought ?? false) === true; - handleMessageChunk( - record, - 'assistant', - { type: 'text', text: part.text }, - isThought ? 'thinking' : 'assistant', - ); - continue; - } - - if ( - part && - typeof part === 'object' && - 'functionCall' in part && - part.functionCall - ) { - flushCurrentMessage(); - const fc = part.functionCall; - const toolName = normalizeToolName( - typeof fc.name === 'string' ? fc.name : '', - ); - // Match ToolCallEmitter behavior: skip tool_call start event for todo_write. - if (toolName === 'todo_write') { - continue; - } - const toolCallId = - typeof fc.id === 'string' && fc.id - ? fc.id - : `${toolName || 'tool'}-${record.uuid}`; - const rawInput = normalizeRawInput(fc.args); - const toolCallMessage = { - uuid: record.uuid, - parentUuid: record.parentUuid, - sessionId: record.sessionId, - timestamp: record.timestamp, - type: 'tool_call', - toolCall: { - toolCallId, - kind: resolveToolKind(toolName), - title: resolveToolTitle(toolName), - status: 'in_progress', - rawInput, - locations: extractLocations(rawInput, undefined), - timestamp: Date.parse(record.timestamp), - }, - }; - addOrMergeToolCallMessage(toolCallMessage); - } - } - break; - } - - case 'tool_result': { - flushCurrentMessage(); - - const toolCallResult = record.toolCallResult ?? {}; - const toolCallId = toolCallResult.callId ?? record.uuid; - const toolName = normalizeToolName(extractToolNameFromRecord(record)); - const rawInput = normalizeRawInput(toolCallResult.args); - - const content = - extractDiffContent(toolCallResult.resultDisplay) ?? - transformPartsToToolCallContent(record.message?.parts ?? []); - - const toolCallMessage = { - uuid: record.uuid, - parentUuid: record.parentUuid, - sessionId: record.sessionId, - timestamp: record.timestamp, - type: 'tool_call', - toolCall: { - toolCallId, - kind: resolveToolKind(toolName), - title: resolveToolTitle(toolName), - status: toolCallResult.error ? 'failed' : 'completed', - rawInput, - content, - locations: extractLocations(rawInput, toolCallResult), - timestamp: Date.parse(record.timestamp), - }, - }; - - addOrMergeToolCallMessage(toolCallMessage); - break; - } - - default: { - // Skip system records or unknown types. - break; - } - } + const timestamp = Date.parse(record.timestamp); + if (Number.isFinite(timestamp)) earliest = Math.min(earliest, timestamp); } + return Number.isFinite(earliest) + ? new Date(earliest).toISOString() + : new Date().toISOString(); +} - flushCurrentMessage(); - - return { sessionId, startTime, messages }; +async function buildProductSessionData(records) { + const conversation = { + sessionId: records[0]?.sessionId ?? 'unknown-session', + startTime: startTimeFor(records), + messages: records, + }; + return { + sessionId: conversation.sessionId, + startTime: conversation.startTime, + messages: [], + metadata: await collectSessionMetadata(conversation, exportConfig), + }; } -function buildSessionDataFromExportJsonl(objects) { - const first = objects[0]; - const sessionId = first.sessionId; - const startTime = first.startTime; - const messages = objects.slice(1); - return { sessionId, startTime, messages }; +function buildLegacySessionData(objects) { + const [metadata, ...messages] = objects; + return { + sessionId: metadata.sessionId, + startTime: metadata.startTime, + messages, + }; } -function defaultOutPathForInput(inputPath) { - if (!inputPath || inputPath === '-') - return path.resolve(process.cwd(), 'export.html'); - const base = path.basename(inputPath, path.extname(inputPath)); - const dir = path.dirname(inputPath); - return path.resolve(dir, `${base}.html`); +function defaultOutPath(inputPath) { + if (inputPath === '-') return path.resolve(process.cwd(), 'export.html'); + const directory = path.dirname(inputPath); + const basename = path.basename(inputPath, path.extname(inputPath)); + return path.resolve(directory, `${basename}.html`); } async function main() { const { input, output } = parseArgs(process.argv); - if (!input) { - printUsage(1); - } + if (!input) printUsage(1); const objects = await readJsonlObjects(input); - if (objects.length === 0) { - throw new Error('Input JSONL is empty.'); - } + if (objects.length === 0) throw new Error('Input JSONL is empty.'); let sessionData; + let records; if (looksLikeExportJsonl(objects)) { - sessionData = buildSessionDataFromExportJsonl(objects); - } else if (objects.every(looksLikeChatRecord)) { - sessionData = convertChatRecordsToSessionData(objects); - } else if (objects.some(looksLikeChatRecord)) { - // Mixed input: keep only ChatRecord-like entries for best-effort export. - const records = objects.filter(looksLikeChatRecord); - sessionData = convertChatRecordsToSessionData(records); + sessionData = buildLegacySessionData(objects); } else { - throw new Error( - 'Unrecognized JSONL format (expected ChatRecord-per-line).', - ); + records = objects.filter(looksLikeChatRecord); + if (records.length === 0) { + throw new Error( + 'Unrecognized JSONL format (expected ChatRecord-per-line).', + ); + } + sessionData = await buildProductSessionData(records); } - const html = toHtml(sessionData); - const outPath = output ? path.resolve(output) : defaultOutPathForInput(input); - - await fsp.mkdir(path.dirname(outPath), { recursive: true }); - await fsp.writeFile(outPath, html, 'utf8'); - console.log(`Wrote HTML export to: ${outPath}`); + const html = toHtml(sessionData, records); + const outputPath = output ? path.resolve(output) : defaultOutPath(input); + await fsp.mkdir(path.dirname(outputPath), { recursive: true }); + await fsp.writeFile(outputPath, html, 'utf8'); + console.log(`Wrote HTML export to: ${outputPath}`); } -main().catch((err) => { - const message = err instanceof Error ? err.message : String(err); - console.error(message); +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); 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..ee76b5ea10a 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,22 @@ -# 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 | Real consumer mapping | Consumers | Fixture/evidence | Owner | Gate | +| ---------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------- | ------------------ | -------------------------------------------------- | +| user/assistant/thought | fixture-stamped candidate or persisted segment ID | runtime ordinal ID remains unchanged; test probe can project native identity | tagged segments remain separate in the actual Web Shell document projector | Web, Tauri, HTML | SDK + direct-daemon/ACP append/prepend/replay tests | CLI + SDK UI | pass; stable under append/prepend/replay | +| tools and grouping | tool call ID | runtime keeps raw fields; document uses typed preview/result only | actual Web Shell tool grouping and document renderer | Web runtime + HTML | raw compatibility, raw-free document, Web Shell adapter tests | SDK UI + Web Shell | pass | +| plan/todo | plan tool call ID and plan ID | runtime keeps raw fields; document uses typed `todo_list` preview/result | actual Web Shell plan/todo renderer | Web runtime + HTML | 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 uses safe identity only | readonly Web Shell history; active response remains host-owned | Web runtime + HTML | raw compatibility and raw-free permission tests | SDK UI + Web Shell | pass | +| replay/prepend | stable native identity | source metadata and persisted record boundaries are preserved | integration-only candidate projection; no VS Code product adapter in MR2A | VS Code candidate | contract probe | CLI + SDK UI | pass; product consumption deferred to MR2B | +| scope isolation | ACP session ID | candidate contract remains explicit | no VS Code live transcript consumer in MR2A | VS Code candidate | MR1/MR2A contract evidence | VS Code | deferred; product lifecycle coverage moves to MR2B | +| VS Code direct daemon | daemon event plus source segment ID | SDK reducer plus read-only stable-ID projection | no product transport in this MR | VS Code candidate | direct-daemon contract matrix | VS Code | pass; validated alternative only | +| VS Code ACP | Qwen ACP live/history segment metadata | test-only thin projection validates the candidate contract | no product adapter or shared renderer dependency in MR2A | VS Code candidate | contract identity matrix | VS Code | deferred; product selection moves to MR2B | +| VS Code host actions | stable rendered item/source identity | legacy VS Code timeline remains authoritative | no new host-action seam in MR2A | VS Code | existing legacy tests | VS Code | deferred to MR2B | +| Tauri distribution | packaged qwen runtime | same daemon blocks and Web Shell build | existing Desktop runtime | 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 | CLI, Web API and VS Code call the actual document exporter | HTML | export policy and formatter tests | CLI | pass | +| export block safety | projected block | canonical JSON Schema for structure; semantic URL/path and aggregate budgets remain explicit | version-bound product HTML template | HTML | schema, canary and product formatter tests | CLI | pass | +| Markdown/resources | Markdown image URL and structured raster | approved data raster only in document mode | actual product HTML/CSP | HTML | static assertions, active CSP probe and browser interception | Web Shell | pass | +| document budgets | shared V1 constant | block/text/image/envelope/depth/array/property/rich-task caps | actual document renderer | HTML | schema/builder tests and maximum browser gate | 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 | +MR2A contract tests keep candidate identity checks inside integration test helpers rather than adding an unconsumed VS Code production adapter. The browser gate and concurrent runner generate HTML through the product formatter. -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 test matrix without changing default reducer ordinal IDs, but MR2A does not select or ship either product path. Product selection and the live timeline move to MR2B. The product HTML browser gate passes; the overall gate remains `fail`. 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-gate.json b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-gate.json index 26787c0bf40..9d9bb09376b 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-gate.json +++ b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/expected-gate.json @@ -3,20 +3,19 @@ "selectedVscodePath": null, "candidates": { "directDaemon": { - "status": "fail", - "stableUnderPartialPrepend": false, - "unstableBlockKinds": ["thought", "assistant", "tool", "permission"], - "missingNativeTextIdentity": ["user", "thought", "assistant"] + "status": "pass", + "stableUnderPartialPrepend": true, + "unstableBlockKinds": [], + "missingNativeTextIdentity": [] }, "acp": { - "status": "fail", - "stableUnderPartialPrepend": false, - "unstableBlockKinds": ["thought", "assistant", "tool"], - "missingNativeTextIdentity": ["user", "thought", "assistant"] + "status": "pass", + "stableUnderPartialPrepend": true, + "unstableBlockKinds": [], + "missingNativeTextIdentity": [] } }, "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" + "VS Code product path selection, live timeline migration, host-action parity, VSIX, and packaged artifact gates move to MR2B" ] } 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..84838f3f0a9 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 @@ -10,5 +10,6 @@ ["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/manifest.json b/integration-tests/fixtures/chat-transcript-contract/v1/cases/representative/manifest.json index 555c9d67a28..6ad8d7a5d26 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,28 @@ { "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": "5c8469615014fadea69fbc047d791add611e077e59f398932bfa4777e4940e67", + "expected-render-items.json": "d51acc8a0b6282898fec49f1870c0e78c901af5136b9866da3da874722d1db7b", + "expected-export.json": "23521fd1203ffa6d47b0a93ac3de6481302fc9271e9150e77496ed42abdc063c", + "expected-gate.json": "d87b72e7995d2d96dbf77948d443f493b81a1737c9246414fd1b16167ae2440f", + "expected-network.json": "ee14c9469e2f80f1262a23ca8452bc41abb9ce43a625176c1b428608c503ad11" } } diff --git a/integration-tests/helpers/chat-transcript-contract.ts b/integration-tests/helpers/chat-transcript-contract.ts new file mode 100644 index 00000000000..d584bd0186a --- /dev/null +++ b/integration-tests/helpers/chat-transcript-contract.ts @@ -0,0 +1,171 @@ +import { readFileSync } from 'node:fs'; +import { + createDaemonTranscriptState, + normalizeDaemonEvent, + reduceDaemonTranscriptEvents, + type DaemonEvent, + type DaemonTranscriptBlock, + type DaemonTranscriptState, +} from '@qwen-code/sdk/daemon'; +import { transcriptBlocksToDaemonMessages } from '../../packages/web-shell/client/adapters/transcriptToMessages.js'; + +export interface TranscriptCandidate { + readonly blocks: readonly DaemonTranscriptBlock[]; + readonly compatible: boolean; +} + +interface AcpTranscriptProbeState extends TranscriptCandidate { + readonly transcript: DaemonTranscriptState; +} + +export function readJsonLines(path: string): unknown[] { + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as unknown); +} + +export function adaptDirectDaemonEvents( + events: readonly DaemonEvent[], + scopeKey: string, +): TranscriptCandidate { + const transcript = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 0 }), + events.flatMap((event) => normalizeDaemonEvent(event)), + { now: 0 }, + ); + return projectStableTranscriptBlockIds(transcript.blocks, scopeKey); +} + +export function adaptAcpTranscriptUpdates( + updates: readonly unknown[], + scopeKey: string, +): TranscriptCandidate { + return updates.reduce( + (state, update) => { + const transcript = reduceDaemonTranscriptEvents( + state.transcript, + normalizeDaemonEvent({ + v: 1, + type: 'session_update', + data: { update }, + }), + { now: 0, maxBlocks: Number.MAX_SAFE_INTEGER }, + ); + return { + transcript, + ...projectStableTranscriptBlockIds(transcript.blocks, scopeKey), + }; + }, + { + transcript: createDaemonTranscriptState({ + now: 0, + maxBlocks: Number.MAX_SAFE_INTEGER, + }), + blocks: [], + compatible: true, + }, + ); +} + +export function projectStableTranscriptBlockIds( + blocks: readonly DaemonTranscriptBlock[], + scopeKey: string, +): TranscriptCandidate { + const stableIdByRuntimeId = new Map(); + const seen = new Set(); + let compatible = true; + for (const block of blocks) { + const identity = getBlockIdentity(block); + if (!identity) { + compatible = false; + continue; + } + const id = `${block.kind}-${hashIdentity([ + scopeKey, + block.kind, + ...identity, + ])}`; + if (seen.has(id)) compatible = false; + seen.add(id); + stableIdByRuntimeId.set(block.id, id); + } + const projected = 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, + }; + }); + return { blocks: projected, compatible }; +} + +function getBlockIdentity( + block: DaemonTranscriptBlock, +): readonly string[] | undefined { + if (block.kind === 'tool') return ['toolCallId', block.toolCallId]; + if (block.kind === 'permission') return ['requestId', block.requestId]; + if ( + block.kind === 'user' && + block.sourceRecordIds && + block.sourceRecordIds.length > 0 + ) { + return ['sourceRecordIds', ...block.sourceRecordIds]; + } + if (block.segmentId) return ['segmentId', block.segmentId]; + if ( + block.kind === 'user' || + block.kind === 'assistant' || + block.kind === 'thought' + ) { + return undefined; + } + return block.eventId === undefined + ? undefined + : ['eventId', String(block.eventId)]; +} + +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')}`; +} + +export function stableTailIdentity( + complete: TranscriptCandidate, + tail: TranscriptCandidate, + completeOffset = 1, +): boolean { + if (!complete.compatible || !tail.compatible) return false; + if ( + JSON.stringify( + complete.blocks.slice(completeOffset).map((block) => block.id), + ) !== JSON.stringify(tail.blocks.map((block) => block.id)) + ) { + return false; + } + const completeMessages = transcriptBlocksToDaemonMessages(complete.blocks); + const tailMessages = transcriptBlocksToDaemonMessages(tail.blocks); + return ( + JSON.stringify( + completeMessages + .slice(completeOffset) + .map((message) => [message.id, message.role]), + ) === + JSON.stringify(tailMessages.map((message) => [message.id, message.role])) + ); +} diff --git a/package-lock.json b/package-lock.json index b5c72eeddc5..c6f63ad0262 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29804,6 +29804,9 @@ "qrcode-terminal": "^0.12.0", "react": "^19.2.4", "read-package-up": "^11.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", "shell-quote": "^1.9.0", "simple-git": "^3.36.0", "string-width": "^7.1.0", @@ -29811,6 +29814,7 @@ "strip-json-comments": "^3.1.1", "tar": "^7.5.19", "undici": "^7.28.0", + "unified": "^11.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.18.0", "yaml": "^2.8.1", @@ -34044,11 +34048,15 @@ "name": "@qwen-code/web-templates", "version": "0.22.3", "devDependencies": { + "@qwen-code/sdk": "file:../sdk-typescript", + "@qwen-code/web-shell": "file:../web-shell", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.2.0", "autoprefixer": "^10.4.22", "postcss": "^8.5.6", + "react": "^19.2.4", + "react-dom": "^19.2.4", "tailwindcss": "^3.4.18", "typescript": "^5.3.3", "vite": "^5.0.0" diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index 10e14151a00..d9f91e68098 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'; @@ -78,6 +79,58 @@ function goalCardRecord( } describe('createTranscriptReplayMachine', () => { + it('stamps stable segment identity across replayed text parts', () => { + const projected = updates( + createTranscriptReplayMachine(), + record('assistant-1', 'assistant', { + message: { + role: 'model', + parts: [ + { text: 'first' }, + { text: 'second' }, + { text: 'thinking', thought: true }, + ], + }, + }), + ); + const segmentIds = projected.map( + (update) => + ( + update._meta as + | { qwenTranscript?: { segmentId?: string } } + | undefined + )?.qwenTranscript?.segmentId, + ); + + expect(segmentIds).toEqual([ + 'assistant-1:0', + 'assistant-1:0', + 'assistant-1:2', + ]); + }); + + 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( @@ -1409,6 +1462,18 @@ describe('createTranscriptReplayMachine', () => { 'agent_message_chunk', 'agent_message_chunk', ]); + expect( + assistant + .slice(0, 2) + .map( + (update) => + ( + update._meta as + | { qwenTranscript?: { segmentId?: string } } + | undefined + )?.qwenTranscript?.segmentId, + ), + ).toEqual(['assistant-1:0', 'assistant-1:1']); const plan = updates( machine, diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index 7e5c37dc8f2..132d9e43930 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; @@ -103,6 +104,7 @@ interface UpdateMetaOptions { readonly sourceRecordIds?: readonly string[]; readonly planToolCallId?: string; readonly todoPlanId?: string; + readonly resultPreviewText?: string; readonly extra?: Readonly>; } @@ -252,6 +254,9 @@ function buildUpdateMeta( ...(options.planToolCallId ? { planToolCallId: options.planToolCallId } : {}), + ...(options.resultPreviewText + ? { resultPreviewText: options.resultPreviewText } + : {}), }; const meta: Record = { ...(options.extra ?? {}), @@ -381,6 +386,7 @@ export function createTranscriptToolCallResultUpdate( content, _meta: buildUpdateMeta({ ...options, + resultPreviewText: getToolContentText(options.contentPrefix), extra: { toolName: options.toolName, provenance: provenance.provenance, @@ -397,6 +403,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; +} + function getReplayRawOutput(resultDisplay: unknown): unknown { if (!isTruncatedSessionDiffDisplay(resultDisplay)) return resultDisplay; if ( @@ -515,12 +539,33 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { ); } let ordinal = 0; - const emit = (update: SessionUpdate): TranscriptReplayEmission => ({ - sourceRecordId: record.uuid, - ...(record.timestamp ? { sourceTimestamp: record.timestamp } : {}), - emissionOrdinal: ordinal++, - update, - }); + let activeSegmentLane: string | undefined; + let activeSegmentId: string | undefined; + const emit = (update: SessionUpdate): TranscriptReplayEmission => { + const emissionOrdinal = ordinal++; + const lane = transcriptSegmentLane(update); + if (lane && (lane !== activeSegmentLane || !activeSegmentId)) { + activeSegmentLane = lane; + activeSegmentId = `${record.uuid}:${emissionOrdinal}`; + } else if (!lane && isTranscriptSegmentBoundary(update)) { + activeSegmentLane = undefined; + activeSegmentId = undefined; + } + const projectedUpdate = + lane && activeSegmentId + ? withTranscriptSegmentId(update, activeSegmentId) + : update; + if (isTranscriptDiscreteMessage(update)) { + activeSegmentLane = undefined; + activeSegmentId = undefined; + } + return { + sourceRecordId: record.uuid, + ...(record.timestamp ? { sourceTimestamp: record.timestamp } : {}), + emissionOrdinal, + update: projectedUpdate, + }; + }; const meta = { timestamp: record.timestamp, sourceRecordIds: [record.uuid], @@ -1165,6 +1210,80 @@ 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 transcriptSegmentLane(update: SessionUpdate): string | undefined { + const record = update as unknown as Record; + const kind = record['sessionUpdate']; + const meta = isObjectRecord(record['_meta']) ? record['_meta'] : undefined; + const parentToolCallId = + typeof meta?.['parentToolCallId'] === 'string' + ? meta['parentToolCallId'] + : 'root'; + if ( + kind === 'user_message_chunk' || + kind === 'agent_message_chunk' || + kind === 'agent_thought_chunk' + ) { + const content = isObjectRecord(record['content']) + ? record['content'] + : undefined; + const contentType = + typeof content?.['type'] === 'string' ? content['type'] : undefined; + if (!contentType) return undefined; + if ( + contentType === 'text' && + (typeof content?.['text'] !== 'string' || content['text'].length === 0) + ) { + return undefined; + } + return `${String(kind)}:${contentType}:${parentToolCallId}`; + } + if (kind === 'shell_output' || kind === 'tool_output') { + const source = typeof meta?.['source'] === 'string' ? meta['source'] : ''; + const stream = typeof record['stream'] === 'string' ? record['stream'] : ''; + return `${String(kind)}:${source}:${stream}`; + } + return undefined; +} + +function isTranscriptSegmentBoundary(update: SessionUpdate): boolean { + const record = update as unknown as Record; + const kind = record['sessionUpdate']; + return ( + typeof kind === 'string' && + kind !== 'agent_message_chunk' && + kind !== 'agent_thought_chunk' && + kind !== 'user_message_chunk' + ); +} + +function isTranscriptDiscreteMessage(update: SessionUpdate): boolean { + const record = update as unknown as Record; + const meta = isObjectRecord(record['_meta']) ? record['_meta'] : undefined; + return meta?.['qwenDiscreteMessage'] === true; +} + function projectGoalControlCommand( cause: GoalStateCause, snapshot: GoalSnapshotV2, diff --git a/packages/cli/package.json b/packages/cli/package.json index eab3daadd75..fae9b1e9a33 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -84,12 +84,16 @@ "qrcode-terminal": "^0.12.0", "react": "^19.2.4", "read-package-up": "^11.0.0", + "remark-gfm": "^4.0.0", + "remark-math": "^6.0.0", + "remark-parse": "^11.0.0", "shell-quote": "^1.9.0", "simple-git": "^3.36.0", "string-width": "^7.1.0", "strip-ansi": "^7.1.0", "strip-json-comments": "^3.1.1", "tar": "^7.5.19", + "unified": "^11.0.0", "undici": "^7.28.0", "wrap-ansi": "^10.0.0", "ws": "^8.18.0", diff --git a/packages/cli/src/acp-integration/session/history-replayer.test.ts b/packages/cli/src/acp-integration/session/history-replayer.test.ts index 24fdd2335f2..67956b6b830 100644 --- a/packages/cli/src/acp-integration/session/history-replayer.test.ts +++ b/packages/cli/src/acp-integration/session/history-replayer.test.ts @@ -80,6 +80,17 @@ describe('HistoryReplayer', () => { timestamp: toEpochMs(record.timestamp), qwenTranscript: { sourceRecordIds: [record.uuid] }, }); + const replayTextMeta = ( + record: ChatRecord, + segmentOrdinal = 0, + extra: Record = {}, + ) => ({ + ...replayMeta(record, extra), + qwenTranscript: { + sourceRecordIds: [record.uuid], + segmentId: `${record.uuid}:${segmentOrdinal}`, + }, + }); const sentUpdates = () => sendUpdateSpy.mock.calls.map( (call: unknown[]) => call[0] as Record, @@ -183,7 +194,7 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'Hello, world!' }, - _meta: replayMeta(record), + _meta: replayTextMeta(record), }); }); @@ -212,7 +223,7 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'save logs' }, - _meta: replayMeta(record, { + _meta: replayTextMeta(record, 0, { source: 'mid_turn_message_injected', qwenDiscreteMessage: true, }), @@ -230,7 +241,7 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'I can help with that.' }, - _meta: replayMeta(record), + _meta: replayTextMeta(record), }); }); @@ -243,7 +254,7 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking about this...' }, - _meta: replayMeta(record), + _meta: replayTextMeta(record), }); }); @@ -266,17 +277,17 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy.mock.calls[0][0]).toEqual({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'First part' }, - _meta: replayMeta(record), + _meta: replayTextMeta(record, 0), }); expect(sendUpdateSpy.mock.calls[1][0]).toEqual({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Second part' }, - _meta: replayMeta(record), + _meta: replayTextMeta(record, 1), }); expect(sendUpdateSpy.mock.calls[2][0]).toEqual({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Third part' }, - _meta: replayMeta(record), + _meta: replayTextMeta(record, 2), }); }); }); @@ -1211,7 +1222,7 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy).toHaveBeenCalledWith({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Context compressed.' }, - _meta: replayMeta(systemRecord, { + _meta: replayTextMeta(systemRecord, 0, { source: 'slash_command', }), }); @@ -1482,7 +1493,7 @@ describe('HistoryReplayer', () => { expect(sendUpdateSpy).toHaveBeenNthCalledWith(1, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello!' }, - _meta: replayMeta(record), + _meta: replayTextMeta(record), }); expect(sendUpdateSpy).toHaveBeenNthCalledWith(2, { sessionUpdate: 'agent_message_chunk', diff --git a/packages/cli/src/export/index.ts b/packages/cli/src/export/index.ts index 04d1a7d4aaf..98737a1edd3 100644 --- a/packages/cli/src/export/index.ts +++ b/packages/cli/src/export/index.ts @@ -6,6 +6,7 @@ export { collectSessionData, + collectSessionMetadata, generateExportFilename, normalizeSessionData, toHtml, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 52587df3314..c4f3c05f0bb 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -24868,7 +24868,7 @@ describe('createServeApp', () => { expect(res.headers['content-disposition']).toMatch( /^attachment; filename="qwen-code-export-.+\.html"$/, ); - expect(res.text).toContain('id="chat-data"'); + expect(res.text).toContain('id="transcript-document"'); expect(res.text).toContain('hello export'); expect(res.text).toContain('export response'); }); diff --git a/packages/cli/src/serve/server/session-export.test.ts b/packages/cli/src/serve/server/session-export.test.ts index d7e11e9b51f..3fe0e00f9ef 100644 --- a/packages/cli/src/serve/server/session-export.test.ts +++ b/packages/cli/src/serve/server/session-export.test.ts @@ -101,4 +101,21 @@ describe('exportSessionTranscript', () => { expect(loadSession).toHaveBeenCalledWith(sessionId); expect(loadArchivedSession).not.toHaveBeenCalled(); }); + + it('passes original records to the HTML document renderer', async () => { + vi.spyOn(SessionService.prototype, 'loadSession').mockResolvedValue( + sessionData, + ); + + await exportSessionTranscript({ + workspaceCwd: '/workspace', + sessionId, + format: 'html', + }); + + expect(toHtml).toHaveBeenCalledWith( + expect.objectContaining({ sessionId }), + sessionData.conversation.messages, + ); + }); }); diff --git a/packages/cli/src/serve/server/session-export.ts b/packages/cli/src/serve/server/session-export.ts index 90159760692..3e8635ae8d6 100644 --- a/packages/cli/src/serve/server/session-export.ts +++ b/packages/cli/src/serve/server/session-export.ts @@ -28,7 +28,7 @@ export type SessionExportFormat = (typeof SESSION_EXPORT_FORMATS)[number]; interface ExportFormatDefinition { mimeType: string; - render: (data: ExportSessionData) => string; + render: (data: ExportSessionData, records?: readonly unknown[]) => string; } const EXPORT_FORMATS: Record = { @@ -110,6 +110,9 @@ export async function exportSessionTranscript(params: { format, filename: generateExportFilename(format), mimeType: formatDefinition.mimeType, - content: formatDefinition.render(normalized), + content: formatDefinition.render( + normalized, + format === 'html' ? sessionData.conversation.messages : undefined, + ), }; } diff --git a/packages/cli/src/ui/commands/exportCommand.test.ts b/packages/cli/src/ui/commands/exportCommand.test.ts index 1d4dca42697..8986dcda66c 100644 --- a/packages/cli/src/ui/commands/exportCommand.test.ts +++ b/packages/cli/src/ui/commands/exportCommand.test.ts @@ -834,7 +834,10 @@ describe('exportCommand', () => { expect.anything(), ); expect(normalizeSessionData).toHaveBeenCalled(); - expect(toHtml).toHaveBeenCalled(); + expect(toHtml).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'test-session-id' }), + mockSessionData.conversation.messages, + ); expect(generateExportFilename).toHaveBeenCalledWith('html'); expect(fs.writeFile).toHaveBeenCalledWith( expect.stringContaining('export-2025-01-01T00-00-00-000Z.html'), diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index c7ec86f66fa..207804b5102 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -32,7 +32,10 @@ import { t } from '../../i18n/index.js'; type ExportFormat = { extension: string; displayName: string; - format: (sessionData: ExportSessionData) => string; + format: ( + sessionData: ExportSessionData, + records?: readonly unknown[], + ) => string; }; const EXPORT_DIR_OUT_OF_CWD = @@ -317,7 +320,10 @@ async function exportSessionAction( config, ); - const content = exportFormat.format(normalizedData); + const content = exportFormat.format( + normalizedData, + exportFormat.extension === 'html' ? conversation.messages : undefined, + ); if (target.outputDirKind === 'custom') { try { diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index 28b3982a729..9626b34760d 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -341,7 +341,7 @@ function createExportSessionConfig(config: ExportConfig): Config { /** * Extract session metadata from ChatRecords. */ -async function extractMetadata( +export async function collectSessionMetadata( conversation: { sessionId: string; startTime: string; @@ -700,7 +700,7 @@ export async function collectSessionData( const messages = exportContext.getMessages(); // Extract metadata from conversation - const metadata = await extractMetadata(conversation, config); + const metadata = await collectSessionMetadata(conversation, config); return { sessionId: conversation.sessionId, diff --git a/integration-tests/fixtures/chat-transcript-contract/v1/schema/export-transcript-document-v1.schema.json b/packages/cli/src/ui/utils/export/export-transcript-document-v1.schema.json similarity index 88% rename from integration-tests/fixtures/chat-transcript-contract/v1/schema/export-transcript-document-v1.schema.json rename to packages/cli/src/ui/utils/export/export-transcript-document-v1.schema.json index d0ee8ac062b..c8809babd17 100644 --- a/integration-tests/fixtures/chat-transcript-contract/v1/schema/export-transcript-document-v1.schema.json +++ b/packages/cli/src/ui/utils/export/export-transcript-document-v1.schema.json @@ -31,7 +31,11 @@ "additionalProperties": false, "required": ["code", "severity", "count"], "properties": { - "code": { "type": "string", "maxLength": 128 }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, "severity": { "enum": ["info", "warning", "error"] }, "count": { "type": "integer", @@ -78,7 +82,11 @@ "text": { "type": "string", "maxLength": 409600 }, "streaming": { "const": false }, "collapsed": { "type": "boolean" }, - "parentToolCallId": { "type": "string", "maxLength": 200 }, + "parentToolCallId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "images": { "type": "array", "maxItems": 1000, @@ -106,7 +114,11 @@ "text": { "type": "string", "maxLength": 409600 }, "streaming": { "const": false }, "collapsed": { "type": "boolean" }, - "parentToolCallId": { "type": "string", "maxLength": 200 }, + "parentToolCallId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "images": { "type": "array", "maxItems": 1000, @@ -135,7 +147,11 @@ "text": { "type": "string", "maxLength": 409600 }, "streaming": { "const": false }, "collapsed": { "type": "boolean" }, - "parentToolCallId": { "type": "string", "maxLength": 200 }, + "parentToolCallId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, "images": { "type": "array", "maxItems": 1000, @@ -179,13 +195,26 @@ "status": { "enum": ["completed", "failed", "cancelled", "canceled"] }, - "toolName": { "type": "string", "maxLength": 200 }, - "toolKind": { "type": "string", "maxLength": 200 }, + "background": { "type": "boolean" }, + "toolName": { "type": "string", "minLength": 1, "maxLength": 200 }, + "toolKind": { "type": "string", "minLength": 1, "maxLength": 200 }, "preview": { "$ref": "#/$defs/toolPreview" }, "resultPreview": { "$ref": "#/$defs/toolResultPreview" }, - "parentToolCallId": { "type": "string", "maxLength": 200 }, - "parentBlockId": { "type": "string", "maxLength": 200 }, - "subagentType": { "type": "string", "maxLength": 200 } + "parentToolCallId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "parentBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "subagentType": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } } }, "shellBlock": { @@ -265,9 +294,13 @@ "items": { "$ref": "#/$defs/permissionOption" } }, "preview": { "$ref": "#/$defs/toolPreview" }, - "toolCallId": { "type": "string", "maxLength": 200 }, - "toolName": { "type": "string", "maxLength": 200 }, - "toolKind": { "type": "string", "maxLength": 200 }, + "toolCallId": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "toolName": { "type": "string", "minLength": 1, "maxLength": 200 }, + "toolKind": { "type": "string", "minLength": 1, "maxLength": 200 }, "resolved": { "enum": ["approved", "rejected", "cancelled", "expired", "resolved"] } @@ -291,7 +324,7 @@ "createdAt": { "const": 0 }, "updatedAt": { "const": 0 }, "text": { "type": "string", "maxLength": 409600 }, - "code": { "type": "string", "maxLength": 128 }, + "code": { "type": "string", "minLength": 1, "maxLength": 128 }, "errorKind": { "enum": [ "missing_binary", @@ -312,7 +345,7 @@ "loop_detected" ] }, - "source": { "type": "string", "maxLength": 128 } + "source": { "type": "string", "minLength": 1, "maxLength": 128 } } }, "errorBlock": { @@ -333,7 +366,7 @@ "createdAt": { "const": 0 }, "updatedAt": { "const": 0 }, "text": { "type": "string", "maxLength": 409600 }, - "code": { "type": "string", "maxLength": 128 }, + "code": { "type": "string", "minLength": 1, "maxLength": 128 }, "errorKind": { "enum": [ "missing_binary", @@ -354,7 +387,7 @@ "loop_detected" ] }, - "source": { "type": "string", "maxLength": 128 } + "source": { "type": "string", "minLength": 1, "maxLength": 128 } } }, "promptCancelledBlock": { @@ -433,7 +466,7 @@ "additionalProperties": false, "required": ["question", "options", "raw"], "properties": { - "header": { "type": "string", "maxLength": 200 }, + "header": { "type": "string", "minLength": 1, "maxLength": 200 }, "question": { "type": "string", "maxLength": 409600 }, "options": { "type": "array", @@ -542,7 +575,7 @@ "properties": { "kind": { "const": "web_fetch" }, "url": { "type": "string", "maxLength": 409600 }, - "method": { "type": "string", "maxLength": 16 } + "method": { "type": "string", "minLength": 1, "maxLength": 16 } } }, { @@ -562,9 +595,13 @@ "required": ["kind", "code"], "properties": { "kind": { "const": "code_block" }, - "language": { "type": "string", "maxLength": 64 }, + "language": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, "code": { "type": "string", "maxLength": 409600 }, - "origin": { "type": "string", "maxLength": 400 } + "origin": { "type": "string", "minLength": 1, "maxLength": 400 } } }, { @@ -625,7 +662,7 @@ "maxLength": 11184835, "pattern": "^data:image/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/]*={0,2}$" }, - "model": { "type": "string", "maxLength": 128 } + "model": { "type": "string", "minLength": 1, "maxLength": 128 } } }, { @@ -636,7 +673,11 @@ "kind": { "const": "subagent_delegation" }, "agentName": { "type": "string", "maxLength": 128 }, "task": { "type": "string", "maxLength": 409600 }, - "parentDelegationId": { "type": "string", "maxLength": 128 } + "parentDelegationId": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } } }, { @@ -716,7 +757,7 @@ ], "required": ["exportedAt", "complete", "truncated"], "properties": { - "title": { "type": "string", "maxLength": 200 }, + "title": { "type": "string", "minLength": 1, "maxLength": 200 }, "startedAt": { "type": "string", "format": "date-time", @@ -729,11 +770,23 @@ }, "complete": { "type": "boolean" }, "truncated": { "type": "boolean" }, - "projectName": { "type": "string", "maxLength": 400 }, - "repository": { "type": "string", "maxLength": 200 }, - "gitBranch": { "type": "string", "maxLength": 200 }, - "model": { "type": "string", "maxLength": 200 }, - "channel": { "type": "string", "maxLength": 100 }, + "projectName": { + "type": "string", + "minLength": 1, + "maxLength": 400 + }, + "repository": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "gitBranch": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "model": { "type": "string", "minLength": 1, "maxLength": 200 }, + "channel": { "type": "string", "minLength": 1, "maxLength": 100 }, "promptCount": { "type": "integer", "minimum": 0, 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..d1d479900b0 --- /dev/null +++ b/packages/cli/src/ui/utils/export/export-transcript-document.test.ts @@ -0,0 +1,1993 @@ +import { describe, expect, it } from 'vitest'; +import { + EXPORT_TRANSCRIPT_LIMITS_V1, + assertExportTranscriptDocumentV1, + classifyPermissionResolutionForExport, + createExportTranscriptDocumentV1, +} from './export-transcript-document.js'; +import { escapeJsonForHtmlScriptData } from './html-script-data.js'; + +const CANARY = 'CHAT_TRANSCRIPT_TEST_SECRET_DO_NOT_EXPORT'; +const EXPORT_OPTIONS = { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', +} as const; + +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, + }); + }); + + 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', + 'Hosted URI file://localhost/home/alice/private.txt', + 'Remote host URI file://host/Users/alice/private.txt', + 'Encoded host URI file://host/%2Fhome%2Falice%2Fprivate.txt', + 'HOME=/home/alice/private.txt', + 'Case variants /users/alice/private.txt /HOME/bob/private.txt', + 'Normalized /home//alice/private.txt /home/./alice/private.txt', + '--dir=/Users/alice/private.txt', + 'encoded=%2Fhome%2Falice%2Fprivate.txt', + 'joined=/home/alice/a,/home/bob/b', + 'traversal=/home/alice/../../etc/passwd', + 'file traversal=file:///home/alice/..', + 'nested=/home/alice/home/notes.txt', + 'forged=[home]/home/alice/private.txt', + 'redundant=//home/alice/private.txt /./home/bob/private.txt', + 'mixed=C:\\Users/alice/private.txt /home\\bob/private.txt', + 'windows dotted=C:\\Users\\.\\alice\\private.txt', + '![safe](data:image/png;base64,/home/AA)', + ].join('\n'), + }, + ], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + 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('Hosted URI file://[home]/private.txt'); + expect(text).toContain('Remote host URI file://[home]/private.txt'); + expect(text).toContain('Encoded host URI file://[home]/private.txt'); + expect(text).toContain( + 'Case variants [home]/private.txt [home]/private.txt', + ); + expect(text).toContain('Normalized [home]/private.txt [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'); + expect(text).not.toContain('/home/alice'); + expect(text).not.toContain('%2Fhome%2Falice'); + expect(text).not.toContain('/home/bob'); + expect(text).toContain('joined=[home]/a,[home]/b'); + expect(text).not.toContain('nested=[home]/home/'); + expect(text).not.toContain('forged=[home]/home/'); + expect(text).not.toContain('//home/alice'); + expect(text).not.toContain('/./home/bob'); + expect(text).not.toContain('C:\\Users/alice'); + expect(text).not.toContain('/home\\bob'); + expect(text).not.toContain('Users\\.\\alice'); + }); + + it.each(['/Users/./bob', '/home/../home/alice', 'C:\\Users\\.\\alice'])( + 'redacts a structured home root with dot segments: %s', + (cwd) => { + const document = createExportTranscriptDocumentV1( + [record('structured-dot-path', null)], + { + ...sessionData, + metadata: { ...sessionData.metadata, cwd }, + }, + EXPORT_OPTIONS, + ); + + expect(document.metadata.projectName).toBe('[home]'); + }, + ); + + it('marks excluded file attachments as incomplete', () => { + const document = createExportTranscriptDocumentV1( + [ + record('user-file-ref', null, { + message: { + role: 'user', + parts: [{ text: 'check\n\n@attachment:///secret.log' }], + }, + systemPayload: { + displayText: 'check\n\n@attachment:///secret.log', + hookContext: '', + attachmentReferences: [ + { + type: 'resource', + attachmentId: 'secret.log', + mimeType: 'text/plain', + size: 6, + }, + ], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toContainEqual({ + code: 'file_attachment_excluded', + severity: 'warning', + count: 1, + }); + }); + + it('re-caps labels after home-path redaction grows them', () => { + const document = createExportTranscriptDocumentV1( + [record('label-growth', null)], + { + ...sessionData, + metadata: { + ...sessionData.metadata, + model: `${'m'.repeat(188)}file:/home/a`, + }, + }, + EXPORT_OPTIONS, + ); + + expect(document.metadata.model).toHaveLength(200); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toContainEqual({ + code: 'label_sanitized', + severity: 'warning', + count: 1, + }); + }); + + it('redacts home roots while preserving percent-encoded basenames', () => { + const document = createExportTranscriptDocumentV1( + [ + record('read-home-root', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'read-home', + name: 'read_file', + args: { path: '/home/alice' }, + }, + }, + { + functionCall: { + id: 'read-literal', + name: 'read_file', + args: { path: '/tmp/%2Fhome%2Falice%2Fnotes' }, + }, + }, + { + functionCall: { + id: 'read-file-url-home', + name: 'read_file', + args: { path: 'file:///home/alice' }, + }, + }, + ], + }, + }), + ], + { + ...sessionData, + metadata: { ...sessionData.metadata, cwd: '/Users/bob' }, + }, + EXPORT_OPTIONS, + ); + + expect(document.metadata.projectName).toBe('[home]'); + expect( + document.blocks + .filter((block) => block.kind === 'tool') + .map((block) => block.preview), + ).toEqual([ + { kind: 'file_read', path: '[home]' }, + { kind: 'file_read', path: '%2Fhome%2Falice%2Fnotes' }, + { kind: 'file_read', path: '[home]' }, + ]); + }); + + it('redacts encoded home paths without aborting on token prefixes or invalid escapes', () => { + const encodedHomePath = '%2Fhome%2Falice'; + const document = createExportTranscriptDocumentV1( + [ + record('encoded-home-paths', null, { + message: { + role: 'user', + parts: [ + { + text: [ + `prefixed=foo${encodedHomePath}`, + `nested=logpath${encodedHomePath}%2Fnotes.txt`, + 'windows=abc%2FUsers%2Fbob', + `assignment=DIR%3D${encodedHomePath}`, + 'leading=%%2Fhome%2Falice', + `invalid=x=${encodedHomePath}%%`, + ].join('\n'), + }, + ], + }, + }), + ], + { + ...sessionData, + metadata: { + ...sessionData.metadata, + gitRepo: `owner/${encodedHomePath}`, + }, + }, + EXPORT_OPTIONS, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).toContain('prefixed=foo[home]'); + expect(text).toContain('nested=logpath[home]'); + expect(text).toContain('windows=abc[home]'); + expect(text).toContain('assignment=DIR=[home]'); + expect(text).toContain('leading=%[home]'); + expect(text).toContain('invalid=x=[home]'); + expect(text).not.toContain(encodedHomePath); + expect(text).not.toContain('%2FUsers%2Fbob'); + expect(document.metadata.repository).toBe(encodedHomePath); + }); + + it('exports qwen-native edit args as a complete file diff', () => { + const document = createExportTranscriptDocumentV1( + [ + record('edit-user', null), + record('edit-start', 'edit-user', { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'edit-native', + name: 'edit', + args: { + file_path: '/workspace/project/src/index.ts', + old_string: 'const value = 1;', + new_string: 'const value = 2;', + }, + }, + }, + ], + }, + }), + record('edit-result', 'edit-start', { + type: 'tool_result', + message: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'edit-native', + name: 'edit', + response: { output: 'Edit applied' }, + }, + }, + ], + }, + toolCallResult: { + callId: 'edit-native', + status: 'success', + resultDisplay: { + fileName: 'src/index.ts', + originalContent: 'const value = 1;', + newContent: 'const value = 2;', + fileDiff: '-const value = 1;\n+const value = 2;', + }, + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + const edit = document.blocks.find( + (block) => block.kind === 'tool' && block.toolName === 'edit', + ); + + expect(edit).toMatchObject({ + preview: { + kind: 'file_diff', + path: 'index.ts', + oldText: 'const value = 1;', + newText: 'const value = 2;', + }, + resultPreview: { kind: 'text', text: 'File change applied' }, + }); + expect(document.metadata).toMatchObject({ + complete: true, + truncated: false, + }); + expect(document.diagnostics).not.toContainEqual( + expect.objectContaining({ code: 'tool_result_presentation_missing' }), + ); + }); + + it('does not treat remote URL paths as local home paths', () => { + const urls = [ + 'https://example.com./home/alice/notes.txt', + 'https://[::1]/home/alice/notes.txt', + ]; + const document = createExportTranscriptDocumentV1( + [ + record('remote-url-text', null, { + message: { + role: 'user', + parts: [{ text: urls.join('\n') }], + }, + }), + record('remote-url-tools', 'remote-url-text', { + type: 'assistant', + message: { + role: 'model', + parts: urls.map((url, index) => ({ + functionCall: { + id: `fetch-${index}`, + name: 'web_fetch', + args: { url }, + }, + })), + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + const fetchUrls = document.blocks.flatMap((block) => + block.kind === 'tool' && block.preview.kind === 'web_fetch' + ? [block.preview.url] + : [], + ); + + expect(text).toBe(urls.join('\n')); + expect(fetchUrls).toEqual(urls); + }); + + it('preserves visible turns across excluded causal system records', () => { + const document = createExportTranscriptDocumentV1( + [ + record('turn-1-user', null, { + message: { role: 'user', parts: [{ text: 'TURN1_USER' }] }, + }), + record('turn-1-assistant', 'turn-1-user', { + type: 'assistant', + message: { role: 'model', parts: [{ text: 'TURN1_ASSISTANT' }] }, + }), + record('turn-result', 'turn-1-assistant', { + type: 'system', + subtype: 'turn_result', + systemPayload: { status: 'completed' }, + }), + record('turn-2-user', 'turn-result', { + message: { role: 'user', parts: [{ text: 'TURN2_USER' }] }, + }), + record('turn-2-assistant', 'turn-2-user', { + type: 'assistant', + message: { role: 'model', parts: [{ text: 'TURN2_ASSISTANT' }] }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + const text = document.blocks + .flatMap((block) => ('text' in block ? [block.text] : [])) + .join('\n'); + + expect(text).toContain('TURN1_USER'); + expect(text).toContain('TURN1_ASSISTANT'); + expect(text).toContain('TURN2_USER'); + expect(text).toContain('TURN2_ASSISTANT'); + expect(text).not.toContain('saved history is incomplete'); + expect(document.diagnostics).not.toContainEqual( + expect.objectContaining({ code: 'history_gap' }), + ); + }); + + it('preserves visible slash-command output', () => { + const document = createExportTranscriptDocumentV1( + [ + record('slash-result', null, { + type: 'system', + subtype: 'slash_command', + systemPayload: { + phase: 'result', + rawCommand: '/summary', + outputHistoryItems: [ + { type: 'assistant', text: 'SLASH_VISIBLE_OUTPUT' }, + ], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + + expect( + document.blocks.some( + (block) => + 'text' in block && block.text.includes('SLASH_VISIBLE_OUTPUT'), + ), + ).toBe(true); + expect(document.metadata.complete).toBe(true); + }); + + it('sanitizes Windows drive-root metadata instead of aborting', () => { + const document = createExportTranscriptDocumentV1( + [], + { ...sessionData, metadata: { ...sessionData.metadata, cwd: 'C:\\' } }, + EXPORT_OPTIONS, + ); + + expect(document.metadata.projectName).toBe('[path]'); + }); + + it('sanitizes typed preview fields before schema validation', () => { + const document = createExportTranscriptDocumentV1( + [ + record('preview-tools', null, { + type: 'assistant', + message: { + role: 'model', + parts: [ + { + functionCall: { + id: 'ask-1', + name: 'ask_user_question', + args: { + questions: [ + { + header: '\u0001', + question: 'Continue?', + options: [], + }, + ], + }, + }, + }, + { + functionCall: { + id: 'read-1', + name: 'read_file', + args: { file_path: 'source.ts', lineRange: [-5, 1.5] }, + }, + }, + { + functionCall: { + id: 'code-1', + name: 'exec_code', + args: { code: 'print(1)', origin: '\u0001' }, + }, + }, + ], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + const previews = document.blocks.flatMap((block) => + block.kind === 'tool' ? [block.preview] : [], + ); + + expect(previews).toContainEqual({ + kind: 'ask_user_question', + questions: [{ question: 'Continue?', options: [], raw: null }], + }); + expect(previews).toContainEqual({ + kind: 'file_read', + path: 'source.ts', + range: [0, 1], + }); + expect(previews).toContainEqual({ kind: 'code_block', code: 'print(1)' }); + }); + + it('rejects backslash authority Markdown destinations', () => { + const document = createExportTranscriptDocumentV1( + [ + record('unsafe-link', null, { + message: { + role: 'user', + parts: [{ text: '[download](/\\evil.example/file)' }], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).toBe('download'); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + }); + + it('does not charge plain text fences as rich render tasks', () => { + const content = Array.from( + { length: EXPORT_TRANSCRIPT_LIMITS_V1.maxRichRenderTasks + 1 }, + (_, index) => `\`\`\`text\nplain-${index}\n\`\`\``, + ).join('\n'); + const document = createExportTranscriptDocumentV1( + [ + record('plain-fences', null, { + message: { role: 'user', parts: [{ text: content }] }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).not.toContain('source fallback'); + expect(document.diagnostics).not.toContainEqual( + expect.objectContaining({ code: 'rich_render_budget_exceeded' }), + ); + }); + + it('degrades instead of aborting when JSON escaping exceeds the envelope', () => { + const raster = 'A'.repeat( + Math.floor(EXPORT_TRANSCRIPT_LIMITS_V1.maxRasterBytes / 3) * 4, + ); + const escapeDenseText = '<'.repeat(100_000); + const records = Array.from({ length: 75 }, (_, index) => + record( + `large-envelope-${index}`, + index === 0 ? null : `large-envelope-${index - 1}`, + { + type: index % 2 === 0 ? 'user' : 'assistant', + message: { + role: index % 2 === 0 ? 'user' : 'model', + parts: + index === 0 + ? [ + { text: escapeDenseText }, + { inlineData: { mimeType: 'image/png', data: raster } }, + { inlineData: { mimeType: 'image/png', data: raster } }, + ] + : [{ text: escapeDenseText }], + }, + }, + ), + ); + const document = createExportTranscriptDocumentV1(records, sessionData, { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }); + + expect( + new TextEncoder().encode( + escapeJsonForHtmlScriptData(JSON.stringify(document)), + ).byteLength, + ).toBeLessThanOrEqual(EXPORT_TRANSCRIPT_LIMITS_V1.maxEnvelopeBytes); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toContainEqual( + expect.objectContaining({ code: 'envelope_budget_exceeded' }), + ); + }); + + 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, + EXPORT_OPTIONS, + ); + 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, + EXPORT_OPTIONS, + ); + 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, + EXPORT_OPTIONS, + ); + 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, + ); + const terminalCases = [ + ['deny', 'rejected'], + ['reject_always', 'rejected'], + ['cancel', 'cancelled'], + ['timeout', 'expired'], + ] as const; + + expect(approved).toEqual({ value: 'approved', lossy: false }); + expect(unknown).toEqual({ value: 'resolved', lossy: true }); + for (const [input, value] of terminalCases) { + expect(classifyPermissionResolutionForExport(input, options)).toEqual({ + value, + lossy: false, + }); + } + expect( + classifyPermissionResolutionForExport('selected:reject', [ + { + optionId: 'reject', + label: 'Reject', + raw: { kind: 'reject_always' }, + }, + ]), + ).toEqual({ value: 'rejected', lossy: false }); + 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', + }, + }, + }, + ], + }, + }), + record('after-large', 'large', { + message: { + role: 'user', + parts: [{ text: 'AFTER_BLOCK_PRESENT' }], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'text_budget_exceeded' }), + ]), + ); + expect(JSON.stringify(document.blocks)).toContain( + '[content omitted: export text budget exceeded]', + ); + expect(JSON.stringify(document.blocks)).toContain('AFTER_BLOCK_PRESENT'); + + 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, + EXPORT_OPTIONS, + ); + + 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('degrades pathological markdown without throwing a raw range error', () => { + const document = createExportTranscriptDocumentV1( + [ + record('deep-markdown', null, { + message: { + role: 'user', + parts: [ + { + text: `${'> '.repeat(6_000)}[link](https://example.com)`, + }, + ], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + + expect(document.blocks[0]).toMatchObject({ + kind: 'user', + text: '[markdown omitted: complexity limit exceeded]', + }); + expect(document.diagnostics).toContainEqual({ + code: 'markdown_complexity_exceeded', + severity: 'warning', + count: 1, + }); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + }); + + it('marks rich-task complexity fallback as incomplete', () => { + const document = createExportTranscriptDocumentV1( + [ + record('rich-task-complexity', null, { + message: { + role: 'user', + parts: [ + { + text: ['```mermaid', 'graph TD', '```', '['.repeat(513)].join( + '\n', + ), + }, + ], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + + expect(document.blocks[0]).toMatchObject({ + kind: 'user', + text: '[markdown omitted: complexity limit exceeded]', + }); + expect(document.diagnostics).toContainEqual({ + code: 'markdown_complexity_exceeded', + severity: 'warning', + count: 1, + }); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + }); + + 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', + }, + }, + EXPORT_OPTIONS, + ); + 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, + EXPORT_OPTIONS, + ); + 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)', + '[space]( javascript:CHAT_TRANSCRIPT_URL_CANARY )', + '', + '', + 'https://carol:password@example.com/bare?CHAT_TRANSCRIPT_URL_CANARY#fragment', + 'www.example.com/path?CHAT_TRANSCRIPT_URL_CANARY', + '`[unequal](javascript:CHAT_TRANSCRIPT_URL_CANARY)``', + '> [evil]: javascript:CHAT_TRANSCRIPT_URL_CANARY', + '> [reference][evil]', + '```js `not-a-fence`', + '[after-invalid-fence](javascript:CHAT_TRANSCRIPT_URL_CANARY)', + '`https://dave:password@example.com/inline?CHAT_TRANSCRIPT_URL_CANARY`', + 'You can clone with:', + ' git clone https://frank:password@example.com/repo.git?CHAT_TRANSCRIPT_URL_CANARY', + '> ```bash', + '> curl https://grace:password@example.com/api?CHAT_TRANSCRIPT_URL_CANARY', + '> ```', + '```text', + 'https://erin:password@example.com/fenced?CHAT_TRANSCRIPT_URL_CANARY', + '```', + ].join('\n'), + }, + ], + }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).toContain('[safe](https://example.com/path)'); + expect(text).toContain('[credential]()'); + expect(text).toContain('\nhttps://example.com/autolink\n'); + expect(text).toContain('https://example.com/bare'); + expect(text).toContain('http://www.example.com/path'); + expect(text).not.toContain( + 'https://example.com/autolink?CHAT_TRANSCRIPT_URL_CANARY', + ); + expect(text).not.toContain( + 'https://example.com/bare?CHAT_TRANSCRIPT_URL_CANARY', + ); + expect(text).not.toContain( + 'http://www.example.com/path?CHAT_TRANSCRIPT_URL_CANARY', + ); + expect(text).toContain( + '`https://dave:password@example.com/inline?CHAT_TRANSCRIPT_URL_CANARY`', + ); + expect(text).toContain('https://example.com/repo.git'); + expect(text).toContain( + '> curl https://grace:password@example.com/api?CHAT_TRANSCRIPT_URL_CANARY', + ); + expect(text).toContain( + 'https://erin:password@example.com/fenced?CHAT_TRANSCRIPT_URL_CANARY', + ); + expect(text).not.toContain('javascript:'); + expect(text).not.toContain('frank:password'); + expect(document.metadata).toMatchObject({ + complete: false, + truncated: true, + }); + expect(document.diagnostics).toEqual( + expect.arrayContaining([ + { code: 'url_rejected', severity: 'warning', count: 6 }, + { code: 'url_sanitized', severity: 'warning', count: 5 }, + ]), + ); + }); + + 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, + EXPORT_OPTIONS, + ); + 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, + EXPORT_OPTIONS, + ); + + 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('counts renderer-compatible fence variants and container fences', () => { + const fence = (index: number): string => { + switch (index % 4) { + case 0: + return `\`\`\`\`mermaid\ngraph TD; A${index}-->B${index}\n\`\`\`\``; + case 1: + return `~~~~ mermaid\ngraph TD; A${index}-->B${index}\n~~~~`; + case 2: + return `> \`\`\`mermaid\n> graph TD; A${index}-->B${index}\n> \`\`\``; + default: + return `\`\`\`\tmermaid\ngraph TD; A${index}-->B${index}\n\`\`\``; + } + }; + const content = Array.from( + { length: EXPORT_TRANSCRIPT_LIMITS_V1.maxRichRenderTasks + 1 }, + (_, index) => fence(index), + ).join('\n'); + const document = createExportTranscriptDocumentV1( + [ + record('rich-variants', null, { + message: { role: 'user', parts: [{ text: content }] }, + }), + ], + sessionData, + EXPORT_OPTIONS, + ); + + 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, + EXPORT_OPTIONS, + ); + 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-file-home-path', + kind: 'user', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + text: 'Leaked file://localhost/HOME/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 and semantic safety violations', () => { + const envelope = ( + blocks: unknown[] = [], + overrides: Record = {}, + ): Record => ({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks, + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + ...overrides, + }); + const block = ( + id: string, + kind: string, + fields: Record = {}, + ): Record => ({ + id, + kind, + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + ...fields, + }); + const tool = (fields: Record): Record => + block('tool-safe', 'tool', { + toolCallId: 'read-1', + title: 'Read', + status: 'completed', + preview: { kind: 'file_read', path: 'index.ts' }, + ...fields, + }); + const cases: Array<{ value: unknown; error: string }> = [ + { + value: envelope([], { rendererVersion: 'latest' }), + error: 'schema_validation_failed', + }, + { + value: { ...envelope(), widened: true }, + error: 'schema_validation_failed', + }, + { + value: envelope([ + block('duplicate', 'prompt_cancelled'), + block('duplicate', 'prompt_cancelled'), + ]), + error: 'duplicate_block_id', + }, + { + value: envelope([ + block('permission-safe', 'permission', { + requestId: 'permission-1', + title: 'Allow read?', + options: [ + { optionId: 'permission-option-1', label: 'Allow', raw: null }, + ], + preview: { kind: 'generic' }, + resolved: 'selected:' + CANARY, + }), + ]), + error: 'schema_validation_failed', + }, + { + value: envelope([tool({ status: 'failed', title: 'Read failed' })]), + error: 'schema_validation_failed', + }, + { + value: envelope([ + tool({ resultPreview: { kind: 'generic', summary: ' ' } }), + ]), + error: 'schema_validation_failed', + }, + { + value: envelope([ + tool({ + preview: { + kind: 'file_read', + path: 'index.ts', + credential: CANARY, + }, + }), + ]), + error: 'schema_validation_failed', + }, + { + value: envelope([ + block('error-safe', 'error', { + text: 'Failed safely', + errorKind: 'unknown-' + CANARY, + }), + ]), + error: 'schema_validation_failed', + }, + { + value: envelope([ + tool({ + toolCallId: 'image-1', + title: 'Generate image', + status: 'cancelled', + preview: { + kind: 'image_generation', + prompt: 'A safe image', + thumbnailUrl: 'data:IMAGE/PNG;base64,iVBORw0KGgo=', + }, + }), + ]), + error: 'schema_validation_failed', + }, + { + value: envelope([ + block('user-safe', 'user', { + text: 'Hello', + usage: { inputTokens: 1, outputTokens: 1 }, + }), + ]), + error: 'schema_validation_failed', + }, + { + value: envelope([ + block('user-safe', 'user', { + text: '![remote](https://example.invalid/track.png)', + streaming: false, + }), + ]), + error: 'invalid_markdown_image', + }, + { + value: envelope([ + block('user-safe', 'user', { + text: '[![remote](https://example.invalid/nested-track.png)](https://example.com)', + streaming: false, + }), + ]), + error: 'invalid_markdown_image', + }, + { + value: envelope([ + block('user-safe', 'user', { + text: '[credential](https://alice:password@example.com/path?token=canary)', + streaming: false, + }), + ]), + error: 'invalid_markdown_url', + }, + { + value: envelope([], { + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + repository: 'https://secret@example.com/qwen-code?token=canary', + }, + }), + error: 'invalid_metadata', + }, + { + value: envelope([], { + diagnostics: [ + { code: 'url_sanitized', severity: 'warning', count: 1 }, + ], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: false, + truncated: false, + }, + }), + error: 'invalid_metadata_state', + }, + ]; + + for (const testCase of cases) { + expect(() => + assertExportTranscriptDocumentV1(testCase.value), + ).toThrowError(testCase.error); + } + }); + + 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 unsafe structured URLs that JSON Schema cannot express', () => { + expect(() => + assertExportTranscriptDocumentV1({ + schemaVersion: 1, + rendererVersion: '0.21.11-test.1', + blocks: [ + { + id: 'unsafe-fetch', + kind: 'tool', + clientReceivedAt: 0, + createdAt: 0, + updatedAt: 0, + toolCallId: 'fetch-1', + title: 'Fetch', + status: 'cancelled', + preview: { + kind: 'web_fetch', + url: 'https://alice:password@example.com/path?token=secret', + }, + }, + ], + diagnostics: [], + metadata: { + exportedAt: '2026-08-16T01:00:00.000Z', + complete: true, + truncated: false, + }, + }), + ).toThrowError('invalid_block'); + }); + + it('preserves semantic validation for diagnostic labels', () => { + const document = createExportTranscriptDocumentV1([], sessionData, { + rendererVersion: '0.21.11-test.1', + exportedAt: '2026-08-16T01:00:00.000Z', + }); + + expect(() => + assertExportTranscriptDocumentV1({ + ...document, + diagnostics: [{ code: 'unsafe\nlabel', severity: 'info', count: 1 }], + }), + ).toThrowError('invalid_diagnostic'); + }); + + 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)', + '[![nested remote](https://example.invalid/nested-track.png?u=victim)](https://example.com)', + '![svg](data:image/svg+xml;base64,PHN2Zy8+)', + '![safe](data:image/png;base64,iVBORw0KGgo=)', + '[![nested safe](data:image/png;base64,iVBORw0KGgo=)](https://example.com)', + '', + '![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, + EXPORT_OPTIONS, + ); + const text = + document.blocks[0]?.kind === 'user' ? document.blocks[0].text : ''; + + expect(text).not.toContain('track.png'); + expect(text).not.toContain('nested-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, + }); + }); + + it('escapes HTML script terminators in serialized document data', () => { + const escaped = escapeJsonForHtmlScriptData( + JSON.stringify({ text: '', `${'a*'.repeat(3_000)}]`].join('\n'), + ], + ])('fails closed for parser/scanner divergence: %s', (_name, input) => { + const activePolicy = policy(); + + expect(sanitizeMarkdownDocument(input, activePolicy)).toBe( + '[markdown omitted: complexity limit exceeded]', + ); + expect(activePolicy.onComplexityLimit).toHaveBeenCalledOnce(); + }); + + it.each([ + ['abrupt-closing comment', `\n${'a*'.repeat(20_000)}]`], + ['comment marker in code span', `\` B\n```', + }), + ), + ); + }); + render(renderMode); + return { container, render }; +} + +function mountManyMermaids(count: number): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + mounted.push({ root, container }); + act(() => { + root.render( + createElement( + TranscriptRenderModeProvider, + { value: 'document' }, + ...Array.from({ length: count }, (_, index) => + createElement(Markdown, { + key: index, + content: `\`\`\`mermaid\ngraph TD\nA${index} --> B${index}\n\`\`\``, + }), + ), + ), + ); + }); + return container; +} + +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 () => { + let resolveInteractiveRender: + | ((value: { svg: string }) => void) + | undefined; + mermaidMock.render + .mockImplementationOnce(() => new Promise(() => {})) + .mockImplementationOnce( + () => + new Promise<{ svg: string }>((resolve) => { + resolveInteractiveRender = resolve; + }), + ); + const view = mountMermaid('document'); + await startMermaidRender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(view.container.querySelector('pre code')?.textContent).toContain( + 'graph TD', + ); + + view.render('interactive'); + await startMermaidRender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(view.container.querySelector('pre code')).toBeNull(); + + await act(async () => { + resolveInteractiveRender?.({ svg: 'interactive' }); + await Promise.resolve(); + await Promise.resolve(); + }); + }); + + it('does not charge queue wait time against document renders', async () => { + mermaidMock.render.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ svg: 'diagram' }), 300); + }), + ); + const container = mountManyMermaids(40); + await startMermaidRender(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(13_000); + }); + + expect(mermaidMock.render).toHaveBeenCalledTimes(40); + expect(container.querySelectorAll('svg')).toHaveLength(40); + expect(container.querySelector('pre code')).toBeNull(); + }); +}); diff --git a/packages/web-shell/client/components/messages/Markdown.test.ts b/packages/web-shell/client/components/messages/Markdown.test.ts index f933cca2998..26b74393ec6 100644 --- a/packages/web-shell/client/components/messages/Markdown.test.ts +++ b/packages/web-shell/client/components/messages/Markdown.test.ts @@ -148,6 +148,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', () => { @@ -169,6 +179,15 @@ describe('markdownUrlTransform', () => { expect(markdownUrlTransform('javascript:alert(1)')).toBe(''); expect(markdownUrlTransform('data:text/html;base64,PHN2Zz4=')).toBe(''); }); + + it('allows only approved data images in document mode', () => { + expect( + markdownUrlTransform('data:image/png;base64,iVBORw0KGgo=', true), + ).toBe('data:image/png;base64,iVBORw0KGgo='); + expect( + markdownUrlTransform('data:image/svg+xml;base64,PHN2Zz4=', true), + ).toBe(''); + }); }); describe('qwen-session:// links', () => { @@ -239,6 +258,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'); @@ -1368,6 +1438,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 b1086a327a3..2c6a1cf692f 100644 --- a/packages/web-shell/client/components/messages/Markdown.tsx +++ b/packages/web-shell/client/components/messages/Markdown.tsx @@ -160,6 +160,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; @@ -170,10 +172,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; @@ -183,12 +189,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'); @@ -279,44 +290,71 @@ function MermaidBlock({ code }: { code: string }) { setSvg(null); setError(null); 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) return; + const mermaid = mod.default; + const configKey = `${mermaidTheme}:${documentMode ? 'document' : 'runtime'}`; + const render = mermaidRenderQueue.then(async () => { + if (cancelled) 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}`; + if (!documentMode) return mermaid.render(id, code.trim()); + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + mermaid.render(id, code.trim()), + new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error('Mermaid render timed out')), + MERMAID_RENDER_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } }); - 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) { - setSvg(svg); - } - } catch (error: unknown) { + if (!cancelled) setSvg(svg); + }) + .catch((error: unknown) => { if (!cancelled) { setError( error instanceof Error ? error.message : 'Mermaid render failed', ); } - } - }); + }); }, 150); return () => { cancelled = true; clearTimeout(timer); }; - }, [code, mermaidTheme]); + }, [code, documentMode, mermaidTheme]); const handleCopy = () => { void writeClipboardText(code) @@ -431,6 +469,7 @@ function CodeBlock({ }) { const { t } = useI18n(); const appTheme = useTheme(); + const documentMode = useTranscriptRenderMode() === 'document'; const [html, setHtml] = useState(null); const [copied, setCopied] = useState(false); @@ -446,6 +485,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' || @@ -496,7 +536,7 @@ function CodeBlock({ return () => { cancelled = true; }; - }, [code, lang, resolvedLang, shikiTheme, isStreaming]); + }, [code, documentMode, lang, resolvedLang, shikiTheme, isStreaming]); const handleCopy = () => { void writeClipboardText(code) @@ -519,7 +559,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, ''); @@ -754,7 +800,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; } @@ -898,6 +947,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); @@ -934,7 +984,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 @@ -978,6 +1031,10 @@ export const Markdown = memo(function Markdown({ ? [rehypeKatex, ...sourceMarkdown.rehypePlugins] : [rehypeKatex]; }, [sourceMarkdown?.rehypePlugins]); + const urlTransform = useMemo( + () => (url: string) => markdownUrlTransform(url, documentMode), + [documentMode], + ); if (!content) return null; @@ -999,7 +1056,7 @@ export const Markdown = memo(function Markdown({ components={componentsWithCharts} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} - urlTransform={markdownUrlTransform} + urlTransform={urlTransform} /> ); const chartAwareMarkdown = chart ? ( 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.module.css b/packages/web-shell/client/components/messages/PlanMessage.module.css index eb7a856ed93..b8235b4f90e 100644 --- a/packages/web-shell/client/components/messages/PlanMessage.module.css +++ b/packages/web-shell/client/components/messages/PlanMessage.module.css @@ -3,7 +3,8 @@ padding: 2px 0; } -.header { +.header, +.headerStatic { display: flex; align-items: baseline; gap: 6px; @@ -12,11 +13,14 @@ margin-bottom: 4px; background: none; border: none; - cursor: pointer; text-align: left; font: inherit; } +.header { + cursor: pointer; +} + .chevron { flex-shrink: 0; width: 12px; diff --git a/packages/web-shell/client/components/messages/PlanMessage.test.tsx b/packages/web-shell/client/components/messages/PlanMessage.test.tsx index f3a3b606ebe..0167c44b1a4 100644 --- a/packages/web-shell/client/components/messages/PlanMessage.test.tsx +++ b/packages/web-shell/client/components/messages/PlanMessage.test.tsx @@ -3,7 +3,9 @@ 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'; +import { todoStateKey, type TodoDetail } from '../../utils/todos'; // Mock the todo contexts so the unit test controls their provider values. vi.mock('../../WebShellContexts', async () => { @@ -15,7 +17,9 @@ vi.mock('../../WebShellContexts', async () => { }); const { PlanMessage } = await import('./PlanMessage'); -const { TodoTimelineContext } = await import('../../WebShellContexts'); +const { TodoDetailContext, TodoTimelineContext } = await import( + '../../WebShellContexts' +); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -42,6 +46,8 @@ function renderPlan( id: string, todos: TodoItem[], timeline?: Map, + documentMode = false, + details = new Map(), ): HTMLElement { const container = document.createElement('div'); document.body.appendChild(container); @@ -49,9 +55,15 @@ function renderPlan( act(() => { root.render( - - - + + + + + + + , ); }); @@ -92,6 +104,25 @@ describe('PlanMessage', () => { expect(container.textContent).toContain('▾'); }); + it('renders the complete plan without controls in document mode', () => { + const details = new Map([ + [ + todoStateKey(TODOS[0]!), + { startTs: 1_000, endTs: 4_000, resources: { inputTokens: 12 } }, + ], + ]); + const container = renderPlan('p1', TODOS, undefined, true, details); + expect(container.textContent).toContain('First task'); + expect(container.textContent).toContain('Second task'); + expect(container.textContent).toContain('Third task'); + expect(container.querySelector('button')).toBeNull(); + expect(container.firstElementChild?.firstElementChild?.className).toContain( + 'headerStatic', + ); + expect(container.textContent).toContain('Input'); + expect(container.textContent).toContain('12'); + }); + 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 3a1ec17459f..e1d95dc08ef 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 '../../WebShellContexts'; 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 2fe0e1ee754..2583dbad478 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 29fbcff4271..4ca0560db5c 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/TodoView.tsx b/packages/web-shell/client/components/messages/TodoView.tsx index c1142b89a40..0e5ef4aa230 100644 --- a/packages/web-shell/client/components/messages/TodoView.tsx +++ b/packages/web-shell/client/components/messages/TodoView.tsx @@ -6,6 +6,7 @@ import { type TodoDetail, type TodoEvent, } from '../../utils/todos'; +import { useTranscriptRenderMode } from '../../transcriptRenderMode'; import { TodoDetailContext } from '../../WebShellContexts'; import { formatTimestamp } from '../MessageTimestamp'; import { formatDuration } from './StatsMessage'; @@ -228,6 +229,7 @@ export function TodoFullList({ numbered?: boolean; }) { const { t } = useI18n(); + const documentMode = useTranscriptRenderMode() === 'document'; const details = useContext(TodoDetailContext); const [expanded, setExpanded] = useState>( () => new Set(), @@ -248,7 +250,7 @@ export function TodoFullList({ const rowKey = todo.id || String(index); const detail = details.get(todoStateKey(todo)); const expandable = hasTodoDetail(detail); - const isOpen = expandable && expanded.has(rowKey); + const isOpen = expandable && (documentMode || expanded.has(rowKey)); const rowInner = ( <> {numbered && ( @@ -260,7 +262,7 @@ export function TodoFullList({ {getTodoStatusIcon(todo.status)} {todo.content} - {expandable && ( + {expandable && !documentMode && ( @@ -269,7 +271,7 @@ export function TodoFullList({ ); return (
- {expandable ? ( + {expandable && !documentMode ? (