commit f3d80b4628e82e89e94440d3cc2d96731eca727d Author: wehub-resource-sync Date: Mon Jul 13 12:31:48 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.agents/docs/README.md b/.agents/docs/README.md new file mode 100644 index 0000000..e0eecf7 --- /dev/null +++ b/.agents/docs/README.md @@ -0,0 +1,11 @@ +# Maintainer Docs + +This directory contains maintainer and agent automation materials that are useful for project operations but are not part of the public user documentation. + +- `standards/` contains required maintainer policies and validation rules. +- `plans/` contains implementation plans and handoff notes. +- `error-experience/` and `good-experience/` contain internal learning records. +- `guides/` contains maintainer-facing workflow and integration playbooks. +- `architecture/manifest.yaml` lists paths covered by the legibility check. + +Keep public user documentation and README assets in `docs/`. diff --git a/.agents/docs/architecture/manifest.yaml b/.agents/docs/architecture/manifest.yaml new file mode 100644 index 0000000..7f88646 --- /dev/null +++ b/.agents/docs/architecture/manifest.yaml @@ -0,0 +1,11 @@ +expected_paths: + - AGENTS.md + - .agents/docs/README.md + - .agents/docs/standards/README.md + - .agents/docs/standards/hard-rules.md + - .agents/docs/standards/validation-and-gates.md + - .agents/docs/standards/e2e-and-evidence.md + - .agents/docs/standards/coding-and-runtime.md + - .agents/docs/standards/workflow-and-review.md + - scripts/check_legibility.py + - .github/workflows/legibility.yml diff --git a/.agents/docs/error-experience/entries/2026-02-25-pr1-stale-base.md b/.agents/docs/error-experience/entries/2026-02-25-pr1-stale-base.md new file mode 100644 index 0000000..0391209 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-25-pr1-stale-base.md @@ -0,0 +1,36 @@ +# PR #1 过期基线分支导致 CI 失败 + +**日期:** 2026-02-25 +**严重级别:** 中 +**标签:** git, CI, uv.lock, rebase + +## 发生了什么 + +PR #1(新增 `--tap-host` 功能)在 CI 中测试失败,原因是它基于过期的 `main` 分支。 +该分支从 `bc7d344` 分叉,而 `main` 已推进到 `bd7ec3f`。两个版本的 `uv.lock` +不兼容,导致依赖解析失败,测试报错。 + +## 根因 + +在打开 PR 前,feature 分支没有 rebase 到最新 `main`。 +`uv.lock` 已产生分叉,过期版本无法解析正确依赖集。 + +## 影响 + +- 原本正确的 PR 在 CI 中失败 +- 需要额外一次 rebase 循环修复 +- 合并延迟了一个 review 回合 + +## 经验 + +**在打开或合并 PR 前,始终先 rebase 到最新 `main`。** + +防止复发的 checklist: +1. 开 PR 前:`git fetch origin && git rebase origin/main` +2. 验证 `uv.lock` 最新:`uv lock --check` +3. rebase 后本地运行完整测试:`uv run pytest tests/ -x --timeout=60` + +## 相关 + +- PR:#1 +- Commits:bc7d344..bd7ec3f diff --git a/.agents/docs/error-experience/entries/2026-02-26-codex-sandbox-git-blocked.md b/.agents/docs/error-experience/entries/2026-02-26-codex-sandbox-git-blocked.md new file mode 100644 index 0000000..c27c4a3 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-26-codex-sandbox-git-blocked.md @@ -0,0 +1,28 @@ +# Codex Sandbox 无法执行 Git Commit + +**日期:** 2026-02-26 +**严重级别:** 中 +**标签:** codex, sandbox, git, environment + +## 问题 + +Codex `--full-auto` sandbox 会阻止对 `.git/index.lock` 和 +`.git/FETCH_HEAD` 的写访问,导致 `git commit` 与 `git fetch` 无法完成。 + +## 影响 + +- Codex 可以通过 `git add` 暂存文件,但无法 commit 或 fetch。 +- 任何要求最后提交的工作流都必须转到外部环境执行。 + +## 变通方案 + +- 用 Codex 完成代码编辑、重构、测试和 lint 检查。 +- Codex 完成后,在 sandbox 外执行 `git add -A && git commit` + (通过 OpenClaw exec 或本地 shell)。 +- 不要在 Codex 任务提示中要求 `git commit`;它会静默失败或直接报错。 + +## 经验 + +Codex sandbox 会限制 `.git/` 目录写入。把会产生文件变更的任务交给 Codex 时, +始终规划一个 Codex 后置 commit 步骤。将流程拆分为: +Codex 编辑 → 外部 git commit → push。 diff --git a/.agents/docs/error-experience/entries/2026-02-26-codex-sandbox-tmux-blocked.md b/.agents/docs/error-experience/entries/2026-02-26-codex-sandbox-tmux-blocked.md new file mode 100644 index 0000000..3688785 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-26-codex-sandbox-tmux-blocked.md @@ -0,0 +1,26 @@ +# Codex Sandbox 阻止 tmux Socket 创建 + +**日期:** 2026-02-26 +**严重级别:** 中 +**标签:** codex, sandbox, tmux, environment + +## 问题 + +在 Codex `--full-auto` 中运行基于 tmux 的 E2E 流程失败,因为 tmux 无法在 +`/private/tmp` 下创建或访问其 socket 路径(permission denied)。 + +## 影响 + +- Codex 可以更新代码和文档,但不能直接执行 tmux 交互测试。 +- 依赖 tmux 的端到端验证必须在 Codex sandbox 外完成。 + +## 变通方案 + +- 用 Codex 处理代码编辑、重构以及可静态/可测试逻辑。 +- 在 Codex 外执行依赖 tmux 的验证(例如 OpenClaw exec 或本地 shell)。 +- 外部验证后,再把结果回填到仓库文档/测试中。 + +## 经验 + +需要系统级终端复用器(`tmux`、`screen`)的任务,无法完整委托给受限的 Codex sandbox。 +必须明确划分 sandbox 安全工作与外部执行职责。 diff --git a/.agents/docs/error-experience/entries/2026-02-26-hardcoded-version-string.md b/.agents/docs/error-experience/entries/2026-02-26-hardcoded-version-string.md new file mode 100644 index 0000000..fedc009 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-26-hardcoded-version-string.md @@ -0,0 +1,23 @@ +# CLI 中硬编码版本字符串 + +**日期:** 2026-02-26 +**严重级别:** 中 +**标签:** versioning, cli, metadata, release + +## 问题 + +`cli.py` 中的 `__version__` 被硬编码为 `"0.1.7"`,发布时从未更新。 +用户即使升级后,用 `-v` 看到的仍是错误版本。 + +## 根因 + +版本字符串是源码字面量,而不是从 package metadata 读取。 + +## 修复 + +将硬编码值替换为 `importlib.metadata.version("claude-tap")`, +使其始终与 `pyproject.toml` 和 PyPI package metadata 一致。 + +## 经验 + +不要硬编码版本字符串。始终使用 `importlib.metadata`(或其他单一真相源,例如从 `pyproject.toml` 动态读取)。 diff --git a/.agents/docs/error-experience/entries/2026-02-26-python313-ssl-aki-required.md b/.agents/docs/error-experience/entries/2026-02-26-python313-ssl-aki-required.md new file mode 100644 index 0000000..0a8632d --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-26-python313-ssl-aki-required.md @@ -0,0 +1,68 @@ +# Python 3.13 SSL 要求证书包含 Authority Key Identifier + +**日期:** 2026-02-26 +**严重级别:** 高(CI 阻塞 - 连续 4 次失败) +**标签:** ssl, certificates, python313, ci, certs.py + +## 问题 + +`test_forward_proxy_connect` 在 Python 3.13 下失败,报错如下: + +``` +SSLCertVerificationError: (1, '[SSL: CERTIFICATE_VERIFY_FAILED] +certificate verify failed: Missing Authority Key Identifier (_ssl.c:1032)') +``` + +CI 在 Python 3.11/3.12 通过,但在 3.13 持续失败。 + +## 根因 + +Python 3.13 收紧了 SSL 证书校验。它现在**要求**由 CA 签发的证书包含 +Authority Key Identifier(AKI)扩展。 + +我们的 `certs.py` 生成结果: +- CA 证书:缺少 `SubjectKeyIdentifier`(SKI) +- Host 证书:缺少 `AuthorityKeyIdentifier`(AKI)和 SKI + +这些扩展在 X.509 中技术上可选,但 Python 3.13 的 SSL 实现会将缺失 AKI +视为校验失败。 + +## 为什么本地未捕获 + +本地开发环境是 Python 3.11,不强制 AKI 存在。 +该问题只在 CI 的 Python 3.13 matrix 中暴露。 + +## 修复 + +在 `certs.py` 中为两类证书都加入正确的 X.509 扩展: + +**CA 证书:** +```python +.add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, +) +``` + +**Host 证书:** +```python +.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key( + self._ca_key.public_key() + ), + critical=False, +) +.add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, +) +``` + +## 经验 + +1. **在假定测试通过之前,务必检查 CI 的全部 Python 版本。** + 本地 3.11 绿灯不代表 3.13 也绿灯。 +2. **自签证书生成必须包含 SKI/AKI 扩展。** + 即使旧版 Python 容忍缺失,新版也可能不容忍。 +3. **当 CI 只在特定 Python 版本失败时,先看该版本 changelog 是否有更严格的安全要求**。 + SSL/TLS 校验是常见收紧区域。 diff --git a/.agents/docs/error-experience/entries/2026-02-26-rg-not-portable.md b/.agents/docs/error-experience/entries/2026-02-26-rg-not-portable.md new file mode 100644 index 0000000..d1bf013 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-26-rg-not-portable.md @@ -0,0 +1,33 @@ +# `rg`(ripgrep)并非所有环境都可用 + +**日期:** 2026-02-26 +**严重级别:** 低 +**标签:** portability, shell, tooling + +## 问题 + +Shell 脚本 `run_real_e2e_tmux.sh` 使用 `rg`(ripgrep)做 JSONL 断言。 +在未安装 ripgrep 或不在 `$PATH` 的环境里,脚本会静默失败或给出误导结果。 + +## 根因 + +`rg` 是 Rust 工具,不属于 POSIX,也不是 macOS 默认安装。 +CI runner、Codex sandbox 和新初始化机器都可能缺少它。 + +## 修复 + +将全部 `rg` 调用替换为 `grep -F`(固定字符串匹配),后者是 POSIX 标准且普遍可用。 + +```bash +# Before (fragile) +rg '"tool_use"' "$JSONL_FILE" + +# After (portable) +grep -F '"tool_use"' "$JSONL_FILE" +``` + +## 经验 + +**在 shell 脚本中优先使用 POSIX 标准工具**:`grep`、`sed`、`awk`、`find`、`cut`。 +`rg`、`fd`、`jq` 等工具应留给交互式使用,或明确声明为依赖。 +脚本必须能在裸环境运行。 diff --git a/.agents/docs/error-experience/entries/2026-02-26-sigttou-suspend-on-exit.md b/.agents/docs/error-experience/entries/2026-02-26-sigttou-suspend-on-exit.md new file mode 100644 index 0000000..ced95f5 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-26-sigttou-suspend-on-exit.md @@ -0,0 +1,30 @@ +# 子进程退出后因 SIGTTOU 被挂起 + +**日期:** 2026-02-26 +**严重级别:** 高 +**标签:** signal, tty, process-group, tcsetpgrp, exit-path + +## 问题 + +Claude Code 退出后,`claude-tap` 尝试打印摘要并生成 HTML 输出, +却每次都触发 `suspended (tty output)`。 + +## 影响 + +高。这是用户感知最强的问题:进程在收尾路径完成前就被挂起, +用户始终拿不到 HTML 输出。 + +## 根因 + +`claude-tap` 通过 `tcsetpgrp` 把终端前台控制权交给 Claude Code 子进程。 +当子进程退出时,`claude-tap` 仍处于后台进程组。任何终端写操作都会触发 `SIGTTOU`, +导致进程被挂起,因此 `finally` 块(HTML 生成与摘要)无法运行。 + +## 修复 + +在调用 `tcsetpgrp` 夺回前台进程组前先忽略 `SIGTTOU`, +之后恢复原始 signal handler。 + +## 经验 + +只要使用 `tcsetpgrp` 进行进程组切换,在切回父进程前台组时就必须处理 `SIGTTOU`。 diff --git a/.agents/docs/error-experience/entries/2026-02-27-pr-screenshot-cache-stale.md b/.agents/docs/error-experience/entries/2026-02-27-pr-screenshot-cache-stale.md new file mode 100644 index 0000000..c2871fc --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-27-pr-screenshot-cache-stale.md @@ -0,0 +1,35 @@ +# GitHub Raw URL 导致 PR 截图缓存陈旧 + +**日期:** 2026-02-27 +**标签:** github, pr, screenshot, cache, review + +## 问题 + +向 PR 分支 push 更新后的截图后,PR 描述仍显示旧图,看起来像变更未生效。 + +## 根因 + +PR 描述引用了稳定不变文件名的 `raw.githubusercontent.com` 路径。 +GitHub/CDN 会在一段时间内继续缓存这些 URL 的旧内容。 + +## 影响 + +- review 混乱:reviewer 误以为 PR 证据未更新。 +- 增加沟通成本与反复手动刷新。 + +## 已实施修复 + +1. 生成带版本后缀的新图片文件名(`*-v2.png`)。 +2. 更新 PR 描述中的图片链接,指向新文件名。 +3. 确认 PR 引用已切换到版本化 URL。 + +## 预防规则 + +更新 PR 内嵌截图时,优先通过改文件名使用不可变图片 URL +(如 `before-v2.png`、`after-v3.png`),不要复用旧文件名。 + +## 验证 Checklist + +- 新文件出现在 `Files changed`。 +- PR markdown 链接指向带版本后缀的文件名。 +- reviewer 无需强制刷新也能看到更新图片。 diff --git a/.agents/docs/error-experience/entries/2026-02-28-codex-reverse-websocket-capture-gap.md b/.agents/docs/error-experience/entries/2026-02-28-codex-reverse-websocket-capture-gap.md new file mode 100644 index 0000000..60b3126 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-02-28-codex-reverse-websocket-capture-gap.md @@ -0,0 +1,35 @@ +# Codex Reverse Mode 可能遗漏 Responses 流量 + +**日期:** 2026-02-28 +**严重级别:** 高 +**标签:** codex, reverse-proxy, websocket, trace-capture + +## 问题 + +在 `--tap-client codex` 的 reverse mode 中,部分运行只捕获到 `/v1/models`, +viewer 中 token usage 为 0。后续对话流量未能稳定捕获为 `/v1/responses`。 + +## 根因 + +Codex 在交互/会话流中可能尝试基于 websocket 的 Responses 路径。 +当用户配置或特性开关启用 websocket 行为时,reverse-mode base URL 路由会出现 +trace 捕获不一致。 + +## 修复 + +- 在 Codex 的 reverse mode 下自动注入: + - `--disable responses_websockets` + - `--disable responses_websockets_v2` +- 保留用户意图:若用户已通过 `--enable`、`--disable` 或 `-c/--config features.=...` + 显式覆盖特性,不再强制覆盖。 + +## 验证 + +- 增加 E2E 断言:reverse-mode 启动包含 websocket-disable flags。 +- 增加 E2E 断言:尊重用户显式 feature override。 +- 运行完整 gate 检查(`ruff`、format check、`pytest tests/ -x --timeout=60`)。 + +## 经验 + +为了保证 proxy 捕获可靠性,不要假设 Codex 传输永远是 HTTP POST。 +在 reverse proxy 场景下,应显式将传输特性固定到可捕获路径,并在测试中让覆盖行为具备确定性。 diff --git a/.agents/docs/error-experience/entries/2026-03-03-codex-ws-timeout-fallback-evidence.md b/.agents/docs/error-experience/entries/2026-03-03-codex-ws-timeout-fallback-evidence.md new file mode 100644 index 0000000..b714479 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-03-03-codex-ws-timeout-fallback-evidence.md @@ -0,0 +1,36 @@ +# Codex WS 超时且已验证 HTTPS 回退(PR #22) + +**日期:** 2026-03-03 +**标签:** codex, websocket, reverse-proxy, validation, fallback + +## 背景 + +在用真实 Codex 运行验证 PR #22(`feat/ws-proxy`)时,我们需要对 WebSocket 传输行为和当前环境中的回退行为提供硬证据。 + +## 发生了什么 + +- 通过 `claude_tap --tap-client codex` 的常规 Codex 运行经由 HTTP/SSE(`POST /v1/responses`)成功。 +- 强制 WS 运行(`--enable responses_websockets` 与 `--enable responses_websockets_v2`)反复连接上游失败: + - `502 Bad Gateway` + - 连接 `wss://chatgpt.com/backend-api/codex/responses` 超时 +- 多次重试后,Codex 自动回退到 HTTPS 传输并成功完成该轮。 + +## 根因(观察) + +观察到的问题是当前环境中的上游 WS 连接超时,而非 proxy 进程本地崩溃。proxy 正确记录了 WS 失败,client 的 fallback 路径恢复了运行。 + +## 证据 + +- `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v1-run.log` +- `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v2-run.log` +- `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v1-run/trace_20260303_180901.jsonl` +- `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v2-run/trace_20260303_180901.jsonl` + +## 经验 + +对传输敏感验证,需明确区分三类陈述: +1. 已实现行为(代码 + 测试), +2. 当前环境中的观察行为, +3. 因网络/运行时约束尚未验证的行为。 + +这样能避免过度声明,同时仍可在已验证范围内推进合并决策。 diff --git a/.agents/docs/error-experience/entries/2026-03-03-pr-screenshot-quality-failures.md b/.agents/docs/error-experience/entries/2026-03-03-pr-screenshot-quality-failures.md new file mode 100644 index 0000000..915b6c9 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-03-03-pr-screenshot-quality-failures.md @@ -0,0 +1,57 @@ +# PR 截图质量失败案例(2026-03-03) + +## 发生了什么 + +PR #22(WebSocket proxy 修复)需要截图证据。在产出可接受截图前,先后出现了三次质量失败: + +### 失败 1:移动端 Viewport 布局 +- **症状**:Trace viewer 渲染为移动端布局,拥挤、单列、难以阅读 +- **根因**:OpenClaw 内置浏览器默认窄 viewport(约 750px),触发响应式移动端断点 +- **影响**:截图展示的是移动端 UI,和用户实际看到的不一致 + +### 失败 2:Unicode 箭头乱码 +- **症状**:日志中的箭头 `→` 与 `←` 在截图里显示为乱码 `鉞@` +- **根因**:日志文件包含 Unicode 箭头。headless 环境中的浏览器或字体渲染破坏了多字节字符;原始 `.log` 被直接提供且未处理 charset。 +- **影响**:关键证据(WS 方向标记)不可读 + +### 失败 3:截图内容错误 +- **症状**:Trace viewer 显示的是 `/v1/models` 请求详情,而非 WebSocket `/v1/responses` 请求 +- **根因**:截图前没有先点击到正确 trace entry,误以为默认视图就是 WS 请求 +- **影响**:截图无法证明 PR 声称的内容 + +### 元失败:缺少提交前审查 +- 三张错误截图都在未先审查的情况下被 commit 并 push 到 PR +- 用户不得不手动检查并逐个指出问题 +- 多轮往返才修复本应在 commit 前捕获的问题 + +## 如何修复 + +1. **Viewport**:截图前执行 `browser act resize width=1440 height=900` +2. **Unicode**:创建自定义 HTML 卡片(`ws-log-clean.html`),使用 HTML entities(`>` `<` `->`)替代原始 Unicode 箭头 +3. **内容**:先导航到正确 trace entry(Turn 2 WEBSOCKET),确认内容后再截图 +4. **审查**:commit 前逐张肉眼检查截图 + +## 形成的标准 + +### 截图提交前 Checklist +1. **Viewport**:截图前设为桌面宽度(≥1280px) +2. **内容**:确认截图展示的内容与声明完全一致 +3. **编码**:检查是否有乱码/损坏字符,尤其是 Unicode 符号、CJK 文本、emoji +4. **布局**:确认渲染为桌面布局(不是移动端响应断点) +5. **可读性**:关键证据文本在 1x 缩放下可清晰阅读 +6. **审查**:`git add` 前查看实际 PNG 文件,禁止盲目提交 + +### 自动化机会 +- `scripts/check_screenshots.sh`:自动检查图像尺寸(拒绝宽度 <1000px,疑似移动端)、文件大小(拒绝 <10KB,疑似错误页)和基础合理性 +- PR 正文模板可以加入截图 checklist 章节 +- 可考虑通过固定 viewport 设置程序化生成证据截图 + +## 预防 + +- 已在 `.agents/docs/standards/e2e-and-evidence.md` 增加截图质量 gate +- 已新增 `scripts/check_screenshots.sh` 用于 pre-commit 自动验证 +- AGENTS.md 应对任何包含视觉证据的 PR 引用该截图标准 + +## 关键结论 + +截图是证据。证据必须在提交前验证。“我截了图”不等于“我验证了这张图能证明我的结论”。 diff --git a/.agents/docs/error-experience/entries/2026-03-03-pr22-websocket-screenshot-quality-regression.md b/.agents/docs/error-experience/entries/2026-03-03-pr22-websocket-screenshot-quality-regression.md new file mode 100644 index 0000000..f25a816 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-03-03-pr22-websocket-screenshot-quality-regression.md @@ -0,0 +1,43 @@ +# PR #22 Screenshot Quality Regression (WebSocket Proxy Fix) + +**Date:** 2026-03-03 +**Tags:** pr, screenshot, viewport, encoding, websocket, review + +## What Happened + +Timeline: + +1. PR #22 (WebSocket proxy fix) required screenshot evidence for the trace viewer output. +2. Screenshots were captured in an openclaw browser session that defaulted to a narrow viewport (~750px width). +3. The rendered trace log contained raw Unicode arrows (`→`, `←`) and those characters displayed as garbled glyphs in the screenshot. +4. The captured screenshot focused on a `GET` request row instead of the `101 WEBSOCKET` row that was the core proof for the fix. +5. The screenshots were committed and pushed without a dedicated screenshot quality review step. + +Specific failures: + +- Desktop UI evidence looked like a mobile layout. +- Important log symbols were corrupted. +- The screenshot did not show the feature being validated. +- No pre-commit evidence verification caught the mismatch. + +## Root Causes + +1. **Viewport control gap:** no enforced minimum viewport width for desktop evidence screenshots. +2. **Encoding safety gap:** generated HTML used raw Unicode arrows in log rendering instead of ASCII-safe symbols or entities. +3. **Content verification gap:** no explicit check that screenshot content matched the exact PR claim (`101 WEBSOCKET` for this fix). +4. **Process gap:** no mandatory screenshot checklist before commit/push. + +## Fix Applied + +1. Defined screenshot standards with hard desktop viewport minimum (`>=1280px`). +2. Added encoding rule to avoid raw Unicode arrows in generated HTML evidence content. +3. Added content verification requirements to confirm screenshots display the specific feature/fix target. +4. Added automated validation script (`scripts/check_screenshots.py`) for baseline image quality checks. +5. Wired screenshot validation into CI for PR evidence images. + +## Lessons Learned + +1. Screenshot evidence needs the same validation rigor as tests and linting. +2. Visual proof must be scoped to the exact behavior under review, not adjacent activity. +3. Encoding decisions in generated artifacts can silently degrade review quality. +4. Lightweight automated checks plus a short human checklist prevent most screenshot regressions. diff --git a/.agents/docs/error-experience/entries/2026-03-03-ws-proxy-debugging-failure.md b/.agents/docs/error-experience/entries/2026-03-03-ws-proxy-debugging-failure.md new file mode 100644 index 0000000..fb0a2c2 --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-03-03-ws-proxy-debugging-failure.md @@ -0,0 +1,92 @@ +# WS Proxy Debugging Failure — Agent Blind Spot + +**Date:** 2026-03-03 +**Severity:** High (hours wasted, multiple agent iterations, cron loops) +**PR:** #22 (WebSocket proxy support) + +## What Happened + +PR #22 added WebSocket proxy support. After implementation, real Codex WS +connections kept timing out. Multiple Codex agents were spawned across many +iterations — a cron monitoring job ran continuously, spawning more agents, +checking status, retrying — but **none** identified the root cause. + +The human user stepped in, asked to check the WS connect code path, and +within minutes pinpointed the exact problem. + +## Root Cause (of the Bug) + +`claude_tap/proxy.py` line 379 had `proxy=None` hardcoded in +`session.ws_connect()`. This explicitly bypassed the Clash Verge proxy +(`http://127.0.0.1:7897`) that all HTTP requests used successfully. + +Direct WSS connections to `wss://chatgpt.com` timed out because the machine +required a proxy for external access. + +## Root Cause (of the Debugging Failure) + +### 1. Agents never read the actual code path + +The `ws_connect()` call had `proxy=None` in plain sight. Any agent reading +that single line should have asked: "Why is proxy disabled for WS when HTTP +requests work fine through the proxy?" + +### 2. Over-reliance on black-box testing + +Agents kept re-running Codex with different flags (`--enable responses_websockets`, +`--enable responses_websockets_v2`) and analyzing timeout logs. None traced +the code path from the WS upgrade handler → upstream connect → `ws_connect()` +call parameters. + +### 3. Cron monitoring loops burned cycles without progress + +A monitoring cron job kept cycling: check status → agent still stuck → +spawn more agents → repeat. Many iterations, zero conceptual progress. +The loop was busy, not productive. + +### 4. No systematic debugging methodology + +A simple comparison would have solved it in minutes: +- **Does HTTP work?** → Yes (through proxy) +- **Does WS work?** → No (timeout) +- **What's different in the code path?** → HTTP uses session default proxy, + WS has explicit `proxy=None` +- **That's the bug.** + +## The Fix + +One line: remove `proxy=None` from `session.ws_connect()`. Let +`trust_env=True` on the `ClientSession` handle proxy resolution naturally. + +Commit: `fcb2982` + +## Lessons Learned + +1. **Read the code before running tests.** When debugging proxy issues, + trace the actual network call and check what proxy settings are passed. + +2. **Compare working vs non-working paths.** HTTP worked, WS didn't. The + diff between those two code paths is exactly where the bug lives. + +3. **Stop the loop early.** If 2-3 iterations of black-box testing haven't + found the issue, switch strategy: read code, add logging, trace the call. + +4. **Simple mental models beat brute force.** "Proxy works for A but not B + → B must handle proxy differently" is a 30-second insight. No amount of + re-running tests with different flags will discover a hardcoded `proxy=None`. + +5. **Cron loops are not debugging.** Automated retry loops are for monitoring + known-good processes, not for solving unknown bugs. When stuck, stop the + loop and think. + +## Standards (Added) + +See `.agents/docs/standards/debugging-standards.md` for the full checklist. + +Key additions: +- Before spawning agents for debugging: spend 5 minutes reading the relevant + code path yourself +- When proxy/network issues: always check what `proxy=` parameter is passed + in the actual connect call +- After 2 failed iterations of the same approach: STOP and change approach +- Document "working vs broken" comparison explicitly before diving into fixes diff --git a/.agents/docs/error-experience/entries/2026-03-10-codex-strip-prefix-url-mismatch.md b/.agents/docs/error-experience/entries/2026-03-10-codex-strip-prefix-url-mismatch.md new file mode 100644 index 0000000..884cb4a --- /dev/null +++ b/.agents/docs/error-experience/entries/2026-03-10-codex-strip-prefix-url-mismatch.md @@ -0,0 +1,61 @@ +# Codex strip_path_prefix URL mismatch + +Date: 2026-03-10 + +## What broke + +Codex CLI through claude-tap proxy returned 404 (HTTP) and 502 (WebSocket) for all requests. + +## Root cause + +`strip_path_prefix="/v1"` was set for the codex client, but `default_target` was `https://api.openai.com` (without `/v1`). This caused: + +- Codex sends `/v1/responses` +- Proxy strips `/v1` → path becomes `/responses` +- Upstream URL: `https://api.openai.com/responses` ← wrong, should be `/v1/responses` + +## What we tried + +### Attempt 1: Remove strip_path_prefix entirely + +Set `strip_path_prefix=""` for all codex clients. This fixed `api.openai.com` (API Key auth) but broke `chatgpt.com/backend-api/codex` (OAuth auth), because that backend expects `/responses` without `/v1`. + +**Why it failed:** Only considered one authentication mode. Codex has two different backends with different URL structures: + +| Backend | Expected path | Needs strip? | +|---------|-------------|-------------| +| `api.openai.com` | `/v1/responses` | No | +| `chatgpt.com/backend-api/codex` | `/responses` | Yes | + +### Attempt 2: Conditional strip based on target URL + +```python +"strip_path_prefix": "/v1" if args.client == "codex" and "api.openai.com" not in args.target else "" +``` + +This correctly handles both backends. + +## What actually fixed it + +The conditional strip (attempt 2), combined with tmux-based real E2E verification against the live chatgpt.com backend. + +## Why the bug wasn't caught originally + +1. **E2E test validated internal consistency, not external correctness.** The fake upstream handler asserted `request.path == "/messages"` (the stripped path), which confirmed stripping worked — but never checked if the constructed upstream URL matched the real API. + +2. **Failure was misattributed to environment.** WS_VERIFY_REPORT.md documented WS 502 failures but concluded "environment/network dependent WS reachability" instead of investigating the URL construction. + +3. **No real-endpoint URL assertion.** No test verified that `target + stripped_path` produces a valid real API URL. + +## Lessons + +1. **For proxy code, test the final upstream URL, not just the forwarded path.** Add assertions like: + ```python + assert upstream_url == "https://api.openai.com/v1/responses" + ``` + +2. **When a client supports multiple backends, enumerate all (client × auth × target) combinations** before modifying URL handling logic. Draw a matrix. + +3. **Don't attribute failures to "environment" without first verifying the code path.** Print/log the actual upstream URL to confirm correctness before blaming network. + +4. **Always run real E2E after proxy changes.** Unit tests with fake upstreams give false confidence for networking code. Use `tmux` to run a real request through the proxy. diff --git a/.agents/docs/good-experience/entries/2026-02-25-mock-e2e-pattern.md b/.agents/docs/good-experience/entries/2026-02-25-mock-e2e-pattern.md new file mode 100644 index 0000000..7dcf75d --- /dev/null +++ b/.agents/docs/good-experience/entries/2026-02-25-mock-e2e-pattern.md @@ -0,0 +1,48 @@ +# Mock E2E 测试模式:Fake Upstream + Fake Claude + +**日期:** 2026-02-25 +**标签:** testing, E2E, mock, best-practice + +## 模式描述 + +现有 E2E 测试套件(`tests/test_e2e.py`)采用了全 mock 方案, +可在无外部依赖的情况下测试整个 claude-tap pipeline: + +1. **Fake upstream server** - 在后台线程运行 aiohttp server, + 模拟 Anthropic API,同时返回 non-streaming 与 streaming(SSE)响应。 + +2. **Fake Claude script** - 在 PATH 中放置临时 Python 脚本作为 `claude` CLI。 + 它向 `ANTHROPIC_BASE_URL`(由 claude-tap 设置为 proxy 地址)发送 HTTP 请求并打印结果。 + +3. **真实 claude-tap** - 以子进程方式运行真实 `claude_tap` module, + 并通过 `--tap-target` 指向 fake upstream。 + +## 为什么效果好 + +- **无外部依赖**:测试可离线运行,无需 API keys +- **可确定性**:相同输入始终产生相同输出 +- **速度快**:无网络时延,无 rate limit +- **覆盖完整**:测试全链路,包括 proxy 启动、请求转发、SSE 重组、JSONL 记录、HTML viewer 生成、API key 脱敏 +- **边界情况稳健**:覆盖上游错误(500)、畸形 SSE、大 payload(100KB+) + +## 关键实现细节 + +- `run_fake_upstream_in_thread()` 使用 `threading.Event` 做同步 +- Fake Claude 脚本由 `_create_fake_claude()` 创建并设为可执行 +- 临时 bin 目录会 prepend 到 `PATH`,让 claude-tap 找到 fake `claude` +- 端口硬编码(19199、19200 等)保持测试隔离 +- Trace 文件写入 `tempfile.mkdtemp()`,断言后清理 + +## 何时使用此模式 + +适用于: +- 测试 proxy 行为(转发、记录、SSE 处理) +- 测试 HTML viewer 生成 +- 测试 header 脱敏与安全能力 +- 在无 Claude API 访问的 CI 中运行 + +## 互补模式 + +若要测试真实 Claude 集成(实际 API 响应、tool use、多轮对话), +请参考 `tests/e2e/` 下的 real E2E 测试。它们需要可用的 `claude` CLI 安装, +且在 CI 中默认跳过。 diff --git a/.agents/docs/good-experience/entries/2026-02-25-proxy-e2e-ci-hardening.md b/.agents/docs/good-experience/entries/2026-02-25-proxy-e2e-ci-hardening.md new file mode 100644 index 0000000..222b4ac --- /dev/null +++ b/.agents/docs/good-experience/entries/2026-02-25-proxy-e2e-ci-hardening.md @@ -0,0 +1,43 @@ +# 面向 Proxy 的 E2E 与 CI 强化 + +**日期:** 2026-02-25 +**标签:** e2e, proxy, oauth, ci, reliability + +## 背景 + +真实 E2E 覆盖曾被环境相关行为阻塞: + +- forward-proxy 运行中的 OAuth 流程出现 `403 Request not allowed`。 +- CI 在 Python 3.13 下因 forward proxy 测试中的 TLS 证书校验更严格而失败。 +- 测试预期误以为首个 API 调用总是 `/v1/messages`,但 OAuth 预检并非如此。 + +## 有效做法 + +1. **上游转发尊重系统 proxy env** + - 在 claude-tap 上游转发使用的 aiohttp session 中设置 `trust_env=True`。 + - 使流量能遵循本地 proxy/VPN 工具(如 Clash),而不是绕过它。 + +2. **提升 forward/OAuth 断言鲁棒性** + - 不假设首条 trace 请求是 `/v1/messages`。 + - 在记录中搜索至少一条 `/v1/messages` 调用,并在该处验证响应内容。 + +3. **为 Python 3.13 强化测试证书** + - 在自签测试证书中同时添加 `SubjectKeyIdentifier` 与 `AuthorityKeyIdentifier` 扩展。 + - 解决 CI 中 `CERTIFICATE_VERIFY_FAILED: Missing Authority Key Identifier`。 + +4. **修复 trace 统计的空值安全** + - 在收集 model usage 指标时防护 `request.body is None`。 + +## 为什么重要 + +- 真实 E2E 可在 proxy 重环境的开发机中通过,避免隐藏的网络路径不一致。 +- CI 在不同 Python 版本间更稳定。 +- 测试验证的是行为本身,而不是偶然的请求顺序。 + +## 运行说明 + +- 真实 E2E 仍通过 `--run-real-e2e` opt-in。 +- Browser integration 测试需要安装 Playwright 与浏览器二进制。 +- 调试 proxy 问题时,两个链路都要验证: + - Claude CLI -> claude-tap + - claude-tap -> upstream(必要时必须遵守 proxy env) diff --git a/.agents/docs/good-experience/entries/2026-02-26-tmux-e2e-success.md b/.agents/docs/good-experience/entries/2026-02-26-tmux-e2e-success.md new file mode 100644 index 0000000..bc50a42 --- /dev/null +++ b/.agents/docs/good-experience/entries/2026-02-26-tmux-e2e-success.md @@ -0,0 +1,40 @@ +# tmux 真实 E2E 成功模式 + +**日期:** 2026-02-26 +**标签:** e2e, tmux, testing, verification + +## 问题 + +tmux `send-keys` 在真实交互式 E2E 运行中,无法稳定向 Claude Code TUI 提交 prompt。 + +## 根因 + +- 部分流程假设 `rg` 可用于断言,但在一些环境中并不存在。 +- 对 Claude Code TUI 在 tmux 下的提交行为建模错误;正确提交键是 `Enter`。 + +## 解决方案 + +- 将脆弱的 `rg` 断言替换为可移植的 `grep -F`。 +- 将默认提交行为统一为 `Enter`。 +- 加入“未命中重试提交”逻辑,降低瞬时输入时序失败。 + +## 验证 + +通过 JSONL 断言验证: + +1. 两个 prompt 都出现在 trace 数据中。 +2. `/v1/messages` 调用至少为 2 次。 +3. 至少一个响应内容块为 `tool_use`。 +4. 生成了 HTML viewer 产物。 + +## 结果 + +- pytest real E2E 用例 `7/7` 通过。 +- tmux 交互式 E2E 通过,且确认捕获到 `tool_use`。 +- 生成了 asciinema 录制。 +- 已截取生成的 HTML viewer browser 截图。 + +## 经验 + +在 tmux 下做真实 Claude TUI 自动化时,可移植性与输入语义比“巧工具”更重要: +优先 `grep -F`,使用 `Enter`,并通过 trace 产物验证。 diff --git a/.agents/docs/good-experience/entries/2026-02-27-viewer-sticky-validation-flow.md b/.agents/docs/good-experience/entries/2026-02-27-viewer-sticky-validation-flow.md new file mode 100644 index 0000000..66b0aae --- /dev/null +++ b/.agents/docs/good-experience/entries/2026-02-27-viewer-sticky-validation-flow.md @@ -0,0 +1,25 @@ +# UI 修复交付模式:Sticky Header + 证据优先验证 + +**日期:** 2026-02-27 +**标签:** ui, viewer, testing, workflow, pr + +## 背景 + +一个小型 UI 行为修复(viewer detail panel 的操作控件 sticky)需要以可靠证据交付,便于 review。 + +## 有效做法 + +1. 先应用最小化、仅 CSS 的修复,不改动 JS 行为。 +2. 提前运行快速质量 gate(`ruff` + `pytest`)以快速发现回归。 +3. 产出可视化证据(滚动前/后状态)供 PR review。 +4. 在 `AGENTS.md` 添加明确流程要求,确保未来 UI 变更始终附带 E2E 验证与截图。 + +## 为什么这个模式好 + +- 低风险:改动面很小。 +- review 清晰度高:截图可直接证明行为。 +- 流程可复用:同样流程可应用于后续 UI 变更。 + +## 经验 + +对 UI 行为修复,证据是交付物的一部分。好的 PR 不只是“代码 + 测试”,还包括“可视化证明 + 明确验证步骤”。 diff --git a/.agents/docs/guides/engineering-practices.md b/.agents/docs/guides/engineering-practices.md new file mode 100644 index 0000000..31a76f6 --- /dev/null +++ b/.agents/docs/guides/engineering-practices.md @@ -0,0 +1,130 @@ +# 工程实践指南 + +本文档对 claude-tap 项目的工程标准进行规范化。 + +## Python 代码风格 + +- **Linter/formatter:** [ruff](https://docs.astral.sh/ruff/) +- **行长限制:** 120 字符 +- **目标版本:** Python 3.11+ +- **Lint 规则:** `E`(errors)、`F`(pyflakes)、`W`(warnings)、`I`(import sorting) +- **忽略规则:** `E501`(行长,由 formatter 而非 lint 强制) + +本地运行: +```bash +uv run ruff check . # Lint +uv run ruff format --check . # Check formatting +uv run ruff format . # Auto-fix formatting +``` + +## 测试策略 + +### 测试分层 + +| Layer | Location | 测试内容 | 在 CI 中运行 | 外部依赖 | +|-------|----------|----------|--------------|----------| +| **Unit** | `tests/test_diff_matching.py` | 纯逻辑(diff matching、parsing) | 是 | 无 | +| **Mock E2E** | `tests/test_e2e.py` | fake upstream + fake Claude 的完整 pipeline | 是 | 无 | +| **Browser integration** | `tests/test_nav_browser.py` | HTML viewer JavaScript 逻辑 | 是(使用 Playwright) | Playwright | +| **Real E2E** | `tests/e2e/` | 真实 Claude CLI 集成 | 否(opt-in) | Claude CLI、API key | + +### 运行测试 + +```bash +# Full CI suite (unit + mock E2E) +uv run pytest tests/ -x --timeout=60 + +# Specific test file +uv run pytest tests/test_e2e.py -x --timeout=120 + +# Real E2E tests (requires claude CLI) +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 + +# All tests including real E2E +uv run pytest tests/ --run-real-e2e --timeout=300 +``` + +### 编写新测试 + +- 使用 `pytest` fixtures 做 setup/teardown(见 `conftest.py`) +- 用 `tempfile.mkdtemp()` 创建临时目录,并始终清理 +- 对 async 测试,使用 `pytest-asyncio`(配置为 `asyncio_mode = "auto"`) +- 慢测试标记为 `@pytest.mark.slow` +- integration 测试标记为 `@pytest.mark.integration` + +## Commit 约定 + +- commit message 使用英文 +- 使用祈使语气:“add feature” 而非 “added feature” +- 使用类型前缀:`feat:`、`fix:`、`refactor:`、`test:`、`docs:`、`chore:` +- subject 行保持在 72 字符以内 +- 对非简单变更,在 body 解释“为什么” + +示例: +``` +feat: add --tap-host flag for custom bind address +fix: handle malformed SSE events without crashing +test: add real E2E tests with Claude CLI integration +docs: update engineering practices guide +``` + +## Pre-Work Checklist + +进行任何代码变更之前: + +1. **检查仓库状态:** + ```bash + git diff --stat + git log --oneline -10 + ``` +2. **确保工作树干净**,或先 stash 变更 +3. **拉取最新 main:** `git fetch origin && git rebase origin/main` +4. **确认测试通过:** `uv run pytest tests/ -x --timeout=60` + +## Feature 的 Worktree 工作流 + +使用 git worktree 进行隔离的 feature 开发: + +```bash +# Create worktree for new feature +git worktree add -b feat/my-feature /tmp/claude-tap-my-feature main + +# Work in the worktree +cd /tmp/claude-tap-my-feature +# ... make changes, run tests ... + +# Merge back (fast-forward only) +cd /path/to/claude-tap +git merge --ff-only feat/my-feature + +# Clean up +git worktree remove /tmp/claude-tap-my-feature +git branch -d feat/my-feature +``` + +收益: +- 主 worktree 保持干净 +- 无需 stash 即可在多个 feature 间切换 +- 自然隔离可防止相互污染 + +## Code Review 流程 + +commit 前: + +1. **Lint:** `uv run ruff check .` +2. **Format:** `uv run ruff format --check .` +3. **Test:** `uv run pytest tests/ -x --timeout=60` +4. **Review diff:** `git diff`,逐行阅读每个变更 +5. **验证范围:** 仅修改了与任务相关的文件吗? + +合并 PR 前: + +1. 所有 CI 检查通过 +2. 无未解决 review 评论 +3. 分支已 rebase 到最新 `main` +4. `uv.lock` 保持一致 + +## 语言 + +代码、注释、commit message 和 skill 文件必须使用英文。 +对外文档必须中英文双份:`README.md` 对应 `README_zh.md`,`docs/guides/*.md` 对应同目录的 `*.zh.md`。 diff --git a/.agents/docs/guides/new-client-integration-playbook.md b/.agents/docs/guides/new-client-integration-playbook.md new file mode 100644 index 0000000..0d4c6bc --- /dev/null +++ b/.agents/docs/guides/new-client-integration-playbook.md @@ -0,0 +1,190 @@ +# Playbook:为 claude-tap 添加新的 LLM Client + +提炼自 Codex 集成(PR #12,2026-02-28)。将其作为可复用框架,用于为任意新的 LLM client(如 Gemini CLI、Grok CLI 等)添加支持。 + +--- + +## 第 1 阶段:侦察 - 理解 Client 的 Wire Protocol + +在写代码前,先回答以下问题: + +1. **Client 调用的是哪个 API endpoint?**(例如 `api.openai.com/v1/responses`、 + `api.anthropic.com/v1/messages`) +2. **它是否有备用 endpoint?**(例如 Codex 对 ChatGPT Plus 用户使用 `chatgpt.com/backend-api/codex`,而不是 `api.openai.com`) +3. **它使用什么传输方式?** HTTP POST?WebSocket?gRPC? + - **Codex 经验**:Codex v0.106.0 在无提示情况下把 `/v1/responses` 从 HTTP 切到 WebSocket。HTTP proxy 看不到任何内容。务必验证实际 wire transport,而不是只看文档。 +4. **哪个 env var 控制 base URL?**(`OPENAI_BASE_URL`、`ANTHROPIC_BASE_URL` 等) +5. **子进程是否真的继承了该 env var?**(Codex 有 Rust 子进程,可能会或可能不会遵循 Node.js 父进程环境) +6. **它使用何种编码/压缩?**(Codex 会发送 zstd 压缩体) + +### 如何调查 + +```bash +# Watch actual network traffic +lsof -i -P | grep + +# Check what the process sees +ps -p -E | tr ' ' '\n' | grep BASE_URL + +# Intercept with mitmproxy for full visibility +mitmproxy --mode reverse:https://api.example.com --listen-port 8080 +``` + +**关键原则**:不要盲信文档。观察真实行为。 + +--- + +## 第 2 阶段:Proxy Wiring - 让每个请求都可见 + +### Checklist + +- [ ] 设置正确的 env var,将流量重定向到 claude-tap 本地 proxy +- [ ] 处理路径映射(client 发送 `/v1/responses`,upstream 期望 `/responses`) +- [ ] 处理请求体编码(zstd、gzip 等) +- [ ] 处理响应流(SSE events、chunked transfer) +- [ ] **若使用 reverse proxy,将传输固定为 HTTP** - 禁用会绕过 HTTP proxy 的 WebSocket/gRPC 特性 +- [ ] 用真实流量验证(不仅是 unit tests) + +### 验证检查点 + +```bash +# Run the client through claude-tap +uv run python -m claude_tap --tap-client -- + +# Check trace file has actual API calls (not just /models) +wc -l .traces/trace_*.jsonl # Should be > 1 +python3 -c " +import json +with open('.traces/trace_.jsonl') as f: + for line in f: + d = json.loads(line) + print(d['request']['path'], d['response']['status']) +" +``` + +**如果 trace 只有 1 行(models/health check),说明真实 API 调用绕过了你的 proxy。** 立即停止并排查传输路径。 + +--- + +## 第 3 阶段:Viewer 兼容性 - 每种 API 格式都不同 + +每个 LLM provider 的响应格式都不同。需要映射这些字段: + +| Concept | Claude (Chat Completions) | OpenAI (Responses API) | Your Client | +|---------|--------------------------|----------------------|-------------| +| System prompt | `body.system` | `body.instructions` | ? | +| Messages | `body.messages[]` | `body.input[]` | ? | +| Message content | `{type: "text", text}` | `{type: "input_text", text}` | ? | +| Token usage | `response.body.usage` | SSE `response.completed` event | ? | +| Response output | `response.body.content` | SSE `response.output_text.delta` | ? | +| Tools | `body.tools[]` | `body.tools[]` | ? | + +### Viewer 修复模式 + +1. 找出 viewer 读取 Claude 特定字段的所有位置 +2. 新增 normalize 函数,把新格式映射到通用结构 +3. 保持向后兼容,旧 trace 仍要能正确渲染 + +### 验证检查点 + +用真实 trace 打开 HTML viewer 并验证: +- [ ] System prompt 已显示 +- [ ] Messages 以正确 role 渲染 +- [ ] Token 计数非零 +- [ ] Turn 间 diff 可用 +- [ ] Response output 已显示 + +--- + +## 第 4 阶段:录制 - 证据是交付物的一部分 + +每次新增 client 集成都需要: + +1. **终端录制**:展示 client 通过 claude-tap 运行的真实 E2E 会话 + - 工具:`asciinema rec` → `agg`(GIF)→ `ffmpeg`(MP4) + - 必须展示:启动 banner、至少 2-3 轮对话、如适用的 tool calls + +2. **Viewer 录制**:通过 Playwright 自动化演示 HTML viewer + - 工具:Playwright `record_video_dir` → `.webm` → `ffmpeg`(MP4) + - 必须展示:system prompt、messages、tokens、diff + +3. **截图**:用于 PR review 的静态证据 + - 至少包括:overview、messages、diff、token stats + +**使用真实 trace 数据,不要用 mock。** reviewer 一眼就能看出来。 + +### PR 截图陷阱 + +PR 描述中使用绝对 URL(`raw.githubusercontent.com/...`),不要用相对路径。 +GitHub PR 正文不会解析相对图片路径。 + +--- + +## 第 5 阶段:防御式设计 - 下一次会坏在哪里? + +集成跑通后,提前预判未来的破坏点: + +- **Client 更新传输方式**:显式固定传输,不要假设永远是 HTTP。 + 记录 workaround,并为原生支持创建 TODO。 +- **Client 修改 API 格式**:将 normalize 函数隔离,便于后续更新。 +- **Client 新增 auth 复杂性**:在 plan 文档记录所需 scopes/permissions。 + +### 始终留下这些资产 + +- [ ] 遇到非显而易见问题时,写 error experience 文档 +- [ ] 已知限制写 TODO/plan 文档(例如 WebSocket 原生支持) +- [ ] 补能覆盖你发现的特定失败模式的测试 + +--- + +## 反模式(来自 Codex 集成) + +| Anti-pattern | 发生了什么 | 经验 | +|-------------|-----------|------| +| 相信“看起来明显”的 API endpoint | 以为是 `api.openai.com`,实际是 `chatgpt.com/backend-api/codex` | 必须追踪真实网络调用 | +| 假设传输一定是 HTTP | Codex 默认使用 WebSocket,proxy 什么都看不到 | 验证 wire transport,必要时固定 | +| 在数据修复前录制演示 | 首版录制中 token 为 0(trace 中有 403 错误) | 先修数据链路,最后再录制 | +| 截图使用旧 trace 数据 | 截图仍显示修复前 trace 的错误 | 修复后必须重新录制 | +| PR 正文使用相对图片路径 | GitHub 上所有图片都坏掉 | 使用 `raw.githubusercontent.com` 绝对 URL | + +--- + +## 模板:新 Client Checklist + +```markdown +## Adding support for: + +### Recon +- [ ] Identified API endpoint(s) +- [ ] Identified transport (HTTP/WS/gRPC) +- [ ] Identified env var for base URL +- [ ] Verified child process inherits env var +- [ ] Identified request encoding + +### Proxy +- [ ] Env var injection working +- [ ] Path mapping correct +- [ ] Request decompression working +- [ ] Response streaming captured +- [ ] Transport pinned to HTTP (if reverse proxy) +- [ ] Trace file has real API calls (not just models) + +### Viewer +- [ ] System prompt displayed +- [ ] Messages rendered +- [ ] Token counts non-zero +- [ ] Diff working +- [ ] Response output shown +- [ ] Old Claude traces still work + +### Evidence +- [ ] Terminal recording (≥3 turns) +- [ ] Viewer recording +- [ ] Screenshots (≥5) +- [ ] PR description with absolute image URLs + +### Docs +- [ ] Error experience (if applicable) +- [ ] TODO for known limitations +- [ ] Checklist template used +``` diff --git a/.agents/docs/guides/viewer-ux-rationale.md b/.agents/docs/guides/viewer-ux-rationale.md new file mode 100644 index 0000000..c252438 --- /dev/null +++ b/.agents/docs/guides/viewer-ux-rationale.md @@ -0,0 +1,42 @@ +# Viewer UX 设计依据 + +本指南记录了稳定的设计决策,这些决策此前分散在一次性的实现规格中。 + +## 导航顺序 + +键盘与触控导航必须遵循侧边栏展示的同一视觉顺序。 + +- 侧边栏的分组与排序定义了用户预期的导航顺序。 +- `j/k` 与方向键导航应按 DOM 顺序在可见项间移动。 +- 移动端上一条/下一条控件应与桌面键盘导航保持相同顺序。 + +## Mobile-First 约束 + +viewer 在窄屏下必须保持可用,且不能出现水平溢出。 + +- 移动端布局应优先一次只显示一个主 pane。 +- detail view 的操作区需要触控友好的控件和清晰边界。 +- diff 与内容密集视图在移动端应切换为堆叠布局。 + +## Diff 匹配语义 + +diff 质量取决于是否比较了相关 turn。 + +- 优先使用带历史感知的匹配(共享消息前缀或等价线程信号)。 +- 如果 fallback 匹配是近似的,应在 UI 中明确给出警告。 +- 允许手动选择目标,使用户可以覆盖自动匹配结果。 + +## 国际化 + +新增面向用户的 UI 文案必须保持一致的本地化策略。 + +- 所有字符串都要经过翻译层。 +- 增加新 key 时保持 language pack 完整。 + +## 测试与范围纪律 + +viewer UX 相关工作应保持聚焦且可验证。 + +- 可行时优先在 `claude_tap/viewer.html` 中做受限改动。 +- 在改进移动端 UX 的同时保持桌面行为稳定。 +- 通过测试与手动 trace-viewer 验证来确认行为。 diff --git a/.agents/docs/plans/2026-02-27-codex-support-handoff.md b/.agents/docs/plans/2026-02-27-codex-support-handoff.md new file mode 100644 index 0000000..c6b550f --- /dev/null +++ b/.agents/docs/plans/2026-02-27-codex-support-handoff.md @@ -0,0 +1,243 @@ +--- +status: completed +--- + +# Codex Support Handoff (Detailed) + +Date: 2026-02-27 +Branch: `feat/codex-client-support` +Repository: `liaohch3/claude-tap` + +## 1. Goal, Scope, and Current Status + +### Original Goal + +Add support for Codex in addition to Claude, while preserving existing Claude behavior. + +### Acceptance Constraint From User + +Real Codex validation must be successful end-to-end. Any `403` is considered a hard failure. + +### Current Status + +- Core implementation for `--tap-client codex` is completed. +- Mock/unit/integration tests are passing locally. +- Real Codex run is still blocked by account/API permissions (`Missing scopes: api.model.read`) and upstream behavior in this environment. +- Work is committed on branch `feat/codex-client-support`. + +## 2. What Was Done (and Why) + +### A. CLI and Runtime Behavior + +#### File: `claude_tap/cli.py` + +Changes made: + +- Added client selection support: + - New flag `--tap-client` with values `claude|codex`, default `claude`. +- Extended launch path so we can run either `claude` or `codex`. +- Reverse proxy env injection is now client-specific: + - Claude: `ANTHROPIC_BASE_URL=http://127.0.0.1:` + - Codex: `OPENAI_BASE_URL=http://127.0.0.1:/v1` +- `--tap-target` default is now derived from client when omitted: + - Claude -> `https://api.anthropic.com` + - Codex -> `https://api.openai.com` +- Updated user-facing startup/shutdown messages to reflect selected client. +- Preserved existing Claude forward-mode behavior, including `--settings` injection path only for Claude. + +Reason: + +- Existing implementation was Claude-only and hardcoded around Anthropic variables. +- Codex requires OpenAI-style base URL behavior in reverse mode. +- Keeping default `claude` preserves backward compatibility. + +### B. Trace Model Enhancement for Viewer + +#### File: `claude_tap/proxy.py` + +Changes made: + +- Added `upstream_base_url` into each trace record via `_build_record(...)`. +- Threaded `upstream_base_url` through streaming and non-streaming handlers. +- Simplified upstream encoding behavior by forcing `Accept-Encoding: identity` to avoid zstd-related client incompatibilities in this environment. + +Reason: + +- Viewer copy-curl was hardcoded to Anthropic domain; needed source-specific upstream reconstruction. +- Codex and some responses exhibited zstd decode failures in environment; identity encoding reduces this proxy-side compatibility risk. + +### C. Forward Proxy Consistency + +#### File: `claude_tap/forward_proxy.py` + +Changes made: + +- Also force `Accept-Encoding: identity` when forwarding upstream requests. + +Reason: + +- Keep behavior consistent with reverse proxy path and reduce compression-related failures. + +### D. Viewer Behavior + +#### File: `claude_tap/viewer.html` + +Changes made: + +- `copyCurl(...)` now uses `entry.upstream_base_url` when available. +- Falls back to `https://api.anthropic.com` for legacy traces. + +Reason: + +- Makes generated curl command accurate for Codex/OpenAI traces. +- Maintains backward compatibility for existing old trace files. + +### E. Test Coverage Updates + +#### File: `tests/test_e2e.py` + +Changes made: + +- Extended `_run_claude_tap(...)` helper with `tap_client` argument. +- `test_parse_args` now validates Codex defaults: + - `--tap-client codex` + - default target -> `https://api.openai.com` +- Added `test_codex_client_reverse_proxy` with fake `codex` executable: + - fake codex uses `OPENAI_BASE_URL` + - fake upstream expects `/v1/messages` + - asserts trace contains expected path/model + - asserts `upstream_base_url` is recorded + - asserts startup output includes `OPENAI_BASE_URL=...` + +Reason: + +- Validate new client behavior without depending on real external credentials. +- Ensure no regressions in argument parsing and runtime wiring. + +### F. Planning Docs + +#### File: `.agents/docs/plans/2026-02-27-codex-support-plan.md` + +Changes made: + +- Added a scoped implementation plan and explicit acceptance/risk notes. + +Reason: + +- Follow repository guidance for plan documentation and clear handoff context. + +## 3. What Was Not Done (or Not Fully Done) + +### A. Real Codex E2E Success + +Not achieved yet due environment/account limitations. + +Observed blocker: + +- Codex model refresh request failed: + - `GET /v1/models?client_version=...` + - `403 Forbidden` + - message includes `Missing scopes: api.model.read` + +Implication: + +- Per user requirement, this means real validation is not complete. + +### B. Dedicated `tests/e2e/` Real Codex Suite + +Not added yet. + +Reason: + +- Given hard requirement that real run must succeed, adding real tests now would produce consistent failures in this environment until permissions are fixed. + +### C. README / README_zh User-Facing Docs + +Not updated in this round. + +Reason: + +- Priority was code path and correctness first. +- Follow-up should document new `--tap-client` behavior and examples. + +## 4. Exact Test Execution and Results + +The following were run locally and passed: + +- `uv run ruff check .` +- `uv run ruff format --check .` +- `uv run pytest tests/ -x --timeout=60` + - Result: `48 passed, 18 skipped` + +Additional targeted tests run: + +- `uv run pytest tests/test_e2e.py -k "test_parse_args or test_codex_client_reverse_proxy" -x --timeout=120` + - Passed +- `uv run pytest tests/test_e2e.py -k "test_e2e or test_forward_proxy_connect or test_codex_client_reverse_proxy" -x --timeout=120` + - Passed + +Real Codex smoke run attempted and failed (as expected in this env): + +- Command pattern: + - `uv run python -m claude_tap --tap-client codex --tap-target https://api.openai.com --tap-no-update-check --tap-no-open -- exec "Reply with exactly: CODEX_REAL_OK" --skip-git-repo-check --json` +- Failure indicators: + - `403 Forbidden` with missing `api.model.read` + - Codex exits non-zero + +## 5. Working Tree Hygiene / Commit Scope + +Important context: + +- There was a pre-existing `uv.lock` modification before this work. +- `log/` contains runtime artifacts and is untracked. +- These were intentionally excluded from commit scope. + +Files intended for this task commit: + +- `claude_tap/cli.py` +- `claude_tap/proxy.py` +- `claude_tap/forward_proxy.py` +- `claude_tap/viewer.html` +- `tests/test_e2e.py` +- `.agents/docs/plans/2026-02-27-codex-support-plan.md` +- `.agents/docs/plans/2026-02-27-codex-support-handoff.md` (this file) + +## 6. Recommended Next Actions for the Next Codex Process + +### 1) Resolve Real Credential/Scope Blocker First + +- Ensure API key/project/org has `api.model.read` (and required response scopes). +- Re-run real Codex smoke command to confirm non-403. + +### 2) Add Real Codex E2E Coverage + +Suggested additions: + +- New test module under `tests/e2e/` for Codex real runs. +- Cover at least: + - single turn success + - multi-turn continuity + - trace generation and path assertions +- Keep `403/401` as hard fail per user rule. + +### 3) Update User Docs + +- Update `README.md` and optionally `README_zh.md` with: + - `--tap-client codex` + - reverse mode example for Codex + - known prerequisite: proper OpenAI scopes + +### 4) Optional: Investigate zstd error path deeper + +- Even after identity preference from proxy to upstream, Codex may still log zstd decode issues in failure scenarios. +- Verify whether this is tied to non-proxied traffic, fallback channels, or specific Codex internal endpoints. + +## 7. Quick Technical Summary for Next Agent Prompt + +If you need an abbreviated bootstrap prompt for another Codex process: + +- Branch: `feat/codex-client-support` +- Core feature done: `--tap-client codex` + OpenAI reverse mode env wiring. +- Tests pass locally (`48 passed, 18 skipped`) for `tests/`. +- Real Codex still blocked by `403 missing scopes: api.model.read`. +- Continue by fixing credentials/scopes and adding real Codex E2E tests + README updates. diff --git a/.agents/docs/plans/2026-02-27-codex-support-plan.md b/.agents/docs/plans/2026-02-27-codex-support-plan.md new file mode 100644 index 0000000..19a61cb --- /dev/null +++ b/.agents/docs/plans/2026-02-27-codex-support-plan.md @@ -0,0 +1,58 @@ +--- +status: completed +--- + +# Codex Support Plan + +Date: 2026-02-27 + +## Goal + +Add first-class Codex support to `claude-tap` while keeping Claude behavior fully backward compatible. + +## Scope + +- Add client selection: `--tap-client {claude,codex}`. +- Keep default client as `claude`. +- For `codex` in reverse proxy mode, inject `OPENAI_BASE_URL=http://127.0.0.1:/v1`. +- Keep existing Claude behavior: `ANTHROPIC_BASE_URL=http://127.0.0.1:`. +- Set default upstream target by client when `--tap-target` is omitted: + - `claude` -> `https://api.anthropic.com` + - `codex` -> `https://api.openai.com` +- Preserve existing forward proxy behavior. +- Add trace metadata `upstream_base_url` to improve viewer cURL reconstruction. + +## Non-Goals + +- Rename package/project branding (`claude-tap`) in this change. +- Add broad provider abstractions beyond Claude/Codex. +- Guarantee Codex ChatGPT login flow under forward proxy mode. + +## Test Strategy + +- Unit / mock E2E regression: + - `uv run pytest tests/test_e2e.py -x --timeout=120` +- New Codex mock E2E: + - fake `codex` binary + fake upstream API + - assert reverse-mode request path and `upstream_base_url` trace field +- Repo gate checks: + - `uv run ruff check .` + - `uv run ruff format --check .` + - `uv run pytest tests/ -x --timeout=60` + +## Success Criteria + +- Claude default workflow unchanged. +- `--tap-client codex` launches `codex` and writes valid trace output. +- Viewer copy-cURL uses `upstream_base_url` if present. +- No regressions in existing test suite. + +## Real E2E Acceptance Rule + +For Codex real E2E validation, HTTP `403/401` is treated as a hard failure. +A run is considered successful only when the end-to-end request completes with successful upstream responses. + +## Risks + +- Codex account scopes may block model-list or response APIs (`403`) even if proxy wiring is correct. +- Codex ChatGPT-web route traffic may not be fully covered by current forward mode assumptions. diff --git a/.agents/docs/plans/2026-02-27-viewer-sticky-e2e-workflow.md b/.agents/docs/plans/2026-02-27-viewer-sticky-e2e-workflow.md new file mode 100644 index 0000000..45c3527 --- /dev/null +++ b/.agents/docs/plans/2026-02-27-viewer-sticky-e2e-workflow.md @@ -0,0 +1,51 @@ +--- +status: completed +--- + +# Viewer Sticky Action Bar + 验证工作流计划 + +## 问题 + +`claude_tap/viewer.html` 中 detail pane 的操作按钮行在用户向下滚动时会消失。 +这会降低重复操作(`Request JSON`、`cURL`、`Diff with Prev`)的效率。 + +## 范围 + +- 在滚动 detail 内容时保持 action bar 可见。 +- 更新仓库工作流指引,确保未来 PR 包含: + - 真实 E2E 验证期望 + - 对影响 UI 变更的截图要求 +- 产出 review 证据(测试输出 + 截图)。 + +## 执行顺序 + +1. 定义目标行为与范围。 +2. 实施最小化 UI 变更。 +3. 运行受影响行为的定向测试。 +4. 运行 E2E/browser 验证并收集截图。 +5. 运行完整项目质量 gate。 +6. 准备包含证据的 PR。 +7. 记录经验教训。 + +## 变更摘要 + +- `claude_tap/viewer.html` + - 让 `.action-bar` 在 detail 滚动容器中保持 sticky。 +- `AGENTS.md` + - 新增 `E2E Validation Requirements` 章节。 + - 新增 `PR Requirements for UI Changes` 章节。 + +## 验证 + +- Unit/integration 测试:`uv run pytest tests/ -x --timeout=60` +- Lint/format 检查: + - `uv run ruff check .` + - `uv run ruff format --check .` +- UI 证据: + - 顶部状态截图 + - 滚动状态截图,确认 sticky action bar 仍可见 + +## 范围外 + +- Viewer 布局的功能重设计。 +- 非相关依赖更新。 diff --git a/.agents/docs/plans/2026-02-28-codex-websocket-support.md b/.agents/docs/plans/2026-02-28-codex-websocket-support.md new file mode 100644 index 0000000..1d13dc1 --- /dev/null +++ b/.agents/docs/plans/2026-02-28-codex-websocket-support.md @@ -0,0 +1,59 @@ +--- +status: active +--- + +# TODO:支持 /v1/responses 的 Codex WebSocket 传输 + +**日期:** 2026-02-28 +**优先级:** 中 +**状态:** 计划中 + +## 背景 + +Codex CLI v0.106.0+ 默认对 `/v1/responses` API 调用使用 WebSocket 传输 +(`responses_websockets` 和 `responses_websockets_v2` 特性)。当前 claude-tap +通过自动注入 `--disable responses_websockets` 的方式绕过该行为并强制使用 HTTP, +从而使现有 HTTP reverse proxy 能捕获全部请求。 + +这个方案可用,但不理想。WebSocket 传输可能更快,且未来 Codex 版本很可能会 +成为默认/唯一路径。 + +## 目标 + +在 claude-tap 的 reverse proxy mode 中原生支持 WebSocket 拦截,使 Codex +可使用默认 WebSocket 传输,同时 claude-tap 仍能捕获全部 API 调用。 + +## 方案选项 + +### 方案 A:WebSocket MITM proxy +- 拦截到 `/v1/responses` 的 WebSocket upgrade 请求 +- 代理 WebSocket 连接并记录全部 frame +- 将 frame 重组为相同 trace 格式(请求体 + SSE 等价事件) +- 优点:对 Codex 透明,无需注入 CLI flag +- 缺点:实现更复杂,需要处理 WS frame 重组 + +### 方案 B:带 CONNECT 隧道的 forward proxy +- 使用 forward proxy mode(HTTP_PROXY/HTTPS_PROXY)并做 TLS 拦截 +- 在 TLS 层同时拦截 HTTP 与 WebSocket 流量 +- 优点:适用于所有传输类型 +- 缺点:需要注入 TLS 证书,系统组成更复杂 + +### 方案 C:混合方案(检测并自适应) +- 检测 Codex 是否使用 WebSocket(检查 Upgrade headers) +- 若是 WebSocket:代理 WS 连接并记录 frame +- 若是 HTTP:使用现有 streaming proxy 路径 +- 优点:向后兼容,可覆盖任意 Codex 版本 +- 缺点:需要维护两条代码路径 + +## 实现说明 + +- Responses API 的 WebSocket frame 很可能遵循类似 SSE 的事件结构 +- 需要进一步确认 Codex 使用的具体 WebSocket 消息格式 +- `aiohttp`(已是依赖)支持 WebSocket proxying +- `--disable` flag 绕过方案应保留为 fallback 选项 + +## 参考 + +- 修复 commit:`a0e00e2`(在 reverse mode 下禁用 websocket 传输) +- 错误经验:`.agents/docs/error-experience/entries/2026-02-28-codex-reverse-websocket-capture-gap.md` +- Codex CLI flags:`--enable/--disable responses_websockets[_v2]` diff --git a/.agents/docs/standards/README.md b/.agents/docs/standards/README.md new file mode 100644 index 0000000..fab8a2f --- /dev/null +++ b/.agents/docs/standards/README.md @@ -0,0 +1,20 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-06 +source_of_truth: AGENTS.md +--- + +# 标准元数据 + +`.agents/docs/standards/*.md` 下所有文件都必须包含 frontmatter,字段包括: + +- `owner`:负责更新的团队或维护者。 +- `last_reviewed`:最近一次策略审查的 ISO 日期 `YYYY-MM-DD`。 +- `source_of_truth`:规范策略来源引用。 + +# 维护工作流 + +1. 更新受影响的标准文件,并刷新 `last_reviewed`。 +2. 保持 `AGENTS.md` 为简洁索引,并链接到更新后的文件。 +3. 本地运行 `python scripts/check_legibility.py`。 +4. 若策略行为发生变化,在 PR 描述中记录理由。 diff --git a/.agents/docs/standards/coding-and-runtime.md b/.agents/docs/standards/coding-and-runtime.md new file mode 100644 index 0000000..7d7086f --- /dev/null +++ b/.agents/docs/standards/coding-and-runtime.md @@ -0,0 +1,35 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-06 +source_of_truth: AGENTS.md +--- + +# 编码标准 + +## 应做 + +- 删除无用代码。 +- 修复测试失败的根因。 +- 使用现有模式,并将范围限制在相关文件。 +- 信任类型不变量,避免对已类型化值进行冗余运行时检查。 +- 保持函数聚焦单一职责。 +- 在脚本中优先使用 POSIX shell 工具。 +- 在脚本中使用 `grep -F` 做固定字符串匹配。 +- 从 metadata 读取 package version,而不是硬编码字符串。 + +## 禁止 + +- 保留注释掉的代码。 +- 添加猜测性的抽象。 +- 无理由抑制 linter 警告。 +- 提交生成文件。 +- 将 refactor 与 feature 工作混在一起。 +- 为未使用代码添加兼容性 shim。 +- 在未做检查时依赖不可移植工具(`rg`、`jq`、`fd` 可能不存在)。 + +# 运行时安全规则 + +- 如果使用 `tcsetpgrp` 的前台控制权切换,在将父进程组切回前台时要处理 `SIGTTOU`。 +- 将 CI 最高 Python 版本(当前为 3.13)视为运行时敏感行为的兼容性上限。 +- TLS 测试/运行时的证书生成必须包含 SKI/AKI 扩展,以兼容 Python 3.13。 +- 涉及证书/proxy/安全敏感变更时,在可用条件下本地以 Python 3.13 验证。 diff --git a/.agents/docs/standards/debugging-standards.md b/.agents/docs/standards/debugging-standards.md new file mode 100644 index 0000000..bc0352e --- /dev/null +++ b/.agents/docs/standards/debugging-standards.md @@ -0,0 +1,104 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-06 +source_of_truth: AGENTS.md +--- + +# Debugging Standards + +## Pre-Debug Checklist + +Before spawning agents or running automated retry loops: + +1. **Read the code path** (5 minutes max). Trace from the entry point to the + failing operation. Look for hardcoded values, skipped parameters, or + divergent paths. + +2. **Compare working vs broken.** If feature A works but feature B doesn't, + write down the differences in their code paths. The bug is in the diff. + +3. **Check the obvious.** Proxy settings, environment variables, port numbers, + feature flags. Most bugs are configuration, not logic. + +## During Debugging + +4. **2-strike rule.** If the same approach (re-run tests, try different flags) + fails twice, STOP. Switch to a different strategy: + - Add targeted logging/print statements + - Read the source code of the failing library + - Reduce to minimal reproduction + - Ask: "What assumption am I making that could be wrong?" + +5. **No infinite loops.** Cron monitoring is for watching known-good processes. + It is not a debugging tool. If a cron loop hasn't produced progress in + 2 cycles, disable it and debug manually. + +6. **Log your hypotheses.** Before each attempt, write down: + - What you think the problem is + - What evidence would confirm/deny it + - What you'll try + This prevents circular reasoning and repeated attempts. + +## Network/Proxy Debugging Specifically + +7. **Always check the actual connect call parameters.** When debugging proxy + issues, find the line where the connection is made and verify: + - Is `proxy=` set? To what? + - Is `trust_env=` True? + - Are environment variables (`HTTP_PROXY`, `HTTPS_PROXY`) being read? + +8. **Use the simplest possible test.** Before complex E2E: + ```bash + # Can we reach the host through proxy? + curl -x http://127.0.0.1:7897 https://target-host/ + # Can we reach it directly? + curl https://target-host/ + ``` + +9. **Check for proxy bypass.** Libraries often have `NO_PROXY`, `proxy=None`, + or per-request proxy overrides that silently skip the system proxy. + +## Proxy URL Construction Verification + +10. **Verify the final upstream URL, not just the forwarded path.** When + modifying path stripping, target URL, or route logic, add an assertion or + log line that prints the fully constructed upstream URL. Confirm it matches + the real API endpoint. Fake upstreams in unit tests only verify internal + consistency — they cannot catch URL mismatches with real APIs. + +11. **Enumerate all (client × auth × target) combinations.** Before changing + URL handling, draw a matrix of every supported configuration: + + ``` + api.openai.com chatgpt.com/backend-api/codex + strip /v1 ✗ 404 ✓ + no strip ✓ ✗ wrong path + conditional ✓ ✓ ← correct + ``` + + Every cell must be verified — either by automated test or real E2E run. + +12. **Run real E2E after any proxy/routing change.** Unit tests with fake + upstreams are necessary but not sufficient. After proxy changes, run at + least one real request through the proxy using tmux: + + ```bash + # Example: verify Codex through proxy + tmux new-session -d -s verify \ + "uv run python -m claude_tap --tap-client codex --tap-target TARGET --tap-no-launch --tap-port 0" + # Then launch client in another window and send a test message + ``` + +13. **Don't attribute failures to "environment" without evidence.** When a + request fails through the proxy, first print/log the constructed upstream + URL. Only blame network/environment after confirming the URL is correct. + See: `.agents/docs/error-experience/entries/2026-03-10-codex-strip-prefix-url-mismatch.md` + +## Post-Debug + +14. **Write the experience doc.** Every non-trivial debugging session produces + an entry in `.agents/docs/error-experience/entries/`. Include: + - What broke + - What you tried (and why it didn't work) + - What actually fixed it + - The lesson for future debugging diff --git a/.agents/docs/standards/e2e-and-evidence.md b/.agents/docs/standards/e2e-and-evidence.md new file mode 100644 index 0000000..b7174a4 --- /dev/null +++ b/.agents/docs/standards/e2e-and-evidence.md @@ -0,0 +1,72 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-29 +source_of_truth: AGENTS.md +--- + +# E2E 验证要求 + +如果变更影响 proxying、trace 捕获、CLI session 流程、auth 处理或其他端到端行为,在开 PR 前必须运行真实 E2E 验证。 + +优先命令: + +```bash +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 +uv run pytest tests/e2e/test_real_proxy.py::TestRealProxy::test_single_turn --run-real-e2e --timeout=180 +``` + +手动替代方案: + +```bash +scripts/run_real_e2e.sh +scripts/run_real_e2e_tmux.sh +``` + +如果无法运行真实 E2E(例如缺少 auth/token),在 PR 正文中记录原因与剩余风险。 + +# 代理/路由变更的额外要求 + +如果变更影响 URL 构造、路径 strip、target 选择或代理转发逻辑: + +1. 参照 `docs/support-matrix.md` 中的支持矩阵,确认所有组合的 URL 构造正确。 +2. 运行 `test_codex_upstream_url_construction` 验证 URL 断言。 +3. 至少对一种真实后端做 tmux E2E 验证(如果 auth 可用)。 +4. 不要将 proxy 请求失败归因为"环境问题",除非已确认构造的 upstream URL 正确。 + +# E2E 对话规则 + +每次 E2E 运行必须至少包含一次完整的多轮对话。 +对于对话验证和截图证据,请使用 tmux 交互流程(`scripts/run_real_e2e_tmux.sh`)。 +不要使用 `claude -p` 的单次运行作为对话完整性的证明。 + +# UI 证据要求 + +对于会改变 UI 布局、样式、交互流程或渲染内容的 PR: + +- 每个变更的页面/状态至少提供一张截图。 +- 当视觉差异重要时,提供变更前后截图。 +- 当移动端行为受影响时,提供移动端截图。 +- 使用来自 `.traces/trace_*.jsonl` 或真实运行输出的真实 trace 产物。 +- 对于与 E2E 相关的 UI 变更,截图必须来自至少完成一次完整多轮对话的运行。 +- 对于 proxy、runtime、client 或 trace 捕获变更,截图必须展示真实 trace viewer/dashboard 中的具体 API entry 或 session 状态,不能使用手写 HTML、终端输出截图、pytest 输出截图或只展示验证摘要的图片替代。 +- PR 正文必须说明截图来源,例如真实 `claude-tap` 命令、导出的 `trace_*.jsonl`、viewer HTML 或 dashboard session。 + +# 截图质量门禁 + +作为 PR 证据提交的每张截图,在 `git add` 之前都必须通过以下检查: + +## 强制检查 +1. **Viewport 宽度 ≥ 1280px** — Headless browser 常默认窄 viewport。截图前务必调整为桌面尺寸(1280x800 或 1440x900)。 +2. **内容与声明一致** — 如果 PR 写的是“已捕获 WS trace”,截图中必须清楚显示 WS trace,而不是其他请求或加载页。 +3. **无编码损坏** — Unicode 箭头(→←)、CJK 字符和 emoji 必须正确渲染。如有疑问,在生成证据页面时使用 ASCII 等价字符或 HTML entity。 +4. **无错误页面** — 404、ERR_EMPTY_RESPONSE、空白页或 “page not found” 不能作为证据。 +5. **最小分辨率** — 图像宽度必须 ≥ 1000px。更窄通常是移动端/平板截图。 +6. **文件大小合理** — 小于 10KB 的截图通常是空白页或错误页。典型 trace viewer 截图为 100KB–500KB。 + +## 最佳实践 +- 对日志/文本证据,优先渲染为有样式的 HTML(深色卡片、monospace、语法高亮),不要直接提供原始 `.log` 文件,以避免字体/编码问题。 +- 截取 trace viewer 时,先导航到指定 entry,再截图。 +- 使用 `scripts/check_screenshots.sh` 自动执行 pre-commit 验证。 + +## 反模式:盲目提交 +不要在未先打开并检查截图的情况下直接 `git add` + `git commit` + `git push`。这会浪费 reviewer 时间并削弱证据信任。 diff --git a/.agents/docs/standards/hard-rules.md b/.agents/docs/standards/hard-rules.md new file mode 100644 index 0000000..093f1ca --- /dev/null +++ b/.agents/docs/standards/hard-rules.md @@ -0,0 +1,18 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-06 +source_of_truth: AGENTS.md +--- + +# 硬性规则 + +以下规则是强制性的。若你无法遵守,请停止并说明原因。 + +1. 每次 commit 前执行 gate 检查:`ruff check`、`ruff format --check`、`pytest`。不得延期修复。 +2. UI 变更要求在 PR 正文中提供使用 `raw.githubusercontent.com` 绝对 URL 的截图。 +3. 每个 commit 只处理一个关注点。不要把 refactor 与 feature 或 bug fix 混在一起。 +4. 代码、注释、commit message 和 skill 文件仅使用英文。对外文档必须中英文双份,中文内容放在 `README_zh.md` 或对应的 `*.zh.md` 文件中。 +5. 截图、演示和测试证据必须使用 `.traces/` 中的真实 trace 数据,禁止 mock 或合成数据。 +6. 编码前必须完成 pre-work checklist;开 PR 或合并前必须完成 pre-PR checklist。 +7. 变更后必须执行 `git add`、`git commit` 和 `git push origin `。 +8. 必须使用 `gh pr create` 创建 GitHub PR;PR 未创建前,工作不算完成。 diff --git a/.agents/docs/standards/screenshot-standards.md b/.agents/docs/standards/screenshot-standards.md new file mode 100644 index 0000000..4df11fb --- /dev/null +++ b/.agents/docs/standards/screenshot-standards.md @@ -0,0 +1,44 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-29 +source_of_truth: AGENTS.md +--- + +# Screenshot Standards + +These rules apply to PR evidence screenshots used to validate UI, viewer output, trace logs, and proxy behavior. + +## Viewport Rules + +1. Desktop screenshots must use a viewport width of at least `1280px`. +2. If a change is desktop-only, do not submit mobile-layout screenshots as primary evidence. +3. If a change intentionally affects mobile behavior, include separate mobile screenshots and label them clearly. + +## Encoding Rules + +1. Use ASCII-safe characters or HTML entities in generated HTML/log views for symbols that may render inconsistently. +2. Do not use raw Unicode arrows (`→`, `←`) in generated HTML evidence content. +3. Prefer explicit text alternatives (`->`, `<-`) or entities (`→`, `←`) to avoid garbled output. + +## Content Verification Rules + +1. Every screenshot must show the exact feature or fix claimed in the PR description. +2. For protocol fixes, capture the specific row/event proving the behavior (example: `101 WEBSOCKET`, not unrelated `GET` rows). +3. Runtime, proxy, viewer, dashboard, and client screenshots must come from a real trace viewer/dashboard or a real browser run generated by `claude-tap`, not from handcrafted HTML, terminal output, or test-output art. +4. PR evidence links for runtime behavior must point to committed images under `.agents/evidence/pr/` and use filenames that identify the real surface, such as `trace-viewer`, `dashboard`, `session`, `e2e`, or `browser`. +5. Before commit, verify the screenshot path and filename match the content shown in the PR markdown links. + +## Screenshot Pre-commit Checklist + +Run this checklist before `git commit` when screenshots are part of the PR: + +1. Viewport width is `>=1280px` for desktop evidence. +2. Screenshot contains the exact target state/line proving the fix. +3. No garbled characters or encoding corruption in visible text. +4. Images are legible, not mostly blank, and reasonably sized. +5. Runtime evidence is backed by a real trace source, and the PR body states the source command or exported `trace_*.jsonl` / viewer HTML. +6. Local automated check passes: + +```bash +python3 scripts/check_screenshots.py .agents/evidence/pr/ +``` diff --git a/.agents/docs/standards/validation-and-gates.md b/.agents/docs/standards/validation-and-gates.md new file mode 100644 index 0000000..2ba518b --- /dev/null +++ b/.agents/docs/standards/validation-and-gates.md @@ -0,0 +1,77 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-06 +source_of_truth: AGENTS.md +--- + +# Pre-commit CI 检查 + +每次 commit 前运行: + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pytest tests/ -x --timeout=60 +``` + +所有检查都必须通过。若格式检查失败,运行 `uv run ruff format .` 后重新检查。 + +# Coverage Targets + +Coverage gates are configured in `pyproject.toml` under `[tool.claude_tap.coverage]`. + +- Backend Python project coverage: at least `65%`. +- Backend Python incremental coverage: at least `80%` of changed executable package lines. +- Frontend viewer JavaScript function coverage: at least `50%` of V8-reported inline JS functions executed by the viewer contract suite. +- Frontend viewer JavaScript incremental coverage: at least `80%` of changed `viewer.html` JavaScript functions exercised by V8 coverage. +- Frontend viewer CSS selector coverage: at least `65%` of queryable CSS selectors must match real DOM states exercised by the viewer contract suite. +- Frontend viewer CSS incremental coverage: at least `80%` of changed queryable `viewer.html` CSS selectors must match exercised DOM states. + +Run the deterministic coverage gate with: + +```bash +python -m coverage run -m pytest tests/ -q +python -m coverage json -o .coverage.json +python scripts/check_coverage.py --python-coverage .coverage.json +``` + +# Meaningful Test Requirements + +Coverage percentage is a floor, not the goal. Tests added only to execute lines +without proving behavior are not acceptable. + +Every new or changed test must assert at least one meaningful contract: + +- returned values, normalized data, status transitions, or error behavior for + Python code; +- rendered DOM sections, visible semantic text, user interactions, browser + runtime errors, V8-executed viewer functions, or CSS-backed layout and theme + states for `viewer.html`; +- persisted files, trace records, or generated evidence when the change affects + filesystem output. + +Viewer tests must prefer generated HTML opened in Chromium for user-visible +behavior. `Full JSON` may be asserted as a fallback section, but a viewer test +must not treat `Full JSON` alone as successful semantic rendering. + +Viewer style changes must prove more than screenshot existence. Tests should +assert durable layout and visual contracts such as desktop/mobile widths, +overflow bounds, expanded content dimensions, and light/dark theme differences. + +# Pre-work Checklist + +在进行任何代码变更之前: + +```bash +git diff --stat +git log --oneline -10 +git fetch origin +``` + +在打开或合并 PR 之前: + +```bash +git rebase origin/main +uv lock --check +uv run pytest tests/ -x --timeout=60 +``` diff --git a/.agents/docs/standards/workflow-and-review.md b/.agents/docs/standards/workflow-and-review.md new file mode 100644 index 0000000..a989e00 --- /dev/null +++ b/.agents/docs/standards/workflow-and-review.md @@ -0,0 +1,75 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-05-29 +source_of_truth: AGENTS.md +--- + +# Worktree 工作流 + +使用 git worktree 进行隔离的 feature 开发: + +```bash +git worktree add -b feat/ /tmp/claude-tap- main +cd /tmp/claude-tap- +uv run pytest tests/ -x --timeout=60 +cd /path/to/claude-tap +git merge --ff-only feat/ +git worktree remove /tmp/claude-tap- +git branch -d feat/ +``` + +# Code Review Checklist + +每次 commit 前: + +1. `uv run ruff check .` +2. `uv run ruff format --check .` +3. `uv run pytest tests/ -x --timeout=60` +4. `git diff` 并检查每一行变更。 +5. 确认只改动了相关文件。 + +# PR 正文策略门禁 + +`scripts/check_pr_policy.py` 是 PR 正文和 evidence 规则的统一机器检查入口。开 PR 前和 review preflight 都必须让它通过。 + +该门禁检查: + +1. PR 正文包含 Summary/Problem/Goal 等摘要段落。 +2. PR 正文包含 Validation/Test plan/Results 等验证段落。 +3. 改动 runtime、viewer、client、proxy 或 UI 行为时,PR 正文包含 `raw.githubusercontent.com` 截图证据链接。 +4. PR 正文中的图片链接使用 `raw.githubusercontent.com` 绝对 URL。 +5. Runtime 行为截图必须链接到 `.agents/evidence/pr/` 下提交的 trace/viewer/dashboard/session/browser 证据图,并在 PR 正文说明真实 trace/viewer 来源。 +6. PR 不包含 raw trace、生成的 trace viewer、日志、secret-like 文件或明显 token。 + +本地检查: + +```bash +scripts/check_pr.sh --no-tests +``` + +CI 检查: + +```bash +python scripts/check_pr_policy.py --event-path "$GITHUB_EVENT_PATH" --changed-files-file /tmp/pr-changed-files.txt +``` + +CI 同时在独立的 `pr-policy` job 和现有 required `lint` job 中运行该检查,避免新增 required status check 之前出现可绕过窗口。 + +# 复利式工程实践 + +记录经验教训: + +- 错误经验:`.agents/docs/error-experience/entries/YYYY-MM-DD-.md` +- 正向经验:`.agents/docs/good-experience/entries/YYYY-MM-DD-.md` +- 汇总:`.agents/docs/error-experience/summary/entries/` 与 `.agents/docs/good-experience/summary/entries/` +- 计划:`.agents/docs/plans/` +- 指南:`docs/guides/` + +在出现重大 bug、CI 失败或发现有价值模式后,创建一条条目并记录根因与经验。 + +# Brain + Hands 协议 + +- Claude Code (Opus):规划大脑,负责架构/API/模式/review 决策。 +- Codex:执行双手,负责样板代码/命令/机械性编辑。 + +不要把架构决策委托给执行工具。 diff --git a/.agents/evidence/images/deepseek-claude-code-serial-detail-scroll-1.png b/.agents/evidence/images/deepseek-claude-code-serial-detail-scroll-1.png new file mode 100644 index 0000000..1949438 Binary files /dev/null and b/.agents/evidence/images/deepseek-claude-code-serial-detail-scroll-1.png differ diff --git a/.agents/evidence/images/deepseek-claude-code-serial-final-response.png b/.agents/evidence/images/deepseek-claude-code-serial-final-response.png new file mode 100644 index 0000000..87c1057 Binary files /dev/null and b/.agents/evidence/images/deepseek-claude-code-serial-final-response.png differ diff --git a/.agents/evidence/images/deepseek-claude-code-serial-overview.png b/.agents/evidence/images/deepseek-claude-code-serial-overview.png new file mode 100644 index 0000000..c32e3cc Binary files /dev/null and b/.agents/evidence/images/deepseek-claude-code-serial-overview.png differ diff --git a/.agents/evidence/images/deepseek-claude-code-serial-sidebar-scroll.png b/.agents/evidence/images/deepseek-claude-code-serial-sidebar-scroll.png new file mode 100644 index 0000000..3ebf0c2 Binary files /dev/null and b/.agents/evidence/images/deepseek-claude-code-serial-sidebar-scroll.png differ diff --git a/.agents/evidence/images/viewer-brand-refresh-real-e2e-2turn-v1.png b/.agents/evidence/images/viewer-brand-refresh-real-e2e-2turn-v1.png new file mode 100644 index 0000000..e7b9eff Binary files /dev/null and b/.agents/evidence/images/viewer-brand-refresh-real-e2e-2turn-v1.png differ diff --git a/.agents/evidence/images/viewer-brand-refresh-real-e2e-tmux-v1.png b/.agents/evidence/images/viewer-brand-refresh-real-e2e-tmux-v1.png new file mode 100644 index 0000000..dc069b6 Binary files /dev/null and b/.agents/evidence/images/viewer-brand-refresh-real-e2e-tmux-v1.png differ diff --git a/.agents/evidence/images/viewer-brand-refresh-real-v1.png b/.agents/evidence/images/viewer-brand-refresh-real-v1.png new file mode 100644 index 0000000..582d2cf Binary files /dev/null and b/.agents/evidence/images/viewer-brand-refresh-real-v1.png differ diff --git a/.agents/evidence/images/viewer-brand-refresh-v1.png b/.agents/evidence/images/viewer-brand-refresh-v1.png new file mode 100644 index 0000000..dcf5a2c Binary files /dev/null and b/.agents/evidence/images/viewer-brand-refresh-v1.png differ diff --git a/.agents/evidence/images/viewer-iframe-embed-query.png b/.agents/evidence/images/viewer-iframe-embed-query.png new file mode 100644 index 0000000..dea290e Binary files /dev/null and b/.agents/evidence/images/viewer-iframe-embed-query.png differ diff --git a/.agents/evidence/images/viewer-sticky-actionbar-scrolled.png b/.agents/evidence/images/viewer-sticky-actionbar-scrolled.png new file mode 100644 index 0000000..d69dc5c Binary files /dev/null and b/.agents/evidence/images/viewer-sticky-actionbar-scrolled.png differ diff --git a/.agents/evidence/images/viewer-sticky-actionbar-top.png b/.agents/evidence/images/viewer-sticky-actionbar-top.png new file mode 100644 index 0000000..dfe6871 Binary files /dev/null and b/.agents/evidence/images/viewer-sticky-actionbar-top.png differ diff --git a/.agents/evidence/images/viewer-sticky-after-v2.png b/.agents/evidence/images/viewer-sticky-after-v2.png new file mode 100644 index 0000000..feb665f Binary files /dev/null and b/.agents/evidence/images/viewer-sticky-after-v2.png differ diff --git a/.agents/evidence/images/viewer-sticky-after.png b/.agents/evidence/images/viewer-sticky-after.png new file mode 100644 index 0000000..feb665f Binary files /dev/null and b/.agents/evidence/images/viewer-sticky-after.png differ diff --git a/.agents/evidence/images/viewer-sticky-before-v2.png b/.agents/evidence/images/viewer-sticky-before-v2.png new file mode 100644 index 0000000..83f0324 Binary files /dev/null and b/.agents/evidence/images/viewer-sticky-before-v2.png differ diff --git a/.agents/evidence/images/viewer-sticky-before.png b/.agents/evidence/images/viewer-sticky-before.png new file mode 100644 index 0000000..83f0324 Binary files /dev/null and b/.agents/evidence/images/viewer-sticky-before.png differ diff --git a/.agents/evidence/pr/210-local/claude-detail-deep.png b/.agents/evidence/pr/210-local/claude-detail-deep.png new file mode 100644 index 0000000..4fede03 Binary files /dev/null and b/.agents/evidence/pr/210-local/claude-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/claude-detail.png b/.agents/evidence/pr/210-local/claude-detail.png new file mode 100644 index 0000000..ae71c65 Binary files /dev/null and b/.agents/evidence/pr/210-local/claude-detail.png differ diff --git a/.agents/evidence/pr/210-local/codex-resume-detail-deep.png b/.agents/evidence/pr/210-local/codex-resume-detail-deep.png new file mode 100644 index 0000000..5cfd429 Binary files /dev/null and b/.agents/evidence/pr/210-local/codex-resume-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/codex-resume-detail.png b/.agents/evidence/pr/210-local/codex-resume-detail.png new file mode 100644 index 0000000..7fc8a12 Binary files /dev/null and b/.agents/evidence/pr/210-local/codex-resume-detail.png differ diff --git a/.agents/evidence/pr/210-local/dashboard-list-deepseek-success.png b/.agents/evidence/pr/210-local/dashboard-list-deepseek-success.png new file mode 100644 index 0000000..4813798 Binary files /dev/null and b/.agents/evidence/pr/210-local/dashboard-list-deepseek-success.png differ diff --git a/.agents/evidence/pr/210-local/dashboard-list.png b/.agents/evidence/pr/210-local/dashboard-list.png new file mode 100644 index 0000000..5eccbad Binary files /dev/null and b/.agents/evidence/pr/210-local/dashboard-list.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-error-detail-deep.png b/.agents/evidence/pr/210-local/deepseek-error-detail-deep.png new file mode 100644 index 0000000..64073bf Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-error-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-error-detail.png b/.agents/evidence/pr/210-local/deepseek-error-detail.png new file mode 100644 index 0000000..a7c85e2 Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-error-detail.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-success-r1-detail-deep.png b/.agents/evidence/pr/210-local/deepseek-success-r1-detail-deep.png new file mode 100644 index 0000000..e40f7cb Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-success-r1-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-success-r1-detail.png b/.agents/evidence/pr/210-local/deepseek-success-r1-detail.png new file mode 100644 index 0000000..0c4d230 Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-success-r1-detail.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-success-r2-detail-deep.png b/.agents/evidence/pr/210-local/deepseek-success-r2-detail-deep.png new file mode 100644 index 0000000..d7a7a5e Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-success-r2-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-success-r2-detail.png b/.agents/evidence/pr/210-local/deepseek-success-r2-detail.png new file mode 100644 index 0000000..c71d568 Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-success-r2-detail.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-success-r3-detail-deep.png b/.agents/evidence/pr/210-local/deepseek-success-r3-detail-deep.png new file mode 100644 index 0000000..c24c266 Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-success-r3-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/deepseek-success-r3-detail.png b/.agents/evidence/pr/210-local/deepseek-success-r3-detail.png new file mode 100644 index 0000000..7a4abda Binary files /dev/null and b/.agents/evidence/pr/210-local/deepseek-success-r3-detail.png differ diff --git a/.agents/evidence/pr/210-local/hermes-detail-deep.png b/.agents/evidence/pr/210-local/hermes-detail-deep.png new file mode 100644 index 0000000..dd01ea6 Binary files /dev/null and b/.agents/evidence/pr/210-local/hermes-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/hermes-detail.png b/.agents/evidence/pr/210-local/hermes-detail.png new file mode 100644 index 0000000..caf780a Binary files /dev/null and b/.agents/evidence/pr/210-local/hermes-detail.png differ diff --git a/.agents/evidence/pr/210-local/kimi-detail-deep.png b/.agents/evidence/pr/210-local/kimi-detail-deep.png new file mode 100644 index 0000000..21159ea Binary files /dev/null and b/.agents/evidence/pr/210-local/kimi-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/kimi-detail.png b/.agents/evidence/pr/210-local/kimi-detail.png new file mode 100644 index 0000000..eb9228f Binary files /dev/null and b/.agents/evidence/pr/210-local/kimi-detail.png differ diff --git a/.agents/evidence/pr/210-local/pi-detail-deep.png b/.agents/evidence/pr/210-local/pi-detail-deep.png new file mode 100644 index 0000000..c758592 Binary files /dev/null and b/.agents/evidence/pr/210-local/pi-detail-deep.png differ diff --git a/.agents/evidence/pr/210-local/pi-detail.png b/.agents/evidence/pr/210-local/pi-detail.png new file mode 100644 index 0000000..efca35a Binary files /dev/null and b/.agents/evidence/pr/210-local/pi-detail.png differ diff --git a/.agents/evidence/pr/210/dashboard-detail-no-flicker.png b/.agents/evidence/pr/210/dashboard-detail-no-flicker.png new file mode 100644 index 0000000..412167e Binary files /dev/null and b/.agents/evidence/pr/210/dashboard-detail-no-flicker.png differ diff --git a/.agents/evidence/pr/210/dashboard-detail-route-stable.png b/.agents/evidence/pr/210/dashboard-detail-route-stable.png new file mode 100644 index 0000000..699b0ce Binary files /dev/null and b/.agents/evidence/pr/210/dashboard-detail-route-stable.png differ diff --git a/.agents/evidence/pr/210/dashboard-language-switch-stable.png b/.agents/evidence/pr/210/dashboard-language-switch-stable.png new file mode 100644 index 0000000..8b22d25 Binary files /dev/null and b/.agents/evidence/pr/210/dashboard-language-switch-stable.png differ diff --git a/.agents/evidence/pr/210/dashboard-session-export-actions.png b/.agents/evidence/pr/210/dashboard-session-export-actions.png new file mode 100644 index 0000000..8e9d620 Binary files /dev/null and b/.agents/evidence/pr/210/dashboard-session-export-actions.png differ diff --git a/.agents/evidence/pr/210/dashboard-session-standalone-viewer.png b/.agents/evidence/pr/210/dashboard-session-standalone-viewer.png new file mode 100644 index 0000000..daa1811 Binary files /dev/null and b/.agents/evidence/pr/210/dashboard-session-standalone-viewer.png differ diff --git a/.agents/evidence/pr/210/session-hover-tooltip.png b/.agents/evidence/pr/210/session-hover-tooltip.png new file mode 100644 index 0000000..faba0c2 Binary files /dev/null and b/.agents/evidence/pr/210/session-hover-tooltip.png differ diff --git a/.agents/evidence/pr/210/session-sort-trace-copy.png b/.agents/evidence/pr/210/session-sort-trace-copy.png new file mode 100644 index 0000000..90df591 Binary files /dev/null and b/.agents/evidence/pr/210/session-sort-trace-copy.png differ diff --git a/.agents/evidence/pr/210/viewer-image-placeholder-recovery.png b/.agents/evidence/pr/210/viewer-image-placeholder-recovery.png new file mode 100644 index 0000000..68dea6d Binary files /dev/null and b/.agents/evidence/pr/210/viewer-image-placeholder-recovery.png differ diff --git a/.agents/evidence/pr/214/codex-real-trace-html.png b/.agents/evidence/pr/214/codex-real-trace-html.png new file mode 100644 index 0000000..a3b2341 Binary files /dev/null and b/.agents/evidence/pr/214/codex-real-trace-html.png differ diff --git a/.agents/evidence/pr/227/codex-forward-trace-viewer.png b/.agents/evidence/pr/227/codex-forward-trace-viewer.png new file mode 100644 index 0000000..3639b2c Binary files /dev/null and b/.agents/evidence/pr/227/codex-forward-trace-viewer.png differ diff --git a/.agents/evidence/pr/246-iframe-embed/default-desktop.png b/.agents/evidence/pr/246-iframe-embed/default-desktop.png new file mode 100644 index 0000000..a0cc230 Binary files /dev/null and b/.agents/evidence/pr/246-iframe-embed/default-desktop.png differ diff --git a/.agents/evidence/pr/246-iframe-embed/embed-dark-controls-visible.png b/.agents/evidence/pr/246-iframe-embed/embed-dark-controls-visible.png new file mode 100644 index 0000000..d075787 Binary files /dev/null and b/.agents/evidence/pr/246-iframe-embed/embed-dark-controls-visible.png differ diff --git a/.agents/evidence/pr/246-iframe-embed/embed-light-compact-hidden-chrome.png b/.agents/evidence/pr/246-iframe-embed/embed-light-compact-hidden-chrome.png new file mode 100644 index 0000000..f5eceb2 Binary files /dev/null and b/.agents/evidence/pr/246-iframe-embed/embed-light-compact-hidden-chrome.png differ diff --git a/.agents/evidence/pr/246-iframe-embed/embed-mobile-compact.png b/.agents/evidence/pr/246-iframe-embed/embed-mobile-compact.png new file mode 100644 index 0000000..e25dd39 Binary files /dev/null and b/.agents/evidence/pr/246-iframe-embed/embed-mobile-compact.png differ diff --git a/.agents/evidence/pr/260-dashboard-smoke.png b/.agents/evidence/pr/260-dashboard-smoke.png new file mode 100644 index 0000000..f7d5963 Binary files /dev/null and b/.agents/evidence/pr/260-dashboard-smoke.png differ diff --git a/.agents/evidence/pr/269/claude-trace-aws-redrock.png b/.agents/evidence/pr/269/claude-trace-aws-redrock.png new file mode 100644 index 0000000..732b3cb Binary files /dev/null and b/.agents/evidence/pr/269/claude-trace-aws-redrock.png differ diff --git a/.agents/evidence/pr/269/dashboard-newapi-bedrock-claude.png b/.agents/evidence/pr/269/dashboard-newapi-bedrock-claude.png new file mode 100644 index 0000000..49e4111 Binary files /dev/null and b/.agents/evidence/pr/269/dashboard-newapi-bedrock-claude.png differ diff --git a/.agents/evidence/pr/276-double-serialized-body/README.md b/.agents/evidence/pr/276-double-serialized-body/README.md new file mode 100644 index 0000000..9f6de68 --- /dev/null +++ b/.agents/evidence/pr/276-double-serialized-body/README.md @@ -0,0 +1,33 @@ +# PR #276 double-serialized request body evidence + +`double-serialized-viewer.png` was captured from a real `python -m claude_tap` +reverse-proxy run against a local fake Claude Code binary and a local fake +upstream server. + +The fake Claude binary sent a double-serialized request body: + +```python +inner = {"model": "claude-test", "messages": [{"role": "user", "content": "hi"}]} +payload = json.dumps(json.dumps(inner)).encode() +``` + +The run confirmed both required behaviors: + +- The fake upstream still received the original one-pass parsed JSON string, so + claude-tap did not mutate the request bytes sent upstream. +- The stored trace request body was a dict, allowing the viewer to render the + user message normally instead of showing an opaque escaped JSON string. + +Validation commands: + +```bash +uv run ruff check claude_tap/forward_proxy.py claude_tap/proxy.py tests/test_e2e.py tests/test_request_body_parsing.py +uv run ruff format --check claude_tap/forward_proxy.py claude_tap/proxy.py tests/test_e2e.py tests/test_request_body_parsing.py +uv run pytest tests/test_request_body_parsing.py tests/test_e2e.py::test_double_serialized_request_body -q +python scripts/check_screenshots.py .agents/evidence/pr/276-double-serialized-body +``` + +The temporary SQLite database and exported HTML used to capture the screenshot +were local-only and are not committed. No API keys, auth tokens, private +prompts, raw trace files, or generated HTML viewers are present in this +evidence directory. diff --git a/.agents/evidence/pr/276-double-serialized-body/double-serialized-viewer.png b/.agents/evidence/pr/276-double-serialized-body/double-serialized-viewer.png new file mode 100644 index 0000000..5dfba4c Binary files /dev/null and b/.agents/evidence/pr/276-double-serialized-body/double-serialized-viewer.png differ diff --git a/.agents/evidence/pr/286/README.md b/.agents/evidence/pr/286/README.md new file mode 100644 index 0000000..184daee --- /dev/null +++ b/.agents/evidence/pr/286/README.md @@ -0,0 +1,81 @@ +# PR 286 Kimi Code CLI Evidence + +Real validation for `--tap-client kimi-code` (not fake upstream). + +## Commands + +```bash +cd /Users/civa/code/kimi-ws2/kimi-code-demo +claude-tap --tap-client kimi-code --tap-no-open --tap-no-update-check -- -p "Reply with exactly: KIMI_CODE_E2E_OK" +``` + +Direct control (no claude-tap): + +```bash +kimi -p "Reply with exactly: KIMI_DIRECT_OK" +``` + +## Results + +- `claude-tap --tap-client kimi-code`: exit 0, assistant text `KIMI_CODE_E2E_OK`, 1 API call captured +- Sandbox env: `KIMI_CODE_HOME` temp dir with patched `config.toml` `base_url` → `http://127.0.0.1:` +- OAuth: `credentials/` and `oauth/` symlinked from real `~/.kimi-code` +- Migration prompt suppressed via `.skip-migration-from-kimi-cli` when `~/.kimi/.migrated-to-kimi-code` targets `~/.kimi-code` + +## Trace source + +- SQLite: `~/.local/share/claude-tap/traces.sqlite3` +- Example session: `3a09233f-f616-4f90-847a-d35178b7fa36` (`client=kimi-code`, `record_count=1`) +- Request path: `/chat/completions`, status `200`, upstream `https://api.kimi.com/coding/v1` + +## Automated tests + +```bash +uv run pytest tests/test_kimi_code_launch.py tests/test_e2e.py::test_kimi_code_client_reverse_proxy -x --timeout=60 +``` + +## Maintainer validation + +Conflict resolution and maintainer-side validation were run from a Linux worktree after rebasing the PR branch onto the latest `main`. + +```bash +uv run --with ruff ruff check . +uv run --with ruff ruff format --check . +uv run --with pytest --with pytest-asyncio --with pytest-timeout pytest tests/ -x --timeout=60 +uv run --with pytest --with pytest-asyncio pytest tests/test_client_config_framework.py tests/test_kimi_code_launch.py -q +env -u MOONSHOT_BASE_URL -u OPENAI_BASE_URL -u OPENROUTER_BASE_URL \ + uv run --with pytest --with pytest-asyncio pytest tests/test_kimi_launch.py tests/test_e2e.py -q -k 'kimi' +uv run --with pytest --with pytest-asyncio pytest tests/test_opencode_launch.py -q +``` + +Results: + +- Full test suite: `696 passed, 25 skipped` +- Focused Kimi Code/client framework suite: `65 passed` +- Focused legacy Kimi suite: `9 passed` +- OpenCode launch suite: `8 passed` + +Local real trace viewer evidence: + +![Kimi real trace viewer](kimi-viewer-real-trace.png) + +This screenshot was generated from the local claude-tap SQLite session `0bb44d26-a814-4e59-a65d-9bf16b41c8aa`, a real Kimi CLI reverse-proxy capture with two `/chat/completions` records. It validates that the existing Kimi capture/export path still renders real Kimi request details after the Kimi Code client changes. The real Kimi Code upstream capture remains the contributor-side session documented above. + +Latest published Kimi Code CLI smoke check: + +```bash +npm view @moonshot-ai/kimi-code version dist-tags.latest +npm exec --yes --package @moonshot-ai/kimi-code@latest -- kimi --version +PATH=/tmp/claude-tap-pr287-kimi-code-latest/node_modules/.bin:$PATH \ + KIMI_CODE_HOME=/tmp/claude-tap-pr287-kimi-smoke/home \ + uv run python -m claude_tap --tap-client kimi-code \ + --tap-output-dir /tmp/claude-tap-pr287-kimi-smoke/traces \ + --tap-no-live --tap-no-open -- --help +``` + +Results: + +- Latest npm package: `@moonshot-ai/kimi-code@0.11.0` +- Latest `kimi --version`: `0.11.0` +- `claude-tap --tap-client kimi-code -- --help`: exit 0, with `KIMI_CODE_HOME` and `KIMI_CODE_BASE_URL` injected through the reverse-proxy sandbox +- A real prompt was attempted with the latest CLI, but the local Linux test environment has no Kimi Code OAuth/API key configured, so the CLI stopped before sending an upstream request. This validates launch/config wiring, while the real upstream request capture is covered by the contributor evidence above. diff --git a/.agents/evidence/pr/286/kimi-viewer-real-trace.png b/.agents/evidence/pr/286/kimi-viewer-real-trace.png new file mode 100644 index 0000000..4bb3461 Binary files /dev/null and b/.agents/evidence/pr/286/kimi-viewer-real-trace.png differ diff --git a/.agents/evidence/pr/287/README.md b/.agents/evidence/pr/287/README.md new file mode 100644 index 0000000..ff4def4 --- /dev/null +++ b/.agents/evidence/pr/287/README.md @@ -0,0 +1,132 @@ +# PR 287 Kimi Code Deep E2E Evidence + +Real validation for `--tap-client kimi-code` using the latest `@moonshot-ai/kimi-code` package and a logged-in local Kimi Code profile. + +## Scenario + +The test used one real Kimi Code conversation resumed across three non-interactive turns. Each turn required file or shell tool use inside an isolated workspace: + +- workspace: `/tmp/claude-tap-kimi-code-deep-287-workspace` +- trace DB: `/tmp/claude-tap-kimi-code-deep-287.sqlite3` +- Kimi resume id: `276aae75-89fb-4093-a6d7-5861c1acc4ef` + +The workspace started with `seed.txt`. Kimi Code then created and modified: + +- `turn1_notes.md` +- `turn2_report.json` +- `turn3_verification.txt` + +## Commands + +Turn 1: + +```bash +CLOUDTAP_DB=/tmp/claude-tap-kimi-code-deep-287.sqlite3 \ +npm exec --yes --package @moonshot-ai/kimi-code@latest -- bash -lc \ +'/tmp/claude-tap-pr287/.venv/bin/claude-tap --tap-client kimi-code --tap-no-open --tap-no-live --tap-no-update-check -- --print --final-message-only -p "Work only inside the current directory. Use your file or shell tools, not just reasoning. First inspect the current directory. Then create turn1_notes.md with two bullet lines, one containing KIMI_DEEP_TURN1. Then run a command that reports the file line count and sha256. Then read the file back. Reply with exactly one line starting with KIMI_DEEP_TURN1_OK and include line_count and sha256."' +``` + +Turn 2: + +```bash +CLOUDTAP_DB=/tmp/claude-tap-kimi-code-deep-287.sqlite3 \ +npm exec --yes --package @moonshot-ai/kimi-code@latest -- bash -lc \ +'/tmp/claude-tap-pr287/.venv/bin/claude-tap --tap-client kimi-code --tap-no-open --tap-no-live --tap-no-update-check -- -r 276aae75-89fb-4093-a6d7-5861c1acc4ef --print --final-message-only -p "Continue the existing session. Use your file or shell tools again. Inspect turn1_notes.md, append a third bullet line containing KIMI_DEEP_TURN2, create turn2_report.json containing the final line count and sha256, then read that JSON file back. Reply with exactly one line starting with KIMI_DEEP_TURN2_OK and include line_count and sha256."' +``` + +Turn 3: + +```bash +CLOUDTAP_DB=/tmp/claude-tap-kimi-code-deep-287.sqlite3 \ +npm exec --yes --package @moonshot-ai/kimi-code@latest -- bash -lc \ +'/tmp/claude-tap-pr287/.venv/bin/claude-tap --tap-client kimi-code --tap-no-open --tap-no-live --tap-no-update-check -- -r 276aae75-89fb-4093-a6d7-5861c1acc4ef --print --final-message-only -p "Continue the same session for a third turn. Use file or shell tools again. Read turn2_report.json, list the workspace files, run a Python command that verifies the sha256 of turn1_notes.md matches the report, and write turn3_verification.txt containing KIMI_DEEP_TURN3 plus the verification result. Then read turn3_verification.txt back. Reply with exactly one line starting with KIMI_DEEP_TURN3_OK and include verified=true."' +``` + +## Results + +| Turn | claude-tap session | API calls | Status | Final response | +| --- | --- | ---: | --- | --- | +| 1 | `1ee9799d-d48f-404b-99c4-f7182fc825dc` | 5 | `complete` | `KIMI_DEEP_TURN1_OK line_count=2 sha256=0473e9a2b20bd4f62ad857f28ae8e4323859f23cfc9cffd6bc6d345b0ea99fd9` | +| 2 | `412db154-5fe6-4b91-9093-3f51f83f7e4c` | 6 | `complete` | `KIMI_DEEP_TURN2_OK line_count=3 sha256=c027e93c45bfbb4194e08d6f5d523949ff8bdf267a77a4f05d66bbf4b3b59367` | +| 3 | `0380294c-9875-4316-9564-809d94a7a4a9` | 5 | `complete` | `KIMI_DEEP_TURN3_OK verified=true` | + +Workspace verification: + +```text +turn1_notes.md: +- This is turn1_notes.md +- KIMI_DEEP_TURN1 +- KIMI_DEEP_TURN2 + +turn2_report.json: +{"line_count":3,"sha256":"c027e93c45bfbb4194e08d6f5d523949ff8bdf267a77a4f05d66bbf4b3b59367"} + +turn3_verification.txt: +KIMI_DEEP_TURN3 verified=True +``` + +## Interactive tmux E2E + +I also ran Kimi Code as a real interactive TUI process under tmux and sent three consecutive prompts with `tmux paste-buffer` / `tmux send-keys`. This validates a single running Kimi Code process, terminal input handling, tool approval prompts, and continuous conversation state without relying on `--print` or `-r` for each turn. + +- tmux session: `ctap-kimi-287` +- workspace: `/tmp/claude-tap-kimi-code-tmux-287-workspace` +- trace DB: `/tmp/claude-tap-kimi-code-tmux-287.sqlite3` +- claude-tap session: `b00ecd17-4c6c-40a6-829a-6f5fdbfc5719` +- Kimi Code session: `81668bcd-c11e-455f-ab55-280dc4da9031` + +Interactive prompts sent: + +1. Create and read back `tmux_turn1.txt`, then reply `KIMI_TMUX_TURN1_OK`. +2. Continue in the same TUI session, append `KIMI_TMUX_TURN2`, run `wc -l` and `sha256sum`, then reply `KIMI_TMUX_TURN2_OK`. +3. Continue in the same TUI session, list files, run Python verification, write/read `tmux_turn3_check.txt`, then reply `KIMI_TMUX_TURN3_OK`. + +The trace captured: + +- 12 total API records +- `client=kimi-code`, `proxy_mode=reverse`, `status=complete` +- final response: `KIMI_TMUX_TURN3_OK` +- tool evidence for `Shell`, `WriteFile`, and `ReadFile` +- real tool approval flow in the interactive TUI + +Workspace verification: + +```text +tmux_turn1.txt: +KIMI_TMUX_TURN1 KIMI_TMUX_287 +KIMI_TMUX_TURN2 + +tmux_turn3_check.txt: +KIMI_TMUX_TURN3 verified=true +``` + +## Screenshots + +All screenshots were taken from real exported viewer HTML generated from the SQLite trace DB. The scroll screenshots intentionally capture different detail-pane positions rather than only the first viewport. +The committed filenames include `viewer` so the PR evidence policy can identify them as runtime trace viewer screenshots. + +- `kimi-code-deep-viewer-turn3-overview.png` - third-turn viewer overview with Kimi Code sidebar entries and tool labels. +- `kimi-code-deep-viewer-turn3-mid-scroll.png` - scrolled detail pane showing conversation history and tool-call context. +- `kimi-code-deep-viewer-turn3-tool-scroll.png` - deeper scroll showing Shell and ReadFile tool results plus resumed user input. +- `kimi-code-deep-viewer-turn3-final-response-scroll.png` - scrolled third-turn tool sequence before the final verification. +- `kimi-code-deep-viewer-turn3-bottom-final-response.png` - bottom scroll showing `turn3_verification.txt` readback and `KIMI_DEEP_TURN3_OK verified=true`. +- `kimi-code-deep-viewer-turn2-overview.png` - second-turn viewer overview. +- `kimi-code-deep-viewer-turn2-tool-scroll.png` - second-turn scrolled tool call/result evidence. +- `kimi-code-tmux-viewer-overview.png` - interactive tmux run overview with 12 records and tool labels. +- `kimi-code-tmux-viewer-turn1-tools-scroll.png` - first tmux prompt scrolled tool evidence. +- `kimi-code-tmux-viewer-turn2-history-scroll.png` - second tmux prompt scrolled history/tool evidence. +- `kimi-code-tmux-viewer-turn3-tools-scroll.png` - third tmux prompt scrolled tool evidence. +- `kimi-code-tmux-viewer-bottom-final-response.png` - bottom scroll showing `KIMI_TMUX_TURN3_OK`. + +## Validation + +```bash +uv run python scripts/check_screenshots.py .agents/evidence/pr/287 +uv run python scripts/verify_screenshots.py /tmp/claude-tap-kimi-code-deep-287-turn2.html /tmp/claude-tap-kimi-code-deep-287-turn3.html +uv run python scripts/verify_screenshots.py /tmp/claude-tap-kimi-code-tmux-287.html +``` + +Results: + +- screenshot quality: `PASS=12 WARN=0 FAIL=0` +- viewer HTML render verification: all exported HTML files passed diff --git a/.agents/evidence/pr/287/kimi-code-deep-viewer-turn2-overview.png b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn2-overview.png new file mode 100644 index 0000000..22cdfe3 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn2-overview.png differ diff --git a/.agents/evidence/pr/287/kimi-code-deep-viewer-turn2-tool-scroll.png b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn2-tool-scroll.png new file mode 100644 index 0000000..2bed3ec Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn2-tool-scroll.png differ diff --git a/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-bottom-final-response.png b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-bottom-final-response.png new file mode 100644 index 0000000..f436dc4 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-bottom-final-response.png differ diff --git a/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-final-response-scroll.png b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-final-response-scroll.png new file mode 100644 index 0000000..d416c48 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-final-response-scroll.png differ diff --git a/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-mid-scroll.png b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-mid-scroll.png new file mode 100644 index 0000000..a8d1673 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-mid-scroll.png differ diff --git a/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-overview.png b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-overview.png new file mode 100644 index 0000000..4499ab0 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-overview.png differ diff --git a/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-tool-scroll.png b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-tool-scroll.png new file mode 100644 index 0000000..f6a4e2e Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-deep-viewer-turn3-tool-scroll.png differ diff --git a/.agents/evidence/pr/287/kimi-code-tmux-viewer-bottom-final-response.png b/.agents/evidence/pr/287/kimi-code-tmux-viewer-bottom-final-response.png new file mode 100644 index 0000000..45423b0 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-tmux-viewer-bottom-final-response.png differ diff --git a/.agents/evidence/pr/287/kimi-code-tmux-viewer-overview.png b/.agents/evidence/pr/287/kimi-code-tmux-viewer-overview.png new file mode 100644 index 0000000..f8c2bd6 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-tmux-viewer-overview.png differ diff --git a/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn1-tools-scroll.png b/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn1-tools-scroll.png new file mode 100644 index 0000000..96f8cb9 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn1-tools-scroll.png differ diff --git a/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn2-history-scroll.png b/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn2-history-scroll.png new file mode 100644 index 0000000..18b5c48 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn2-history-scroll.png differ diff --git a/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn3-tools-scroll.png b/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn3-tools-scroll.png new file mode 100644 index 0000000..5df2799 Binary files /dev/null and b/.agents/evidence/pr/287/kimi-code-tmux-viewer-turn3-tools-scroll.png differ diff --git a/.agents/evidence/pr/292/prompt-export-real-trace.png b/.agents/evidence/pr/292/prompt-export-real-trace.png new file mode 100644 index 0000000..65a6b8d Binary files /dev/null and b/.agents/evidence/pr/292/prompt-export-real-trace.png differ diff --git a/.agents/evidence/pr/305-cache-hit-rate/README.md b/.agents/evidence/pr/305-cache-hit-rate/README.md new file mode 100644 index 0000000..079692e --- /dev/null +++ b/.agents/evidence/pr/305-cache-hit-rate/README.md @@ -0,0 +1 @@ +chore: retrigger CI for PR #305 diff --git a/.agents/evidence/pr/305-cache-hit-rate/cache-hit-rate-viewer.png b/.agents/evidence/pr/305-cache-hit-rate/cache-hit-rate-viewer.png new file mode 100644 index 0000000..31a48fb Binary files /dev/null and b/.agents/evidence/pr/305-cache-hit-rate/cache-hit-rate-viewer.png differ diff --git a/.agents/evidence/pr/305-cache-hit-rate/trigger.txt b/.agents/evidence/pr/305-cache-hit-rate/trigger.txt new file mode 100644 index 0000000..59b036a --- /dev/null +++ b/.agents/evidence/pr/305-cache-hit-rate/trigger.txt @@ -0,0 +1 @@ +chore: retrigger CI after evidence update for PR #305 diff --git a/.agents/evidence/pr/328-loopback-proxy-trace.png b/.agents/evidence/pr/328-loopback-proxy-trace.png new file mode 100644 index 0000000..cafa1c3 Binary files /dev/null and b/.agents/evidence/pr/328-loopback-proxy-trace.png differ diff --git a/.agents/evidence/pr/335/README.md b/.agents/evidence/pr/335/README.md new file mode 100644 index 0000000..d2c3591 --- /dev/null +++ b/.agents/evidence/pr/335/README.md @@ -0,0 +1,36 @@ +# PR #335 — macOS monitor safety-chain E2E evidence + +Addresses maintainer @liaohch3's remaining merge condition: real validation of +the macOS monitor inject → capture → restore → force-quit-recovery flow. + +## Automated (isolated `$HOME`) — `e2e_safety_chain.py` + +Exercises the **real** `claude_tap.global_inject` code and the **real** +`claude-tap monitor-restore` CLI entrypoint against a throwaway `$HOME`, so the +developer's real `~/.claude` / `~/.codex` are never modified. + +``` +uv run python .agents/evidence/pr/335/e2e_safety_chain.py # -> 22/22 checks passed +``` + +Full output: [`e2e_safety_chain.log`](./e2e_safety_chain.log). + +| Maintainer step | Covered | Evidence | +|---|---|---| +| 3. `~/.claude/settings.json` + `~/.codex/config.toml` injected with local proxy URLs | ✅ | both files point at `127.0.0.1:`; pre-existing keys preserved; custom Codex provider `base_url` rerouted too (not just legacy `openai_base_url`) | +| 5. Stop Monitor → both files restore byte-for-byte | ✅ | sha256 + mode equal to originals; `.tap-backup` files removed; state cleared | +| 6. Force-quit while active → `monitor-restore` recovers files **and** processes | ✅ | after a simulated force-quit (injection + state left on disk), `claude-tap monitor-restore` restores both files byte-for-byte and terminates the recorded orphan proxy | + +Also confirmed: `monitor-state.json` and config backups are created with +restrictive `0o600` permissions. + +## Requires a manual macOS GUI run (out of scope for headless harness) + +- Step 1–2: build the `.app` bundle, launch from Finder, click **Start Monitor** + and accept the confirmation dialog. +- Step 4: launch fresh Claude Code and Codex sessions and confirm the dashboard + captures both. + +The confirmation gate itself is covered by unit tests +(`tests/test_macos_app.py`): every start path — Start Monitor, Open Dashboard +(from stopped), and launch auto-start — routes through `_confirm_start_monitor()`. diff --git a/.agents/evidence/pr/335/e2e_safety_chain.log b/.agents/evidence/pr/335/e2e_safety_chain.log new file mode 100644 index 0000000..e236c32 --- /dev/null +++ b/.agents/evidence/pr/335/e2e_safety_chain.log @@ -0,0 +1,33 @@ +HOME=/var/folders/zk/rd35z49x7jj7_j48_zq3jh3c0000gn/T/claude-tap-e2e-8pjad16s +claude sha=178e77b3c57ab72b… mode=0o600 +codex sha=551461707325d546… mode=0o600 + +== Step 3: Start Monitor -> inject base URLs == + [PASS] monitor state is active after enable() + [PASS] monitor-state.json written with 0o600 + [PASS] claude settings.json points at local proxy + [PASS] pre-existing claude env preserved + [PASS] codex openai_base_url points at local proxy + [PASS] codex custom provider base_url ALSO rerouted (not just legacy key) + [PASS] settings.json backup exists with original 0o600 perms + [PASS] config.toml backup exists with original 0o600 perms + +== Step 5: Stop Monitor -> restore == + [PASS] monitor state cleared after disable() + [PASS] settings.json restored BYTE-FOR-BYTE + [PASS] settings.json restored with original perms + [PASS] settings.json backup removed + [PASS] config.toml restored BYTE-FOR-BYTE + [PASS] config.toml restored with original perms + [PASS] config.toml backup removed + +== Step 6: force-quit while active -> claude-tap monitor-restore == + [PASS] state present after simulated force-quit (injection left behind) + [PASS] config still injected pre-restore + [PASS] monitor-restore exited 0 (got 0) + [PASS] monitor state cleared by monitor-restore + [PASS] settings.json restored BYTE-FOR-BYTE by monitor-restore + [PASS] config.toml restored BYTE-FOR-BYTE by monitor-restore + [PASS] orphaned monitor proxy terminated by monitor-restore + +==== 22/22 checks passed ==== diff --git a/.agents/evidence/pr/335/e2e_safety_chain.py b/.agents/evidence/pr/335/e2e_safety_chain.py new file mode 100644 index 0000000..bc4eb4a --- /dev/null +++ b/.agents/evidence/pr/335/e2e_safety_chain.py @@ -0,0 +1,164 @@ +"""Isolated E2E for the macOS monitor safety chain (PR #335). + +Exercises the real ``claude_tap.global_inject`` code and the real +``claude-tap monitor-restore`` CLI entrypoint against a throwaway ``$HOME`` so +the developer's real ``~/.claude`` / ``~/.codex`` are never touched. + +Covers the automatable portion of the maintainer's requested E2E: + 3. verify ~/.claude/settings.json and ~/.codex/config.toml are injected + 5. Stop Monitor -> verify both files restore byte-for-byte + 6. force-quit while active -> ``monitor-restore`` recovers files AND processes + +Steps 1/2 (build + launch the .app from Finder, click Start Monitor) and step 4 +(fresh Claude/Codex sessions captured by the dashboard) require a GUI macOS run +and are out of scope for this headless harness. + +Run: uv run python .agents/evidence/pr/335/e2e_safety_chain.py +""" + +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +CLAUDE_PORT = 45871 +CODEX_PORT = 45872 + +CLAUDE_SETTINGS = """\ +{ + "model": "claude-sonnet-4", + "env": { + "SOME_EXISTING": "keep-me" + } +} +""" + +# Custom Codex provider selected -> exercises the provider base_url rewrite path. +CODEX_CONFIG = """\ +model = "gpt-5" +model_provider = "myco" +openai_base_url = "https://api.example.com/v1" + +[model_providers.myco] +name = "MyCo" +base_url = "https://api.example.com/v1" +wire_api = "responses" +""" + + +def sha(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def mode(path: Path) -> int: + return path.stat().st_mode & 0o777 + + +results: list[tuple[bool, str]] = [] + + +def check(cond: bool, msg: str) -> None: + results.append((bool(cond), msg)) + print(f" [{'PASS' if cond else 'FAIL'}] {msg}") + + +def main() -> int: + tmp = Path(tempfile.mkdtemp(prefix="claude-tap-e2e-")) + os.environ["HOME"] = str(tmp) + os.environ["CODEX_HOME"] = str(tmp / ".codex") + + # Real code under test, imported after $HOME is redirected. + from claude_tap import global_inject as gi + + claude_path = tmp / ".claude" / "settings.json" + codex_path = tmp / ".codex" / "config.toml" + claude_path.parent.mkdir(parents=True) + codex_path.parent.mkdir(parents=True) + claude_path.write_text(CLAUDE_SETTINGS) + codex_path.write_text(CODEX_CONFIG) + claude_path.chmod(0o600) + codex_path.chmod(0o600) + + orig = {p: (sha(p), mode(p)) for p in (claude_path, codex_path)} + print(f"HOME={tmp}") + print(f"claude sha={orig[claude_path][0][:16]}… mode={oct(orig[claude_path][1])}") + print(f"codex sha={orig[codex_path][0][:16]}… mode={oct(orig[codex_path][1])}") + + # -- Step 3: inject ------------------------------------------------------ + print("\n== Step 3: Start Monitor -> inject base URLs ==") + gi.enable(claude_port=CLAUDE_PORT, codex_port=CODEX_PORT) + claude_txt = claude_path.read_text() + codex_txt = codex_path.read_text() + check(gi.is_active(), "monitor state is active after enable()") + check(mode(gi._state_file()) == 0o600, "monitor-state.json written with 0o600") + check(f"127.0.0.1:{CLAUDE_PORT}" in claude_txt, "claude settings.json points at local proxy") + check('"SOME_EXISTING": "keep-me"' in claude_txt, "pre-existing claude env preserved") + check(f"127.0.0.1:{CODEX_PORT}" in codex_txt, "codex openai_base_url points at local proxy") + check( + codex_txt.count(f"http://127.0.0.1:{CODEX_PORT}/v1") >= 2, + "codex custom provider base_url ALSO rerouted (not just legacy key)", + ) + for p in (claude_path, codex_path): + b = p.with_name(p.name + ".tap-backup") + check(b.exists() and mode(b) == orig[p][1], f"{p.name} backup exists with original 0o600 perms") + + # -- Step 5: stop -> byte-for-byte restore ------------------------------- + print("\n== Step 5: Stop Monitor -> restore ==") + gi.disable() + check(not gi.is_active(), "monitor state cleared after disable()") + for p in (claude_path, codex_path): + check(sha(p) == orig[p][0], f"{p.name} restored BYTE-FOR-BYTE") + check(mode(p) == orig[p][1], f"{p.name} restored with original perms") + check(not p.with_name(p.name + ".tap-backup").exists(), f"{p.name} backup removed") + + # -- Step 6: force-quit while active -> monitor-restore recovers --------- + print("\n== Step 6: force-quit while active -> claude-tap monitor-restore ==") + orphan = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)", "claude_tap", "--tap-no-launch"]) + time.sleep(0.3) + # Enable with the orphan recorded, then simulate a force-quit by NOT calling + # disable(): injected config + monitor-state remain on disk, app is gone. + gi.enable( + claude_port=CLAUDE_PORT, + codex_port=CODEX_PORT, + processes=[{"pid": orphan.pid, "command": "claude_tap --tap-no-launch"}], + ) + check(gi.is_active(), "state present after simulated force-quit (injection left behind)") + check(f"127.0.0.1:{CLAUDE_PORT}" in claude_path.read_text(), "config still injected pre-restore") + + # Real recovery command: `claude-tap monitor-restore`. + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys; sys.argv=['claude-tap','monitor-restore']; " + "from claude_tap.cli import main_entry; main_entry()", + ], + env={**os.environ, "HOME": str(tmp), "CODEX_HOME": str(tmp / ".codex")}, + capture_output=True, + text=True, + ) + check(proc.returncode == 0, f"monitor-restore exited 0 (got {proc.returncode})") + check(not gi.is_active(), "monitor state cleared by monitor-restore") + for p in (claude_path, codex_path): + check(sha(p) == orig[p][0], f"{p.name} restored BYTE-FOR-BYTE by monitor-restore") + + # Orphan proxy must be killed (it "looks like" a monitor process). + time.sleep(0.3) + alive = orphan.poll() is None + if alive: + orphan.kill() + check(not alive, "orphaned monitor proxy terminated by monitor-restore") + + passed = sum(1 for ok, _ in results if ok) + total = len(results) + print(f"\n==== {passed}/{total} checks passed ====") + return 0 if passed == total else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/evidence/pr/352/352-after-viewer.png b/.agents/evidence/pr/352/352-after-viewer.png new file mode 100644 index 0000000..07b4fea Binary files /dev/null and b/.agents/evidence/pr/352/352-after-viewer.png differ diff --git a/.agents/evidence/pr/352/352-before-viewer.png b/.agents/evidence/pr/352/352-before-viewer.png new file mode 100644 index 0000000..b09f7e3 Binary files /dev/null and b/.agents/evidence/pr/352/352-before-viewer.png differ diff --git a/.agents/evidence/pr/352/README.md b/.agents/evidence/pr/352/README.md new file mode 100644 index 0000000..697aa08 --- /dev/null +++ b/.agents/evidence/pr/352/README.md @@ -0,0 +1,19 @@ +# PR 352 Evidence + +This evidence covers the Chat Completions `choices[].delta.reasoning` / +`choices[].delta.reasoning_details` viewer fix and the requested real New API +GPT check. + +- `352-before-viewer.png`: reporter-compatible `delta.reasoning` stream before + the fix; the viewer only renders the visible answer. +- `352-after-viewer.png`: the same stream after the fix; the viewer renders a + thinking block. +- `real-gpt-5.5-chat-completions-viewer.png`: real New API `gpt-5.5` + `/v1/chat/completions` stream rendered in the viewer. + +The real GPT stream returned visible `delta.content` and usage with +`completion_tokens_details.reasoning_tokens`, but did not emit +`choices[].delta.reasoning`, `reasoning_details`, or `reasoning_content`. The +fix is therefore verified with the reporter-compatible OpenAI-compatible +provider field shape, and the real GPT capture validates the live gateway/viewer +path. diff --git a/.agents/evidence/pr/352/real-gpt-5.5-chat-completions-viewer.png b/.agents/evidence/pr/352/real-gpt-5.5-chat-completions-viewer.png new file mode 100644 index 0000000..7b4799b Binary files /dev/null and b/.agents/evidence/pr/352/real-gpt-5.5-chat-completions-viewer.png differ diff --git a/.agents/evidence/pr/372/trace-viewer-additional-tools.png b/.agents/evidence/pr/372/trace-viewer-additional-tools.png new file mode 100644 index 0000000..339b6ba Binary files /dev/null and b/.agents/evidence/pr/372/trace-viewer-additional-tools.png differ diff --git a/.agents/evidence/pr/PR44_VERIFY_REPORT.md b/.agents/evidence/pr/PR44_VERIFY_REPORT.md new file mode 100644 index 0000000..2077596 --- /dev/null +++ b/.agents/evidence/pr/PR44_VERIFY_REPORT.md @@ -0,0 +1,72 @@ +# PR #44 Verification Report + +Date: 2026-03-19 +PR: https://github.com/liaohch3/claude-tap/pull/44 +Branch: `fix/codex-responses-40-41` + +## Scope + +This PR fixes viewer and parser support for OpenAI Codex Responses traces: +- `#40` Empty thinking block, zero token counts, null response body +- `#41` User messages missing in HTML/JSONL viewer + +## Verification + +### 1. Unit and integration tests + +```bash +uv run pytest tests/test_responses_support.py tests/test_responses_browser.py -q # 5 passed +uv run pytest tests/ -x --timeout=60 -q # 103 passed, 25 skipped +uv run ruff check . && uv run ruff format --check . # clean +``` + +### 2. Real Codex E2E through claude-tap (OAuth path) + +Command: + +```bash +claude-tap --tap-client codex \ + --tap-target https://chatgpt.com/backend-api/codex \ + --tap-output-dir /tmp/pr44-real-e2e \ + --tap-no-open --tap-no-update-check \ + -- exec "Read pyproject.toml in and tell me the project name and version" \ + --dangerously-bypass-approvals-and-sandbox +``` + +Result: **4 API calls captured** (2x `POST /v1/responses` + 2x `GET /v1/models`) + +| Call | Turn | Endpoint | Tokens | Duration | Status | +|------|------|----------|--------|----------|--------| +| 1 | 1 | GET /v1/models | 0 | 5.3s | 200 | +| 2 | 2 | POST /v1/responses | 16,200 | 5.5s | 200 | +| 3 | 3 | GET /v1/models | 0 | 2.2s | 200 | +| 4 | 4 | POST /v1/responses | 16,343 | 2.8s | 200 | + +Total: 32,543 tokens (32,377 input + 166 output) + +Key observations: +- Codex OAuth falls back from WebSocket to HTTP/SSE when proxied +- Each Responses call includes 112 SSE events with full tool-use traces +- The agent performed shell commands (`sed`, `find`, `rg`) within a single session + +### 3. Viewer evidence screenshots + +Captured via Playwright at `1440x1000` viewport from the real HTML trace. + +- `pr44-turn2-messages.png`: Sidebar shows 4 calls (2 Codex/gpt-5.4 + 2 unknown/models). + Messages section expanded with `developer` (system prompt) and `user` message clearly visible. + Token counts displayed (16,200 tok, 16,343 tok). **Proves #41 fix**. +- `pr44-turn2-response.png`: Response section expanded showing assistant reply with + project name and version. Token usage and SSE events present. **Proves #40 fix**. + +## What Was Verified + +- Real Codex OAuth trace captured by `claude-tap` as Responses format +- Viewer displays user messages from `request.body.input` (fix for #41) +- Viewer displays assistant response text and token usage (fix for #40) +- Multiple API calls visible in sidebar (real agent behavior, not single-shot) +- SSE events preserved and browsable in viewer + +## Merge Recommendation + +Recommendation: **MERGE**. diff --git a/.agents/evidence/pr/WS_VERIFY_REPORT.md b/.agents/evidence/pr/WS_VERIFY_REPORT.md new file mode 100644 index 0000000..c1b624f --- /dev/null +++ b/.agents/evidence/pr/WS_VERIFY_REPORT.md @@ -0,0 +1,86 @@ +# PR #22 WebSocket Verification Report + +Date: 2026-03-03 +PR: https://github.com/liaohch3/claude-tap/pull/22 +Branch: `feat/ws-proxy` +Workspace: `/private/tmp/claude-tap-pr22-ws-verify-20260303` +Evidence root: `/tmp/pr22-ws-unblock-evidence-20260303/` + +## Scope Decision + +This report applies the "truthful high-confidence" merge policy: +- Merge is acceptable if implementation claims are correct and verified boundaries are explicit. +- Merge is blocked only if the PR claims real upstream WS success that is not proven. + +## Verification Matrix + +1. Real E2E (Claude, pytest marker path) + - Command: `uv run pytest tests/e2e/test_real_proxy.py::TestRealProxy::test_single_turn --run-real-e2e --timeout=180 -vv` + - Result: PASS + - Evidence: `/tmp/pr22-ws-unblock-evidence-20260303/pytest-real-proxy-single-turn.log` + +2. Real E2E (Claude, multi-turn tmux conversation) + - Command: `scripts/run_real_e2e_tmux.sh` + - Result: PASS (full interactive multi-turn run completed) + - Evidence: + - `/tmp/pr22-ws-unblock-evidence-20260303/run_real_e2e_tmux.log` + - `/tmp/pr22-ws-unblock-evidence-20260303/tmux-simple-1772532398.log` + - `/tmp/pr22-ws-unblock-evidence-20260303/ctap-tmux-simple-1772532398/trace_20260303_180638.jsonl` + +3. Real Codex reverse-proxy run (default transport) + - Command: `uv run python -m claude_tap --tap-client codex ... -- exec "Reply with exactly: PR22_CODEX_PROXY_OK" ...` + - Result: PASS (response completed through proxy) + - Transport observed: HTTP/SSE (`POST /v1/responses`, no WS upgrade) + - Evidence: + - `/tmp/pr22-ws-unblock-evidence-20260303/codex-reverse-run.log` + - `/tmp/pr22-ws-unblock-evidence-20260303/codex-reverse-run/trace_20260303_180811.jsonl` + +4. Real Codex forced WebSocket attempts + - Commands: + - `--enable responses_websockets` + - `--enable responses_websockets_v2` + - Result: WS upstream connect repeatedly failed with `502 Bad Gateway` timeout to `wss://chatgpt.com/backend-api/codex/responses`; Codex then fell back to HTTPS and completed successfully. + - Evidence: + - `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v1-run.log` + - `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v2-run.log` + - `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v1-run/trace_20260303_180901.jsonl` + - `/tmp/pr22-ws-unblock-evidence-20260303/codex-ws-v2-run/trace_20260303_180901.jsonl` + +5. Unit/integration WS proxy tests in PR scope + - Command: `uv run pytest tests/test_ws_proxy.py -v` + - Result: PASS + - Coverage includes success relay (`101`), upstream WS failure (`502`), and HTTP+WS coexistence. + +## What Is Implemented + +- Reverse proxy WS handler is implemented in `claude_tap/proxy.py`. +- Root cause fixed: removed hardcoded `proxy=None` override from upstream `session.ws_connect(...)`, so WS now follows the same `ClientSession(trust_env=True)` proxy behavior as HTTP/SSE. +- WS relay and trace recording (`transport=websocket`, `method=WEBSOCKET`, `ws_events`) are implemented. +- Upstream WS connection failure path returns `502` and records an error. +- Codex launch path no longer forces `--disable responses_websockets`. + +## What Is Verified + +- WS implementation behavior is verified in automated tests (`tests/test_ws_proxy.py`). +- Regression guard added to assert upstream `ws_connect` is called without a `proxy=None` override. +- Real Codex runs through proxy are verified. +- Real forced WS attempts are verified to hit the WS path and then fall back to HTTPS on repeated upstream timeout. + +## What Is Not Verified In This Environment + +- A successful live upstream WS handshake (`101`) to `wss://chatgpt.com/backend-api/codex/responses`. +- A real trace containing live upstream `ws_events` from a successful Codex WS session. + +## Residual Risk + +- Primary residual risk is environment/network dependent WS reachability to ChatGPT Codex WSS endpoint. +- Functional risk in proxy implementation is reduced by passing WS tests plus real fallback observation. + +## Merge Recommendation (Current Environment) + +Recommendation: **MERGE WITH EXPLICIT CLAIM BOUNDARIES**. + +Required PR wording constraints: +- State that WS proxy support is implemented and test-validated. +- State that in this environment, forced real WS upstream connection timed out and fallback-to-HTTPS was observed. +- Do not claim real upstream WS success was observed. diff --git a/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-final.png b/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-final.png new file mode 100644 index 0000000..9858c91 Binary files /dev/null and b/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-final.png differ diff --git a/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-top.png b/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-top.png new file mode 100644 index 0000000..33f69d3 Binary files /dev/null and b/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-top.png differ diff --git a/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-version.png b/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-version.png new file mode 100644 index 0000000..d8ecebb Binary files /dev/null and b/.agents/evidence/pr/aiohttp-3141-real-codex/codex-real-version.png differ diff --git a/.agents/evidence/pr/bedrock-default-filter/sidebar-default-main-turns.png b/.agents/evidence/pr/bedrock-default-filter/sidebar-default-main-turns.png new file mode 100644 index 0000000..cf00a21 Binary files /dev/null and b/.agents/evidence/pr/bedrock-default-filter/sidebar-default-main-turns.png differ diff --git a/.agents/evidence/pr/bedrock-eventstream-viewer/bedrock-response-viewer.png b/.agents/evidence/pr/bedrock-eventstream-viewer/bedrock-response-viewer.png new file mode 100644 index 0000000..57e69b9 Binary files /dev/null and b/.agents/evidence/pr/bedrock-eventstream-viewer/bedrock-response-viewer.png differ diff --git a/.agents/evidence/pr/chat-completions-choice-response.png b/.agents/evidence/pr/chat-completions-choice-response.png new file mode 100644 index 0000000..cd1f12c Binary files /dev/null and b/.agents/evidence/pr/chat-completions-choice-response.png differ diff --git a/.agents/evidence/pr/codebuddy-cli/01-overview.png b/.agents/evidence/pr/codebuddy-cli/01-overview.png new file mode 100644 index 0000000..da57b2b Binary files /dev/null and b/.agents/evidence/pr/codebuddy-cli/01-overview.png differ diff --git a/.agents/evidence/pr/codebuddy-cli/02-tools.png b/.agents/evidence/pr/codebuddy-cli/02-tools.png new file mode 100644 index 0000000..945dc8e Binary files /dev/null and b/.agents/evidence/pr/codebuddy-cli/02-tools.png differ diff --git a/.agents/evidence/pr/codebuddy-cli/README.md b/.agents/evidence/pr/codebuddy-cli/README.md new file mode 100644 index 0000000..e54f3cf --- /dev/null +++ b/.agents/evidence/pr/codebuddy-cli/README.md @@ -0,0 +1,39 @@ +# CodeBuddy CLI PR Evidence + +Generated from a real CodeBuddy CLI run on 2026-05-24 against the iOA login deployment. + +## Command + +```bash +uv run claude-tap --tap-live --tap-client codebuddy +# Then in the launched CodeBuddy session: type `hi` +``` + +The reverse proxy auto-detected the upstream from CodeBuddy's login cache +(`~/.codebuddy/local_storage/entry_933d5543e80177622c17a73869c0fad7.info`) +and resolved to `https://copilot.tencent.com/v2`. + +## Trace artifacts + +- Trace JSONL: `.traces/2026-05-24/trace_224112.jsonl` +- Trace HTML: `.traces/2026-05-24/trace_224112.html` +- Live viewer URL: `http://127.0.0.1:45109` +- Local proxy port: `http://127.0.0.1:44667` +- Cumulative API tokens: 76,176 (3 turns: Plan + Subagent ×2) + +## Screenshots + +| File | Field coverage | +|------|----------------| +| `01-overview.png` | System Prompt + Messages + Response + turn list (OPUS-4.7-1M / HAIKU-4.5 with subagents). Single screenshot showing live viewer side by side with the running CodeBuddy CLI terminal. | +| `02-tools.png` | Tools schema panel (expanded) showing the JSON tool definitions CodeBuddy sends to the LLM. | + +Both screenshots are ≥1280px viewport and use real trace data (no mocks). + +## Validation + +```bash +uv run ruff check claude_tap/cli.py tests/test_codebuddy_launch.py +uv run ruff format --check claude_tap/cli.py tests/test_codebuddy_launch.py +uv run pytest tests/ -x --timeout=60 # 443 passed, 25 skipped +``` diff --git a/.agents/evidence/pr/codex-app-listener/codexapp-dashboard-remote-viewer-detail.png b/.agents/evidence/pr/codex-app-listener/codexapp-dashboard-remote-viewer-detail.png new file mode 100644 index 0000000..b016bed Binary files /dev/null and b/.agents/evidence/pr/codex-app-listener/codexapp-dashboard-remote-viewer-detail.png differ diff --git a/.agents/evidence/pr/codex-app-listener/codexapp-dashboard-trace.png b/.agents/evidence/pr/codex-app-listener/codexapp-dashboard-trace.png new file mode 100644 index 0000000..984d41c Binary files /dev/null and b/.agents/evidence/pr/codex-app-listener/codexapp-dashboard-trace.png differ diff --git a/.agents/evidence/pr/codex-app-listener/codexapp-session-groups-real-viewer.png b/.agents/evidence/pr/codex-app-listener/codexapp-session-groups-real-viewer.png new file mode 100644 index 0000000..7ae3347 Binary files /dev/null and b/.agents/evidence/pr/codex-app-listener/codexapp-session-groups-real-viewer.png differ diff --git a/.agents/evidence/pr/codex-app-listener/codexapp-session-regroup-collapsed-real-viewer.png b/.agents/evidence/pr/codex-app-listener/codexapp-session-regroup-collapsed-real-viewer.png new file mode 100644 index 0000000..e254512 Binary files /dev/null and b/.agents/evidence/pr/codex-app-listener/codexapp-session-regroup-collapsed-real-viewer.png differ diff --git a/.agents/evidence/pr/codex-app-listener/codexapp-user-input-groups-real-session.png b/.agents/evidence/pr/codex-app-listener/codexapp-user-input-groups-real-session.png new file mode 100644 index 0000000..61f6cda Binary files /dev/null and b/.agents/evidence/pr/codex-app-listener/codexapp-user-input-groups-real-session.png differ diff --git a/.agents/evidence/pr/codex-display-turn-normalization/codex-viewer-display-turns.png b/.agents/evidence/pr/codex-display-turn-normalization/codex-viewer-display-turns.png new file mode 100644 index 0000000..a533d7d Binary files /dev/null and b/.agents/evidence/pr/codex-display-turn-normalization/codex-viewer-display-turns.png differ diff --git a/.agents/evidence/pr/codex-http-context/README.md b/.agents/evidence/pr/codex-http-context/README.md new file mode 100644 index 0000000..814991b --- /dev/null +++ b/.agents/evidence/pr/codex-http-context/README.md @@ -0,0 +1,23 @@ +# Codex HTTP Context Evidence + +These screenshots come from real Codex 0.144.1 runs against the ChatGPT Codex +backend. The test prompt used only a controlled `/tmp` workspace. Raw traces +are intentionally not committed. + +## Before + +`before-dashboard-continuation-warning.png` was captured from `origin/main`. +Codex used its built-in WebSocket provider. The tool-result turn contains a +`previous_response_id` but no user message in that request, so the viewer shows +the stateful continuation warning. + +## After + +`after-codex-http-full-context.png` was captured after launching Codex with the +temporary `claude-tap-openai` provider and `supports_websockets=false`. The +initial run and resumed turn produced four `POST /v1/responses` records and no +WebSocket records. Every POST contained a user message; tool-result POSTs also +contained the preceding tool call and output, with no `previous_response_id`. + +The after screenshot shows the resumed user turn, tool call, and tool result in +the same self-contained request context without a continuation warning. diff --git a/.agents/evidence/pr/codex-http-context/after-codex-http-full-context.png b/.agents/evidence/pr/codex-http-context/after-codex-http-full-context.png new file mode 100644 index 0000000..e6b0603 Binary files /dev/null and b/.agents/evidence/pr/codex-http-context/after-codex-http-full-context.png differ diff --git a/.agents/evidence/pr/codex-http-context/before-dashboard-continuation-warning.png b/.agents/evidence/pr/codex-http-context/before-dashboard-continuation-warning.png new file mode 100644 index 0000000..ce7e1c8 Binary files /dev/null and b/.agents/evidence/pr/codex-http-context/before-dashboard-continuation-warning.png differ diff --git a/.agents/evidence/pr/codex-responses-continuity/01-sidebar-starts-at-real-turn.png b/.agents/evidence/pr/codex-responses-continuity/01-sidebar-starts-at-real-turn.png new file mode 100644 index 0000000..e9c45a9 Binary files /dev/null and b/.agents/evidence/pr/codex-responses-continuity/01-sidebar-starts-at-real-turn.png differ diff --git a/.agents/evidence/pr/codex-responses-continuity/02-turn-2-first-tool-call.png b/.agents/evidence/pr/codex-responses-continuity/02-turn-2-first-tool-call.png new file mode 100644 index 0000000..e9c45a9 Binary files /dev/null and b/.agents/evidence/pr/codex-responses-continuity/02-turn-2-first-tool-call.png differ diff --git a/.agents/evidence/pr/codex-responses-continuity/03-turn-2-2-continuity.png b/.agents/evidence/pr/codex-responses-continuity/03-turn-2-2-continuity.png new file mode 100644 index 0000000..1f0ad06 Binary files /dev/null and b/.agents/evidence/pr/codex-responses-continuity/03-turn-2-2-continuity.png differ diff --git a/.agents/evidence/pr/compact-trace-storage/compact-e2e-detail.png b/.agents/evidence/pr/compact-trace-storage/compact-e2e-detail.png new file mode 100644 index 0000000..9e3f944 Binary files /dev/null and b/.agents/evidence/pr/compact-trace-storage/compact-e2e-detail.png differ diff --git a/.agents/evidence/pr/compact-trace-storage/compact-e2e-mid-scroll.png b/.agents/evidence/pr/compact-trace-storage/compact-e2e-mid-scroll.png new file mode 100644 index 0000000..abd8042 Binary files /dev/null and b/.agents/evidence/pr/compact-trace-storage/compact-e2e-mid-scroll.png differ diff --git a/.agents/evidence/pr/compact-trace-storage/compact-e2e-overview.png b/.agents/evidence/pr/compact-trace-storage/compact-e2e-overview.png new file mode 100644 index 0000000..9a1067b Binary files /dev/null and b/.agents/evidence/pr/compact-trace-storage/compact-e2e-overview.png differ diff --git a/.agents/evidence/pr/compact-trace-storage/compact-e2e-stats.json b/.agents/evidence/pr/compact-trace-storage/compact-e2e-stats.json new file mode 100644 index 0000000..6b815bd --- /dev/null +++ b/.agents/evidence/pr/compact-trace-storage/compact-e2e-stats.json @@ -0,0 +1,35 @@ +{ + "base": "/claude-tap-compact-e2e-20260530-044817", + "session_id": "fd0e8549-4e44-41bc-8ba1-570c97ba1b9d", + "db_bytes": 552960, + "wal_bytes": 0, + "shm_bytes": 32768, + "record_payload_bytes": 384374, + "blob_count": 5, + "blob_payload_bytes": 84590, + "stored_payload_plus_blobs": 468964, + "materialized_jsonl_bytes": 2248100, + "materialized_gzip_bytes": 702140, + "records": 27, + "compact_rows": 26, + "paths": { + "/v1/models?client_version=0.135.0": 1, + "/v1/responses": 26 + }, + "models": { + "gpt-5.5": 26 + }, + "function_calls": 20, + "output_messages": 5, + "workspace_bytes": 368, + "workspace_files": { + "data.csv": 24, + "final.txt": 16, + "metrics.json": 122, + "notes.md": 118, + "project.toml": 74, + "round3.txt": 14 + }, + "stored_payload_savings_percent": 79.14, + "db_file_savings_percent_vs_materialized_jsonl": 75.4 +} diff --git a/.agents/evidence/pr/compact-trace-storage/compact-rewrite-stats.json b/.agents/evidence/pr/compact-trace-storage/compact-rewrite-stats.json new file mode 100644 index 0000000..61dd01a --- /dev/null +++ b/.agents/evidence/pr/compact-trace-storage/compact-rewrite-stats.json @@ -0,0 +1,18 @@ +{ + "source_jsonl": "/claude-tap-storage-baseline-20260530-035427/codex-tmux-5turn-storage-baseline.jsonl", + "rewrite_base": "/claude-tap-compact-rewrite-final-ahzir_s4", + "session_id": "21cd91af-62ad-4eb4-9df4-d2ca2a7f76a3", + "records": 26, + "source_jsonl_bytes": 2506380, + "source_jsonl_gzip_bytes": 802997, + "record_payload_bytes": 139917, + "blob_count": 3, + "blob_payload_bytes": 70696, + "stored_payload_plus_blobs": 210613, + "compact_rows": 26, + "db_bytes": 278528, + "roundtrip_sha256": "8b6a7925ca9079d9a35ba087a4a78ae041e153abf7a629ff66308b6eaf772bc4", + "source_sha256": "8b6a7925ca9079d9a35ba087a4a78ae041e153abf7a629ff66308b6eaf772bc4", + "roundtrip_exact": true, + "stored_payload_savings_percent": 91.6 +} diff --git a/.agents/evidence/pr/compact-trace-storage/compact-standalone-overview.png b/.agents/evidence/pr/compact-trace-storage/compact-standalone-overview.png new file mode 100644 index 0000000..aba6223 Binary files /dev/null and b/.agents/evidence/pr/compact-trace-storage/compact-standalone-overview.png differ diff --git a/.agents/evidence/pr/compact-trace-storage/compact-standalone-stats.json b/.agents/evidence/pr/compact-trace-storage/compact-standalone-stats.json new file mode 100644 index 0000000..4354b0a --- /dev/null +++ b/.agents/evidence/pr/compact-trace-storage/compact-standalone-stats.json @@ -0,0 +1,16 @@ +{ + "source_jsonl": "/claude-tap-storage-baseline-20260530-035427/codex-tmux-5turn-storage-baseline.jsonl", + "compact_bundle": "not committed; generated locally for validation", + "compact_html": "not committed; generated locally for validation", + "records": 26, + "source_jsonl_bytes": 2506380, + "source_jsonl_gzip_bytes": 802997, + "compact_bundle_bytes": 211368, + "compact_bundle_gzip_bytes": 30061, + "compact_html_bytes": 485446, + "standalone_bundle_savings_percent": 91.57, + "standalone_html_savings_vs_old_embedded_html_percent": 80.75, + "materialized_sha256": "8b6a7925ca9079d9a35ba087a4a78ae041e153abf7a629ff66308b6eaf772bc4", + "source_sha256": "8b6a7925ca9079d9a35ba087a4a78ae041e153abf7a629ff66308b6eaf772bc4", + "roundtrip_exact": true +} diff --git a/.agents/evidence/pr/compact-trace-storage/compact-standalone-tool-detail.png b/.agents/evidence/pr/compact-trace-storage/compact-standalone-tool-detail.png new file mode 100644 index 0000000..486ce3f Binary files /dev/null and b/.agents/evidence/pr/compact-trace-storage/compact-standalone-tool-detail.png differ diff --git a/.agents/evidence/pr/compact-trace-storage/compact-stress-stats.json b/.agents/evidence/pr/compact-trace-storage/compact-stress-stats.json new file mode 100644 index 0000000..a5592ad --- /dev/null +++ b/.agents/evidence/pr/compact-trace-storage/compact-stress-stats.json @@ -0,0 +1,100 @@ +{ + "scenario": "synthetic_storage_stress", + "note": "Synthetic deterministic records; no raw trace, HTML, compact bundle, terminal log, credential, or private prompt is committed.", + "parameters": { + "turns": 100, + "tool_calls_per_turn": 10, + "records": 1000, + "image_every_turns": 10, + "image_records": 10, + "tools_per_record": 16, + "instructions_bytes_per_record": 14560 + }, + "sizes": { + "materialized_jsonl_bytes": 135318933, + "materialized_jsonl_gzip_bytes": 1741011, + "sqlite_db_bytes": 4096000, + "sqlite_wal_bytes": 4152992, + "record_payload_bytes": 2432933, + "blob_count": 2, + "blob_payload_bytes": 67049, + "stored_payload_plus_blobs": 2499982, + "compact_rows": 1000, + "compact_bundle_bytes_generated_locally": 2501343, + "compact_bundle_gzip_bytes_generated_locally": 57654, + "compact_html_bytes_generated_locally": 2775211 + }, + "savings": { + "stored_payload_vs_materialized_percent": 98.15, + "sqlite_db_file_vs_materialized_percent": 96.97, + "compact_bundle_vs_materialized_percent": 98.15 + }, + "performance": { + "phases": { + "append_1000_records_to_sqlite": { + "wall_seconds": 3.9309, + "cpu_seconds": 2.2039, + "avg_cpu_percent_single_process": 56.07, + "rss_start_kib": 185008, + "rss_end_kib": 187320, + "max_rss_kib": 318080 + }, + "load_records_materialized": { + "wall_seconds": 0.0685, + "cpu_seconds": 0.0685, + "avg_cpu_percent_single_process": 100.0, + "rss_start_kib": 187320, + "rss_end_kib": 196676, + "max_rss_kib": 318080 + }, + "export_jsonl_materialized": { + "wall_seconds": 1.056, + "cpu_seconds": 1.0558, + "avg_cpu_percent_single_process": 99.98, + "rss_start_kib": 196676, + "rss_end_kib": 335048, + "max_rss_kib": 467992 + }, + "export_compact_bundle": { + "wall_seconds": 1.2419, + "cpu_seconds": 1.2418, + "avg_cpu_percent_single_process": 99.99, + "rss_start_kib": 335048, + "rss_end_kib": 334036, + "max_rss_kib": 467992 + }, + "load_compact_trace_bundle": { + "wall_seconds": 0.0698, + "cpu_seconds": 0.0698, + "avg_cpu_percent_single_process": 100.0, + "rss_start_kib": 334036, + "rss_end_kib": 343800, + "max_rss_kib": 467992 + }, + "generate_compact_html_viewer": { + "wall_seconds": 0.0563, + "cpu_seconds": 0.0563, + "avg_cpu_percent_single_process": 99.99, + "rss_start_kib": 345648, + "rss_end_kib": 352808, + "max_rss_kib": 467992 + } + }, + "max_rss_kib_total": 484940, + "max_rss_mib_total": 473.57, + "append_records_per_second": 254.39 + }, + "correctness": { + "loaded_records_equal_original": true, + "export_jsonl_sha256_matches_original": true, + "compact_materialized_equal_original": true, + "record_count_matches": true, + "image_record_count_matches": true, + "html_generated": true + }, + "hashes": { + "materialized_jsonl_sha256": "a65812e3741d5fed6f4203ddbfcc57b86d8d888c6c09d26ab62ab91b8087374e", + "exported_jsonl_sha256": "a65812e3741d5fed6f4203ddbfcc57b86d8d888c6c09d26ab62ab91b8087374e", + "compact_bundle_materialized_jsonl_sha256": "a65812e3741d5fed6f4203ddbfcc57b86d8d888c6c09d26ab62ab91b8087374e" + } +} diff --git a/.agents/evidence/pr/content-blocks/claude-code-content-blocks-clean.png b/.agents/evidence/pr/content-blocks/claude-code-content-blocks-clean.png new file mode 100644 index 0000000..f367cd2 Binary files /dev/null and b/.agents/evidence/pr/content-blocks/claude-code-content-blocks-clean.png differ diff --git a/.agents/evidence/pr/content-blocks/codex-content-blocks-clean.png b/.agents/evidence/pr/content-blocks/codex-content-blocks-clean.png new file mode 100644 index 0000000..9f29ca9 Binary files /dev/null and b/.agents/evidence/pr/content-blocks/codex-content-blocks-clean.png differ diff --git a/.agents/evidence/pr/content-blocks/codex-real-image-block-visible.png b/.agents/evidence/pr/content-blocks/codex-real-image-block-visible.png new file mode 100644 index 0000000..5ac7997 Binary files /dev/null and b/.agents/evidence/pr/content-blocks/codex-real-image-block-visible.png differ diff --git a/.agents/evidence/pr/content-blocks/codex-real-resume-deep-clean.png b/.agents/evidence/pr/content-blocks/codex-real-resume-deep-clean.png new file mode 100644 index 0000000..c4099ab Binary files /dev/null and b/.agents/evidence/pr/content-blocks/codex-real-resume-deep-clean.png differ diff --git a/.agents/evidence/pr/content-blocks/codex-real-resume-mid-clean.png b/.agents/evidence/pr/content-blocks/codex-real-resume-mid-clean.png new file mode 100644 index 0000000..8a8e9f0 Binary files /dev/null and b/.agents/evidence/pr/content-blocks/codex-real-resume-mid-clean.png differ diff --git a/.agents/evidence/pr/content-blocks/codex-real-resume-top-clean.png b/.agents/evidence/pr/content-blocks/codex-real-resume-top-clean.png new file mode 100644 index 0000000..60168c2 Binary files /dev/null and b/.agents/evidence/pr/content-blocks/codex-real-resume-top-clean.png differ diff --git a/.agents/evidence/pr/dashboard-first-user-prompt/dashboard-first-user-prompt.png b/.agents/evidence/pr/dashboard-first-user-prompt/dashboard-first-user-prompt.png new file mode 100644 index 0000000..919b39f Binary files /dev/null and b/.agents/evidence/pr/dashboard-first-user-prompt/dashboard-first-user-prompt.png differ diff --git a/.agents/evidence/pr/dashboard-macos-menu.png b/.agents/evidence/pr/dashboard-macos-menu.png new file mode 100644 index 0000000..c42b8bd Binary files /dev/null and b/.agents/evidence/pr/dashboard-macos-menu.png differ diff --git a/.agents/evidence/pr/default-compact-export/README.md b/.agents/evidence/pr/default-compact-export/README.md new file mode 100644 index 0000000..480ed3c --- /dev/null +++ b/.agents/evidence/pr/default-compact-export/README.md @@ -0,0 +1,16 @@ +# Default Compact Export Evidence + +Source: a real local claude-tap SQLite session was copied from the read-only source database into +`/tmp/claude-tap-compact-check/subset.sqlite3` before export and dashboard validation. The live local +claude-tap database was not used as the writable test database. + +Validation: + +- Exported session `77736052-6c05-4b3a-a836-ca4235717a3f` from the temporary subset database. +- Raw JSONL export from SQLite: `/tmp/claude-tap-compact-check/raw-compare/trace.raw.jsonl`, 363.18 MiB. +- Compact export: `/tmp/claude-tap-compact-check/raw-compare/trace.raw.ctap.json`, 67.71 MiB. +- HTML export: `/tmp/claude-tap-compact-check/trace.html`, 68.00 MiB, contains `EMBEDDED_TRACE_COMPACT_DATA` + and no full `const EMBEDDED_TRACE_DATA =` injection. +- Normalized JSON view comparison: `/tmp/claude-tap-compact-check/trace.full.json`, 92.39 MiB. This is not + the raw JSONL baseline; it is the explicit `--format json` viewer/export representation. +- Localhost dashboard screenshot: `dashboard-compact-export.png`. diff --git a/.agents/evidence/pr/default-compact-export/dashboard-compact-export.png b/.agents/evidence/pr/default-compact-export/dashboard-compact-export.png new file mode 100644 index 0000000..f9ad6d7 Binary files /dev/null and b/.agents/evidence/pr/default-compact-export/dashboard-compact-export.png differ diff --git a/.agents/evidence/pr/empty-trace-viewer/README.md b/.agents/evidence/pr/empty-trace-viewer/README.md new file mode 100644 index 0000000..6080a90 --- /dev/null +++ b/.agents/evidence/pr/empty-trace-viewer/README.md @@ -0,0 +1,45 @@ +# Empty Trace Viewer Evidence + +Date: 2026-05-17 + +## Scope + +This evidence covers the viewer behavior for a real generated trace file with zero captured API calls. The page must render an explicit empty trace state instead of falling back to the generic file picker. + +## Artifacts + +- `empty-trace.jsonl`: empty JSONL trace input +- `empty-trace-viewer.png`: Playwright screenshot at 1440x900 + +The generated self-contained `empty-trace.html` was used during local validation but is intentionally not committed because it duplicates the full viewer template and adds thousands of low-signal review lines. + +## Assertions Added + +- Empty embedded trace renders `.empty-trace-state` +- Viewer shows `No API calls captured` +- Viewer shows `Captured API calls: 0` +- Sidebar entries are absent and the sidebar/detail panels are hidden +- Trace path bar still shows both JSONL and HTML paths +- The unloaded file-picker template is still rejected by `verify_screenshots.py` +- PNG blankness analysis can sample large screenshots while still rejecting blank white images + +## Validation Run + +- `uv run pytest tests/test_check_coverage.py tests/test_check_screenshots.py tests/test_verify_screenshots.py tests/test_viewer_contracts.py::test_viewer_empty_embedded_trace_renders_explicit_no_api_calls_state -q` +- `uv run pytest tests/test_translate_i18n.py -q` +- `uv run pytest tests/test_viewer_contracts.py -q` +- `uv run python scripts/verify_screenshots.py ` +- `uv run python scripts/check_screenshots.py .agents/evidence/pr/empty-trace-viewer/empty-trace-viewer.png` +- `uv run ruff check .` +- `uv run ruff format --check .` +- `uv run pytest tests/ -x --timeout=60` +- `uv run python scripts/check_coverage.py --base origin/main --skip-python` + +## Results + +- Targeted tests: 16 passed +- Full viewer contract tests: 12 passed +- Full test suite: 358 passed, 25 skipped, 4 warnings +- PR screenshot validation: 1 passed, 0 warnings, 0 failures +- Viewer coverage gate: JS diff 100.00% >= 80.00%; CSS diff 100.00% >= 80.00% +- Viewer project coverage: JS functions 62.81% >= 50.00%; CSS selectors 76.52% >= 65.00% diff --git a/.agents/evidence/pr/empty-trace-viewer/empty-trace-viewer.png b/.agents/evidence/pr/empty-trace-viewer/empty-trace-viewer.png new file mode 100644 index 0000000..00eaf9e Binary files /dev/null and b/.agents/evidence/pr/empty-trace-viewer/empty-trace-viewer.png differ diff --git a/.agents/evidence/pr/empty-trace-viewer/empty-trace.jsonl b/.agents/evidence/pr/empty-trace-viewer/empty-trace.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.agents/evidence/pr/export-html-ui/export-html-dashboard-button.png b/.agents/evidence/pr/export-html-ui/export-html-dashboard-button.png new file mode 100644 index 0000000..fab1075 Binary files /dev/null and b/.agents/evidence/pr/export-html-ui/export-html-dashboard-button.png differ diff --git a/.agents/evidence/pr/export-html-ui/export-html-mobile-menu.png b/.agents/evidence/pr/export-html-ui/export-html-mobile-menu.png new file mode 100644 index 0000000..117b4fa Binary files /dev/null and b/.agents/evidence/pr/export-html-ui/export-html-mobile-menu.png differ diff --git a/.agents/evidence/pr/export-html-ui/exported-html-renders.png b/.agents/evidence/pr/export-html-ui/exported-html-renders.png new file mode 100644 index 0000000..6cc94cf Binary files /dev/null and b/.agents/evidence/pr/export-html-ui/exported-html-renders.png differ diff --git a/.agents/evidence/pr/export-json-menu/dashboard-export-json-menu.png b/.agents/evidence/pr/export-json-menu/dashboard-export-json-menu.png new file mode 100644 index 0000000..e5b2556 Binary files /dev/null and b/.agents/evidence/pr/export-json-menu/dashboard-export-json-menu.png differ diff --git a/.agents/evidence/pr/fix-diff-json-tools-target/diff-added-tool-expanded.png b/.agents/evidence/pr/fix-diff-json-tools-target/diff-added-tool-expanded.png new file mode 100644 index 0000000..18bf6e4 Binary files /dev/null and b/.agents/evidence/pr/fix-diff-json-tools-target/diff-added-tool-expanded.png differ diff --git a/.agents/evidence/pr/fix-diff-json-tools-target/diff-params-field-level.png b/.agents/evidence/pr/fix-diff-json-tools-target/diff-params-field-level.png new file mode 100644 index 0000000..608b030 Binary files /dev/null and b/.agents/evidence/pr/fix-diff-json-tools-target/diff-params-field-level.png differ diff --git a/.agents/evidence/pr/fix-diff-json-tools-target/diff-turn-41-parent.png b/.agents/evidence/pr/fix-diff-json-tools-target/diff-turn-41-parent.png new file mode 100644 index 0000000..fa4a5a2 Binary files /dev/null and b/.agents/evidence/pr/fix-diff-json-tools-target/diff-turn-41-parent.png differ diff --git a/.agents/evidence/pr/gemini-cli/README.md b/.agents/evidence/pr/gemini-cli/README.md new file mode 100644 index 0000000..b62918c --- /dev/null +++ b/.agents/evidence/pr/gemini-cli/README.md @@ -0,0 +1,51 @@ +# Gemini CLI PR Evidence + +Generated from real Gemini CLI OAuth / Code Assist runs on 2026-05-13. + +## Commands + +```bash +PROMPT='Real claude-tap validation for Gemini. Use shell to run pwd and test whether pyproject.toml exists. Then answer with exactly: VALIDATION_CLIENT=gemini, PWD=, PYPROJECT_EXISTS=.' +timeout 240 uv run python -m claude_tap --tap-client gemini --tap-output-dir .traces/real-validation --tap-no-open --tap-no-update-check -- --skip-trust -p "$PROMPT" --yolo --output-format text + +PROMPT='Second-turn claude-tap validation for Gemini. Continue the previous session. Use shell to run: printf "VALIDATION_CLIENT=gemini SECOND_TURN=true\n"; pwd. Then answer with exactly those two facts.' +timeout 240 uv run python -m claude_tap --tap-client gemini --tap-output-dir .traces/real-validation --tap-no-open --tap-no-update-check -- --skip-trust --resume latest -p "$PROMPT" --yolo --output-format text +``` + +## Results + +- First turn: `.traces/real-validation/2026-05-13/trace_121407.jsonl`, 15 POST records, final output `VALIDATION_CLIENT=gemini, PWD=/home/liaohch3/src/github.com/liaohch3/claude-tap-3, PYPROJECT_EXISTS=true`. +- Resume turn: `.traces/real-validation/2026-05-13/trace_121438.jsonl`, 13 POST records, final output `VALIDATION_CLIENT=gemini SECOND_TURN=true` plus the repo path. +- Hosts captured: `cloudcode-pa.googleapis.com`, `oauth2.googleapis.com`, and `play.googleapis.com`. +- Generation endpoint captured: `/v1internal:streamGenerateContent?alt=sse`. + +## Screenshot Quality Gate + +The screenshots below were generated only after Playwright assertions confirmed that the rendered viewer contained: + +- `System Prompt` with Gemini's `systemInstruction` text. +- `Tools` with Gemini `functionDeclarations`. +- `Messages` with Gemini `contents`, prior `functionCall`, and `functionResponse` tool results. +- `Response` with parsed SSE text output or tool calls. +- `SSE Events` parsed from the raw Google SSE body. + +## Screenshots + +- `first-01-system-and-tools.png` +- `first-02-message-history.png` +- `first-03-tool-result-history.png` +- `first-04-response-output.png` +- `resume-01-system-and-tools.png` +- `resume-02-message-history.png` +- `resume-03-tool-result-history.png` +- `resume-04-response-output.png` + +## Validation + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pytest tests/ -x --timeout=60 +uv run python scripts/check_screenshots.py .agents/evidence/pr/gemini-cli +uv run python scripts/verify_screenshots.py .traces/real-validation/2026-05-13/trace_121407.html .traces/real-validation/2026-05-13/trace_121438.html +``` diff --git a/.agents/evidence/pr/gemini-cli/first-01-system-and-tools.png b/.agents/evidence/pr/gemini-cli/first-01-system-and-tools.png new file mode 100644 index 0000000..2212957 Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/first-01-system-and-tools.png differ diff --git a/.agents/evidence/pr/gemini-cli/first-02-message-history.png b/.agents/evidence/pr/gemini-cli/first-02-message-history.png new file mode 100644 index 0000000..d0a0af8 Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/first-02-message-history.png differ diff --git a/.agents/evidence/pr/gemini-cli/first-03-tool-result-history.png b/.agents/evidence/pr/gemini-cli/first-03-tool-result-history.png new file mode 100644 index 0000000..e29ffaa Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/first-03-tool-result-history.png differ diff --git a/.agents/evidence/pr/gemini-cli/first-04-response-output.png b/.agents/evidence/pr/gemini-cli/first-04-response-output.png new file mode 100644 index 0000000..45add5f Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/first-04-response-output.png differ diff --git a/.agents/evidence/pr/gemini-cli/resume-01-system-and-tools.png b/.agents/evidence/pr/gemini-cli/resume-01-system-and-tools.png new file mode 100644 index 0000000..609c487 Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/resume-01-system-and-tools.png differ diff --git a/.agents/evidence/pr/gemini-cli/resume-02-message-history.png b/.agents/evidence/pr/gemini-cli/resume-02-message-history.png new file mode 100644 index 0000000..16fc96b Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/resume-02-message-history.png differ diff --git a/.agents/evidence/pr/gemini-cli/resume-03-tool-result-history.png b/.agents/evidence/pr/gemini-cli/resume-03-tool-result-history.png new file mode 100644 index 0000000..3931a43 Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/resume-03-tool-result-history.png differ diff --git a/.agents/evidence/pr/gemini-cli/resume-04-response-output.png b/.agents/evidence/pr/gemini-cli/resume-04-response-output.png new file mode 100644 index 0000000..d1424fc Binary files /dev/null and b/.agents/evidence/pr/gemini-cli/resume-04-response-output.png differ diff --git a/.agents/evidence/pr/gemini-native-direct-body-viewer/trace-viewer-gemini-native-tools.png b/.agents/evidence/pr/gemini-native-direct-body-viewer/trace-viewer-gemini-native-tools.png new file mode 100644 index 0000000..039bc29 Binary files /dev/null and b/.agents/evidence/pr/gemini-native-direct-body-viewer/trace-viewer-gemini-native-tools.png differ diff --git a/.agents/evidence/pr/gemini-native-viewer-primary/trace-viewer-default-gemini-native-visible.png b/.agents/evidence/pr/gemini-native-viewer-primary/trace-viewer-default-gemini-native-visible.png new file mode 100644 index 0000000..fd07919 Binary files /dev/null and b/.agents/evidence/pr/gemini-native-viewer-primary/trace-viewer-default-gemini-native-visible.png differ diff --git a/.agents/evidence/pr/gemini-sse-reassembly/trace-viewer-gemini-sse.png b/.agents/evidence/pr/gemini-sse-reassembly/trace-viewer-gemini-sse.png new file mode 100644 index 0000000..aa554a6 Binary files /dev/null and b/.agents/evidence/pr/gemini-sse-reassembly/trace-viewer-gemini-sse.png differ diff --git a/.agents/evidence/pr/issue-191-upstream-target-diagnostics/issue-191-e2e-trace-viewer.png b/.agents/evidence/pr/issue-191-upstream-target-diagnostics/issue-191-e2e-trace-viewer.png new file mode 100644 index 0000000..b9cc387 Binary files /dev/null and b/.agents/evidence/pr/issue-191-upstream-target-diagnostics/issue-191-e2e-trace-viewer.png differ diff --git a/.agents/evidence/pr/issue-267-bulk-delete-performance/dashboard-bulk-edit-real-trace.png b/.agents/evidence/pr/issue-267-bulk-delete-performance/dashboard-bulk-edit-real-trace.png new file mode 100644 index 0000000..3589e0b Binary files /dev/null and b/.agents/evidence/pr/issue-267-bulk-delete-performance/dashboard-bulk-edit-real-trace.png differ diff --git a/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/01-dashboard-system-cache.png b/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/01-dashboard-system-cache.png new file mode 100644 index 0000000..88731eb Binary files /dev/null and b/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/01-dashboard-system-cache.png differ diff --git a/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/02-audit-cache-overview.png b/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/02-audit-cache-overview.png new file mode 100644 index 0000000..b687ff4 Binary files /dev/null and b/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/02-audit-cache-overview.png differ diff --git a/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/03-audit-tool-chain.png b/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/03-audit-tool-chain.png new file mode 100644 index 0000000..2ee5050 Binary files /dev/null and b/.agents/evidence/pr/issue-346-bedrock-newapi-multiturn/03-audit-tool-chain.png differ diff --git a/.agents/evidence/pr/issue-346-bedrock-newapi-real/dashboard-real-bedrock-newapi.png b/.agents/evidence/pr/issue-346-bedrock-newapi-real/dashboard-real-bedrock-newapi.png new file mode 100644 index 0000000..2f730b6 Binary files /dev/null and b/.agents/evidence/pr/issue-346-bedrock-newapi-real/dashboard-real-bedrock-newapi.png differ diff --git a/.agents/evidence/pr/issue-347-dashboard-version/dashboard-version-badge.png b/.agents/evidence/pr/issue-347-dashboard-version/dashboard-version-badge.png new file mode 100644 index 0000000..6b44180 Binary files /dev/null and b/.agents/evidence/pr/issue-347-dashboard-version/dashboard-version-badge.png differ diff --git a/.agents/evidence/pr/issue-363-dashboard-search/after-dashboard-search-finds-compacted-message.png b/.agents/evidence/pr/issue-363-dashboard-search/after-dashboard-search-finds-compacted-message.png new file mode 100644 index 0000000..4f0452d Binary files /dev/null and b/.agents/evidence/pr/issue-363-dashboard-search/after-dashboard-search-finds-compacted-message.png differ diff --git a/.agents/evidence/pr/issue-363-dashboard-search/before-dashboard-search-misses-compacted-message.png b/.agents/evidence/pr/issue-363-dashboard-search/before-dashboard-search-misses-compacted-message.png new file mode 100644 index 0000000..2d931f2 Binary files /dev/null and b/.agents/evidence/pr/issue-363-dashboard-search/before-dashboard-search-misses-compacted-message.png differ diff --git a/.agents/evidence/pr/issue129/README.md b/.agents/evidence/pr/issue129/README.md new file mode 100644 index 0000000..b8f915f --- /dev/null +++ b/.agents/evidence/pr/issue129/README.md @@ -0,0 +1,58 @@ +# Issue 129 DeepSeek Token Label Validation + +Date: 2026-05-07 + +This evidence validates the viewer wording change from PR #133 against a real +Claude Code session routed through the DeepSeek Anthropic-compatible API with +`claude-tap`. + +## Environment + +- Claude Code: 2.1.132 +- Upstream target: `https://api.deepseek.com/anthropic` +- claude-tap mode: reverse proxy +- Trace source: `.traces/deepseek-issue129-manual-1778145043/2026-05-07/trace_091044.jsonl` +- HTML source: `.traces/deepseek-issue129-manual-1778145043/2026-05-07/trace_091044.html` + +The trace files are local evidence and are not committed because they may +contain prompt or environment context. Authentication headers are redacted by +claude-tap. + +## Prompt Coverage + +The run used three interactive Claude Code prompts. Each prompt requested Bash +tool use and a marker in the final response: + +- `TOOL_ROUND_ONE_OK` +- `TOOL_ROUND_TWO_OK` +- `TOOL_ROUND_THREE_OK` + +## Observed Trace Summary + +- Records: 8 +- `/v1/messages` requests: 8 +- `tool_use` text occurrences: 32 +- `tool_result` text occurrences: 9 +- Input tokens: 24,366 +- Output tokens: 1,550 +- Total API tokens (`input_tokens + output_tokens`): 25,916 +- Cache read input tokens: 119,040 +- Cache creation input tokens: 0 + +## Screenshots + +- `deepseek-real-token-summary.png` shows the real DeepSeek trace with the + updated `累计 API Token` header wording. +- `deepseek-real-tool-dialogue.png` shows the same real trace after selecting a + later request with tool/dialogue context. + +## Validation Commands + +```bash +uv run python scripts/check_screenshots.py \ + .agents/evidence/pr/issue129/deepseek-real-token-summary.png \ + .agents/evidence/pr/issue129/deepseek-real-tool-dialogue.png + +uv run python scripts/verify_screenshots.py \ + .traces/deepseek-issue129-manual-1778145043/2026-05-07/trace_091044.html +``` diff --git a/.agents/evidence/pr/issue129/deepseek-real-token-summary.png b/.agents/evidence/pr/issue129/deepseek-real-token-summary.png new file mode 100644 index 0000000..affc948 Binary files /dev/null and b/.agents/evidence/pr/issue129/deepseek-real-token-summary.png differ diff --git a/.agents/evidence/pr/issue129/deepseek-real-tool-dialogue.png b/.agents/evidence/pr/issue129/deepseek-real-tool-dialogue.png new file mode 100644 index 0000000..e55627f Binary files /dev/null and b/.agents/evidence/pr/issue129/deepseek-real-tool-dialogue.png differ diff --git a/.agents/evidence/pr/issue129/token-summary-label.png b/.agents/evidence/pr/issue129/token-summary-label.png new file mode 100644 index 0000000..a3e7e04 Binary files /dev/null and b/.agents/evidence/pr/issue129/token-summary-label.png differ diff --git a/.agents/evidence/pr/issue143/README.md b/.agents/evidence/pr/issue143/README.md new file mode 100644 index 0000000..4956dd2 --- /dev/null +++ b/.agents/evidence/pr/issue143/README.md @@ -0,0 +1,42 @@ +# Issue 143 Codex Cached Tokens Evidence + +Date: 2026-05-09 + +This evidence validates the Codex / OpenAI Responses cache-read token mapping +against a real Codex CLI run captured through `claude-tap`. + +## Environment + +- Codex CLI: 0.125.0 +- claude-tap mode: reverse proxy +- Upstream target: `https://chatgpt.com/backend-api/codex` +- Trace source: `.traces/issue143-codex-cache/2026-05-09/trace_093315.jsonl` +- HTML source: `.traces/issue143-codex-cache/2026-05-09/trace_093315.html` + +The trace files are local evidence and are not committed because they may +contain request headers or prompt context. Authentication headers are redacted +by `claude-tap`. + +## Observed Trace Summary + +- API calls: 2 +- Responses call: 1 +- Input tokens: 15,862 +- Output tokens: 9 +- Cache read tokens: 7,552 + +## Screenshots + +- `codex-cached-tokens.png` shows the real Codex Responses trace with + `input_tokens_details.cached_tokens` displayed as `Cache Read` in both the + header summary and the selected trace token bar. + +## Validation Commands + +```bash +uv run python scripts/check_screenshots.py \ + .agents/evidence/pr/issue143/codex-cached-tokens.png + +uv run python scripts/verify_screenshots.py \ + .traces/issue143-codex-cache/2026-05-09/trace_093315.html +``` diff --git a/.agents/evidence/pr/issue143/codex-cached-tokens.png b/.agents/evidence/pr/issue143/codex-cached-tokens.png new file mode 100644 index 0000000..2f10150 Binary files /dev/null and b/.agents/evidence/pr/issue143/codex-cached-tokens.png differ diff --git a/.agents/evidence/pr/issue150/README.md b/.agents/evidence/pr/issue150/README.md new file mode 100644 index 0000000..0ebc94c --- /dev/null +++ b/.agents/evidence/pr/issue150/README.md @@ -0,0 +1,9 @@ +# Issue 150 Evidence + +Source trace: `.traces/issue150-codex-tool-order/2026-05-11/2026-05-11/trace_041105.jsonl` + +The screenshot was captured from a real Codex WebSocket run through claude-tap. The run produced two separate `exec_command` calls and two matching tool results in one WebSocket session. Local repository paths were redacted in the screenshot as `[repo]`; system/user context blocks were hidden to keep the evidence focused on the Messages ordering and avoid exposing local context. + +Evidence: + +- `codex-ws-tool-result-order.png` shows the final derived viewer entry rendering `ASSISTANT tool_use -> TOOL result -> ASSISTANT tool_use -> TOOL result`. diff --git a/.agents/evidence/pr/issue150/codex-ws-tool-result-order.png b/.agents/evidence/pr/issue150/codex-ws-tool-result-order.png new file mode 100644 index 0000000..7049d04 Binary files /dev/null and b/.agents/evidence/pr/issue150/codex-ws-tool-result-order.png differ diff --git a/.agents/evidence/pr/issue164/codex-newapi-provider-captured.png b/.agents/evidence/pr/issue164/codex-newapi-provider-captured.png new file mode 100644 index 0000000..7e54b8e Binary files /dev/null and b/.agents/evidence/pr/issue164/codex-newapi-provider-captured.png differ diff --git a/.agents/evidence/pr/issue87/codex-real-e2e-viewer.png b/.agents/evidence/pr/issue87/codex-real-e2e-viewer.png new file mode 100644 index 0000000..f2ba885 Binary files /dev/null and b/.agents/evidence/pr/issue87/codex-real-e2e-viewer.png differ diff --git a/.agents/evidence/pr/issue87/codex-sdk-real-e2e-viewer.png b/.agents/evidence/pr/issue87/codex-sdk-real-e2e-viewer.png new file mode 100644 index 0000000..1b805c0 Binary files /dev/null and b/.agents/evidence/pr/issue87/codex-sdk-real-e2e-viewer.png differ diff --git a/.agents/evidence/pr/issue87/codex-sdk-real-ws-continuation-warning.png b/.agents/evidence/pr/issue87/codex-sdk-real-ws-continuation-warning.png new file mode 100644 index 0000000..ec8c7aa Binary files /dev/null and b/.agents/evidence/pr/issue87/codex-sdk-real-ws-continuation-warning.png differ diff --git a/.agents/evidence/pr/issue87/codex-sdk-real-ws-message-history.png b/.agents/evidence/pr/issue87/codex-sdk-real-ws-message-history.png new file mode 100644 index 0000000..a0ec6d3 Binary files /dev/null and b/.agents/evidence/pr/issue87/codex-sdk-real-ws-message-history.png differ diff --git a/.agents/evidence/pr/macos-menu-open-dashboard/README.md b/.agents/evidence/pr/macos-menu-open-dashboard/README.md new file mode 100644 index 0000000..7e43a55 --- /dev/null +++ b/.agents/evidence/pr/macos-menu-open-dashboard/README.md @@ -0,0 +1,82 @@ +# macOS menu monitor E2E evidence + +Date: 2026-06-29 + +Branch: `codex/macos-menu-app` + +Evidence directory, local only: `.traces/macos-menu-e2e-20260629-144944` + +## Environment + +- macOS 26.5.1, arm64 +- Built app bundle with `.venv/bin/python -m claude_tap build-macos-app` +- Launched app bundle through LaunchServices with `open -n "dist/Claude Tap.app" --args --tap-no-auto-start` +- `uv` was available at `/Users/petershi/.local/bin/uv`, but not on the default shell `PATH` +- Claude Code CLI: `/Users/petershi/.local/bin/claude`, version `2.1.191` +- Codex CLI: `/Applications/Codex.app/Contents/Resources/codex`, version `0.142.3` + +## Config Baseline + +- `~/.claude/settings.json`: `d654e9c53be723101fd16090abd01c130e983afa37481d69008f98c0e209e555`, 1948 bytes, mode `600` +- `~/.codex/config.toml`: `0f2c9a9e36058286cfc778fbb54f1fcd052b6a6dc6398c0dade2aa8de99afaf1`, 3984 bytes, mode `600` + +## Injection + +Starting the monitor created `~/.claude-tap/monitor-state.json`, backup files for both configs, and dashboard/proxy process records: + +- `dashboard` +- `claude proxy` +- `codex proxy` + +Injected values observed: + +- Claude: `ANTHROPIC_BASE_URL=http://127.0.0.1:19528` +- Codex: `openai_base_url = "http://127.0.0.1:19529/v1"` + +## Client Capture + +Codex real CLI run: + +- Command: `codex exec --skip-git-repo-check --sandbox read-only --output-last-message ...` +- Prompt marker: `CODEX_TAP_MACOS_E2E_OK` +- Exit status: `0` +- Dashboard DB result: `client=codex`, `proxy_mode=reverse`, `record_count=14`, `model=gpt-5.5` + +Claude Code real CLI run: + +- Command: `claude -p ... --output-format json --max-budget-usd 0.05 --permission-mode dontAsk` +- Exit status: `1` +- Result: `Not logged in · Please run /login` +- `ANTHROPIC_API_KEY` was not present in the shell environment +- Dashboard DB result: `client=claude`, `proxy_mode=reverse`, `record_count=0` + +## Restore + +After client validation, `claude-tap monitor-restore` returned status `0`. + +- `~/.claude-tap/monitor-state.json`: absent +- `~/.claude/settings.json`: byte-exact baseline restore, hash `d654e9c53be723101fd16090abd01c130e983afa37481d69008f98c0e209e555` +- `~/.codex/config.toml`: byte-exact baseline restore, hash `0f2c9a9e36058286cfc778fbb54f1fcd052b6a6dc6398c0dade2aa8de99afaf1` + +Normal controller stop cycle: + +- `controller.stop()` returned `true` +- Monitor state removed +- Claude config restored byte-for-byte +- Codex config restored byte-for-byte + +Force-kill recovery: + +- Started active monitor with recorded PIDs: dashboard `82942`, Claude proxy `82943`, Codex proxy `82944` +- Killed parent process `82931` without calling stop +- Confirmed recorded dashboard/proxy children remained and monitor state was present +- Ran `claude-tap monitor-restore` +- Monitor state removed +- Recorded dashboard/proxy children were cleaned up +- Claude config restored byte-for-byte +- Codex config restored byte-for-byte + +## Gaps + +- Literal menu clicking through System Events was blocked by macOS TCC: `osascript is not allowed assistive access`. +- Full Claude Code request capture was blocked by local authentication state: Claude CLI reported `Not logged in · Please run /login`, and no `ANTHROPIC_API_KEY` was present. diff --git a/.agents/evidence/pr/message-content-compaction/codex-feature-comparison-viewer.png b/.agents/evidence/pr/message-content-compaction/codex-feature-comparison-viewer.png new file mode 100644 index 0000000..83e5a5d Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/codex-feature-comparison-viewer.png differ diff --git a/.agents/evidence/pr/message-content-compaction/codex-main-comparison-viewer.png b/.agents/evidence/pr/message-content-compaction/codex-main-comparison-viewer.png new file mode 100644 index 0000000..c81adb6 Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/codex-main-comparison-viewer.png differ diff --git a/.agents/evidence/pr/message-content-compaction/compaction-stats.json b/.agents/evidence/pr/message-content-compaction/compaction-stats.json new file mode 100644 index 0000000..7bd3fd6 --- /dev/null +++ b/.agents/evidence/pr/message-content-compaction/compaction-stats.json @@ -0,0 +1,28 @@ +{ + "format": "message-content-compaction-v1", + "real_trace": { + "source": ".traces/codex-tools-20260509/2026-05-09/trace_081725.jsonl", + "records": 1, + "raw_canonical_jsonl_bytes": 561233, + "sqlite_payload_json_bytes": 450216, + "sqlite_blob_payload_bytes": 89977, + "sqlite_compact_payload_bytes": 540193, + "sqlite_compact_ratio": 0.9625, + "blob_count": 5, + "jsonl_roundtrip": true, + "compact_bundle_bytes": 542175, + "compact_bundle_roundtrip": true + }, + "long_history_stress": { + "records": 80, + "raw_canonical_jsonl_bytes": 8902421, + "sqlite_payload_json_bytes": 431422, + "sqlite_blob_payload_bytes": 184926, + "sqlite_compact_payload_bytes": 616348, + "sqlite_compact_ratio": 0.0692, + "blob_count": 30, + "jsonl_roundtrip": true, + "compact_bundle_bytes": 619978, + "compact_bundle_roundtrip": true + } +} diff --git a/.agents/evidence/pr/message-content-compaction/cross-agent-and-codex-comparison-stats.json b/.agents/evidence/pr/message-content-compaction/cross-agent-and-codex-comparison-stats.json new file mode 100644 index 0000000..a4ad239 --- /dev/null +++ b/.agents/evidence/pr/message-content-compaction/cross-agent-and-codex-comparison-stats.json @@ -0,0 +1,235 @@ +{ + "generated_at": "2026-06-11T16:21:00Z", + "notes": [ + "Cross-agent traces were driven through real tmux TUI input, five user turns per client, ten tool calls requested per turn.", + "Codex main-vs-feature comparison used the same prompt template and workspace shape on separate real tmux sessions.", + "Feature raw comparison DB imported legacy .traces because the first launch omitted --tap-output-dir; normalized SQLite metrics use a VACUUMed single-session copy from the real captured target session." + ], + "cross_agent_tmux": { + "claude": { + "session_id": "d4ea3192-15be-44c6-97ea-44bcdf2ff907", + "client": "claude", + "status": "active", + "record_count": 62, + "tool_call_total": 50, + "records_with_tool_calls": 50, + "turn_first_records": { + "1": 2, + "2": 15, + "3": 27, + "4": 39, + "5": 51 + }, + "markers": { + "E2E_DONE_TURN_1": 50, + "E2E_DONE_TURN_2": 38, + "E2E_DONE_TURN_3": 26, + "E2E_DONE_TURN_4": 14, + "E2E_DONE_TURN_5": 2 + }, + "sqlite": { + "sqlite_bytes": 2953216, + "page_size": 4096, + "page_count": 721, + "freelist_count": 0, + "record_count": 62, + "record_payload_json_bytes": 2669792, + "blob_count": 35, + "blob_declared_bytes": 120104, + "blob_payload_json_bytes": 119892 + }, + "exports": { + "claude.compact.json": 2798133, + "claude.full.json": 9094888, + "claude.html": 3092394 + }, + "sqlite_checkpointed_bytes": 2953216 + }, + "codex": { + "session_id": "4a929180-711f-4c1e-bbaf-567a7d2f4b81", + "client": "codex", + "status": "complete", + "record_count": 57, + "tool_call_total": 50, + "records_with_tool_calls": 50, + "turn_first_records": { + "1": 3, + "2": 14, + "3": 25, + "4": 36, + "5": 47 + }, + "markers": { + "E2E_DONE_TURN_1": 1, + "E2E_DONE_TURN_2": 1, + "E2E_DONE_TURN_3": 1, + "E2E_DONE_TURN_4": 1, + "E2E_DONE_TURN_5": 1 + }, + "sqlite": { + "sqlite_bytes": 667648, + "page_size": 4096, + "page_count": 163, + "freelist_count": 0, + "record_count": 57, + "record_payload_json_bytes": 487923, + "blob_count": 14, + "blob_declared_bytes": 76383, + "blob_payload_json_bytes": 75170 + }, + "exports": { + "codex.compact.json": 566380, + "codex.full.json": 1045276, + "codex.html": 859019 + }, + "sqlite_checkpointed_bytes": 667648 + }, + "kimi": { + "session_id": "298b1c99-8bd1-493d-843a-a556d1385264", + "client": "kimi", + "status": "active", + "record_count": 55, + "tool_call_total": 50, + "records_with_tool_calls": 50, + "turn_first_records": { + "1": 1, + "2": 12, + "3": 23, + "4": 34, + "5": 45 + }, + "markers": { + "E2E_DONE_TURN_1": 92, + "E2E_DONE_TURN_2": 35, + "E2E_DONE_TURN_3": 24, + "E2E_DONE_TURN_4": 13, + "E2E_DONE_TURN_5": 2 + }, + "sqlite": { + "sqlite_bytes": 1155072, + "page_size": 4096, + "page_count": 282, + "freelist_count": 0, + "record_count": 55, + "record_payload_json_bytes": 951033, + "blob_count": 12, + "blob_declared_bytes": 64235, + "blob_payload_json_bytes": 62972 + }, + "exports": { + "kimi.compact.json": 1016815, + "kimi.full.json": 5160604, + "kimi.html": 1310669 + }, + "sqlite_checkpointed_bytes": 1155072 + } + }, + "codex_main_vs_feature": { + "main": { + "session_id": "6762a34e-f46f-40ef-8467-d1ac633f5b7b", + "client": "codex", + "status": "complete", + "record_count": 59, + "tool_call_total": 50, + "records_with_tool_calls": 50, + "turn_first_records": { + "1": 3, + "2": 15, + "3": 26, + "4": 37, + "5": 49 + }, + "markers": { + "E2E_DONE_TURN_1": 2, + "E2E_DONE_TURN_2": 2, + "E2E_DONE_TURN_3": 2, + "E2E_DONE_TURN_4": 1, + "E2E_DONE_TURN_5": 1 + }, + "sqlite": { + "sqlite_bytes": 884736, + "page_size": 4096, + "page_count": 216, + "freelist_count": 0, + "record_count": 59, + "record_payload_json_bytes": 741324, + "blob_count": 3, + "blob_declared_bytes": 47204, + "blob_payload_json_bytes": 47184 + }, + "exports": { + "codex-main.compact.json": 792065, + "codex-main.full.json": 1063869, + "codex-main.html": 1083814 + }, + "normalized_single_session_sqlite": { + "sqlite_bytes": 884736, + "page_size": 4096, + "page_count": 216, + "freelist_count": 0, + "record_count": 59, + "record_payload_json_bytes": 741324, + "blob_count": 3, + "blob_declared_bytes": 47204, + "blob_payload_json_bytes": 47184 + }, + "git_ref": "origin/main@8e89fff" + }, + "feature": { + "session_id": "83d85947-2ab9-4349-a1b7-6a42d46c1c4a", + "client": "codex", + "status": "complete", + "record_count": 57, + "tool_call_total": 50, + "records_with_tool_calls": 50, + "turn_first_records": { + "1": 3, + "2": 14, + "3": 25, + "4": 36, + "5": 47 + }, + "markers": { + "E2E_DONE_TURN_1": 1, + "E2E_DONE_TURN_2": 1, + "E2E_DONE_TURN_3": 1, + "E2E_DONE_TURN_4": 1, + "E2E_DONE_TURN_5": 1 + }, + "sqlite": { + "sqlite_bytes": 7389184, + "page_size": 4096, + "page_count": 1804, + "freelist_count": 0, + "record_count": 57, + "record_payload_json_bytes": 486349, + "blob_count": 11, + "blob_declared_bytes": 73152, + "blob_payload_json_bytes": 71939 + }, + "exports": { + "codex-feature.compact.json": 561235, + "codex-feature.full.json": 1045254, + "codex-feature.html": 853883 + }, + "normalized_single_session_sqlite": { + "sqlite_bytes": 663552, + "page_size": 4096, + "page_count": 162, + "freelist_count": 0, + "record_count": 57, + "record_payload_json_bytes": 486349, + "blob_count": 11, + "blob_declared_bytes": 73152, + "blob_payload_json_bytes": 71939 + }, + "git_ref": "feat/message-content-compaction@50230b6+working-tree" + }, + "savings_vs_main": { + "normalized_sqlite_percent": 25.0, + "html_percent": 21.21, + "compact_json_percent": 29.14, + "record_payload_json_percent": 34.39 + } + } +} diff --git a/.agents/evidence/pr/message-content-compaction/cross-agent-claude-viewer.png b/.agents/evidence/pr/message-content-compaction/cross-agent-claude-viewer.png new file mode 100644 index 0000000..b851f8d Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/cross-agent-claude-viewer.png differ diff --git a/.agents/evidence/pr/message-content-compaction/cross-agent-codex-viewer.png b/.agents/evidence/pr/message-content-compaction/cross-agent-codex-viewer.png new file mode 100644 index 0000000..f172a68 Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/cross-agent-codex-viewer.png differ diff --git a/.agents/evidence/pr/message-content-compaction/cross-agent-kimi-viewer.png b/.agents/evidence/pr/message-content-compaction/cross-agent-kimi-viewer.png new file mode 100644 index 0000000..f9c1699 Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/cross-agent-kimi-viewer.png differ diff --git a/.agents/evidence/pr/message-content-compaction/message-compaction-dashboard-localhost.png b/.agents/evidence/pr/message-content-compaction/message-compaction-dashboard-localhost.png new file mode 100644 index 0000000..7caafe6 Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/message-compaction-dashboard-localhost.png differ diff --git a/.agents/evidence/pr/message-content-compaction/message-compaction-real-viewer.png b/.agents/evidence/pr/message-content-compaction/message-compaction-real-viewer.png new file mode 100644 index 0000000..f0e2d51 Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/message-compaction-real-viewer.png differ diff --git a/.agents/evidence/pr/message-content-compaction/tmux-compaction-stats.json b/.agents/evidence/pr/message-content-compaction/tmux-compaction-stats.json new file mode 100644 index 0000000..1d72099 --- /dev/null +++ b/.agents/evidence/pr/message-content-compaction/tmux-compaction-stats.json @@ -0,0 +1,54 @@ +{ + "scenario": "real_tmux_codex_5_turn_tool_call_storage_compaction", + "trace_session_id": "db5a1ad5-525e-4d25-89bb-22a9efe39df8", + "codex_cli_version_seen_in_tui": "0.135.0", + "model_seen_in_tui": "gpt-5.5", + "prompt_method": "tmux paste-buffer + send-keys into one interactive Codex TUI session", + "workspace": "/var/tmp/claude-tap-codex-tmux-compaction-315/workspace", + "trace_db": "/var/tmp/claude-tap-codex-tmux-compaction-315/trace.sqlite3", + "outputs": { + "full_json": "/var/tmp/claude-tap-codex-tmux-compaction-315/out/tmux-real-full.json", + "compact_bundle": "/var/tmp/claude-tap-codex-tmux-compaction-315/out/tmux-real.ctap.json", + "html": "/var/tmp/claude-tap-codex-tmux-compaction-315/out/tmux-real.html" + }, + "tmux_user_turns": 5, + "declared_shell_tool_calls_by_turn": [ + 12, + 11, + 12, + 12, + 14 + ], + "function_calls_from_trace": 61, + "records": 67, + "request_messages_seen": 0, + "request_input": { + "total_items": 68, + "records_with_nonempty_input": 66, + "max_items_in_one_request": 3 + }, + "sqlite": { + "record_count": 67, + "payload_json_bytes": 383285, + "blob_count": 17, + "blob_size_bytes": 98535, + "blob_payload_json_bytes": 97336, + "compact_total_bytes": 481820 + }, + "sizes": { + "full_canonical_jsonl_bytes": 5006342, + "export_full_json_bytes": 1278071, + "compact_bundle_bytes": 483986, + "html_bytes": 775702 + }, + "savings": { + "sqlite_compact_vs_full_canonical_ratio": 0.096242, + "sqlite_compact_vs_full_canonical_smaller_percent": 90.38, + "compact_bundle_vs_export_full_json_ratio": 0.378685, + "compact_bundle_vs_export_full_json_smaller_percent": 62.13 + }, + "roundtrip": { + "compact_bundle_materializes_to_full_records": true, + "materialized_record_count": 67 + } +} diff --git a/.agents/evidence/pr/message-content-compaction/tmux-real-dashboard-localhost.png b/.agents/evidence/pr/message-content-compaction/tmux-real-dashboard-localhost.png new file mode 100644 index 0000000..eb20526 Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/tmux-real-dashboard-localhost.png differ diff --git a/.agents/evidence/pr/message-content-compaction/tmux-real-viewer.png b/.agents/evidence/pr/message-content-compaction/tmux-real-viewer.png new file mode 100644 index 0000000..4104ea7 Binary files /dev/null and b/.agents/evidence/pr/message-content-compaction/tmux-real-viewer.png differ diff --git a/.agents/evidence/pr/mimo-e2e/mimo-e2e.png b/.agents/evidence/pr/mimo-e2e/mimo-e2e.png new file mode 100644 index 0000000..c58b524 Binary files /dev/null and b/.agents/evidence/pr/mimo-e2e/mimo-e2e.png differ diff --git a/.agents/evidence/pr/opencode-real-quality/README.md b/.agents/evidence/pr/opencode-real-quality/README.md new file mode 100644 index 0000000..09a7fc9 --- /dev/null +++ b/.agents/evidence/pr/opencode-real-quality/README.md @@ -0,0 +1,79 @@ +# OpenCode Real Trace Evidence + +Generated from real OpenCode CLI runs through `claude-tap --tap-client opencode` +on 2026-05-13. + +## Real Trace Inputs + +- First turn trace: `.traces/opencode-real-quality/2026-05-13/trace_161051.jsonl` +- First turn viewer: `.traces/opencode-real-quality/2026-05-13/trace_161051.html` +- Resume trace: `.traces/opencode-real-quality/2026-05-13/trace_161110.jsonl` +- Resume viewer: `.traces/opencode-real-quality/2026-05-13/trace_161110.html` +- OpenAI OAuth trace: `.traces/opencode-openai-oauth/2026-05-13/trace_164543.jsonl` +- OpenAI OAuth viewer: `.traces/opencode-openai-oauth/2026-05-13/trace_164543.html` + +## Runs + +First turn: + +```bash +claude-tap --tap-client opencode \ + --tap-output-dir .traces/opencode-real-quality \ + --tap-no-open --tap-no-update-check -- \ + run -m opencode/deepseek-v4-flash-free \ + --format json --dangerously-skip-permissions \ + "Use the shell/bash tool to run exactly: printf 'OPENCODE_TOOL_ONE\n'; pwd . Then answer with the exact output." +``` + +Resume turn: + +```bash +claude-tap --tap-client opencode \ + --tap-output-dir .traces/opencode-real-quality \ + --tap-no-open --tap-no-update-check -- \ + run -m opencode/deepseek-v4-flash-free \ + --format json --dangerously-skip-permissions \ + --session ses_1dde5052fffefXsEYsepPZ0b09 \ + "Second turn: use the shell/bash tool to run exactly: printf 'OPENCODE_TOOL_TWO\n'; ls pyproject.toml . Then answer with both the previous OPENCODE_TOOL_ONE output path and the new command output." +``` + +OpenAI OAuth provider: + +```bash +opencode providers login -p openai +opencode providers list +opencode models openai + +claude-tap --tap-client opencode \ + --tap-output-dir .traces/opencode-openai-oauth \ + --tap-no-open --tap-no-update-check -- \ + run -m openai/gpt-5.4-mini-fast \ + --format json --dangerously-skip-permissions \ + "Use the bash tool twice. First run exactly: printf 'OPENCODE_OPENAI_OAUTH_TOOL_ONE\n'; pwd . Second run exactly: printf 'OPENCODE_OPENAI_OAUTH_TOOL_TWO\n'; ls pyproject.toml . Then answer with both exact command outputs." +``` + +## Assertions Before Screenshots + +The screenshot script opened each real HTML viewer in Chromium and asserted: + +- `Full JSON` is present, but not the only section. +- `Tools`, `System Prompt`, `Messages`, and `Response` sections render. +- The OpenCode system prompt contains `You are opencode` or `You are OpenCode`. +- Tool list includes `bash`. +- Message history contains `OPENCODE_TOOL_ONE` and `OPENCODE_TOOL_TWO`. +- Tool results contain the real `pwd` output and `pyproject.toml`. +- Resume response contains `Path from OPENCODE_TOOL_ONE`. +- Token display includes `Cache Read`. +- The OpenAI OAuth trace renders `Messages` rather than `Request Context` for HTTP Responses API calls. +- The OpenAI OAuth trace captures ChatGPT Codex upstream calls at `/backend-api/codex/responses`. +- The OpenAI OAuth trace contains two real `bash` function calls, two tool outputs, and final assistant output. + +## Evidence Images + +- `opencode-first-01-system-tools.png` +- `opencode-first-02-tool-result-output.png` +- `opencode-resume-01-multiturn-history.png` +- `opencode-resume-02-final-output-tokens.png` +- `opencode-openai-oauth-01-system-tools-cache.png` +- `opencode-openai-oauth-02-message-history-tools.png` +- `opencode-openai-oauth-03-final-output-sse.png` diff --git a/.agents/evidence/pr/opencode-real-quality/opencode-first-01-system-tools.png b/.agents/evidence/pr/opencode-real-quality/opencode-first-01-system-tools.png new file mode 100644 index 0000000..3e3c183 Binary files /dev/null and b/.agents/evidence/pr/opencode-real-quality/opencode-first-01-system-tools.png differ diff --git a/.agents/evidence/pr/opencode-real-quality/opencode-first-02-tool-result-output.png b/.agents/evidence/pr/opencode-real-quality/opencode-first-02-tool-result-output.png new file mode 100644 index 0000000..b1cb0de Binary files /dev/null and b/.agents/evidence/pr/opencode-real-quality/opencode-first-02-tool-result-output.png differ diff --git a/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-01-system-tools-cache.png b/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-01-system-tools-cache.png new file mode 100644 index 0000000..91e6146 Binary files /dev/null and b/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-01-system-tools-cache.png differ diff --git a/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-02-message-history-tools.png b/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-02-message-history-tools.png new file mode 100644 index 0000000..adb736e Binary files /dev/null and b/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-02-message-history-tools.png differ diff --git a/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-03-final-output-sse.png b/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-03-final-output-sse.png new file mode 100644 index 0000000..ec8f999 Binary files /dev/null and b/.agents/evidence/pr/opencode-real-quality/opencode-openai-oauth-03-final-output-sse.png differ diff --git a/.agents/evidence/pr/opencode-real-quality/opencode-resume-01-multiturn-history.png b/.agents/evidence/pr/opencode-real-quality/opencode-resume-01-multiturn-history.png new file mode 100644 index 0000000..27958d7 Binary files /dev/null and b/.agents/evidence/pr/opencode-real-quality/opencode-resume-01-multiturn-history.png differ diff --git a/.agents/evidence/pr/opencode-real-quality/opencode-resume-02-final-output-tokens.png b/.agents/evidence/pr/opencode-real-quality/opencode-resume-02-final-output-tokens.png new file mode 100644 index 0000000..0de4b5e Binary files /dev/null and b/.agents/evidence/pr/opencode-real-quality/opencode-resume-02-final-output-tokens.png differ diff --git a/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-first-final-top.png b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-first-final-top.png new file mode 100644 index 0000000..a8a137a Binary files /dev/null and b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-first-final-top.png differ diff --git a/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-events-scroll.png b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-events-scroll.png new file mode 100644 index 0000000..a7eb0b6 Binary files /dev/null and b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-events-scroll.png differ diff --git a/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-final-top.png b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-final-top.png new file mode 100644 index 0000000..700bffa Binary files /dev/null and b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-final-top.png differ diff --git a/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-response-scroll.png b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-response-scroll.png new file mode 100644 index 0000000..4476f18 Binary files /dev/null and b/.agents/evidence/pr/pi-cli-support/pi-openai-oauth-second-response-scroll.png differ diff --git a/.agents/evidence/pr/pr122/validation.png b/.agents/evidence/pr/pr122/validation.png new file mode 100644 index 0000000..ebe196e Binary files /dev/null and b/.agents/evidence/pr/pr122/validation.png differ diff --git a/.agents/evidence/pr/pr139/README.md b/.agents/evidence/pr/pr139/README.md new file mode 100644 index 0000000..ee4433c --- /dev/null +++ b/.agents/evidence/pr/pr139/README.md @@ -0,0 +1,70 @@ +# PR 139 Real Kimi CLI Evidence + +Generated on 2026-05-08 from a real interactive `kimi` CLI run through `claude-tap --tap-client kimi`. + +This evidence replaces the earlier deterministic fake-upstream screenshots. The fake upstream remains useful for repeatable pytest coverage, but PR evidence screenshots should show real client and real upstream behavior. + +## Trace Source + +- JSONL: `.traces/regression-kimi-real-interactive-1778229907/2026-05-08/trace_164507.jsonl` +- Viewer: `.traces/regression-kimi-real-interactive-1778229907/2026-05-08/trace_164507.html` +- Client: `kimi, version 1.41.0` +- Capture command shape: `claude-tap --tap-client kimi --tap-output-dir .traces/regression-kimi-real-interactive-1778229907 --tap-no-open --tap-no-update-check -- --yolo` +- Session shape: one continuous interactive Kimi CLI process with five consecutive user prompts in the same conversation. + +## Coverage + +- Real Kimi CLI process launched by `claude-tap` +- Real upstream Kimi Chat Completions requests captured at `/chat/completions` +- Captured records: 11 +- Response statuses: 11 x 200 +- Conversation rounds: `Round 1` through `Round 5` in one interactive session +- Tool calls captured from assistant responses: 11 `Shell` calls +- Final assistant responses captured: `KIMI_ROUND_1_DONE`, `KIMI_ROUND_2_DONE`, `KIMI_ROUND_3_DONE`, `KIMI_ROUND_4_DONE`, `KIMI_ROUND_5_DONE` +- Request-history check: no empty assistant `content` messages were found in captured requests. +- Trace redaction check: the validation script confirmed the known DeepSeek key string is not present in this Kimi trace. + +## Screenshots + +- `kimi-real-multiturn-01-system-overview.png` - first captured request with the real Kimi system prompt visible. +- `kimi-real-multiturn-02-round1-tool-scrolled.png` - scrolled Round 1 tool-call response. +- `kimi-real-multiturn-03-round3-scrolled.png` - middle of the same session with Round 3 selected. +- `kimi-real-multiturn-04-round5-history-scrolled.png` - final turn request history after scrolling through accumulated messages. +- `kimi-real-multiturn-05-final-response-scrolled.png` - final assistant response marker from Round 5. +- `kimi-real-multiturn-06-full-json-scrolled.png` - scrolled Full JSON from the real multi-turn trace. + +## Validation + +```bash +UV_NO_SYNC=1 uv run python scripts/check_screenshots.py .agents/evidence/pr/pr139 +UV_NO_SYNC=1 uv run python scripts/verify_screenshots.py .traces/regression-kimi-real-interactive-1778229907/2026-05-08/trace_164507.html +UV_NO_SYNC=1 uv run python - <<'PY' +import json +from collections import Counter +from pathlib import Path + +path = Path(".traces/regression-kimi-real-interactive-1778229907/2026-05-08/trace_164507.jsonl") +records = [json.loads(line) for line in path.read_text().splitlines() if line.strip()] +text = path.read_text() + +tool_calls = Counter() +empty_assistant_messages = 0 + +for record in records: + for msg in record["request"]["body"].get("messages", []): + if msg.get("role") == "assistant" and msg.get("content") in ("", []): + empty_assistant_messages += 1 + message = record["response"]["body"]["choices"][0]["message"] + for tool_call in message.get("tool_calls") or []: + tool_calls[tool_call["function"]["name"]] += 1 + +assert len(records) == 11 +assert all(record["request"]["path"] == "/chat/completions" for record in records) +assert all(record["response"]["status"] == 200 for record in records) +assert tool_calls["Shell"] == 11 +assert empty_assistant_messages == 0 +for round_id in range(1, 6): + assert f"KIMI_ROUND_{round_id}_DONE" in text +assert "sk-31b30bd5398e41f9b86f4763b4592cb1" not in text +PY +``` diff --git a/.agents/evidence/pr/pr139/kimi-real-multiturn-01-system-overview.png b/.agents/evidence/pr/pr139/kimi-real-multiturn-01-system-overview.png new file mode 100644 index 0000000..5b01bdb Binary files /dev/null and b/.agents/evidence/pr/pr139/kimi-real-multiturn-01-system-overview.png differ diff --git a/.agents/evidence/pr/pr139/kimi-real-multiturn-02-round1-tool-scrolled.png b/.agents/evidence/pr/pr139/kimi-real-multiturn-02-round1-tool-scrolled.png new file mode 100644 index 0000000..0034f91 Binary files /dev/null and b/.agents/evidence/pr/pr139/kimi-real-multiturn-02-round1-tool-scrolled.png differ diff --git a/.agents/evidence/pr/pr139/kimi-real-multiturn-03-round3-scrolled.png b/.agents/evidence/pr/pr139/kimi-real-multiturn-03-round3-scrolled.png new file mode 100644 index 0000000..bae5f26 Binary files /dev/null and b/.agents/evidence/pr/pr139/kimi-real-multiturn-03-round3-scrolled.png differ diff --git a/.agents/evidence/pr/pr139/kimi-real-multiturn-04-round5-history-scrolled.png b/.agents/evidence/pr/pr139/kimi-real-multiturn-04-round5-history-scrolled.png new file mode 100644 index 0000000..0506087 Binary files /dev/null and b/.agents/evidence/pr/pr139/kimi-real-multiturn-04-round5-history-scrolled.png differ diff --git a/.agents/evidence/pr/pr139/kimi-real-multiturn-05-final-response-scrolled.png b/.agents/evidence/pr/pr139/kimi-real-multiturn-05-final-response-scrolled.png new file mode 100644 index 0000000..235b09d Binary files /dev/null and b/.agents/evidence/pr/pr139/kimi-real-multiturn-05-final-response-scrolled.png differ diff --git a/.agents/evidence/pr/pr139/kimi-real-multiturn-06-full-json-scrolled.png b/.agents/evidence/pr/pr139/kimi-real-multiturn-06-full-json-scrolled.png new file mode 100644 index 0000000..7376634 Binary files /dev/null and b/.agents/evidence/pr/pr139/kimi-real-multiturn-06-full-json-scrolled.png differ diff --git a/.agents/evidence/pr/pr22-websocket-last-turn-wide.png b/.agents/evidence/pr/pr22-websocket-last-turn-wide.png new file mode 100644 index 0000000..d8f90cc Binary files /dev/null and b/.agents/evidence/pr/pr22-websocket-last-turn-wide.png differ diff --git a/.agents/evidence/pr/pr22-websocket-viewer-wide.png b/.agents/evidence/pr/pr22-websocket-viewer-wide.png new file mode 100644 index 0000000..d8f90cc Binary files /dev/null and b/.agents/evidence/pr/pr22-websocket-viewer-wide.png differ diff --git a/.agents/evidence/pr/pr22/trace-viewer-summary.png b/.agents/evidence/pr/pr22/trace-viewer-summary.png new file mode 100644 index 0000000..4bdabf6 Binary files /dev/null and b/.agents/evidence/pr/pr22/trace-viewer-summary.png differ diff --git a/.agents/evidence/pr/pr22/ws-upgrade-log.png b/.agents/evidence/pr/pr22/ws-upgrade-log.png new file mode 100644 index 0000000..8b66e12 Binary files /dev/null and b/.agents/evidence/pr/pr22/ws-upgrade-log.png differ diff --git a/.agents/evidence/pr/pr26-test-evidence.png b/.agents/evidence/pr/pr26-test-evidence.png new file mode 100644 index 0000000..0484c0d Binary files /dev/null and b/.agents/evidence/pr/pr26-test-evidence.png differ diff --git a/.agents/evidence/pr/pr291/README.md b/.agents/evidence/pr/pr291/README.md new file mode 100644 index 0000000..b5a29d9 --- /dev/null +++ b/.agents/evidence/pr/pr291/README.md @@ -0,0 +1,10 @@ +# PR 291 Dashboard Evidence + +This evidence validates the dashboard quit control and same-origin quit-token flow. + +- Screenshot: `dashboard-quit-token.png` +- Source: temporary real JSONL trace at `/private/tmp/claude-tap-pr291-dashboard/traces/2026-06-05/trace_141500.jsonl` +- Capture: dashboard server started with `LiveViewerServer(dashboard_mode=True)` after migrating the temporary trace into a temporary SQLite database. +- Validation: `uv run python scripts/check_screenshots.py .agents/evidence/pr/pr291/dashboard-quit-token.png` + +The raw trace and SQLite database are intentionally not committed. diff --git a/.agents/evidence/pr/pr291/dashboard-quit-token.png b/.agents/evidence/pr/pr291/dashboard-quit-token.png new file mode 100644 index 0000000..6a62c45 Binary files /dev/null and b/.agents/evidence/pr/pr291/dashboard-quit-token.png differ diff --git a/.agents/evidence/pr/pr303/dashboard-null-usage-session.png b/.agents/evidence/pr/pr303/dashboard-null-usage-session.png new file mode 100644 index 0000000..7d1285d Binary files /dev/null and b/.agents/evidence/pr/pr303/dashboard-null-usage-session.png differ diff --git a/.agents/evidence/pr/pr329/README.md b/.agents/evidence/pr/pr329/README.md new file mode 100644 index 0000000..043172c --- /dev/null +++ b/.agents/evidence/pr/pr329/README.md @@ -0,0 +1,11 @@ +# PR 329 — Session Detail Back Button + +Screenshot showing the `←` back button in `.detail-page-head` bar on the dashboard session detail page. + +**Changes:** +- Back button in detail page header calls `showListView()` +- Clears `state.selectedSessionId` before returning to list (fixes stale-fetch race) + +**Source:** +- Local dashboard at `http://localhost:3000` (via `claude-tap dashboard`) +- Real trace: session detail page with actual trace data from local SQLite store diff --git a/.agents/evidence/pr/pr329/README.zh.md b/.agents/evidence/pr/pr329/README.zh.md new file mode 100644 index 0000000..a571c89 --- /dev/null +++ b/.agents/evidence/pr/pr329/README.zh.md @@ -0,0 +1,11 @@ +# PR 329 — Session Detail 页返回按钮 + +仪表盘会话详情页头部 `←` 返回按钮的截图。 + +**改动:** +- 详情页头部的返回按钮调用 `showListView()` +- 返回列表前清除 `state.selectedSessionId`(修复请求竞态问题) + +**源:** +- 通过 `claude-tap dashboard` 启动本地 dashboard:`http://localhost:3000` +- 真实轨迹:任意 session id 的会话详情页 diff --git a/.agents/evidence/pr/pr329/session-back-button.png b/.agents/evidence/pr/pr329/session-back-button.png new file mode 100644 index 0000000..fe5b4cb Binary files /dev/null and b/.agents/evidence/pr/pr329/session-back-button.png differ diff --git a/.agents/evidence/pr/pr359/README.md b/.agents/evidence/pr/pr359/README.md new file mode 100644 index 0000000..3e080fc --- /dev/null +++ b/.agents/evidence/pr/pr359/README.md @@ -0,0 +1,11 @@ +# PR 359 dashboard evidence + +Source: real macOS menu monitor E2E trace database at `.traces/macos-menu-e2e-20260629-144944/traces.sqlite3`. + +Screenshot: + +- `dashboard-macos-menu-e2e.png` captures the dashboard rendered from that database with `CLOUDTAP_DB` pointed at the source SQLite file. + +Validation: + +- `uv run python scripts/check_screenshots.py .agents/evidence/pr/pr359` diff --git a/.agents/evidence/pr/pr359/dashboard-macos-menu-e2e.png b/.agents/evidence/pr/pr359/dashboard-macos-menu-e2e.png new file mode 100644 index 0000000..3a0cc06 Binary files /dev/null and b/.agents/evidence/pr/pr359/dashboard-macos-menu-e2e.png differ diff --git a/.agents/evidence/pr/pr44/pr44-turn2-messages.png b/.agents/evidence/pr/pr44/pr44-turn2-messages.png new file mode 100644 index 0000000..2a05b1d Binary files /dev/null and b/.agents/evidence/pr/pr44/pr44-turn2-messages.png differ diff --git a/.agents/evidence/pr/pr44/pr44-turn2-response.png b/.agents/evidence/pr/pr44/pr44-turn2-response.png new file mode 100644 index 0000000..b5acaf6 Binary files /dev/null and b/.agents/evidence/pr/pr44/pr44-turn2-response.png differ diff --git a/.agents/evidence/pr/pr63/pr63-collapsed.png b/.agents/evidence/pr/pr63/pr63-collapsed.png new file mode 100644 index 0000000..d3e41b9 Binary files /dev/null and b/.agents/evidence/pr/pr63/pr63-collapsed.png differ diff --git a/.agents/evidence/pr/pr63/pr63-expanded.png b/.agents/evidence/pr/pr63/pr63-expanded.png new file mode 100644 index 0000000..ab83923 Binary files /dev/null and b/.agents/evidence/pr/pr63/pr63-expanded.png differ diff --git a/.agents/evidence/pr/pr73/README.md b/.agents/evidence/pr/pr73/README.md new file mode 100644 index 0000000..cc2dc06 --- /dev/null +++ b/.agents/evidence/pr/pr73/README.md @@ -0,0 +1,134 @@ +# PR 73 Evidence + +PR: `fix(codex): relay websocket traffic in forward proxy` + +This directory contains real evidence for PR #73. The screenshots were taken from the generated trace viewer HTML files of two real Codex runs after the forward-proxy websocket relay fix. + +## Real runs + +### 1. Direct forward-mode Codex run + +Command: + +```bash +cd /Users/liaohch3 +uv run --project /Users/liaohch3/src/github.com/liaohch3/claude-tap \ + python -m claude_tap \ + --tap-client codex \ + --tap-proxy-mode forward \ + --tap-output-dir /tmp/codex-forward-real \ + --tap-no-open \ + --tap-no-update-check \ + -- exec "run pwd and reply with exactly the pwd" \ + --dangerously-bypass-approvals-and-sandbox +``` + +Artifacts: + +- JSONL: `/tmp/codex-forward-real/2026-04-20/trace_011250.jsonl` +- HTML: `/tmp/codex-forward-real/2026-04-20/trace_011250.html` +- Log: `/tmp/codex-forward-real/2026-04-20/trace_011250.log` + +Observed in log: + +- `WS UPGRADE /backend-api/codex/responses` +- `WS closed (18329ms, 3 client->upstream, 56 upstream->client)` + +Observed in trace: + +- `transport=websocket` + +### 2. Real alias run via `cxx` + +Command: + +```bash +source ~/.zshrc +cd /Users/liaohch3 +cxx exec "run pwd and reply with exactly the pwd" +``` + +Artifacts: + +- JSONL: `/Users/liaohch3/.claude-tap-traces/2026-04-20/trace_011853.jsonl` +- HTML: `/Users/liaohch3/.claude-tap-traces/2026-04-20/trace_011853.html` +- Log: `/Users/liaohch3/.claude-tap-traces/2026-04-20/trace_011853.log` + +Observed in log: + +- `WS UPGRADE /backend-api/codex/responses` +- `WS closed (10023ms, 3 client->upstream, 70 upstream->client)` + +Observed behavior: + +- caller working directory remained `/Users/liaohch3` +- no `405 Method Not Allowed` websocket fallback noise +- no `SSL connection is closed` terminal spam + +### 3. Real alias rerun after websocket output reconstruction fix + +Command: + +```bash +source ~/.zshrc +cd /Users/liaohch3 +cxx exec "run pwd and reply with exactly the pwd" +``` + +Artifacts: + +- JSONL: `/Users/liaohch3/.claude-tap-traces/2026-04-20/trace_104528.jsonl` +- HTML: `/Users/liaohch3/.claude-tap-traces/2026-04-20/trace_104528.html` +- Log: `/Users/liaohch3/.claude-tap-traces/2026-04-20/trace_104528.log` + +Observed in log: + +- `WS UPGRADE /backend-api/codex/responses` +- `WS closed (10186ms, 3 client->upstream, 73 upstream->client)` + +Observed in trace: + +- `response.body.output[0].content[0].text == "/Users/liaohch3"` + +Observed behavior: + +- top-level websocket response body contains the final assistant message +- viewer now renders the final answer instead of showing only the request payload + +## Screenshots + +- `pr73-direct-forward-websocket-fixed-output.png` + - Source viewer: `/tmp/codex-forward-real-fixed/2026-04-20/trace_112817.html` + - Captures the repaired direct forward-mode run with websocket upgrade and visible final assistant output +- `pr73-cxx-forward-websocket-fixed-output.png` + - Source viewer: `/Users/liaohch3/.claude-tap-traces/2026-04-20/trace_104528.html` + - Captures the repaired alias run with visible final assistant output + +## Local validation + +Commands: + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pytest tests/ -x --timeout=60 +uv run pytest tests/test_e2e.py -k "forward_proxy_connect_websocket or forward_proxy_connect" -x --timeout=120 +uv run pytest tests/test_ws_proxy.py -k build_ws_record_merges_incremental_request_and_output_items -x --timeout=120 +uv run pytest tests/test_responses_browser.py -x --timeout=120 +uv run python scripts/check_screenshots.py .agents/evidence/pr/pr73 +uv run python scripts/verify_screenshots.py \ + /Users/liaohch3/.claude-tap-traces/2026-04-20/trace_011853.html \ + /tmp/codex-forward-real/2026-04-20/trace_011250.html +uv run python scripts/verify_screenshots.py /Users/liaohch3/.claude-tap-traces/2026-04-20/trace_104528.html +``` + +Results: + +- `uv run ruff check .` -> passed +- `uv run ruff format --check .` -> passed +- `uv run pytest tests/ -x --timeout=60` -> `138 passed, 25 skipped, 4 warnings in 26.63s` +- targeted websocket regression -> `2 passed, 27 deselected in 0.82s` +- websocket reconstruction unit test -> `1 passed, 9 deselected in 0.19s` +- browser reconstruction regression -> `3 passed in 1.48s` +- screenshot quality check -> `PASS=4 WARN=0 FAIL=0` +- viewer rendering verification -> all 3 viewer HTML files passed diff --git a/.agents/evidence/pr/pr73/pr73-cxx-forward-websocket-fixed-output.png b/.agents/evidence/pr/pr73/pr73-cxx-forward-websocket-fixed-output.png new file mode 100644 index 0000000..f673677 Binary files /dev/null and b/.agents/evidence/pr/pr73/pr73-cxx-forward-websocket-fixed-output.png differ diff --git a/.agents/evidence/pr/pr73/pr73-cxx-forward-websocket.png b/.agents/evidence/pr/pr73/pr73-cxx-forward-websocket.png new file mode 100644 index 0000000..bfaf41a Binary files /dev/null and b/.agents/evidence/pr/pr73/pr73-cxx-forward-websocket.png differ diff --git a/.agents/evidence/pr/pr73/pr73-direct-forward-websocket-fixed-output.png b/.agents/evidence/pr/pr73/pr73-direct-forward-websocket-fixed-output.png new file mode 100644 index 0000000..705685d Binary files /dev/null and b/.agents/evidence/pr/pr73/pr73-direct-forward-websocket-fixed-output.png differ diff --git a/.agents/evidence/pr/pr73/pr73-direct-forward-websocket.png b/.agents/evidence/pr/pr73/pr73-direct-forward-websocket.png new file mode 100644 index 0000000..fb4a5dd Binary files /dev/null and b/.agents/evidence/pr/pr73/pr73-direct-forward-websocket.png differ diff --git a/.agents/evidence/pr/pr74/README.md b/.agents/evidence/pr/pr74/README.md new file mode 100644 index 0000000..c180f4c --- /dev/null +++ b/.agents/evidence/pr/pr74/README.md @@ -0,0 +1,69 @@ +# PR 74 Evidence + +PR: `fix(codex): honor env proxy settings for forward websocket upstreams` + +This directory contains real evidence for the first-message `cxx` reconnect fix. +The screenshot was taken from a real interactive Codex trace after the forward +proxy started passing environment-derived proxy settings into `aiohttp.ws_connect()`. + +## Real interactive run + +Command: + +```bash +source ~/.zshrc +cd /Users/liaohch3 +cxx +``` + +First message entered in the session: + +```text +say hello +``` + +Artifacts: + +- JSONL: `/Users/liaohch3/.claude-tap-traces/2026-04-21/trace_162156.jsonl` +- HTML: `/Users/liaohch3/.claude-tap-traces/2026-04-21/trace_162156.html` +- Log: `/Users/liaohch3/.claude-tap-traces/2026-04-21/trace_162156.log` + +Observed in log: + +- `16:22:05 [Turn 15] -> WS UPGRADE /backend-api/codex/responses` +- `16:22:05 [Turn 15] -> WS upstream via proxy http://127.0.0.1:7897` +- `16:24:06 [Turn 15] <- WS closed (121520ms, 1 client->upstream, 3 upstream->client)` + +Observed behavior: + +- the first interactive message upgraded to websocket immediately +- no `upstream WS connect failed` entries were emitted for the session +- the session did not show the `Reconnecting...` loop that previously happened on the first message + +## Screenshot + +- `pr74-cxx-first-message-no-reconnect.png` + - Source viewer: `/Users/liaohch3/.claude-tap-traces/2026-04-21/trace_162156.html` + - Captures the real first-message websocket turn selected in the viewer + +## Local validation + +Commands: + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pytest tests/ -x --timeout=60 +uv run pytest tests/test_e2e.py -k "forward_proxy_connect_websocket" -x --timeout=120 +uv run python scripts/check_screenshots.py .agents/evidence/pr/pr74 +uv run python scripts/verify_screenshots.py /Users/liaohch3/.claude-tap-traces/2026-04-21/trace_162156.html +``` + +Results: + +- `uv run ruff check .` -> passed +- `uv run ruff format --check .` -> passed +- `uv run pytest tests/ -x --timeout=60` -> `140 passed, 25 skipped, 4 warnings in 26.86s` +- targeted websocket regression -> `2 passed, 28 deselected` +- screenshot quality check -> `PASS=1 WARN=0 FAIL=0` +- viewer rendering verification -> `trace_162156.html: OK` diff --git a/.agents/evidence/pr/pr74/pr74-cxx-first-message-no-reconnect.png b/.agents/evidence/pr/pr74/pr74-cxx-first-message-no-reconnect.png new file mode 100644 index 0000000..8835717 Binary files /dev/null and b/.agents/evidence/pr/pr74/pr74-cxx-first-message-no-reconnect.png differ diff --git a/.agents/evidence/pr/pr75/README.md b/.agents/evidence/pr/pr75/README.md new file mode 100644 index 0000000..27d6130 --- /dev/null +++ b/.agents/evidence/pr/pr75/README.md @@ -0,0 +1,57 @@ +# PR 75 Evidence + +PR: `fix(viewer): label Codex responses input as request context` + +This directory contains real evidence for the Codex responses viewer fix. +The screenshot was taken from a real archived trace after the viewer stopped +labeling Codex `request.body.input` as normal `Messages`. + +## Real sample + +Artifacts: + +- JSONL: `/Users/liaohch3/Desktop/claude-tap-jsonl-samples/trace_100137.jsonl` +- HTML: `/Users/liaohch3/Desktop/claude-tap-jsonl-samples/trace_100137.html` + +Selected entry: + +- `Turn 20` +- `WEBSOCKET /backend-api/codex/responses` + +Observed before the fix: + +- the detail pane showed `Messages` +- that section included many historical `assistant` messages from `request.body.input` +- this looked like the current turn emitted multiple consecutive assistant replies + +Observed after the fix: + +- the same section is labeled `Request Context` +- the actual current-turn output remains under `Response` +- the UI no longer implies that historical assistant context is the current response + +## Screenshot + +- `pr75-codex-request-context-label.png` + - Source viewer: `/Users/liaohch3/Desktop/claude-tap-jsonl-samples/trace_100137.html` + - Captures the selected `Turn 20` entry after the viewer fix + +## Local validation + +Commands: + +```bash +uv run ruff check . +uv run ruff format --check . +uv run pytest tests/ -x --timeout=60 +uv run python scripts/check_screenshots.py .agents/evidence/pr/pr75 +uv run python scripts/verify_screenshots.py /Users/liaohch3/Desktop/claude-tap-jsonl-samples/trace_100137.html +``` + +Results: + +- `uv run ruff check .` -> passed +- `uv run ruff format --check .` -> passed +- `uv run pytest tests/ -x --timeout=60` -> `141 passed, 25 skipped, 4 warnings in 26.93s` +- screenshot quality check -> `PASS=1 WARN=0 FAIL=0` +- viewer rendering verification -> `trace_100137.html: OK` diff --git a/.agents/evidence/pr/pr75/pr75-codex-request-context-label.png b/.agents/evidence/pr/pr75/pr75-codex-request-context-label.png new file mode 100644 index 0000000..8df2d65 Binary files /dev/null and b/.agents/evidence/pr/pr75/pr75-codex-request-context-label.png differ diff --git a/.agents/evidence/pr/pr79/export-html-viewer.png b/.agents/evidence/pr/pr79/export-html-viewer.png new file mode 100644 index 0000000..a3ff9b0 Binary files /dev/null and b/.agents/evidence/pr/pr79/export-html-viewer.png differ diff --git a/.agents/evidence/pr/pr94/dashboard-real-traces.png b/.agents/evidence/pr/pr94/dashboard-real-traces.png new file mode 100644 index 0000000..e93c038 Binary files /dev/null and b/.agents/evidence/pr/pr94/dashboard-real-traces.png differ diff --git a/.agents/evidence/pr/pr99/viewer-real-codex-tool-name-fallback.png b/.agents/evidence/pr/pr99/viewer-real-codex-tool-name-fallback.png new file mode 100644 index 0000000..cecc2d0 Binary files /dev/null and b/.agents/evidence/pr/pr99/viewer-real-codex-tool-name-fallback.png differ diff --git a/.agents/evidence/pr/pr99/viewer-tool-name-fallback.png b/.agents/evidence/pr/pr99/viewer-tool-name-fallback.png new file mode 100644 index 0000000..f2dfe71 Binary files /dev/null and b/.agents/evidence/pr/pr99/viewer-tool-name-fallback.png differ diff --git a/.agents/evidence/pr/qoder-cli/qoder-trace-detail-scrolled.png b/.agents/evidence/pr/qoder-cli/qoder-trace-detail-scrolled.png new file mode 100644 index 0000000..b1e5936 Binary files /dev/null and b/.agents/evidence/pr/qoder-cli/qoder-trace-detail-scrolled.png differ diff --git a/.agents/evidence/pr/qoder-cli/qoder-trace-last-entry-expanded.png b/.agents/evidence/pr/qoder-cli/qoder-trace-last-entry-expanded.png new file mode 100644 index 0000000..39d26f4 Binary files /dev/null and b/.agents/evidence/pr/qoder-cli/qoder-trace-last-entry-expanded.png differ diff --git a/.agents/evidence/pr/readable-json-escapes/viewer-tool-call-params-collapsed.png b/.agents/evidence/pr/readable-json-escapes/viewer-tool-call-params-collapsed.png new file mode 100644 index 0000000..516ae1b Binary files /dev/null and b/.agents/evidence/pr/readable-json-escapes/viewer-tool-call-params-collapsed.png differ diff --git a/.agents/evidence/pr/readable-json-escapes/viewer-tool-call-params-expanded.png b/.agents/evidence/pr/readable-json-escapes/viewer-tool-call-params-expanded.png new file mode 100644 index 0000000..203659e Binary files /dev/null and b/.agents/evidence/pr/readable-json-escapes/viewer-tool-call-params-expanded.png differ diff --git a/.agents/evidence/pr/remove-auto-update/codex-real-compatibility.png b/.agents/evidence/pr/remove-auto-update/codex-real-compatibility.png new file mode 100644 index 0000000..414c8a3 Binary files /dev/null and b/.agents/evidence/pr/remove-auto-update/codex-real-compatibility.png differ diff --git a/.agents/evidence/pr/remove-auto-update/codex-real-final.png b/.agents/evidence/pr/remove-auto-update/codex-real-final.png new file mode 100644 index 0000000..414c8a3 Binary files /dev/null and b/.agents/evidence/pr/remove-auto-update/codex-real-final.png differ diff --git a/.agents/evidence/pr/remove-auto-update/codex-real-trace.png b/.agents/evidence/pr/remove-auto-update/codex-real-trace.png new file mode 100644 index 0000000..fddf499 Binary files /dev/null and b/.agents/evidence/pr/remove-auto-update/codex-real-trace.png differ diff --git a/.agents/evidence/pr/seo-agent-trace-viewer/guide-desktop.png b/.agents/evidence/pr/seo-agent-trace-viewer/guide-desktop.png new file mode 100644 index 0000000..c699b89 Binary files /dev/null and b/.agents/evidence/pr/seo-agent-trace-viewer/guide-desktop.png differ diff --git a/.agents/evidence/pr/seo-agent-trace-viewer/guide-zh-mobile.png b/.agents/evidence/pr/seo-agent-trace-viewer/guide-zh-mobile.png new file mode 100644 index 0000000..0fff070 Binary files /dev/null and b/.agents/evidence/pr/seo-agent-trace-viewer/guide-zh-mobile.png differ diff --git a/.agents/evidence/pr/seo-agent-trace-viewer/home-desktop.png b/.agents/evidence/pr/seo-agent-trace-viewer/home-desktop.png new file mode 100644 index 0000000..6bae8b7 Binary files /dev/null and b/.agents/evidence/pr/seo-agent-trace-viewer/home-desktop.png differ diff --git a/.agents/evidence/pr/seo-agent-trace-viewer/home-mobile.png b/.agents/evidence/pr/seo-agent-trace-viewer/home-mobile.png new file mode 100644 index 0000000..213fdab Binary files /dev/null and b/.agents/evidence/pr/seo-agent-trace-viewer/home-mobile.png differ diff --git a/.agents/evidence/pr/seo-agent-trace-viewer/home-zh-desktop.png b/.agents/evidence/pr/seo-agent-trace-viewer/home-zh-desktop.png new file mode 100644 index 0000000..6025950 Binary files /dev/null and b/.agents/evidence/pr/seo-agent-trace-viewer/home-zh-desktop.png differ diff --git a/.agents/evidence/pr/session-dashboard/README.md b/.agents/evidence/pr/session-dashboard/README.md new file mode 100644 index 0000000..65f9891 --- /dev/null +++ b/.agents/evidence/pr/session-dashboard/README.md @@ -0,0 +1,10 @@ +# Session Dashboard Evidence + +Generated from a real local `.traces/` capture served through `claude-tap dashboard`: + +- `.traces/dashboard-multi-agent-20260520-191156/` + +The screenshots verify: + +- `session-dashboard.png`: table-first dashboard using the existing viewer theme, cross-agent filters, session overview metrics, language switching, and first user prompt rows. +- `session-dashboard-detail.png`: session detail view embedding the generated HTML trace viewer so long prompts, tools, code blocks, raw trace sections, and language preference keep the original rendering. diff --git a/.agents/evidence/pr/session-dashboard/session-dashboard-detail.png b/.agents/evidence/pr/session-dashboard/session-dashboard-detail.png new file mode 100644 index 0000000..abc4807 Binary files /dev/null and b/.agents/evidence/pr/session-dashboard/session-dashboard-detail.png differ diff --git a/.agents/evidence/pr/session-dashboard/session-dashboard.png b/.agents/evidence/pr/session-dashboard/session-dashboard.png new file mode 100644 index 0000000..1950081 Binary files /dev/null and b/.agents/evidence/pr/session-dashboard/session-dashboard.png differ diff --git a/.agents/evidence/pr/session-round-grouping/session-round-grouping.png b/.agents/evidence/pr/session-round-grouping/session-round-grouping.png new file mode 100644 index 0000000..d8741cc Binary files /dev/null and b/.agents/evidence/pr/session-round-grouping/session-round-grouping.png differ diff --git a/.agents/evidence/pr/session-row-delete/dashboard-delete-confirm-modal.png b/.agents/evidence/pr/session-row-delete/dashboard-delete-confirm-modal.png new file mode 100644 index 0000000..0f3bd5f Binary files /dev/null and b/.agents/evidence/pr/session-row-delete/dashboard-delete-confirm-modal.png differ diff --git a/.agents/evidence/pr/split-cli/cli-help.png b/.agents/evidence/pr/split-cli/cli-help.png new file mode 100644 index 0000000..173c729 Binary files /dev/null and b/.agents/evidence/pr/split-cli/cli-help.png differ diff --git a/.agents/evidence/pr/split-cli/real-clients/claude-detail.png b/.agents/evidence/pr/split-cli/real-clients/claude-detail.png new file mode 100644 index 0000000..90866a7 Binary files /dev/null and b/.agents/evidence/pr/split-cli/real-clients/claude-detail.png differ diff --git a/.agents/evidence/pr/split-cli/real-clients/claude-overview.png b/.agents/evidence/pr/split-cli/real-clients/claude-overview.png new file mode 100644 index 0000000..b2c00c0 Binary files /dev/null and b/.agents/evidence/pr/split-cli/real-clients/claude-overview.png differ diff --git a/.agents/evidence/pr/split-cli/real-clients/codex-detail.png b/.agents/evidence/pr/split-cli/real-clients/codex-detail.png new file mode 100644 index 0000000..e9bf3d6 Binary files /dev/null and b/.agents/evidence/pr/split-cli/real-clients/codex-detail.png differ diff --git a/.agents/evidence/pr/split-cli/real-clients/codex-overview.png b/.agents/evidence/pr/split-cli/real-clients/codex-overview.png new file mode 100644 index 0000000..e9aa03a Binary files /dev/null and b/.agents/evidence/pr/split-cli/real-clients/codex-overview.png differ diff --git a/.agents/evidence/pr/split-cli/real-clients/kimi-detail.png b/.agents/evidence/pr/split-cli/real-clients/kimi-detail.png new file mode 100644 index 0000000..d39dd26 Binary files /dev/null and b/.agents/evidence/pr/split-cli/real-clients/kimi-detail.png differ diff --git a/.agents/evidence/pr/split-cli/real-clients/kimi-overview.png b/.agents/evidence/pr/split-cli/real-clients/kimi-overview.png new file mode 100644 index 0000000..34acd8b Binary files /dev/null and b/.agents/evidence/pr/split-cli/real-clients/kimi-overview.png differ diff --git a/.agents/evidence/pr/split-viewer-assets/codex-real-dashboard-session.png b/.agents/evidence/pr/split-viewer-assets/codex-real-dashboard-session.png new file mode 100644 index 0000000..2db83cb Binary files /dev/null and b/.agents/evidence/pr/split-viewer-assets/codex-real-dashboard-session.png differ diff --git a/.agents/evidence/pr/split-viewer-assets/codex-real-static-viewer.png b/.agents/evidence/pr/split-viewer-assets/codex-real-static-viewer.png new file mode 100644 index 0000000..034d558 Binary files /dev/null and b/.agents/evidence/pr/split-viewer-assets/codex-real-static-viewer.png differ diff --git a/.agents/evidence/pr/split-viewer-assets/codex-real-viewer-nodes-overview.png b/.agents/evidence/pr/split-viewer-assets/codex-real-viewer-nodes-overview.png new file mode 100644 index 0000000..30c4d90 Binary files /dev/null and b/.agents/evidence/pr/split-viewer-assets/codex-real-viewer-nodes-overview.png differ diff --git a/.agents/evidence/pr/split-viewer-assets/codex-real-viewer-response-detail.png b/.agents/evidence/pr/split-viewer-assets/codex-real-viewer-response-detail.png new file mode 100644 index 0000000..5380c1d Binary files /dev/null and b/.agents/evidence/pr/split-viewer-assets/codex-real-viewer-response-detail.png differ diff --git a/.agents/evidence/pr/sqlite-lock-contention/sqlite-lock-real-codex-viewer.png b/.agents/evidence/pr/sqlite-lock-contention/sqlite-lock-real-codex-viewer.png new file mode 100644 index 0000000..ae719be Binary files /dev/null and b/.agents/evidence/pr/sqlite-lock-contention/sqlite-lock-real-codex-viewer.png differ diff --git a/.agents/evidence/pr/sqlite-lock-contention/sqlite-lock-real-dashboard.png b/.agents/evidence/pr/sqlite-lock-contention/sqlite-lock-real-dashboard.png new file mode 100644 index 0000000..2f683ee Binary files /dev/null and b/.agents/evidence/pr/sqlite-lock-contention/sqlite-lock-real-dashboard.png differ diff --git a/.agents/evidence/pr/sqlite-trace-store/dashboard-detail-loading.png b/.agents/evidence/pr/sqlite-trace-store/dashboard-detail-loading.png new file mode 100644 index 0000000..a31f1b7 Binary files /dev/null and b/.agents/evidence/pr/sqlite-trace-store/dashboard-detail-loading.png differ diff --git a/.agents/evidence/pr/sqlite-trace-store/dashboard-load-more.png b/.agents/evidence/pr/sqlite-trace-store/dashboard-load-more.png new file mode 100644 index 0000000..5536150 Binary files /dev/null and b/.agents/evidence/pr/sqlite-trace-store/dashboard-load-more.png differ diff --git a/.agents/evidence/pr/sqlite-trace-store/dashboard-loading.png b/.agents/evidence/pr/sqlite-trace-store/dashboard-loading.png new file mode 100644 index 0000000..786151f Binary files /dev/null and b/.agents/evidence/pr/sqlite-trace-store/dashboard-loading.png differ diff --git a/.agents/evidence/pr/sqlite-trace-store/dashboard-sqlite.png b/.agents/evidence/pr/sqlite-trace-store/dashboard-sqlite.png new file mode 100644 index 0000000..88aac4e Binary files /dev/null and b/.agents/evidence/pr/sqlite-trace-store/dashboard-sqlite.png differ diff --git a/.agents/evidence/pr/stale-active-delete/dashboard-full-viewer-retained.png b/.agents/evidence/pr/stale-active-delete/dashboard-full-viewer-retained.png new file mode 100644 index 0000000..2cd4add Binary files /dev/null and b/.agents/evidence/pr/stale-active-delete/dashboard-full-viewer-retained.png differ diff --git a/.agents/evidence/pr/stale-active-delete/dashboard-redacted-detail.png b/.agents/evidence/pr/stale-active-delete/dashboard-redacted-detail.png new file mode 100644 index 0000000..26ab68b Binary files /dev/null and b/.agents/evidence/pr/stale-active-delete/dashboard-redacted-detail.png differ diff --git a/.agents/evidence/pr/stale-active-delete/dashboard-stale-active-delete.png b/.agents/evidence/pr/stale-active-delete/dashboard-stale-active-delete.png new file mode 100644 index 0000000..c1b202c Binary files /dev/null and b/.agents/evidence/pr/stale-active-delete/dashboard-stale-active-delete.png differ diff --git a/.agents/evidence/pr/stream-events-default-off/codex-default-messages.png b/.agents/evidence/pr/stream-events-default-off/codex-default-messages.png new file mode 100644 index 0000000..9b93f16 Binary files /dev/null and b/.agents/evidence/pr/stream-events-default-off/codex-default-messages.png differ diff --git a/.agents/evidence/pr/stream-events-default-off/codex-default-system-prompt.png b/.agents/evidence/pr/stream-events-default-off/codex-default-system-prompt.png new file mode 100644 index 0000000..d6bd230 Binary files /dev/null and b/.agents/evidence/pr/stream-events-default-off/codex-default-system-prompt.png differ diff --git a/.agents/evidence/pr/stream-events-default-off/default-no-raw-events.png b/.agents/evidence/pr/stream-events-default-off/default-no-raw-events.png new file mode 100644 index 0000000..17d1427 Binary files /dev/null and b/.agents/evidence/pr/stream-events-default-off/default-no-raw-events.png differ diff --git a/.agents/evidence/pr/stream-events-default-off/opt-in-raw-events.png b/.agents/evidence/pr/stream-events-default-off/opt-in-raw-events.png new file mode 100644 index 0000000..43ebd44 Binary files /dev/null and b/.agents/evidence/pr/stream-events-default-off/opt-in-raw-events.png differ diff --git a/.agents/evidence/pr/technical-log-tabs/history-delete-control.png b/.agents/evidence/pr/technical-log-tabs/history-delete-control.png new file mode 100644 index 0000000..3604da9 Binary files /dev/null and b/.agents/evidence/pr/technical-log-tabs/history-delete-control.png differ diff --git a/.agents/evidence/pr/technical-log-tabs/trace-copy-followup.png b/.agents/evidence/pr/technical-log-tabs/trace-copy-followup.png new file mode 100644 index 0000000..14fa5cb Binary files /dev/null and b/.agents/evidence/pr/technical-log-tabs/trace-copy-followup.png differ diff --git a/.agents/evidence/pr/technical-log-tabs/trace-view.png b/.agents/evidence/pr/technical-log-tabs/trace-view.png new file mode 100644 index 0000000..e565ddb Binary files /dev/null and b/.agents/evidence/pr/technical-log-tabs/trace-view.png differ diff --git a/.agents/evidence/pr/tool-search/README.md b/.agents/evidence/pr/tool-search/README.md new file mode 100644 index 0000000..b509861 --- /dev/null +++ b/.agents/evidence/pr/tool-search/README.md @@ -0,0 +1,27 @@ +# Tool Search Viewer Evidence + +Generated on 2026-05-09 from the real Codex Responses trace provided for this issue. + +## Trace Source + +- Source JSONL: `/tmp/codex-toolsearch-study/2026-05-09/trace_171258.jsonl` +- Regenerated local viewer: `.traces/tool-search-real/2026-05-09/trace_171258.html` +- Client path: `/v1/responses` +- Relevant events: `response.output_item.done` with `tool_search_call`, followed by request input `tool_search_output` +- Additional real Codex JSONL: `.traces/issue87-real-codex/trace_170706.jsonl` +- Additional regenerated viewer: `.traces/responses-function-real/trace_170706.html` +- Additional event: `response.output_item.done` with standard `function_call` for `exec_command` + +## Screenshots + +- `tool-search-response.png` - response section renders `tool_search` with query and limit from the WebSocket `response.output_item.done` item. +- `tool-search-output-context.png` - following request context renders the `tool_search_output` result with returned namespace/tool names. +- `responses-function-call.png` - response section renders a standard Responses `function_call` for `exec_command` from a real Codex trace. + +## Validation + +```bash +uv run python scripts/check_screenshots.py .agents/evidence/pr/tool-search +uv run python scripts/verify_screenshots.py .traces/tool-search-real/2026-05-09/trace_171258.html +uv run python scripts/verify_screenshots.py .traces/responses-function-real/trace_170706.html +``` diff --git a/.agents/evidence/pr/tool-search/responses-function-call.png b/.agents/evidence/pr/tool-search/responses-function-call.png new file mode 100644 index 0000000..21a4c1e Binary files /dev/null and b/.agents/evidence/pr/tool-search/responses-function-call.png differ diff --git a/.agents/evidence/pr/tool-search/tool-search-output-context.png b/.agents/evidence/pr/tool-search/tool-search-output-context.png new file mode 100644 index 0000000..6a78720 Binary files /dev/null and b/.agents/evidence/pr/tool-search/tool-search-output-context.png differ diff --git a/.agents/evidence/pr/tool-search/tool-search-response.png b/.agents/evidence/pr/tool-search/tool-search-response.png new file mode 100644 index 0000000..1d7467c Binary files /dev/null and b/.agents/evidence/pr/tool-search/tool-search-response.png differ diff --git a/.agents/evidence/pr/trace-viewer-openai-compatible-usage-zero-alias.png b/.agents/evidence/pr/trace-viewer-openai-compatible-usage-zero-alias.png new file mode 100644 index 0000000..42df6ae Binary files /dev/null and b/.agents/evidence/pr/trace-viewer-openai-compatible-usage-zero-alias.png differ diff --git a/.agents/evidence/pr/viewer-i18n-source/README.md b/.agents/evidence/pr/viewer-i18n-source/README.md new file mode 100644 index 0000000..7022cfc --- /dev/null +++ b/.agents/evidence/pr/viewer-i18n-source/README.md @@ -0,0 +1,9 @@ +# Viewer i18n Source Split Evidence + +- Source trace: `.traces/codex-tools-20260509/2026-05-09/trace_081725.jsonl` +- Generated viewer: `/tmp/claude-tap-viewer-i18n-source.html` +- Screenshot: `viewer-i18n-source-codex-trace.png` +- Browser assertions: sidebar rendered, i18n bootstrap script embedded, and System Prompt, Messages, Tools, and Response sections were visible. +- Validation: + - `uv run python scripts/check_screenshots.py .agents/evidence/pr/viewer-i18n-source` + - `uv run python scripts/verify_screenshots.py /tmp/claude-tap-viewer-i18n-source.html` diff --git a/.agents/evidence/pr/viewer-i18n-source/viewer-i18n-source-codex-trace.png b/.agents/evidence/pr/viewer-i18n-source/viewer-i18n-source-codex-trace.png new file mode 100644 index 0000000..849835e Binary files /dev/null and b/.agents/evidence/pr/viewer-i18n-source/viewer-i18n-source-codex-trace.png differ diff --git a/.agents/evidence/pr/viewer-quality/README.md b/.agents/evidence/pr/viewer-quality/README.md new file mode 100644 index 0000000..bb83e0c --- /dev/null +++ b/.agents/evidence/pr/viewer-quality/README.md @@ -0,0 +1,25 @@ +# Viewer Quality Framework Evidence + +This evidence belongs to the viewer stability PR. The screenshots were generated +from deterministic contract traces after Playwright assertions confirmed that +the rendered viewer contained semantic sections, not only `Full JSON`. + +## Quality Gate + +Each screenshot was captured only after the browser test confirmed: + +- No `pageerror` or `console.error` was emitted. +- `Full JSON` existed as a fallback section. +- At least one semantic section existed before the fallback. +- Required semantic sections were present for the trace shape. +- Required tools and detail text were visible in the rendered DOM. + +## Screenshots + +- `anthropic_messages.png` — Anthropic Messages contract with tools, system prompt, + message history, tool result, response, and token usage. +- `codex_websocket.png` — Codex WebSocket contract with request context, reconstructed + response output, stream events, and token usage. +- `gemini.png` — Gemini contract with `systemInstruction`, `contents`, + `functionDeclarations`, `functionCall`, `functionResponse`, SSE output, and + Gemini usage metadata. diff --git a/.agents/evidence/pr/viewer-quality/anthropic_messages.png b/.agents/evidence/pr/viewer-quality/anthropic_messages.png new file mode 100644 index 0000000..9e64f1b Binary files /dev/null and b/.agents/evidence/pr/viewer-quality/anthropic_messages.png differ diff --git a/.agents/evidence/pr/viewer-quality/codex_websocket.png b/.agents/evidence/pr/viewer-quality/codex_websocket.png new file mode 100644 index 0000000..ca7ec2f Binary files /dev/null and b/.agents/evidence/pr/viewer-quality/codex_websocket.png differ diff --git a/.agents/evidence/pr/viewer-quality/gemini.png b/.agents/evidence/pr/viewer-quality/gemini.png new file mode 100644 index 0000000..cef38a1 Binary files /dev/null and b/.agents/evidence/pr/viewer-quality/gemini.png differ diff --git a/.agents/evidence/pr/viewer-search-duplicate-request-id.png b/.agents/evidence/pr/viewer-search-duplicate-request-id.png new file mode 100644 index 0000000..c6c589a Binary files /dev/null and b/.agents/evidence/pr/viewer-search-duplicate-request-id.png differ diff --git a/.agents/evidence/pr/viewer-search/quote-search.png b/.agents/evidence/pr/viewer-search/quote-search.png new file mode 100644 index 0000000..b84fae3 Binary files /dev/null and b/.agents/evidence/pr/viewer-search/quote-search.png differ diff --git a/.agents/evidence/pr/vscode-wrapper-open/codex-viewer-preview.png b/.agents/evidence/pr/vscode-wrapper-open/codex-viewer-preview.png new file mode 100644 index 0000000..0f20df0 Binary files /dev/null and b/.agents/evidence/pr/vscode-wrapper-open/codex-viewer-preview.png differ diff --git a/.agents/evidence/pr/win32-no-console-popup/README.md b/.agents/evidence/pr/win32-no-console-popup/README.md new file mode 100644 index 0000000..522cf60 --- /dev/null +++ b/.agents/evidence/pr/win32-no-console-popup/README.md @@ -0,0 +1,14 @@ +# Evidence: Windows subprocess CREATE_NO_WINDOW fix + +## What changed + +`_start_background_update()` and `update_main()` in `claude_tap/cli_update.py` +now pass `subprocess.CREATE_NO_WINDOW` on Windows to prevent a visible Python +console window from flashing during background auto-updates or explicit +`claude-tap update` runs. + +## Evidence + +- **win32-trace-evidence.png**: pytest output showing all 4 Windows branch + tests passing — verifying `CREATE_NO_WINDOW` is applied on `sys.platform == + "win32"` and absent on posix. diff --git a/.agents/evidence/pr/win32-no-console-popup/win32-trace-evidence.png b/.agents/evidence/pr/win32-no-console-popup/win32-trace-evidence.png new file mode 100644 index 0000000..3d2b296 Binary files /dev/null and b/.agents/evidence/pr/win32-no-console-popup/win32-trace-evidence.png differ diff --git a/.agents/evidence/pr/windows-pip-update-fix/README.md b/.agents/evidence/pr/windows-pip-update-fix/README.md new file mode 100644 index 0000000..6510a4c --- /dev/null +++ b/.agents/evidence/pr/windows-pip-update-fix/README.md @@ -0,0 +1,20 @@ +# Windows pip update evidence + +`dashboard-session.png` shows the real dashboard session created by the PR +checkout on Windows. The session used an isolated local database and custom +dashboard port: + +```powershell +$env:CLOUDTAP_DB = "D:\projects\goal\.tmp\pr319-evidence\claude-tap.sqlite3" +.\.venv\Scripts\python.exe -m claude_tap --tap-no-launch --tap-no-open ` + --tap-live-port 31927 ` + --tap-output-dir D:\projects\goal\.tmp\pr319-evidence\.traces +``` + +The screenshot was captured from `http://127.0.0.1:31927` after the production +startup path created the session and spawned the shared dashboard. It confirms +that the dashboard process is active at the custom address that the update +instructions must target. The isolated database and raw session data are not +committed. + +No API keys, prompts, or user data are present. diff --git a/.agents/evidence/pr/windows-pip-update-fix/dashboard-session.png b/.agents/evidence/pr/windows-pip-update-fix/dashboard-session.png new file mode 100644 index 0000000..20ee96a Binary files /dev/null and b/.agents/evidence/pr/windows-pip-update-fix/dashboard-session.png differ diff --git a/.agents/recordings/01_error_banner_and_sidebar.png b/.agents/recordings/01_error_banner_and_sidebar.png new file mode 100644 index 0000000..47c0bfa Binary files /dev/null and b/.agents/recordings/01_error_banner_and_sidebar.png differ diff --git a/.agents/recordings/02_sidebar_error_styling.png b/.agents/recordings/02_sidebar_error_styling.png new file mode 100644 index 0000000..93f48b8 Binary files /dev/null and b/.agents/recordings/02_sidebar_error_styling.png differ diff --git a/.agents/recordings/03_copy_button_fallback_success.png b/.agents/recordings/03_copy_button_fallback_success.png new file mode 100644 index 0000000..a8ba60a Binary files /dev/null and b/.agents/recordings/03_copy_button_fallback_success.png differ diff --git a/.agents/recordings/codex-demo.cast b/.agents/recordings/codex-demo.cast new file mode 100644 index 0000000..2ffda97 --- /dev/null +++ b/.agents/recordings/codex-demo.cast @@ -0,0 +1,25 @@ +{"version":3,"term":{"cols":80,"rows":24},"timestamp":1772259356,"command":"uv run python -m claude_tap --tap-client codex --tap-no-update-check -- exec 'List the Python files in this project, then count the total lines of code across all .py files. Show your work step by step.'","env":{"SHELL":"/bin/zsh"}} +[0.414, "o", "🔍 claude-tap v0.1.18 listening on http://127.0.0.1:51916\r\n📁 Trace file: .traces/trace_20260228_141557.jsonl\r\n"] +[0.000, "o", "\r\n🚀 Starting Codex CLI: codex --disable responses_websockets_v2 --disable responses_websockets exec List the Python files in this project, then count the total lines of code across all .py files. Show your work step by step.\r\n OPENAI_BASE_URL=http://127.0.0.1:51916/v1\r\n\r\n"] +[2.332, "o", "OpenAI Codex v0.106.0 (research preview)\r\n--------\r\nworkdir: /Users/liaohaochun/WorkSpace/code/src/github.com/liaohch3/claude-tap\r\nmodel: gpt-5.3-codex\r\nprovider: openai\r\napproval: never\r\nsandbox: workspace-write [workdir, /tmp, $TMPDIR]\r\nreasoning effort: none\r\nreasoning summaries: none\r\nsession id: 019ca2e3-bb53-71c0-90b8-07daa7e06a30\r\n--------\r\nuser\r\nList the Python files in this project, then count the total lines of code across all .py files. Show your work step by step.\r\n"] +[0.000, "o", "mcp startup: no servers\r\n"] +[10.666, "o", "\r\n"] +[0.034, "o", "thinking\r\n**Listing Python files**\r\n"] +[0.484, "o", "codex\r\nI’ll do this in two explicit steps: first enumerate every `.py` file in the repo, then run a line count across that exact file list and show the subtotal/total output.\r\n"] +[0.312, "o", "exec\r\n/bin/zsh -lc \"rg --files -g '*.py'\" in /Users/liaohaochun/WorkSpace/code/src/github.com/liaohch3/claude-tap"] +[0.054, "o", " succeeded in 53ms:\r\nclaude_tap/__main__.py\r\nclaude_tap/live.py\r\nclaude_tap/cli.py\r\nclaude_tap/sse.py\r\nclaude_tap/export.py\r\nclaude_tap/__init__.py\r\nclaude_tap/certs.py\r\nclaude_tap/forward_proxy.py\r\nclaude_tap/proxy.py\r\nclaude_tap/viewer.py\r\nclaude_tap/trace.py\r\ndocs/recordings/record_viewer.py\r\ndocs/gen_architecture.py\r\ntests/e2e/test_real_proxy.py\r\ntests/e2e/__init__.py\r\ntests/e2e/conftest.py\r\ntests/test_e2e.py\r\ntests/__init__.py\r\ntests/conftest.py\r\ntests/test_nav_browser.py\r\n"] +[3.165, "o", "\r\n"] +[0.112, "o", "thinking\r\n**Counting lines in Python files**\r\n"] +[0.361, "o", "codex\r\nI’ve got the full file list. Next I’ll run `wc -l` on that same list to show per-file line counts and the total.\r\n"] +[0.445, "o", "exec\r\n/bin/zsh -lc \"rg --files -g '*.py' | xargs wc -l\" in /Users/liaohaochun/WorkSpace/code/src/github.com/liaohch3/claude-tap"] +[0.053, "o", " succeeded in 53ms:\r\n 36 claude_tap/__main__.py\r\n 146 claude_tap/live.py\r\n 683 claude_tap/cli.py\r\n 104 claude_tap/sse.py\r\n 210 claude_tap/export.py\r\n 49 claude_tap/__init__.py\r\n 212 claude_tap/certs.py\r\n 521 claude_tap/forward_proxy.py\r\n 306 claude_tap/proxy.py\r\n 40 claude_tap/viewer.py\r\n 80 claude_tap/trace.py\r\n 170 docs/recordings/record_viewer.py\r\n 65 docs/gen_architecture.py\r\n 245 tests/e2e/test_real_proxy.py\r\n 1 tests/e2e/__init__.py\r\n 82 tests/e2e/conftest.py\r\n 2478 tests/test_e2e.py\r\n 0 tests/__init__.py\r\n 29 tests/conftest.py\r\n 312 tests/test_nav_browser.py\r\n"] +[9.634, "o", "codex\r\n1. Listed all Python files with:\r\n```bash\r\nrg --files -g '*.py'\r\n```\r\n\r\nPython files found:\r\n```text\r\nclaude_tap/__main__.py\r\nclaude_tap/live.py\r\nclaude_tap/cli.py\r\nclaude_tap/sse.py\r\nclaude_tap/export.py\r\nclaude_tap/__init__.py\r\nclaude_tap/certs.py\r\nclaude_tap/forward_proxy.py\r\nclaude_tap/proxy.py\r\nclaude_tap/viewer.py\r\nclaude_tap/trace.py\r\ndocs/recordings/record_viewer.py\r\ndocs/gen_architecture.py\r\ntests/e2e/test_real_proxy.py\r\ntests/e2e/__init__.py\r\ntests/e2e/conftest.py\r\ntests/test_e2e.py\r\ntests/__init__.py\r\ntests/conftest.py\r\ntests/test_nav_browser.py\r\ntests/test_diff_matching.py\r\n```\r\n\r\n2. Counted lines across that same list with:\r\n```bash\r\nrg --files -g '*.py' | xargs wc -l\r\n```\r\n\r\n`wc -l` output (per file + total):\r\n```text\r\n 36 claude_tap/__main__.py\r\n 146 claude_tap/live.py\r\n 683 claude_tap/cli.py\r\n 104 claude_tap/sse.py\r\n 210 claude_tap/export.py\r\n 49 claude_tap/__init__.py\r\n 212 claude_tap/certs.py\r\n 521 claude_tap/forward_proxy.py\r\n 306 claude_tap/prox"] +[0.000, "o", "y.py\r\n 40 claude_tap/viewer.py\r\n 80 claude_tap/trace.py\r\n 170 docs/recordings/record_viewer.py\r\n 65 docs/gen_architecture.py\r\n 245 tests/e2e/test_real_proxy.py\r\n 1 tests/e2e/__init__.py\r\n 82 tests/e2e/conftest.py\r\n 2478 tests/test_e2e.py\r\n 0 tests/__init__.py\r\n 29 tests/conftest.py\r\n 312 tests/test_nav_browser.py\r\n 446 tests/test_diff_matching.py\r\n 6215 total\r\n```\r\n\r\nTotal lines across all `.py` files: **6215**."] +[0.000, "o", "\r\n"] +[0.014, "o", "tokens used\r\n4,063\r\n1. Listed all Python files with:\r\n```bash\r\nrg --files -g '*.py'\r\n```\r\n\r\nPython files found:\r\n```text\r\nclaude_tap/__main__.py\r\nclaude_tap/live.py\r\nclaude_tap/cli.py\r\nclaude_tap/sse.py\r\nclaude_tap/export.py\r\nclaude_tap/__init__.py\r\nclaude_tap/certs.py\r\nclaude_tap/forward_proxy.py\r\nclaude_tap/proxy.py\r\nclaude_tap/viewer.py\r\nclaude_tap/trace.py\r\ndocs/recordings/record_viewer.py\r\ndocs/gen_architecture.py\r\ntests/e2e/test_real_proxy.py\r\ntests/e2e/__init__.py\r\ntests/e2e/conftest.py\r\ntests/test_e2e.py\r\ntests/__init__.py\r\ntests/conftest.py\r\ntests/test_nav_browser.py\r\ntests/test_diff_matching.py\r\n```\r\n\r\n2. Counted lines across that same list with:\r\n```bash\r\nrg --files -g '*.py' | xargs wc -l\r\n```\r\n\r\n`wc -l` output (per file + total):\r\n```text\r\n 36 claude_tap/__main__.py\r\n 146 claude_tap/live.py\r\n 683 claude_tap/cli.py\r\n 104 claude_tap/sse.py\r\n 210 claude_tap/export.py\r\n 49 claude_tap/__init__.py\r\n 212 claude_tap/certs.py\r\n 521 claude_tap/forward_proxy.py\r\n 306 cl"] +[0.000, "o", "aude_tap/proxy.py\r\n 40 claude_tap/viewer.py\r\n 80 claude_tap/trace.py\r\n 170 docs/recordings/record_viewer.py\r\n 65 docs/gen_architecture.py\r\n 245 tests/e2e/test_real_proxy.py\r\n 1 tests/e2e/__init__.py\r\n 82 tests/e2e/conftest.py\r\n 2478 tests/test_e2e.py\r\n 0 tests/__init__.py\r\n 29 tests/conftest.py\r\n 312 tests/test_nav_browser.py\r\n 446 tests/test_diff_matching.py\r\n 6215 total\r\n```\r\n\r\nTotal lines across all `.py` files: **6215**.\r\n"] +[0.015, "o", "\r\n📋 Codex CLI exited with code 0\r\n"] +[0.009, "o", "\r\n📊 Trace summary:\r\n API calls: 4\r\n Trace: .traces/trace_20260228_141557.jsonl\r\n Log: .traces/trace_20260228_141557.log\r\n View: .traces/trace_20260228_141557.html\r\n"] +[0.000, "o", "\r\n🌐 Opening viewer in browser..."] +[0.000, "o", "\r\n"] +[0.040, "x", "0"] diff --git a/.agents/recordings/codex-demo.gif b/.agents/recordings/codex-demo.gif new file mode 100644 index 0000000..38b74ee Binary files /dev/null and b/.agents/recordings/codex-demo.gif differ diff --git a/.agents/recordings/demo.mp4 b/.agents/recordings/demo.mp4 new file mode 100644 index 0000000..3190db6 Binary files /dev/null and b/.agents/recordings/demo.mp4 differ diff --git a/.agents/recordings/demo_zh.mp4 b/.agents/recordings/demo_zh.mp4 new file mode 100644 index 0000000..de76f35 Binary files /dev/null and b/.agents/recordings/demo_zh.mp4 differ diff --git a/.agents/recordings/diff-scroll-bottom.png b/.agents/recordings/diff-scroll-bottom.png new file mode 100644 index 0000000..a7ea0e4 Binary files /dev/null and b/.agents/recordings/diff-scroll-bottom.png differ diff --git a/.agents/recordings/diff-scroll-top.png b/.agents/recordings/diff-scroll-top.png new file mode 100644 index 0000000..d56be89 Binary files /dev/null and b/.agents/recordings/diff-scroll-top.png differ diff --git a/.agents/recordings/search-01-open.png b/.agents/recordings/search-01-open.png new file mode 100644 index 0000000..e3717b7 Binary files /dev/null and b/.agents/recordings/search-01-open.png differ diff --git a/.agents/recordings/search-02-highlight.png b/.agents/recordings/search-02-highlight.png new file mode 100644 index 0000000..83d9ff5 Binary files /dev/null and b/.agents/recordings/search-02-highlight.png differ diff --git a/.agents/recordings/search-03-navigate.png b/.agents/recordings/search-03-navigate.png new file mode 100644 index 0000000..3edbb2a Binary files /dev/null and b/.agents/recordings/search-03-navigate.png differ diff --git a/.agents/recordings/viewer-01-turn1-overview.png b/.agents/recordings/viewer-01-turn1-overview.png new file mode 100644 index 0000000..138abe0 Binary files /dev/null and b/.agents/recordings/viewer-01-turn1-overview.png differ diff --git a/.agents/recordings/viewer-02-tools-sse-expanded.png b/.agents/recordings/viewer-02-tools-sse-expanded.png new file mode 100644 index 0000000..8ed665f Binary files /dev/null and b/.agents/recordings/viewer-02-tools-sse-expanded.png differ diff --git a/.agents/recordings/viewer-03-request-json-scrolled.png b/.agents/recordings/viewer-03-request-json-scrolled.png new file mode 100644 index 0000000..0ff3f66 Binary files /dev/null and b/.agents/recordings/viewer-03-request-json-scrolled.png differ diff --git a/.agents/recordings/viewer-04-turn5.png b/.agents/recordings/viewer-04-turn5.png new file mode 100644 index 0000000..8926e15 Binary files /dev/null and b/.agents/recordings/viewer-04-turn5.png differ diff --git a/.agents/recordings/viewer-05-diff.png b/.agents/recordings/viewer-05-diff.png new file mode 100644 index 0000000..f760f46 Binary files /dev/null and b/.agents/recordings/viewer-05-diff.png differ diff --git a/.agents/recordings/viewer-06-curl.png b/.agents/recordings/viewer-06-curl.png new file mode 100644 index 0000000..8926e15 Binary files /dev/null and b/.agents/recordings/viewer-06-curl.png differ diff --git a/.agents/recordings/viewer-07-turn10.png b/.agents/recordings/viewer-07-turn10.png new file mode 100644 index 0000000..4dd485f Binary files /dev/null and b/.agents/recordings/viewer-07-turn10.png differ diff --git a/.agents/recordings/viewer-08-dark-mode.png b/.agents/recordings/viewer-08-dark-mode.png new file mode 100644 index 0000000..90c6e27 Binary files /dev/null and b/.agents/recordings/viewer-08-dark-mode.png differ diff --git a/.agents/recordings/viewer-09-sidebar-scrolled.png b/.agents/recordings/viewer-09-sidebar-scrolled.png new file mode 100644 index 0000000..6dcea98 Binary files /dev/null and b/.agents/recordings/viewer-09-sidebar-scrolled.png differ diff --git a/.agents/recordings/viewer-10-last-turn.png b/.agents/recordings/viewer-10-last-turn.png new file mode 100644 index 0000000..d8f90cc Binary files /dev/null and b/.agents/recordings/viewer-10-last-turn.png differ diff --git a/.agents/recordings/viewer-11-final-wide.png b/.agents/recordings/viewer-11-final-wide.png new file mode 100644 index 0000000..d8f90cc Binary files /dev/null and b/.agents/recordings/viewer-11-final-wide.png differ diff --git a/.agents/recordings/viewer-codex-01-overview.png b/.agents/recordings/viewer-codex-01-overview.png new file mode 100644 index 0000000..36f09dd Binary files /dev/null and b/.agents/recordings/viewer-codex-01-overview.png differ diff --git a/.agents/recordings/viewer-codex-02-messages.png b/.agents/recordings/viewer-codex-02-messages.png new file mode 100644 index 0000000..b3ed398 Binary files /dev/null and b/.agents/recordings/viewer-codex-02-messages.png differ diff --git a/.agents/recordings/viewer-codex-02-system-prompt.png b/.agents/recordings/viewer-codex-02-system-prompt.png new file mode 100644 index 0000000..46b0c9f Binary files /dev/null and b/.agents/recordings/viewer-codex-02-system-prompt.png differ diff --git a/.agents/recordings/viewer-codex-03-messages.png b/.agents/recordings/viewer-codex-03-messages.png new file mode 100644 index 0000000..f2588ce Binary files /dev/null and b/.agents/recordings/viewer-codex-03-messages.png differ diff --git a/.agents/recordings/viewer-codex-03-scrolled.png b/.agents/recordings/viewer-codex-03-scrolled.png new file mode 100644 index 0000000..e874202 Binary files /dev/null and b/.agents/recordings/viewer-codex-03-scrolled.png differ diff --git a/.agents/recordings/viewer-codex-04-response.png b/.agents/recordings/viewer-codex-04-response.png new file mode 100644 index 0000000..bdb9c8d Binary files /dev/null and b/.agents/recordings/viewer-codex-04-response.png differ diff --git a/.agents/recordings/viewer-codex-04-turn3.png b/.agents/recordings/viewer-codex-04-turn3.png new file mode 100644 index 0000000..e7e69d8 Binary files /dev/null and b/.agents/recordings/viewer-codex-04-turn3.png differ diff --git a/.agents/recordings/viewer-codex-05-diff.png b/.agents/recordings/viewer-codex-05-diff.png new file mode 100644 index 0000000..c6eda40 Binary files /dev/null and b/.agents/recordings/viewer-codex-05-diff.png differ diff --git a/.agents/recordings/viewer-codex-05-tokens.png b/.agents/recordings/viewer-codex-05-tokens.png new file mode 100644 index 0000000..46b0c9f Binary files /dev/null and b/.agents/recordings/viewer-codex-05-tokens.png differ diff --git a/.agents/recordings/viewer-codex-06-diff.png b/.agents/recordings/viewer-codex-06-diff.png new file mode 100644 index 0000000..8407b08 Binary files /dev/null and b/.agents/recordings/viewer-codex-06-diff.png differ diff --git a/.agents/recordings/viewer-codex-08-tools.png b/.agents/recordings/viewer-codex-08-tools.png new file mode 100644 index 0000000..c4d879f Binary files /dev/null and b/.agents/recordings/viewer-codex-08-tools.png differ diff --git a/.agents/recordings/viewer-demo.mp4 b/.agents/recordings/viewer-demo.mp4 new file mode 100644 index 0000000..2b4ed16 Binary files /dev/null and b/.agents/recordings/viewer-demo.mp4 differ diff --git a/.agents/skills/codex-e2e-test/SKILL.md b/.agents/skills/codex-e2e-test/SKILL.md new file mode 100644 index 0000000..14108b5 --- /dev/null +++ b/.agents/skills/codex-e2e-test/SKILL.md @@ -0,0 +1,312 @@ +--- +name: codex-e2e-test +description: Run PR-grade real Codex E2E validation through claude-tap, including resume turns, multiple tool calls, optional image input, viewer verification, and screenshot evidence. +tags: testing, e2e, codex, responses-api +--- + +# Codex E2E Test Skill + +Run real end-to-end validation that starts `claude-tap` from local source, +connects to the real Codex CLI via OAuth, captures OpenAI Responses API traces, +and produces viewer screenshots suitable for PR evidence. + +Use this skill for every PR that changes capture, proxying, viewer rendering, +session/dashboard behavior, client launch logic, trace ordering, content blocks, +tools, token usage, or screenshot/demo assets. If a PR cannot run this flow, +state why in the PR and cover the same risk with another real client trace. + +## Prerequisites + +- `codex` CLI installed (`npm install -g @openai/codex`) and authenticated via OAuth +- Python dev dependencies: `uv sync --extra dev` +- Playwright installed: `uv run playwright install chromium` + +Verify OAuth works: + +```bash +codex exec "say hello" --dangerously-bypass-approvals-and-sandbox +``` + +If it fails with token errors, re-authenticate: + +```bash +codex auth login +``` + +## Key Difference from Claude E2E + +Codex uses the **OpenAI Responses API** (`/v1/responses`) instead of Anthropic Messages API. +With OAuth authentication, the upstream is `https://chatgpt.com/backend-api/codex`, +**not** `https://api.openai.com`. + +The proxy must be told the correct target with `--tap-target`. + +## Run a Real Codex E2E Trace + +Prefer the resume + multimodal flow below for PR evidence. The simple commands +are only smoke tests for checking local setup. + +### Simple (single tool call) + +```bash +claude-tap --tap-client codex \ + --tap-target https://chatgpt.com/backend-api/codex \ + --tap-output-dir /tmp/codex-e2e \ + --tap-no-open \ + -- exec "say hello" \ + --dangerously-bypass-approvals-and-sandbox +``` + +### Multi-call (triggers multiple API requests) + +Use a task that requires shell tool use — this forces the agent to make multiple +Responses API calls (models lookup + actual responses): + +```bash +claude-tap --tap-client codex \ + --tap-target https://chatgpt.com/backend-api/codex \ + --tap-output-dir /tmp/codex-e2e \ + --tap-no-open \ + -- exec "Read pyproject.toml and tell me the project name and version" \ + --dangerously-bypass-approvals-and-sandbox +``` + +Expected: 4+ API calls (2x `GET /v1/models` + 2x `POST /v1/responses`). + +## Resume + Multimodal Content-Block Trace + +Use this flow for viewer changes that affect message rendering, content block +boundaries, tool call ordering, images, or copy/select behavior. It creates a +real Codex session, resumes it at least once, forces multiple shell tool calls +per user turn, and attaches an actual image so the trace includes multimodal +content. This is the default PR evidence flow. + +### 1. Prepare an isolated workspace + +```bash +mkdir -p /tmp/claude-tap-real-codex-workspace +printf 'project = "claude-tap-real-codex-e2e"\n' \ + > /tmp/claude-tap-real-codex-workspace/project.toml +``` + +Create or copy a small valid PNG into the workspace. If you need a deterministic +local image, generate it with Python's standard library: + +```bash +python3 - <<'PY' +from pathlib import Path +import struct +import zlib + +def chunk(kind: bytes, data: bytes) -> bytes: + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + +width = height = 8 +rows = b"".join(b"\x00" + (b"\x2f\x80\xed\xff" * width) for _ in range(height)) +png = ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") +) +Path("/tmp/claude-tap-real-codex-workspace/input.png").write_bytes(png) +PY +``` + +### 2. Start a real Codex session through claude-tap + +Run from the repository, but point Codex at the isolated workspace with `-C`. +The prompt should explicitly require several tool calls so the viewer has +enough messages, tool calls, and response blocks to inspect. + +```bash +uv run claude-tap --tap-client codex \ + --tap-target https://chatgpt.com/backend-api/codex \ + --tap-output-dir /tmp/claude-tap-real-codex-traces \ + --tap-no-open \ + -- exec -C /tmp/claude-tap-real-codex-workspace \ + --image /tmp/claude-tap-real-codex-workspace/input.png \ + --dangerously-bypass-approvals-and-sandbox \ + "Inspect this workspace and the attached image. Use shell tools to run pwd, list files, inspect project.toml, inspect input.png, then write codex_e2e_report.txt with your findings. Keep all writes inside this workspace." +``` + +### 3. Resume the same Codex session with another real turn + +Use the session id printed by the first Codex run when possible. Avoid relying +on `--last` on busy maintainer machines because it can resume an unrelated +recent Codex session. + +```bash +uv run claude-tap --tap-client codex \ + --tap-target https://chatgpt.com/backend-api/codex \ + --tap-output-dir /tmp/claude-tap-real-codex-traces \ + --tap-no-open \ + -- exec resume \ + --image /tmp/claude-tap-real-codex-workspace/input.png \ + --dangerously-bypass-approvals-and-sandbox \ + "Continue the same investigation in /tmp/claude-tap-real-codex-workspace. Use shell tools to read /tmp/claude-tap-real-codex-workspace/codex_e2e_report.txt, compute the byte size of /tmp/claude-tap-real-codex-workspace/input.png, and write /tmp/claude-tap-real-codex-workspace/codex_e2e_followup.txt. Then summarize what changed since the previous turn." +``` + +### 4. Capture multi-position viewer screenshots + +Take screenshots at multiple scroll positions, including a deeper position in +the same detail pane. Store them under `.agents/evidence/pr//` and use +`raw.githubusercontent.com` links in the PR body. + +```bash +mkdir -p .agents/evidence/pr/codex-real-e2e + +uv run python - <<'PY' +from pathlib import Path +from playwright.sync_api import sync_playwright + +html_files = sorted(Path("/tmp/claude-tap-real-codex-traces").rglob("trace_*.html")) +if not html_files: + raise SystemExit("No viewer HTML found in /tmp/claude-tap-real-codex-traces") +html = html_files[-1] +out_dir = Path(".agents/evidence/pr/codex-real-e2e") + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1760, "height": 1100}, device_scale_factor=1) + page.goto(f"file://{html}", wait_until="domcontentloaded", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.evaluate("document.documentElement.setAttribute('data-theme', 'light')") + + # Select the last Responses call so resume context is visible. + page.evaluate( + """() => { + const items = Array.from(document.querySelectorAll('.sidebar-item')); + const responses = items.filter(item => item.textContent.includes('/v1/responses')); + (responses.at(-1) || items.at(-1))?.click(); + }""" + ) + page.wait_for_timeout(300) + + page.evaluate( + """() => { + for (const section of document.querySelectorAll('#detail .section')) { + const title = section.querySelector('.title')?.textContent || ''; + const body = section.querySelector('.section-body'); + const header = section.querySelector('.section-header'); + if (!body || !header) continue; + const shouldOpen = ['System Prompt', 'Messages', 'Response'].includes(title); + const isOpen = body.classList.contains('open'); + if (shouldOpen !== isOpen) header.click(); + } + }""" + ) + page.wait_for_timeout(200) + + def shot(name: str, scroll_top: int) -> None: + page.evaluate("y => { const d = document.querySelector('#detail'); if (d) d.scrollTop = y; }", scroll_top) + page.wait_for_timeout(200) + page.screenshot(path=str(out_dir / name), full_page=False) + + shot("codex-real-top.png", 0) + shot("codex-real-mid.png", 700) + shot("codex-real-deep.png", 1400) + + image_count = page.evaluate( + """() => { + const img = document.querySelector('#detail img'); + if (!img) return 0; + img.scrollIntoView({ block: 'center', inline: 'nearest' }); + return document.querySelectorAll('#detail img').length; + }""" + ) + if image_count: + page.wait_for_timeout(200) + page.screenshot(path=str(out_dir / "codex-real-image.png"), full_page=False) + + browser.close() +PY +``` + +Validate screenshots: + +```bash +uv run python scripts/check_screenshots.py .agents/evidence/pr/codex-real-e2e +``` + +### 5. Verify the trace + +- The output directory contains at least two real `.jsonl` traces and generated + `.html` viewers from `claude-tap`. +- The trace includes multiple `POST /v1/responses` entries with status 200 and + non-zero token usage. +- At least one request contains image content from the `--image` attachment. +- The viewer shows Messages and Response sections with multiple content blocks + separated cleanly. +- Tool calls and tool results stay interleaved in the order they happened. +- Copy buttons still copy the complete logical message/section text, not only + one visual content block. +- Text selection across adjacent content blocks remains possible in the browser. +- Screenshot evidence includes at least two scroll depths for the same resumed + trace, not only the top of the page. +- If the trace contains image input, screenshot evidence includes the rendered + image block or records why the client did not send image content into the API + request body. + +## Taking Viewer Screenshots with Playwright + +```python +from playwright.sync_api import sync_playwright +import time, glob + +html = glob.glob("/tmp/codex-e2e/trace_*.html")[-1] + +with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page(viewport={"width": 1440, "height": 1000}) + page.goto(f"file://{html}") + page.wait_for_load_state("networkidle") + time.sleep(1) + + # Select a Responses call (data-idx matches trace line index) + page.click('.sidebar-item[data-idx="1"]') + time.sleep(0.5) + + # Collapse System Prompt, keep Messages open + page.evaluate("""() => { + const h = document.querySelectorAll('.section-header')[1]; + const next = h.nextElementSibling; + if (next && getComputedStyle(next).display !== 'none') h.click(); + }""") + + # Scroll to Messages section + page.evaluate("""() => { + document.querySelectorAll('.section-header')[2] + .scrollIntoView({behavior: 'instant', block: 'start'}); + }""") + time.sleep(0.3) + page.screenshot(path="/tmp/codex-e2e/messages.png") + + browser.close() +``` + +## Verification Checklist + +- [ ] Trace `.jsonl` has ≥2 `POST /v1/responses` entries +- [ ] Response status is 200 (not 401/502) +- [ ] Token counts are non-zero in Responses calls +- [ ] HTML viewer is generated (`trace_*.html`) +- [ ] Sidebar shows multiple calls with model name and token counts +- [ ] Messages section shows `user` message text (verifies #41 fix) +- [ ] Response section shows assistant reply (verifies #40 fix) + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| WebSocket 502 then HTTP 401 | Default target `api.openai.com` rejects ChatGPT OAuth tokens | Use `--tap-target https://chatgpt.com/backend-api/codex` | +| `Missing scopes: api.responses.write` | API key lacks Responses API access | Use OAuth (`codex auth login`) instead of `OPENAI_API_KEY` | +| Only 1 API call | Simple prompt completed in one round | Use a task requiring tool use (file reads, shell commands) | +| `OPENAI_BASE_URL is deprecated` warning | Codex v0.115+ prefers config.toml | Harmless — proxy still works via env var | + +## Notes + +- Codex with OAuth uses WebSocket first, then falls back to HTTP/SSE when proxied. + The fallback is transparent — traces capture the HTTP/SSE path correctly. +- Each `codex exec` session also calls `GET /v1/models` for model discovery. +- The `--dangerously-bypass-approvals-and-sandbox` flag is required for non-interactive exec. diff --git a/.agents/skills/demo-video/SKILL.md b/.agents/skills/demo-video/SKILL.md new file mode 100644 index 0000000..f7e2759 --- /dev/null +++ b/.agents/skills/demo-video/SKILL.md @@ -0,0 +1,86 @@ +--- +name: demo-video +description: Generate demo assets (GIF/MP4) from real tmux E2E runs and viewer screenshots using asciinema and Playwright +user_invocable: true +--- + +# Skill: Demo Video Generation + +Generate demo assets from a real tmux E2E run and viewer screenshots. + +## Proven Workflow + +### 1) Record terminal session in tmux with asciinema + +```bash +tmux new-session -d -s demo -x 160 -y 46 +tmux send-keys -t demo "asciinema rec /tmp/claude-tap-recordings/demo.cast" Enter +tmux send-keys -t demo "cd /path/to/claude-tap && scripts/run_real_e2e_tmux.sh" Enter +# ... wait until run finishes ... +tmux send-keys -t demo "exit" Enter +``` + +Notes: + +- `Enter` is the submit key for Claude Code TUI in tmux. +- Use tool-triggering first prompt text so trace includes `tool_use`. + +### 2) Convert `.cast` to GIF with `agg` + +```bash +agg /tmp/claude-tap-recordings/demo.cast /tmp/claude-tap-recordings/demo.gif +``` + +### 3) Convert GIF to MP4 with ffmpeg + +```bash +ffmpeg -y -i /tmp/claude-tap-recordings/demo.gif -movflags +faststart -pix_fmt yuv420p .agents/recordings/demo.mp4 +``` + +## Browser Screenshots (HTML Viewer) + +Use Playwright CDP to attach to a running Chrome/Chromium instance on port `9222`. + +```python +browser = playwright.chromium.connect_over_cdp("http://127.0.0.1:9222") +page = browser.contexts[0].pages[0] +``` + +### Reliable UI interactions + +- Click entries by visible text content, for example: + +```python +page.query_selector('text="轮次 22"').click() +``` + +- Open diff view by clicking button text: + +```python +page.query_selector('text="对比上次"').click() +``` + +- For SPA/overflow layouts, scroll actual scrollable containers (not `window`): + +```python +page.evaluate(""" +() => { + for (const el of document.querySelectorAll('*')) { + if (el.scrollHeight > el.clientHeight) { + el.scrollTop += 300; + } + } +} +""") +``` + +## Output Targets + +- `docs/demo.gif` +- `.agents/recordings/demo.mp4` +- Optional localized variants (`docs/demo_zh.gif`, `.agents/recordings/demo_zh.mp4`) + +## Avoid + +- Do not reference non-existent scripts such as `cast_to_gif_ultra.py` or `make_final_demo_v2.py`. +- Do not hardcode selectors like `.detail` unless verified in the current viewer build. diff --git a/.agents/skills/e2e-test/SKILL.md b/.agents/skills/e2e-test/SKILL.md new file mode 100644 index 0000000..06c966d --- /dev/null +++ b/.agents/skills/e2e-test/SKILL.md @@ -0,0 +1,40 @@ +--- +name: e2e-test +description: Run claude-tap end-to-end tests with pytest +user_invocable: true +--- + +# claude-tap E2E Test + +Run this skill after modifying core logic in claude-tap, especially: +- Proxy handler / SSE reassembly (`__init__.py`) +- TraceWriter (JSONL writing, flush behavior) +- HTML viewer generation (`viewer.html`, `_generate_html_viewer`) +- LiveViewerServer (SSE streaming) +- Signal handling / graceful shutdown +- Manual update command / trace cleanup + +## Steps + +1. Run the full test suite: + +```bash +uv run pytest tests/test_e2e.py -v --timeout=120 +``` + +Or run a single test: + +```bash +uv run pytest tests/test_e2e.py::test_e2e -v # Full E2E pipeline +uv run pytest tests/test_e2e.py::test_trace_cleanup -v # Trace cleanup +uv run pytest tests/test_e2e.py::test_startup_does_not_contact_pypi -v # No startup update network +``` + +2. Read the output. Each test prints `PASSED` or `FAILED`. + +3. If tests fail, check: + - **test_e2e fails**: Core proxy pipeline issue. Check `proxy_handler`, `_handle_streaming`, `TraceWriter.write`. + - **test_trace_cleanup fails**: Manifest logic issue. Check `_load_manifest`, `_cleanup_traces`, `_register_trace`. + - **test_startup_does_not_contact_pypi fails**: Startup made an unexpected update-related network request. + - **test_live_viewer_* fails**: Viewer HTML issues. Check `viewer.html` for `preserveDetail` chain, `updateNavButtons`. + - **Timeout**: May be a network/port issue, not a claude-tap bug. diff --git a/.agents/skills/js-in-html-testing/SKILL.md b/.agents/skills/js-in-html-testing/SKILL.md new file mode 100644 index 0000000..ec1a8c5 --- /dev/null +++ b/.agents/skills/js-in-html-testing/SKILL.md @@ -0,0 +1,122 @@ +--- +name: js-in-html-testing +description: Test JS logic embedded in HTML using two-layer strategy - Python unit tests + Playwright browser integration tests +user_invocable: false +--- + +# JS-in-HTML Two-Layer Testing Strategy + +For JavaScript logic embedded in HTML files (e.g., diff navigation in viewer.html), use a two-layer testing approach. + +## Layer 1: Python Unit Tests (fast algorithm verification) + +Replicate core JS algorithms in Python and verify correctness via pytest. + +Best for: pure computation logic, state decisions, matching algorithms — anything that doesn't depend on the DOM. + +**Example**: `tests/test_diff_matching.py` + +```python +# Replicate JS findPrevSameModel / findNextSameModel +def find_diff_parent_by_prefix(entries, idx): + ... + +def find_next_by_prefix(entries, idx): + ... + +# Replicate JS updateNavButtons state computation +def compute_nav_button_states(entries, cur_idx): + prev_idx = find_diff_parent_by_prefix(entries, cur_idx) + ... + return (prev_enabled, next_enabled) +``` + +Advantages: fast (0.02s), no browser dependency, integrates with existing pytest setup. + +## Layer 2: Playwright Browser Integration Tests (DOM interaction verification) + +Generate HTML with test data embedded, open it in real Chromium via Playwright, and verify DOM state and user interactions. + +Best for: button disabled states, overlay show/hide, keyboard events, click navigation, etc. + +**Example**: `tests/test_nav_browser.py` + +### Building test HTML + +```python +def _build_test_html(): + from claude_tap.viewer import VIEWER_SCRIPT_ANCHOR, _read_viewer_template + + template = _read_viewer_template() + records = [json.dumps(e) for e in TEST_ENTRIES] + data_js = "const EMBEDDED_TRACE_DATA = [\n" + ",\n".join(records) + "\n];\n" + # Inject data into template + return template.replace( + VIEWER_SCRIPT_ANCHOR, + f"\n{VIEWER_SCRIPT_ANCHOR}", + 1, + ) +``` + +### Playwright fixture + +```python +@pytest.fixture(scope="module") +def browser_page(html_file): + from playwright.sync_api import sync_playwright + pw = sync_playwright().start() + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{html_file}") + page.wait_for_selector(".sidebar-item", timeout=5000) + yield page + browser.close() + pw.stop() +``` + +### Verifying DOM state + +```python +def _get_nav_state(page): + return page.evaluate("""() => { + const overlay = document.querySelector('.diff-overlay'); + if (!overlay) return { overlayExists: false }; + return { + overlayExists: true, + prevDisabled: overlay.querySelector('.diff-nav-prev')?.disabled, + nextDisabled: overlay.querySelector('.diff-nav-next')?.disabled, + title: overlay.querySelector('.diff-title')?.textContent, + }; + }""") +``` + +### Simulating user interaction + +```python +# Click buttons +page.click(".diff-nav-next") +# Keyboard navigation +page.keyboard.press("ArrowRight") +# Call internal JS functions +page.evaluate("selectEntry(2)") +page.evaluate("showDiff()") +``` + +## Notes + +- Trace data may contain `` text (e.g., when Claude discusses code), which breaks `", '')` +- Test data must match the viewer's expected JSONL format (including `turn`, `duration_ms`, `request_id`, `request.path`, etc.) +- Playwright requires `uv pip install playwright` + +## Running + +```bash +# Fast unit tests +uv run pytest tests/test_diff_matching.py -v + +# Browser integration tests +uv run pytest tests/test_nav_browser.py -v + +# All (excluding slow e2e) +uv run pytest tests/ --ignore=tests/test_e2e.py -v +``` diff --git a/.agents/skills/legibility-check/SKILL.md b/.agents/skills/legibility-check/SKILL.md new file mode 100644 index 0000000..3bb356c --- /dev/null +++ b/.agents/skills/legibility-check/SKILL.md @@ -0,0 +1,45 @@ +--- +name: legibility-check +description: Validate maintainer docs structure, standards freshness, manifest paths, and plan state. Run this after modifying any file under .agents/docs/standards/, .agents/docs/plans/, .agents/docs/architecture/, or AGENTS.md — it catches stale metadata, broken manifest paths, and plan state drift before CI does. +user_invocable: true +--- + +# Legibility Check + +Run deterministic checks for maintainer docs that mirror what CI enforces via `.github/workflows/legibility.yml`. Catching these locally saves a round-trip to CI. + +## What it checks + +1. **Standards freshness** — every `.agents/docs/standards/*.md` must have frontmatter with `owner`, `last_reviewed` (ISO date), and `source_of_truth`. Files reviewed more than 60 days ago produce a warning. +2. **Architecture manifest** — every path listed in `.agents/docs/architecture/manifest.yaml` under `expected_paths:` must exist in the repo. +3. **Plan state drift** — every `.agents/docs/plans/**/*.md` must have a `status` frontmatter field (`active`, `completed`, or `cancelled`). Completed plans must not contain unchecked `- [ ]` checkboxes (outside fenced code blocks). + +## Run + +```bash +uv run python scripts/check_legibility.py +``` + +Options: +- `--freshness-days N` — change the staleness threshold (default: 60) +- `--strict-freshness` — promote stale warnings to failures +- `--repo-root PATH` — override repo root (default: cwd) + +## Fixing common failures + +| Failure | Fix | +|---------|-----| +| `missing frontmatter key 'X'` | Add the missing key to the YAML frontmatter block at the top of the file | +| `last_reviewed must be ISO date` | Use `YYYY-MM-DD` format | +| `last_reviewed ... is stale` | Update `last_reviewed` to today's date after reviewing the content | +| `expected path missing: X` | Either create the file or remove the stale entry from `manifest.yaml` | +| `status must be one of [...]` | Add `status: active` (or `completed`/`cancelled`) to plan frontmatter | +| `completed plan still contains unchecked TODO` | Check off remaining items or change status back to `active` | + +## After fixing + +Re-run the check to confirm all issues are resolved before committing: + +```bash +uv run python scripts/check_legibility.py && echo "All clear" +``` diff --git a/.agents/skills/playwright-screen-recording/SKILL.md b/.agents/skills/playwright-screen-recording/SKILL.md new file mode 100644 index 0000000..9888fc2 --- /dev/null +++ b/.agents/skills/playwright-screen-recording/SKILL.md @@ -0,0 +1,92 @@ +--- +name: playwright-screen-recording +description: Record browser test videos with Playwright for PR review and bug fix verification +user_invocable: false +--- + +# Playwright Screen Recording for Test Verification + +Use Playwright's video recording to capture headless browser operations as .webm videos for PR review or bug fix verification. + +## Core Usage + +```python +from playwright.sync_api import sync_playwright +import tempfile +from pathlib import Path + +video_dir = Path(tempfile.mkdtemp()) + +with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": 1400, "height": 900}, + record_video_dir=str(video_dir), + record_video_size={"width": 1400, "height": 900}, + ) + page = context.new_page() + page.goto(f"file:///path/to/test.html") + + # ... perform test actions, add pauses for readability ... + page.wait_for_timeout(800) # pause so viewers can see the current state + + page.close() + context.close() # video is finalized after context.close() + browser.close() + +# Retrieve the recorded video +videos = list(video_dir.glob("*.webm")) +if videos: + videos[0].rename("demo.webm") +``` + +## Use Cases + +- **Bug fix verification**: record before/after comparisons showing button state changes, UI behavior differences +- **PR Review**: attach .webm video so reviewers can visually understand the change +- **Regression test evidence**: record critical interaction paths as visual proof of passing tests + +## Recording Tips + +### Add pauses between actions + +```python +page.click(".some-button") +page.wait_for_timeout(800) # let viewers see the click effect + +page.keyboard.press("ArrowRight") +page.wait_for_timeout(600) # let viewers see the navigation result +``` + +### Combine assertions with terminal logging + +```python +state = get_nav_state(page) +print(f"[1] Title: {state['title']}") +print(f" prev disabled: {state['prevDisabled']} (expected: True)") +assert state["prevDisabled"] is True +print(" PASS") +``` + +Terminal output paired with the recorded video provides dual verification. + +### Prefer real data + +Use existing real trace data from the project rather than synthetic data for more convincing demos: + +```python +# Build test HTML from a real trace file +records = [] +with open(".traces/trace_xxx.jsonl") as f: + for line in f: + # Escape to prevent breaking the HTML script block + records.append(line.strip().replace("", '')) +``` + +## Notes + +- Video format is `.webm` (VP8 codec), supported by most players and browsers +- Each `page` produces a separate video file +- `record_video_size` controls video resolution — keep it consistent with `viewport` +- Recording works in headless mode, no display required +- Video files are typically a few hundred KB, suitable for attaching to PRs or chat diff --git a/.agents/skills/pr-preflight/SKILL.md b/.agents/skills/pr-preflight/SKILL.md new file mode 100644 index 0000000..2b335c9 --- /dev/null +++ b/.agents/skills/pr-preflight/SKILL.md @@ -0,0 +1,79 @@ +--- +name: pr-preflight +description: Full pre-PR merge-readiness check. Run this before opening or merging a pull request — it validates local gates (lint, format, tests), CI status, screenshot evidence, and PR metadata in one pass. Also useful for reviewing an existing PR's readiness. +user_invocable: true +--- + +# PR Preflight + +One-command merge-readiness check that combines local gates, CI status, and PR policy checks. Mirrors what reviewers look for so issues are caught before review, not during. + +## Check an existing PR + +```bash +scripts/check_pr.sh +``` + +This runs: +1. **PR metadata** — fetches title, state, draft status, merge state, branch info +2. **CI checks** — counts pass/fail/pending GitHub Actions checks +3. **Local gates** — runs lint, format, and tests locally: + - `uv run ruff check .` + - `uv run ruff format --check .` + - `uv run pytest tests/ -x --timeout=60` +4. **PR body policy** — validates required sections, evidence links, and blocked artifacts +5. **Verdict** — `READY` or `NOT_READY` with specific reasons + +### Options + +| Flag | Purpose | +|------|---------| +| `--repo OWNER/REPO` | Override repository (default: auto-detect via `gh`) | +| `--no-tests` | Skip local test gates (useful when you just want CI + metadata check) | + +### Exit codes + +| Code | Meaning | +|------|---------| +| 0 | All checks passed — ready to merge | +| 1 | Script error (missing tool, network failure) | +| 2 | Not ready — at least one check failed | + +## Run local gates only (no PR needed) + +If you haven't opened a PR yet and just want to validate locally: + +```bash +uv run ruff check . && uv run ruff format --check . && uv run pytest tests/ -x --timeout=60 +``` + +Or use the pre-commit hook (auto-runs lint on commit): + +```bash +git config core.hooksPath .githooks +``` + +## What blocks merge + +The script reports `NOT_READY` if any of these are true: +- PR is not in OPEN state +- PR is still a draft +- Merge state is not CLEAN or HAS_HOOKS +- Any CI check is failing +- Any CI check is still pending +- Local gates (lint/format/tests) fail +- PR body policy fails, such as missing evidence for runtime/viewer/client changes + +## Typical workflow + +```bash +# 1. Make sure local gates pass +uv run ruff check . && uv run ruff format --check . && uv run pytest tests/ -x --timeout=60 + +# 2. Push and open PR +git push origin my-branch +gh pr create --title "feat: ..." --body "..." + +# 3. Wait for CI, then run full preflight +scripts/check_pr.sh +``` diff --git a/.agents/skills/push-release/SKILL.md b/.agents/skills/push-release/SKILL.md new file mode 100644 index 0000000..b669e53 --- /dev/null +++ b/.agents/skills/push-release/SKILL.md @@ -0,0 +1,46 @@ +--- +name: push-release +description: Push to GitHub and optionally bump version to trigger PyPI release +user_invocable: true +--- + +# Push & Release + +Push code to GitHub. If the pending commits contain feature changes, bump the version number so CI auto-publishes to PyPI. + +## Workflow + +1. **Check working tree**: Ensure no uncommitted changes (prompt user to commit first if dirty). + +2. **Determine whether a version bump is needed**: + - Read current version from `pyproject.toml` + - Run `git log origin/main..HEAD --oneline` to inspect pending commits + - If commits include feature changes (feat/fix/refactor, not purely docs/chore/test), a bump is needed + - If only docs, tests, or CI changes, skip the bump + +3. **If bump is needed**: + - Choose bump level based on change type: + - **patch** (0.1.4 → 0.1.5): bug fixes, minor improvements + - **minor** (0.1.4 → 0.2.0): new features + - **major** (0.1.4 → 1.0.0): breaking changes + - Update the `version` field in `pyproject.toml` + - Update `__version__` in `claude_tap/__init__.py` + - `git commit --amend` to fold the version bump into the last commit (avoids extra commits) + +4. **Push**: + ```bash + git push origin main + ``` + +5. **Confirm CI status**: + - Inform the user that CI will automatically: lint → test → auto-tag → PyPI publish + - Provide the GitHub Actions link: https://github.com/liaohch3/claude-tap/actions + +## Important: Version Bump = PyPI Release + +The CI pipeline works as follows: push to main → auto-tag (only if version changed) → PyPI publish (triggered by new tag). + +**A version bump is the ONLY way to trigger a new PyPI release.** If you push without bumping the version, CI will skip tagging and nothing gets published. So whenever commits include meaningful code changes (features, fixes, improvements), you MUST bump the version before pushing. + +- Version numbers in `pyproject.toml` and `claude_tap/__init__.py` must stay in sync +- Only skip the bump for pure docs/test/CI changes that don't affect the published package diff --git a/.agents/skills/real-e2e-test/SKILL.md b/.agents/skills/real-e2e-test/SKILL.md new file mode 100644 index 0000000..bc4c0f2 --- /dev/null +++ b/.agents/skills/real-e2e-test/SKILL.md @@ -0,0 +1,83 @@ +--- +name: real-e2e-test +description: Run real E2E tests against Claude CLI in pytest and tmux modes +tags: testing, e2e, integration, tmux +--- + +# Real E2E Test Skill + +Run real end-to-end tests that start `claude-tap` from local source, connect to the +real Claude CLI, and verify trace output. + +## Prerequisites + +- `claude` CLI installed and authenticated +- Python dev dependencies installed: `uv sync --extra dev` +- `tmux` installed for interactive mode (`brew install tmux`) + +## Mode 1: Pytest Real E2E (7 test cases) + +### Run all real E2E tests +```bash +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 -v +``` + +### Run a single test +```bash +uv run pytest tests/e2e/test_real_proxy.py::TestRealProxy::test_single_turn --run-real-e2e --timeout=180 -v -s +``` + +### Run with debug output +```bash +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 -v -s --tb=long +``` + +## Mode 2: tmux Interactive Real E2E + +Use this when you need to validate non-`-p` interactive behavior in Claude Code TUI. + +```bash +scripts/run_real_e2e_tmux.sh +``` + +Optional overrides: + +```bash +PROMPT_ONE="Use the shell tool to run command ls in the current directory, then reply with any 5 filenames only." \ +PROMPT_TWO="Thank you." \ +SUBMIT_KEY="Enter" \ +PERMISSION_MODE="bypassPermissions" \ +scripts/run_real_e2e_tmux.sh +``` + +Important tmux interaction notes: + +- Submit key is `Enter` for Claude Code TUI in tmux (confirmed working). +- `PROMPT_ONE` should intentionally trigger tool use. +- For portability, use `grep -F` instead of `rg` in shell assertions (`rg` may be unavailable). + +## Verification Checklist (for both modes) + +- Latest trace `.jsonl` contains both prompts (`PROMPT_ONE`, `PROMPT_TWO`) +- At least 2 requests hit `/v1/messages` +- At least one response content block has `"type": "tool_use"` +- HTML viewer file is generated (`trace_*.html`) + +## Notes + +- Real E2E tests are skipped by default; `--run-real-e2e` is required. +- Each pytest case starts a fresh proxy server and trace directory. +- Timeouts are intentionally generous because real API calls are involved. +- tmux mode includes retry logic for prompt submission and post-run JSONL assertions. + +## Pytest Test Cases + +| Test | Timeout | What It Tests | +|------|---------|---------------| +| `test_single_turn` | 180s | Basic prompt/response trace capture | +| `test_multi_turn` | 300s | Conversation memory with `-c` flag | +| `test_tool_use` | 180s | Tool use generates multiple trace records | +| `test_html_viewer_generated` | 180s | HTML viewer generated with embedded trace data | +| `test_api_key_redaction` | 180s | API keys redacted from trace output | +| `test_streaming_sse_capture` | 180s | SSE events captured in streaming mode | +| `test_trace_summary` | 180s | CLI stdout includes trace summary and API call count | diff --git a/.agents/skills/screenshot-validation/SKILL.md b/.agents/skills/screenshot-validation/SKILL.md new file mode 100644 index 0000000..c35a202 --- /dev/null +++ b/.agents/skills/screenshot-validation/SKILL.md @@ -0,0 +1,76 @@ +--- +name: screenshot-validation +description: Validate screenshot and viewer HTML quality for PR evidence. Run this after adding or modifying images under .agents/evidence/pr/ or .agents/recordings/, or after generating a new viewer HTML file. Combines image quality checks (resolution, blankness, file size) with Playwright-based viewer rendering verification. +user_invocable: true +--- + +# Screenshot Validation + +Validate that evidence images and viewer HTML files meet quality standards before committing. This catches issues that would otherwise fail CI or produce misleading PR evidence. + +## Image quality check + +Checks PNG/JPG/GIF/WEBP files for: +- **Minimum dimensions**: 400x400 pixels (hard fail) +- **Desktop viewport width**: >= 1280px (warning if narrower) +- **File size**: <= 5MB (warning if larger) +- **Blankness detection** (PNG only): fails if > 90% of pixels are white/transparent + +### Run on specific files or directories + +```bash +uv run python scripts/check_screenshots.py .agents/evidence/pr/ +uv run python scripts/check_screenshots.py .agents/recordings/ +uv run python scripts/check_screenshots.py path/to/specific-image.png +``` + +### Run on git-staged images + +```bash +scripts/check_screenshots.sh +``` + +This shell wrapper automatically finds staged PNG/JPG files and runs the quality check on them — useful as a pre-commit sanity check. + +## Viewer HTML rendering verification + +Uses Playwright (headless Chromium) to verify that generated viewer HTML files actually render correctly — not just raw JSON or Python errors. + +Checks: +- No JavaScript errors on page load +- Normal traces render a sidebar with entries and a detail panel +- Empty embedded traces render the explicit "No API calls captured" state +- Body text doesn't contain raw JSON dumps or Python tracebacks + +### Run + +```bash +uv run python scripts/verify_screenshots.py .traces/trace_*.html +``` + +Requires Playwright to be installed (`uv pip install playwright && playwright install chromium`). + +## Typical workflow + +After generating new evidence for a PR: + +```bash +# 1. Check image quality +uv run python scripts/check_screenshots.py .agents/evidence/pr/ + +# 2. If you generated new viewer HTML, verify it renders +uv run python scripts/verify_screenshots.py .traces/trace_*.html + +# 3. If all passes, stage and commit +git add .agents/evidence/pr/ +``` + +## Fixing common failures + +| Failure | Fix | +|---------|-----| +| `very small image (WxH; minimum is 400x400)` | Retake screenshot at a larger viewport or higher resolution | +| `narrow desktop viewport (Wpx < 1280px)` | Resize browser window to >= 1280px wide before capturing | +| `mostly blank/white image` | Ensure the screenshot captures actual content, not an empty page | +| `No sidebar — viewer not rendered` | Viewer HTML is broken; regenerate from trace JSONL | +| `JS errors` | Check viewer.html for syntax errors in embedded data | diff --git a/.agents/skills/translate-i18n/SKILL.md b/.agents/skills/translate-i18n/SKILL.md new file mode 100644 index 0000000..ddc92d1 --- /dev/null +++ b/.agents/skills/translate-i18n/SKILL.md @@ -0,0 +1,69 @@ +--- +name: translate-i18n +description: Fill missing i18n translations in the viewer source JSON. Run this after adding or modifying English or Chinese UI strings in claude_tap/viewer_i18n.json — it auto-translates to ja, ko, fr, ar, de, ru via OpenRouter. +user_invocable: true +--- + +# Translate i18n + +Automatically fill missing translations for the viewer's `claude_tap/viewer_i18n.json` source file. The script uses English and Chinese as source languages and translates to Japanese, Korean, French, Arabic, German, and Russian. + +## Prerequisites + +- `OPENROUTER_API_KEY` must be set in the environment (it is in the user's `.zshrc`) +- Default model: `google/gemini-2.5-flash` + +## Workflow + +### 1. Check what's missing (dry run) + +Always preview first to confirm which keys need translation: + +```bash +uv run python scripts/translate_i18n.py --dry-run +``` + +This parses `claude_tap/viewer_i18n.json`, finds keys present in both `en` and `zh-CN` but missing in other languages, and lists them without modifying the file. + +### 2. Run the translation + +```bash +uv run python scripts/translate_i18n.py +``` + +The script calls OpenRouter once per target language, then writes the translations back into `viewer_i18n.json` in-place. + +### 3. Verify the result + +After translation, run the formatter and tests to make sure nothing broke: + +```bash +uv run python -m json.tool claude_tap/viewer_i18n.json >/dev/null +uv run pytest tests/test_translate_i18n.py -v +``` + +## Options + +| Flag | Purpose | +|------|---------| +| `--dry-run` | Show missing keys only, no file changes | +| `--model MODEL` | Override the OpenRouter model (default: `google/gemini-2.5-flash`) | +| `--target {viewer,cli}` | Translation target preset (default: `viewer`) | +| `--file PATH` | Override target file path | +| `--object-name NAME` | Override the legacy JS/Python i18n object name | + +## How it works + +The script: +1. Loads the viewer i18n JSON source file +2. Validates that every language block is a string-to-string map +3. Identifies keys present in `en` + `zh-CN` but missing in target languages +4. Sends a structured prompt to OpenRouter with existing translations for consistency +5. Normalizes fullwidth punctuation for CJK languages (matching zh-CN style) +6. Inserts new entries after the existing keys in each target language + +## Common scenarios + +**Added a new UI string**: Add the key to both `en` and `zh-CN` blocks in `claude_tap/viewer_i18n.json`, then run this skill. The other 6 languages will be filled automatically. + +**Changed an existing string**: The script only fills *missing* keys. To re-translate an existing key, first delete it from the target language blocks, then run the script. diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..a7d1fbf --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,99 @@ +{ + "projectName": "claude-tap", + "projectOwner": "liaohch3", + "repoType": "github", + "repoHost": "https://github.com", + "files": [ + "README.md", + "README_zh.md" + ], + "imageSize": 100, + "commit": false, + "contributorsPerLine": 7, + "contributors": [ + { + "login": "liaohch3", + "name": "liaohch3", + "avatar_url": "https://avatars.githubusercontent.com/u/34056481", + "profile": "https://github.com/liaohch3", + "contributions": [ + "code", + "doc", + "maintenance", + "test" + ] + }, + { + "login": "WEIFENG2333", + "name": "BKK", + "avatar_url": "https://avatars.githubusercontent.com/u/61730227", + "profile": "https://github.com/WEIFENG2333", + "contributions": [ + "code" + ] + }, + { + "login": "YoungCan-Wang", + "name": "YoungCan-Wang", + "avatar_url": "https://avatars.githubusercontent.com/u/73347006", + "profile": "https://github.com/YoungCan-Wang", + "contributions": [ + "code" + ] + }, + { + "login": "oxkrypton", + "name": "0xkrypton", + "avatar_url": "https://avatars.githubusercontent.com/u/154910746", + "profile": "https://github.com/oxkrypton", + "contributions": [ + "code" + ] + }, + { + "login": "googs1025", + "name": "CYJiang", + "avatar_url": "https://avatars.githubusercontent.com/u/86391540", + "profile": "https://github.com/googs1025", + "contributions": [ + "code" + ] + }, + { + "login": "TITOCHAN2023", + "name": "陈展鹏", + "avatar_url": "https://avatars.githubusercontent.com/u/138754853", + "profile": "https://github.com/TITOCHAN2023", + "contributions": [ + "doc" + ] + }, + { + "login": "devtalker", + "name": "devtalker", + "avatar_url": "https://avatars.githubusercontent.com/u/23204195", + "profile": "https://github.com/devtalker", + "contributions": [ + "code" + ] + }, + { + "login": "dingyaguang117", + "name": "Yaguang Ding", + "avatar_url": "https://avatars.githubusercontent.com/u/1930778", + "profile": "https://github.com/dingyaguang117", + "contributions": [ + "code" + ] + }, + { + "login": "sephymartin", + "name": "Sephy", + "avatar_url": "https://avatars.githubusercontent.com/u/299891", + "profile": "https://github.com/sephymartin", + "contributions": [ + "code" + ] + } + ] +} diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..6f97830 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,12 @@ +# Claude Code Bridge + +This repository uses a single source of truth for engineering rules: + +- Follow [`../AGENTS.md`](../AGENTS.md) for all workflow, testing, and review requirements. + +Skill layout for multi-agent compatibility: + +- Canonical skills directory: `.agents/skills/` +- Claude compatibility path: `.claude/skills -> ../.agents/skills` (symlink) + +Do not duplicate policy text in this file. Keep all normative rules in `AGENTS.md`. diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..6a5fd1f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Pre-commit gate checks — mirrors CI lint job. +# Install: git config core.hooksPath .githooks +set -e + +echo "🔍 pre-commit: ruff check" +uv run ruff check . + +echo "🔍 pre-commit: ruff format --check" +uv run ruff format --check . + +echo "✅ pre-commit: all checks passed" diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..f410611 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,24 @@ + + +## Summary + + +## Environment + +- claude-tap version: +- Client: Claude Code / Codex CLI / other +- OS: +- Python version: +- Install method: + +## Details + + +## Validation + +- [ ] I searched existing issues and pull requests. +- [ ] I removed secrets and private trace data from this report. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..7ad068e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,86 @@ +name: Bug report +description: Report a reproducible problem with claude-tap. +title: "bug: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug. Do not include API keys, auth tokens, private prompts, raw trace files, or generated HTML viewers unless they are fully redacted. + - type: checkboxes + id: preflight + attributes: + label: Preflight + options: + - label: I searched existing issues and pull requests. + required: true + - label: I removed secrets and private trace data from this report. + required: true + - type: input + id: version + attributes: + label: claude-tap version + description: Output of `claude-tap --version` or package version. + placeholder: "0.1.39" + validations: + required: true + - type: dropdown + id: client + attributes: + label: Client + options: + - Claude Code + - Codex CLI + - Other + validations: + required: true + - type: dropdown + id: proxy-mode + attributes: + label: Proxy mode + options: + - reverse + - forward + - not sure + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: steps + attributes: + label: Reproduction steps + description: Use sanitized commands and data. + placeholder: | + 1. Run ... + 2. Open ... + 3. See ... + validations: + required: true + - type: textarea + id: logs + attributes: + label: Sanitized logs or trace summary + description: Paste only redacted excerpts. Do not attach private trace files. + render: text + - type: textarea + id: environment + attributes: + label: Environment + description: OS, Python version, install method, Claude Code/Codex version, relevant proxy/VPN setup. + placeholder: | + OS: + Python: + Install method: + Client version: + Network/proxy setup: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..c41470e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/liaohch3/claude-tap/security/advisories/new + about: Please report vulnerabilities privately. Do not include secrets or private traces in public issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..22aa8f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,56 @@ +name: Feature request +description: Propose an improvement or new capability. +title: "feat: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Please describe the user problem first. Avoid including private prompts, raw traces, or generated viewer files. + - type: checkboxes + id: preflight + attributes: + label: Preflight + options: + - label: I searched existing issues and pull requests. + required: true + - label: This request does not include secrets or private trace data. + required: true + - type: textarea + id: problem + attributes: + label: Problem + description: What workflow is blocked or painful today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the behavior or interface you want. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - CLI + - Proxy routing + - Trace format + - Viewer + - Export + - Documentation + - Release/CI + - Other + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: textarea + id: evidence + attributes: + label: Sanitized evidence + description: Link to redacted screenshots, recordings, or sample traces if useful. diff --git a/.github/PULL_REQUEST_TEMPLATE/chore.md b/.github/PULL_REQUEST_TEMPLATE/chore.md new file mode 100644 index 0000000..6b27917 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/chore.md @@ -0,0 +1,30 @@ +## Summary +- +- +- + +## Context +- Why this maintenance change is needed now: +- What friction, drift, or operational cost it removes: + +## Scope +- Areas touched: +- Explicitly not changed: + +## Validation +- [x] `uv run ruff check .` +- [x] `uv run ruff format --check .` +- [x] `uv run pytest tests/ -x --timeout=60` +- [x] Any required evidence uses real data from `.traces/` +- [ ] If any gate is skipped, explain why and get reviewer approval before merge + +## Results +- +- + +## Follow-up +- Remaining issues or debt to address later: + +## Risk / Rollback +- Main risk: +- Rollback plan: diff --git a/.github/PULL_REQUEST_TEMPLATE/docs.md b/.github/PULL_REQUEST_TEMPLATE/docs.md new file mode 100644 index 0000000..e1ab910 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/docs.md @@ -0,0 +1,20 @@ +## Summary +- +- +- + +## Purpose +- Why this document, plan, or note is needed now: +- How it relates to implementation or reviewer workflow: + +## Scope +- Files updated: +- Code paths explicitly not changed: + +## Validation +- [x] Content reviewed for accuracy and consistency +- [x] Links, commands, and paths were checked +- [ ] No code execution was required + +## Notes +- If this PR changes standards or workflow docs, mention any follow-up checks or owner review needed: diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md new file mode 100644 index 0000000..114fbdd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/feature.md @@ -0,0 +1,35 @@ +## Summary +- +- +- + +## Background +- Current limitation: +- Why this feature is being added now: + +## Scope +- Areas touched: +- Explicitly not changed: + +## Validation +- [x] `uv run ruff check .` +- [x] `uv run ruff format --check .` +- [x] `uv run pytest tests/ -x --timeout=60` +- [x] Functional, smoke, or E2E verification is listed below +- [x] UI changes include `raw.githubusercontent.com` screenshot URLs when applicable +- [x] Any screenshots, recordings, or demos use real `.traces/` data +- [ ] If any gate or evidence is skipped, explain why and get reviewer approval before merge + +## Results +- +- + +## Evidence +- Screenshots / recordings / trace files: + +## Follow-up +- Known gaps left out of this PR: + +## Risk / Rollback +- Main risk: +- Rollback plan: diff --git a/.github/PULL_REQUEST_TEMPLATE/fix.md b/.github/PULL_REQUEST_TEMPLATE/fix.md new file mode 100644 index 0000000..849bc3a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/fix.md @@ -0,0 +1,37 @@ +## Problem +- Symptoms: +- Root cause: + +## Fix Summary +- +- +- + +## Scope +- Areas touched: +- Explicitly not changed: + +## Validation +- [x] Old behavior was reproduced or inspected with concrete evidence +- [x] `uv run ruff check .` +- [x] `uv run ruff format --check .` +- [x] `uv run pytest tests/ -x --timeout=60` +- [x] Fix verification and regression checks are listed below +- [x] UI changes include `raw.githubusercontent.com` screenshot URLs when applicable +- [x] Any screenshots, recordings, or demos use real `.traces/` data +- [ ] If any gate or evidence is skipped, explain why and get reviewer approval before merge + +## Results +- +- + +## Evidence +- Reproduction trace / logs: +- Fix verification trace / logs: + +## Follow-up +- Remaining issues intentionally deferred: + +## Risk / Rollback +- Main risk: +- Rollback plan: diff --git a/.github/PULL_REQUEST_TEMPLATE/plan.md b/.github/PULL_REQUEST_TEMPLATE/plan.md new file mode 100644 index 0000000..8af45e9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/plan.md @@ -0,0 +1,20 @@ +## Goal +- This PR only updates design, plan, checklist, or TODO material. + +## Outputs +- New or updated design docs: +- New or updated TODO / plan entries: +- Planned phases or milestones: + +## Key Decisions +- Decision 1: +- Decision 2: +- Explicitly deferred: + +## Validation +- [x] Plan or design reviewed for scope clarity +- [x] Proposed phases and ownership are clear +- [ ] No code execution was required + +## Follow-up +- Expected implementation PRs or branches: diff --git a/.github/PULL_REQUEST_TEMPLATE/refactor.md b/.github/PULL_REQUEST_TEMPLATE/refactor.md new file mode 100644 index 0000000..419d9ea --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/refactor.md @@ -0,0 +1,30 @@ +## Refactor Summary +- +- +- + +## Background +- Why this refactor is needed now: +- What complexity, duplication, or debt it reduces: + +## Invariants +- External behavior expected to remain unchanged: +- Internal structure being improved: + +## Validation +- [x] `uv run ruff check .` +- [x] `uv run ruff format --check .` +- [x] `uv run pytest tests/ -x --timeout=60` +- [x] Regression verification is listed below +- [ ] If any gate is skipped, explain why and get reviewer approval before merge + +## Results +- +- + +## Follow-up +- Remaining cleanup intentionally deferred: + +## Risk / Rollback +- Most likely regression point: +- Rollback plan: diff --git a/.github/PULL_REQUEST_TEMPLATE/test.md b/.github/PULL_REQUEST_TEMPLATE/test.md new file mode 100644 index 0000000..23fa906 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/test.md @@ -0,0 +1,33 @@ +## Summary +- +- +- + +## Background +- Why test coverage is being added or adjusted: +- Risks this test work is meant to cover: + +## Scope +- New or updated tests: +- Covered code paths: + +## Validation +- [x] `uv run ruff check .` +- [x] `uv run ruff format --check .` +- [x] `uv run pytest tests/ -x --timeout=60` +- [x] Any real E2E assertions and evidence are listed below +- [ ] If any gate or test path is skipped, explain why and get reviewer approval before merge + +## Results +- +- + +## Evidence +- Relevant traces, logs, or recordings: + +## Follow-up +- Remaining coverage gaps intentionally deferred: + +## Risk / Rollback +- Main risk: +- Rollback plan: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c0bd2f3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +## Summary + +- + +## Type + +- [ ] Bug fix +- [ ] Feature +- [ ] Documentation +- [ ] Test or tooling +- [ ] Maintenance + +## Validation + +- [ ] `uv run ruff check .` +- [ ] `uv run ruff format --check .` +- [ ] `uv run pytest tests/ -x --timeout=60` +- [ ] Not required; docs-only or metadata-only change. + +## Evidence + +- Screenshots / recordings / trace files: +- If runtime, viewer, client, proxy, or UI behavior changed, include `raw.githubusercontent.com` screenshot URLs here. + +## Privacy + +- [ ] This PR does not include API keys, auth tokens, private prompts, raw trace files, or generated HTML viewers. diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..fe2ef3f --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,142 @@ +name: Auto Release + +on: + push: + branches: [main] + +jobs: + auto-release: + # Skip explicit non-release commits to avoid loops. + if: > + !startsWith(github.event.head_commit.message, 'chore: bump version') && + !contains(github.event.head_commit.message, '[skip release]') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Validate release bot token + env: + RELEASE_BOT_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + run: | + if [ -z "$RELEASE_BOT_TOKEN" ]; then + echo "::error::RELEASE_BOT_TOKEN secret is required for release automation" + exit 1 + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_BOT_TOKEN }} + + - name: Calculate next patch version + id: version + run: | + latest=$(git tag --list 'v*' --sort=-v:refname | head -1) + if [ -z "$latest" ]; then + next="v0.1.0" + else + ver="${latest#v}" + major=$(echo "$ver" | cut -d. -f1) + minor=$(echo "$ver" | cut -d. -f2) + patch=$(echo "$ver" | cut -d. -f3) + next="v${major}.${minor}.$((patch + 1))" + fi + echo "tag=$next" >> "$GITHUB_OUTPUT" + echo "Next version: $next" + + - name: Update changelog for next version + id: changelog + env: + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN }} + run: | + tag="${{ steps.version.outputs.tag }}" + version="${tag#v}" + python scripts/update_changelog.py --version "$version" + if git diff --quiet CHANGELOG.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + branch="release/${tag}" + pr_body_file="$(mktemp)" + cat > "$pr_body_file" </dev/null || true)" + if [ "${check_count:-0}" -gt 0 ]; then + break + fi + echo "Waiting for release PR checks to start..." + sleep 10 + done + if [ "${check_count:-0}" -eq 0 ]; then + echo "::error::No release PR checks started for #${pr_number}" + exit 1 + fi + + gh pr checks "$pr_number" --watch --fail-fast --interval 10 + gh pr merge "$pr_number" --admin --squash --delete-branch + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Create and push tag + if: steps.changelog.outputs.changed == 'false' + run: | + tag="${{ steps.version.outputs.tag }}" + git tag "$tag" + git push origin "$tag" + echo "Created tag $tag" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f59040e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,140 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + pyproject.toml + uv.lock + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . + - name: Collect changed files for PR policy + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: gh pr view "$PR_NUMBER" --json files --jq '.files[].path' > /tmp/pr-changed-files.txt + - name: Validate PR body policy + if: github.event_name == 'pull_request' + run: python scripts/check_pr_policy.py --event-path "$GITHUB_EVENT_PATH" --changed-files-file /tmp/pr-changed-files.txt + + screenshot-quality: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Collect screenshot-related changes + id: screenshot_changes + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh pr view "$PR_NUMBER" --json files --jq '.files[].path' > /tmp/pr-changed-files.txt + if grep -Eq '^(\.agents/evidence/pr/|scripts/check_screenshots\.(py|sh))' /tmp/pr-changed-files.txt; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + else + echo "should_run=false" >> "$GITHUB_OUTPUT" + fi + - name: Validate screenshot evidence quality + if: github.event_name != 'pull_request' || steps.screenshot_changes.outputs.should_run == 'true' + run: python scripts/check_screenshots.py .agents/evidence/pr/ + - name: Skip unchanged screenshot evidence + if: github.event_name == 'pull_request' && steps.screenshot_changes.outputs.should_run != 'true' + run: echo "No screenshot evidence or screenshot checker changes in this PR; skipping full screenshot scan." + + pr-policy: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Collect changed files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: gh pr view "$PR_NUMBER" --json files --jq '.files[].path' > /tmp/pr-changed-files.txt + - name: Validate PR body policy + run: python scripts/check_pr_policy.py --event-path "$GITHUB_EVENT_PATH" --changed-files-file /tmp/pr-changed-files.txt + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + pyproject.toml + uv.lock + - run: pip install -e ".[dev]" + - run: python -m pytest tests/ -x --timeout=60 + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: | + pyproject.toml + uv.lock + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('uv.lock') }} + restore-keys: | + ${{ runner.os }}-playwright- + - run: pip install -e ".[dev]" playwright + - run: python -m playwright install --with-deps chromium + - run: python -m coverage erase + - run: python -m coverage run --append -m pytest tests/ --ignore=tests/test_e2e.py --ignore=tests/test_nav_browser.py --ignore=tests/test_responses_browser.py --ignore=tests/test_search_browser.py --ignore=tests/test_perf_viewer.py --ignore=tests/test_viewer_contracts.py -x --timeout=60 -q + - run: python -m coverage run --append -m pytest tests/test_e2e.py -q + - run: python -m coverage run --append -m pytest tests/test_nav_browser.py tests/test_perf_viewer.py -q + - run: python -m coverage run --append -m pytest tests/test_responses_browser.py -q + - run: python -m coverage run --append -m pytest tests/test_search_browser.py tests/test_viewer_contracts.py -q + - run: python -m coverage json -o .coverage.json + - run: python scripts/check_coverage.py --python-coverage .coverage.json diff --git a/.github/workflows/legibility.yml b/.github/workflows/legibility.yml new file mode 100644 index 0000000..2b2dfdc --- /dev/null +++ b/.github/workflows/legibility.yml @@ -0,0 +1,22 @@ +name: Legibility + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + legibility: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Run deterministic legibility checks + run: python scripts/check_legibility.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..823253e --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,84 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to publish (e.g. v0.1.27)" + required: true + +jobs: + ci: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + pyproject.toml + uv.lock + - run: pip install ruff + - run: ruff check . + - run: ruff format --check . + - run: python scripts/check_changelog.py --tag "${{ inputs.tag || github.ref_name }}" + - run: pip install -e ".[dev]" + - run: python -m pytest tests/ -x --timeout=60 + + publish: + needs: ci + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + cache-dependency-glob: uv.lock + + - name: Set up Python + run: uv python install 3.13 + + - name: Build package + run: uv build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tag="${{ inputs.tag || github.ref_name }}" + # Skip if release already exists (e.g. manual tag push) + if gh release view "$tag" &>/dev/null; then + echo "Release $tag already exists, skipping" + exit 0 + fi + prev_tag=$(git describe --tags --abbrev=0 "${tag}^" 2>/dev/null || echo "") + if [ -n "$prev_tag" ]; then + notes="$(git log "${prev_tag}..${tag}" --pretty=format:'- %h %s')" + else + notes="$(git log --pretty=format:'- %h %s' -n 20)" + fi + gh release create "$tag" \ + --title "$tag" \ + --notes "$notes" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1614888 --- /dev/null +++ b/.gitignore @@ -0,0 +1,97 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Trace output +.traces/ +.test_output/ +test-results/ +.cloudtap-manifest.json + +# Test artifacts +test-traces/ +*.cast +timing.txt +typescript.txt + +# IDE / Editor +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Demo / recording files +*.mp4 +*.webm +*.gif +# But keep public demo assets and curated maintainer recordings +!docs/demo.gif +!docs/demo_zh.gif +!.agents/recordings/demo.mp4 +!.agents/recordings/demo_zh.mp4 +!.agents/recordings/codex-demo.gif +!.agents/recordings/codex-demo.cast +!.agents/recordings/viewer-demo.mp4 +*.tape +demo-*.png +demo_commands.sh +real_demo.sh +real-demo-* +terminal-*.png +record_*.py +!scripts/record_viewer.py +record_*.sh +script-* + +# Frame directories (demo generation artifacts) +*_frames/ +*_frames_*/ +frames/ + +# Demo generation scripts +cast_to_gif*.py +svg_to_gif*.py +make_demo_gifs.py +make_final_demo*.py +render_excalidraw.* +render_node.mjs +concat*.txt +tui_cmd.sh +split-preview.png + +# Node.js (excalidraw rendering) +package.json +package-lock.json +node_modules/ + +# Architecture diagram variants (keep only final) +docs/architecture_v*.svg +docs/architecture_v*.png +docs/architecture_excalidraw* +docs/architecture_kroki* +docs/architecture.excalidraw +docs/architecture_v2.png +docs/architecture_v3.* + +# SVG demo files +demo-cli.svg +demo-tui*.svg + +# Dev-only scripts (kept locally, not in repo) +test_and_view.sh +test_e2e_tap.sh + +# Runtime logs +log/ + +# Ruff / lint cache +.ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3df3735 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.12 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: pytest + name: pytest (fast, excludes slow/e2e) + entry: uv run pytest -x -q --timeout=30 -m "not slow and not real_e2e" --ignore=tests/e2e + language: system + pass_filenames: false + always_run: true + stages: [pre-commit] + + - id: e2e-reminder + name: e2e reminder + entry: sh -c 'echo "⚠️ pre-commit 跳过了 slow/e2e 测试。push 前请手动运行:"; echo " uv run pytest tests/ -x --timeout=60"; echo " uv run pytest tests/e2e/ --run-real-e2e --timeout=180"; exit 0' + language: system + pass_filenames: false + always_run: true + stages: [pre-commit] + + - id: legibility + name: legibility + entry: uv run python scripts/check_legibility.py + language: system + pass_filenames: false + always_run: true + stages: [pre-commit] diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..80b33f1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# Maintainer Automation Notes + +External contributors should start with [`CONTRIBUTING.md`](CONTRIBUTING.md). This file documents stricter maintainer and agent automation workflows; it is not the public contribution checklist. + +# AGENTS 索引 + +本文件是贡献者规则的入口。详细策略文本位于 `.agents/docs/standards/*.md`。 + +## Documentation Boundary + +`docs/` is reserved for public project documentation and README assets that open-source users or external contributors read directly. Maintainer policies, implementation plans, learning records, PR evidence, and recording assets belong under `.agents/` so internal automation workflows stay out of the public docs tree. + +Public user-facing documentation must be bilingual. Add or update the English document and the Simplified Chinese counterpart together; use `README.md` with `README_zh.md`, and use `*.zh.md` beside English guide files under `docs/guides/`. + +## Review Guidelines + +When reviewing pull requests, prioritize findings that can cause correctness bugs, regressions, security or privacy issues, release failures, broken CI, broken packaging, or misleading evidence. Treat these as high-priority review findings. + +Do not block on style-only or preference-only comments unless they make the code materially harder to maintain or violate an existing repository rule. + +For every pull request, check: + +1. The implementation matches the requested scope and does not mix unrelated refactors, feature work, or generated artifacts. +2. Tests contain meaningful assertions that would fail for realistic regressions, not only line coverage. +3. UI, viewer, or evidence changes include real trace-backed screenshots or recordings when required by repository policy. +4. Public documentation changes are bilingual, with matching English and Simplified Chinese updates. +5. Workflow, release, credential, subprocess, network, filesystem, and dependency changes use least privilege and avoid leaking secrets. +6. Commit titles, PR titles, PR bodies, and evidence links follow the repository contribution and preflight requirements. + +When a finding is speculative, state the assumption and the concrete evidence needed to confirm it. Prefer a small number of actionable, high-signal findings over broad commentary. + +## Pre-commit Hook + +项目提供了 `.githooks/pre-commit` 自动在 commit 前运行 lint 检查。首次 clone 后执行: + +```bash +git config core.hooksPath .githooks +``` + +## 不可协商规则 + +以下规则是强制性的,并会在 review 中执行: + +1. 每次 commit 前运行 gate 检查:`uv run ruff check .`、`uv run ruff format --check .`、`uv run pytest tests/ -x --timeout=60`。启用 pre-commit hook 可自动执行 lint 检查。 +2. UI 变更要求在 PR 中提供使用 `raw.githubusercontent.com` 绝对 URL 的截图。 +3. 每个 commit 只处理一个关注点(不要在同一 commit 中混合 refactor 与 feature/fix)。 +4. 代码、注释、commit message 和 skill 文件仅使用英文。对外文档必须中英文双份,中文内容放在 `README_zh.md` 或对应的 `*.zh.md` 文件中。 +5. 证据必须使用 `.traces/` 中的真实 trace 数据(禁止合成 mock 截图/演示)。 +6. 编码前必须执行 pre-work checklist,开 PR 前必须执行 pre-PR checklist。 +7. 不要留下仅本地存在的工作;你必须执行 `git add`、`git commit` 和 `git push`。 +8. 你必须使用 `gh pr create` 打开 GitHub PR。 + +## 标准目录 + +- 硬性规则与仓库策略:`.agents/docs/standards/hard-rules.md` +- 验证 gate 与必需命令:`.agents/docs/standards/validation-and-gates.md` +- E2E 与截图证据要求:`.agents/docs/standards/e2e-and-evidence.md` +- 截图采集与验证标准:`.agents/docs/standards/screenshot-standards.md` +- 编码与运行时安全规则:`.agents/docs/standards/coding-and-runtime.md` +- 工作流、review 与 Brain/Hands 协议:`.agents/docs/standards/workflow-and-review.md` +- 调试方法论与反模式:`.agents/docs/standards/debugging-standards.md` +- 客户端支持矩阵与 URL 构造规则:`docs/support-matrix.md` +- 标准文档元数据与维护流程:`.agents/docs/standards/README.md` + +## Skills 目录 + +可复用的 agent 技能位于 `.agents/skills/`,按功能分类: + +### 测试 +- `e2e-test`:pytest E2E 测试套件 +- `real-e2e-test`:真实 Claude CLI E2E 测试(pytest + tmux 模式) +- `js-in-html-testing`:HTML 内嵌 JS 的两层测试策略(Python 单测 + Playwright) + +### 验证 +- `legibility-check`:文档结构、标准 freshness、manifest 路径、plan 状态检查 +- `screenshot-validation`:截图质量 + viewer HTML 渲染验证 +- `pr-preflight`:PR 合并就绪全面检查(lint + test + CI + 截图) + +### 翻译 +- `translate-i18n`:自动补全 viewer I18N 缺失翻译(via OpenRouter) + +### 发布 +- `push-release`:推送代码并按需 bump 版本触发 PyPI 发布 + +### 资产生成 +- `demo-video`:从真实 E2E 运行录制演示视频 +- `playwright-screen-recording`:Playwright 录屏用于 PR review + +## Scripts 目录 + +`scripts/` 下的确定性脚本,部分已被 skill 包装: + +| 脚本 | 对应 Skill | 用途 | +|------|-----------|------| +| `check_legibility.py` | `legibility-check` | 文档可读性检查(CI: `legibility.yml`) | +| `check_changelog.py` | `legibility-check` | publish 阶段校验 release tag 与 `CHANGELOG.md` 覆盖 | +| `update_changelog.py` | `legibility-check` | auto-release 发 tag 前自动补齐 `CHANGELOG.md` release section,分支保护下会走 auto-merge release PR | +| `check_coverage.py` | - | Python/backend 与 viewer.html/frontend 的项目覆盖率和增量覆盖率 gate | +| `check_pr_policy.py` | `pr-preflight` | PR body、证据链接、危险文件和运行时变更 evidence policy gate(CI: `pr-policy`) | +| `check_screenshots.py` | `screenshot-validation` | 截图质量检查(CI: `ci.yml`) | +| `check_screenshots.sh` | `screenshot-validation` | 检查 git staged 图片的 shell 包装 | +| `verify_screenshots.py` | `screenshot-validation` | Playwright viewer HTML 渲染验证 | +| `check_pr.sh` | `pr-preflight` | PR 合并就绪检查 | +| `translate_i18n.py` | `translate-i18n` | 自动翻译 i18n 缺失 key | +| `run_real_e2e.sh` | `real-e2e-test` | 真实 E2E(非 tmux) | +| `run_real_e2e_tmux.sh` | `real-e2e-test` | 真实 E2E(tmux 交互模式) | + +## 可读性检查 + +确定性的可读性检查由 `scripts/check_legibility.py` 实现,并在 CI 中通过 `.github/workflows/legibility.yml` 运行。 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3349baf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,821 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + + + + + + + + + + + +## [0.1.134] - 2026-07-11 + +### Changed +- refactor(update): remove automatic startup upgrades (#383) +## [0.1.133] - 2026-07-11 + +### Changed +- chore(deps): pin aiohttp to 3.14.1 (#381) +## [0.1.132] - 2026-07-11 + +### Changed +- fix(macos): reuse healthy proxies and reap orphans (#359) +## [0.1.131] - 2026-07-11 + +### Changed +- fix(codex): capture complete context over HTTP (#376) +## [0.1.130] - 2026-07-10 + +### Changed +- Simplify viewer export menus (#373) +## [0.1.129] - 2026-07-10 + +### Changed +- fix(viewer): read Responses additional tools +- test: add additional tools viewer evidence +- fix(viewer): preserve Codex websocket tools +## [0.1.128] - 2026-07-10 + +### Changed +- Make compact trace the default export format (#368) +## [0.1.127] - 2026-07-09 + +### Changed +- fix(dashboard): search compacted trace blobs +## [0.1.126] - 2026-07-01 + +### Changed +- Render Chat Completions delta reasoning +## [0.1.125] - 2026-07-01 + +### Changed +- Show macOS monitor startup failures (#335) +### Added +- Add a local macOS menu bar app launcher with dashboard monitor controls. + +### Changed +- Harden macOS monitor config injection rollback, restore, and routing behavior. + + +## [0.1.124] - 2026-06-28 + +### Changed +- fix: hide Windows console for update subprocesses +## [0.1.123] - 2026-06-27 + +### Changed +- fix: refresh stale dashboard after updates (#348) +- feat: support Claude Code Vertex and Bedrock gateways (#349) +### Added +- Add Claude Code Vertex `ANTHROPIC_VERTEX_BASE_URL` target detection and proxy routing support. +- Add Claude Code compatibility for Anthropic-compatible Bedrock gateway models such as `bedrock/claude-opus-4-6`. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +## [0.1.122] - 2026-06-24 + +### Changed +- [codex] Fix viewer search for duplicate request ids (#342) +- Fix Gemini SSE response reconstruction (#339) +- fix(sse): reconstruct Responses output from streaming item events (#343) +## [0.1.121] - 2026-06-22 + +### Changed +- feat: add MiMo Code client support (#338) +## [0.1.120] - 2026-06-18 + +### Changed +- fix upstream target diagnostics +## [0.1.119] - 2026-06-17 + +### Changed +- fix: do not proxy loopback upstream targets +## [0.1.118] - 2026-06-17 + +### Changed +- feat: add back button on session detail page +## [0.1.117] - 2026-06-17 + +### Changed +- fix: mock codex provider detection in reverse-inject test +## [0.1.116] - 2026-06-17 + +### Changed +- feat(viewer): add cache hit rate display in header stats bar +## [0.1.115] - 2026-06-15 + +### Changed +- feat: add Codex App transcript listener (#297) +## [0.1.114] - 2026-06-12 + +### Changed +- fix: decode double-serialized JSON request bodies +## [0.1.113] - 2026-06-12 + +### Changed +- docs: add Phistory ecosystem showcase (#321) +## [0.1.112] - 2026-06-12 + +### Changed +- fix: prevent Windows pip background update corruption (#319) +## [0.1.111] - 2026-06-12 + +### Changed +- feat: add Kimi Code tap client +## [0.1.110] - 2026-06-12 + +### Changed +- Add bulk session deletion and lazy dashboard paging (#298) +## [0.1.109] - 2026-06-12 + +### Changed +- perf: compact repeated message content in traces +## [0.1.108] - 2026-06-10 + +### Changed +- fix: render direct Gemini native traces (#312) +## [0.1.107] - 2026-06-10 + +### Changed +- fix: keep Gemini native traces visible +## [0.1.106] - 2026-06-10 + +### Changed +- feat: clarify shared dashboard lifecycle controls (#293) +## [0.1.105] - 2026-06-09 + +### Changed +- fix: guard against None values in usage token fields causing dashboard 500 (#303) +## [0.1.104] - 2026-06-09 + +### Changed +- Document local proxy upstream support +## [0.1.103] - 2026-06-08 + +### Changed +- Fix Codex display turn numbering (#266) +## [0.1.102] - 2026-06-06 + +### Changed +- Add capture-only prompt snapshot export +## [0.1.101] - 2026-06-03 + +### Changed +- Add dashboard session delete action (#236) +## [0.1.100] - 2026-06-02 + +### Changed +- feat: add AWS Bedrock support for Claude Code tracing +## [0.1.99] - 2026-06-02 + +### Changed +- refactor: split viewer assets (#271) +- docs: add agent trace viewer seo pages +## [0.1.98] - 2026-06-02 + +### Changed +- Fix dashboard first prompt extraction (#270) +## [0.1.97] - 2026-06-01 + +### Changed +- Add dashboard HTML export (#265) +## [0.1.96] - 2026-06-01 + +### Changed +- perf: compact repeated trace payload fields (#250) +- fix: hide Windows background subprocess consoles (#260) +## [0.1.95] - 2026-06-01 + +### Changed +- feat: add readable tool call parameters (#261) +- fix: support VSCode Claude wrapper browser controls (#244) +## [0.1.94] - 2026-05-31 + +### Changed +- Fix viewer session round grouping (#235) +## [0.1.93] - 2026-05-31 + +### Changed +- Refactor CLI module boundaries (#247) +## [0.1.92] - 2026-05-30 + +### Changed +- fix: count OpenAI usage when aliases are zero +### Fixed +- Count OpenAI-compatible `prompt_tokens` and `completion_tokens` when provider aliases report `input_tokens` and `output_tokens` as zero. + +## [0.1.91] - 2026-05-30 + +### Changed +- perf: guard trace compaction and skip package noise (#227) +### Changed +- Skip persisted trace records for package registry metadata and archive downloads in forward proxy mode while still forwarding responses to clients. + + + + + + +## [0.1.90] - 2026-05-30 + +### Changed +- fix: render chat completion choices in viewer (#251) +## [0.1.89] - 2026-05-30 + +### Changed +- Fix Codex Responses viewer continuity (#245) +## [0.1.88] - 2026-05-30 + +### Changed +- Fix viewer global search navigation (#239) +## [0.1.87] - 2026-05-29 + +### Changed +- feat(viewer): support iframe embed query options (#246) +## [0.1.86] - 2026-05-28 + +### Changed +- feat!: omit raw stream events by default (#231) +### Changed +- **Breaking change:** raw SSE and WebSocket stream event arrays are no longer persisted by default. Pass `--tap-store-stream-events` when capturing a trace to store those raw event arrays in trace storage and viewer/export output; traces captured without the flag cannot recover the omitted raw events later. + +## [0.1.85] - 2026-05-28 + +### Changed +- fix(codex): capture custom provider base URLs (#228) +### Fixed +- Capture Codex custom OpenAI-compatible providers by overriding the selected provider base URL, not only `openai_base_url`. + + + + + + + + + + +## [0.1.84] - 2026-05-26 + +### Changed +- feat: add sqlite trace history index (#210) +## [0.1.83] - 2026-05-26 + +### Changed +- ci: improve workflow caching and merge latency +## [0.1.82] - 2026-05-25 + +### Changed +- perf(ws): release websocket trace buffers without dropping trailing events +## [0.1.81] - 2026-05-25 + +### Changed +- feat: add CodeBuddy CLI client support (#190) +## [0.1.80] - 2026-05-24 + +### Changed +- fix(viewer): show message content block boundaries +## [0.1.79] - 2026-05-24 + +### Changed +- docs: document VS Code wrapper setup (#220) +## [0.1.78] - 2026-05-24 + +### Changed +- docs: add Codex review guidelines (#218) +- ui(viewer): 优化滚动条视觉效果和操作体验 (#206) +## [0.1.77] - 2026-05-24 + +### Changed +- fix: Make backports-zstd conditional for Python <3.14 +## [0.1.76] - 2026-05-22 + +### Changed +- fix: unblock protected auto release (#208) +## [0.1.75] - 2026-05-22 + +### Added +- Add Antigravity CLI (agy) client support (#197). +- Add the session dashboard for browsing saved trace sessions (#200). +- Add `--tap-no-live` to disable the live viewer server and restore the pre-v0.1.75 default behavior for scripts, CI, remote shells, or other non-interactive runs. + +### Changed +- **Breaking change:** the live viewer now starts by default when `claude-tap` runs a client, so users can watch trace records while the agent is still running (#192). +- `--tap-no-open` now prevents both the live viewer and the generated HTML viewer from auto-opening in a browser (#192). +- Add Gemini LLM paths to the primary viewer path filter (#196). +- Write completed WebSocket traces immediately (#195). +- Add trace log display support (#189). +- Enforce the PR policy gate in CI (#199). + +## [0.1.74] - 2026-05-17 + +### Changed +- refactor: move viewer i18n strings to JSON source (#186) +## [0.1.73] - 2026-05-17 + +### Changed +- fix viewer empty trace evidence quality (#184) +## [0.1.72] - 2026-05-17 + +### Changed +- Add Qoder CLI support (#179) +## [0.1.71] - 2026-05-17 + +### Changed +- docs: expand messages in light viewer screenshot (#181) +## [0.1.70] - 2026-05-17 + +### Changed +- docs: refresh README and demo assets (#178) +## [0.1.69] - 2026-05-14 + +### Changed +- Detect Claude custom upstream target (#118) +## [0.1.68] - 2026-05-14 + +### Changed +- docs: show Anthropic Python SDK in proxy-only mode +## [0.1.67] - 2026-05-14 + +### Changed +- feat(viewer): add collapsible JSON tree view +## [0.1.66] - 2026-05-14 + +### Changed +- feat(cli): add update subcommand (#114) +## [0.1.65] - 2026-05-14 + +### Changed +- feat: support Pi CLI capture (#172) +## [0.1.64] - 2026-05-13 + +### Changed +- test(opencode): add real trace viewer evidence (#170) +### Added +- Add OpenCode real trace viewer evidence, OpenAI OAuth Responses evidence, and dedicated viewer contracts for multi-turn tool-call traces. + +## [0.1.63] - 2026-05-13 + +### Changed +- test(viewer): add cross-client quality contracts (#168) +### Added +- Add cross-client HTML viewer contract tests for semantic sections, runtime errors, visual layout states, and V8 coverage of core inline JavaScript functions. +- Add backend/frontend project, incremental, and viewer CSS selector coverage targets enforced by CI. + +## [0.1.62] - 2026-05-13 + +### Changed +- feat(cli): add Gemini CLI client (#166) +### Added +- Add Gemini CLI client support with forward proxy default. +- Render Gemini CLI system prompts, messages, tool calls, tool results, SSE output, and token usage in the viewer. + + + + + + + + + + + + + + + + + + + + + +## [0.1.61] - 2026-05-13 + +### Changed +- refactor(cli): support multiple reverse base URL envs (#160) +## [0.1.60] - 2026-05-11 + +### Changed +- add --tap-allow-path argument to support custom api prefixes (#122) +## [0.1.59] - 2026-05-11 + +### Changed +- feat(cli): add hermes-agent client (forward proxy by default) (#97) +## [0.1.58] - 2026-05-11 + +### Changed +- Fix dotted WebSocket turn ordering (#130) +## [0.1.57] - 2026-05-11 + +### Changed +- fix(viewer): improve diff targets and tool details (#148) +## [0.1.56] - 2026-05-11 + +### Changed +- fix(viewer): interleave Codex tool results (#151) +## [0.1.55] - 2026-05-09 + +### Changed +- fix(viewer): generalize Responses tool item normalization (#145) +## [0.1.54] - 2026-05-09 + +### Changed +- fix(viewer): show Codex cached tokens (#144) +## [0.1.53] - 2026-05-08 + +### Changed +- docs: strengthen Kimi evidence with real multiturn trace (#141) +## [0.1.52] - 2026-05-08 + +### Changed +- feat: add Kimi CLI client support (#139) +## [0.1.51] - 2026-05-08 + +### Changed +- Harden viewer metadata for Codex string bodies (#137) +## [0.1.50] - 2026-05-07 + +### Changed +- fix(viewer): clarify token summary label (#133) +## [0.1.49] - 2026-05-07 + +### Changed +- fix(viewer): count Responses function calls (#131) +## [0.1.48] - 2026-05-07 + +### Changed +- fix(proxy): support DeepSeek Claude Code metadata (#121) +## [0.1.47] - 2026-05-06 + +### Changed +- chore: split internal docs from public docs (#125) +- docs: collapse detailed README sections (#127) +## [0.1.46] - 2026-05-06 + +### Changed +- feat(cli): support cursor cli tracing (#119) +## [0.1.45] - 2026-05-05 + +### Changed +- feat(cli): add opencode client support +- fix(ci): use release bot token for auto release +## [0.1.44] - 2026-05-04 + +### Changed +- feat(cli): add standalone dashboard command +- fix(viewer): label Codex responses input as request context +## [0.1.43] - 2026-05-04 + +### Changed +- fix(cli): inject settings for Claude reverse proxy +## [0.1.42] - 2026-05-04 + +### Changed +- docs: fix README accuracy (#107) +- Add missing skill names to local skill metadata (#93) +## [0.1.41] - 2026-05-03 + +### Fixed +- Make auto-release open and auto-merge a changelog pull request when the main + branch is protected, then publish after that release PR is merged. + +## [0.1.40] - 2026-05-03 + +### Changed +- Package versions are now derived from git tags via `setuptools-scm`, so local + builds and PyPI releases use the same version source. +- PyPI publishing no longer mutates `pyproject.toml` during the release job. +- Auto-release can insert missing changelog sections before tagging, and publish + still verifies that the exact tag being published is documented. + +## [0.1.39] - 2026-05-02 + +### Fixed +- Surface Codex forward WebSocket responses in traces and viewer output. + +## [0.1.38] - 2026-04-29 + +### Changed +- Warn on stateful Codex Responses continuations. + +## [0.1.37] - 2026-04-29 + +### Fixed +- Unbreak `claude-tap` on Windows. + +## [0.1.36] - 2026-04-28 + +### Fixed +- Handle null trace bodies during export. + +## [0.1.35] - 2026-04-28 + +### Added +- Add HTML viewer output to the `export` command. + +## [0.1.34] - 2026-04-27 + +### Fixed +- Hide auxiliary Bedrock setup calls in the viewer. + +## [0.1.33] - 2026-04-27 + +### Fixed +- Decode Bedrock EventStream traces for viewer rendering. + +## [0.1.32] - 2026-04-21 + +### Added +- Auto-detect Codex ChatGPT targets and force HTTP transport when needed. + +## [0.1.31] - 2026-04-21 + +### Fixed +- Honor environment proxy settings for Codex forward WebSocket upstreams. + +## [0.1.30] - 2026-04-20 + +### Fixed +- Relay WebSocket traffic in forward proxy mode. + +## [0.1.29] - 2026-04-19 + +### Fixed +- Improve Codex proxy compatibility and WebSocket trace reconstruction. + +## [0.1.28] - 2026-04-19 + +### Added +- Add focused pull request templates. + +## [0.1.27] - 2026-03-26 + +### Fixed +- Trigger publishing via `workflow_dispatch` from auto-release. +- Move auto-release publishing out of inline tag-trigger assumptions. + +## [0.1.26] - 2026-03-26 + +### Added +- Auto-release on every merge to `main`. + +## [0.1.25] - 2026-03-26 + +### Fixed +- Collapse path filter chips to prevent viewer header overflow. + +## [0.1.24] - 2026-03-21 + +### Fixed +- Compact viewer layout, merge token stats into the header, and fix the date picker. + +## [0.1.23] - 2026-03-21 + +### Fixed +- Persist section collapse state across turns. +- Add cross-midnight trace cleanup handling. + +## [0.1.22] - 2026-03-21 + +### Fixed +- Refactor live viewer SSE handling to deduplicate records and simplify naming. + +## [0.1.21] - 2026-03-20 + +### Added +- Date-based trace storage with a date picker in the live viewer. + +## [0.1.20] - 2026-03-19 + +### Added +- Support OpenAI Responses API traces in the viewer and SSE parser. + +### Changed +- Migrate skills to directory-based `SKILL.md` format. +- Improve CLI `--help` with argument groups and examples. + +### Fixed +- Add a proxy path allowlist to block scanner and crawler requests. +- Correct Codex upstream URL construction for OAuth and API-key modes. + +## [0.1.19] - 2026-03-10 + +### Added +- Add Codex client support for proxy tracing. +- Add WebSocket proxy support for Codex CLI. +- Add automated PR merge-readiness checks. +- Add OpenRouter-backed i18n translation helper. +- Add MVP agent legibility checks and standards index. +- Add enhanced viewer search and large-trace performance improvements. + +### Changed +- Translate internal markdown docs to zh-CN. +- Add screenshot quality standards and automated screenshot checks. + +### Fixed +- Make PyPI publishing more robust with GitHub Release and PyPI verification. +- Make the diff overlay scrollable for long diffs. + +## [0.1.18] - 2026-02-26 + +### Fixed +- Ignore `SIGTTOU` before reclaiming the foreground process group to prevent suspend on exit. + +## [0.1.17] - 2026-02-26 + +### Fixed +- Read the package version from package metadata instead of a hardcoded string. + +## [0.1.16] - 2026-02-26 + +### Added +- Graceful `Ctrl+C` / `Ctrl+Z` shutdown. +- Open the generated HTML viewer by default. + +## [0.1.15] - 2026-02-26 + +### Changed +- Bump `.python-version` to 3.13 to match the CI matrix ceiling. + +## [0.1.14] - 2026-02-26 + +### Added +- Document the Python 3.13 SSL AKI requirement in error-experience notes. + +## [0.1.13] - 2026-02-26 + +### Added +- Forward proxy mode with HTTP `CONNECT` tunneling and TLS termination. +- Real E2E scripts with tmux support for interactive and non-interactive flows. +- Engineering practice and compounding-engineering documentation for agent workflows. + +### Changed +- CI and test hardening for real proxy/E2E scenarios and Python 3.13 certificate validation. +- Replace the `AGENTS.md` symlink with a regular file. +- Real E2E fixtures and OAuth preflight handling were stabilized. + +### Fixed +- Add SKI/AKI extensions to generated certificates for Python 3.13 SSL compatibility. + +## [0.1.12] - 2026-02-25 + +### Added +- Sidebar task-type coloring and live-mode detail-scroll reset fix. + +### Changed +- Viewer UX improvements: non-blocking browser open, sidebar timestamps, and scroll preservation. +- Task fingerprinting now uses the full system prompt instead of only the first line. +- Import order cleanup to satisfy ruff lint rules. + +### Contributors +- WEIFENG2333 (#3, #4, #5, #6) + +## [0.1.11] - 2026-02-25 + +### Changed +- Packaging and release progression toward the 0.1.12 viewer/community update series. + +## [0.1.10] - 2026-02-25 + +### Changed +- Packaging and release progression toward the 0.1.12 viewer/community update series. + +## [0.1.9] - 2026-02-25 + +### Fixed +- Removed 1MB request body size limit in proxy mode. + +## [0.1.8] - 2026-02-24 + +### Added +- `--tap-host` flag to configure bind address. + +## [0.1.7] - 2026-02-24 + +### Fixed +- Diff navigation button boundary logic in the viewer. +- aiohttp server noise in terminal output. +- Natural-language message rendering compatibility by using `div.pre-text`. + +### Changed +- CI: auto-publish to PyPI on push to `main`. +- Repository policy documentation for local pre-commit checks. + +## [0.1.6] - 2026-02-21 + +### Added +- Mobile responsive viewer improvements. +- Mobile previous/next request navigation. +- Diff fallback warning and manual diff-target selector. +- Smart update check and trace cleanup improvements. + +### Fixed +- Keyboard/mobile navigation now follows visual sidebar order. +- Diff matching robustness for subagent-thread detection: + - Strip `cache_control` from message hash inputs. + - Increase message-hash truncation length for better separation. + +## [0.1.5] - 2026-02-18 + +### Added +- `claude-tap export` command to export trace JSONL to Markdown or JSON format. +- `--tap-live` flag for SSE-based real-time trace viewer. +- `--tap-live-port` flag to choose the live-viewer port. +- `--tap-open` flag to auto-open HTML viewer after exit. +- Token summary bar with input/output/cache_read/cache_write breakdown. +- `py.typed` marker file for PEP 561 support. +- Coverage configuration in `pyproject.toml`. +- This `CHANGELOG.md` file. + +### Changed +- Refactored monolithic `__init__.py` into focused modules (`sse.py`, `trace.py`, `live.py`, `proxy.py`, `viewer.py`, `cli.py`). +- Migrated tests to pytest with a structured `tests/` layout. +- Entry point changed to `claude_tap.cli:main_entry` (public API unchanged). + +### Removed +- `anthropic` dependency (SSE reassembly uses built-in implementation). +- Cost estimation feature (pricing data maintenance overhead). + +## [0.1.4] - 2026-02-16 + +### Added +- `--tap-live` real-time viewer with SSE updates. + +### Changed +- Viewer UI improvements for image rendering, file path display, and live-mode behavior. + +## [0.1.3] - 2026-02-16 + +### Added +- `-v/--version` CLI flag. +- PyPI badges in README. +- Pre-commit hooks configuration. +- pytest-based test infrastructure. + +### Changed +- Applied ruff formatting to all Python files. + +## [0.1.2] - 2026-02-15 + +### Added +- Structural diff view in HTML viewer. +- Side-by-side comparison for consecutive requests. +- Turn ordering fix. + +## [0.1.1] - 2026-02-15 + +### Fixed +- Stdout buffering issue with uv tool. +- Transparent argument passthrough to claude. + +## [0.1.0] - 2026-02-15 + +### Added +- Initial release. +- Local reverse proxy for Claude Code API requests. +- JSONL trace recording. +- Self-contained HTML viewer with: + - Light/dark mode + - i18n support (8 languages) + - Token usage display + - SSE event inspection + - System prompt viewing + - cURL export diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..72c4185 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ +# Code of Conduct + +## Our Standard + +This project should be a respectful, practical place to discuss `claude-tap`, local proxying, trace inspection, and related developer tooling. + +Examples of constructive behavior: + +- Be direct and specific about technical problems. +- Assume good intent while still asking for evidence. +- Keep criticism focused on code, docs, behavior, and user impact. +- Respect privacy when discussing traces, prompts, logs, screenshots, and recordings. +- Help make issues and pull requests actionable. + +Examples of unacceptable behavior: + +- Harassment, insults, threats, or sustained personal attacks +- Sexualized language or imagery +- Publishing someone else's private information +- Sharing unredacted credentials, private traces, or sensitive local data +- Repeated off-topic disruption after maintainers ask for a change + +## Enforcement + +Maintainers may edit, hide, or remove comments, issues, pull requests, or other contributions that violate this code of conduct. Maintainers may also temporarily or permanently restrict participation when needed to protect the project and its contributors. + +## Reporting + +Report conduct concerns privately to the maintainers. Do not include sensitive personal data, credentials, or private trace files in public issues. + +Security vulnerabilities should be reported through the security reporting process, not through public conduct reports. + +## Scope + +This code of conduct applies to project spaces, including GitHub issues, pull requests, discussions, reviews, and any project-managed communication channel. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a125328 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# Contributing + +Thanks for considering a contribution to `claude-tap`. + +This project is a local proxy and trace viewer for AI coding clients. Changes can affect request routing, credential handling, trace files, and generated HTML output, so small, well-scoped pull requests are easiest to review. + +## Start Here + +1. Open an issue for bugs, feature requests, and behavior changes unless the fix is obvious. +2. Keep each pull request focused on one concern. +3. Include the commands you ran and any relevant trace, screenshot, or recording evidence. +4. Do not include private prompts, API keys, auth tokens, local file contents, or unredacted `.traces/` output in issues or pull requests. + +Maintainer and automation-specific workflow notes live in `AGENTS.md`. External contributors do not need to follow agent-only steps such as opening PRs with `gh` or using repository skills. + +## Development Setup + +```bash +git clone https://github.com/liaohch3/claude-tap.git +cd claude-tap +uv sync --extra dev +``` + +Install the local CLI during development: + +```bash +uv run python -m claude_tap --help +``` + +## Local Checks + +Run the focused checks that match your change: + +```bash +uv run --extra dev ruff check . +uv run --extra dev ruff format --check . +uv run --extra dev pytest tests/ -x --timeout=60 +``` + +For viewer or browser-facing changes, install Playwright browsers before running browser tests: + +```bash +uv run playwright install chromium +uv run --extra dev pytest tests/test_nav_browser.py tests/test_responses_browser.py -x --timeout=60 +``` + +Real end-to-end tests require a working Claude CLI or Codex CLI account and are opt-in: + +```bash +uv run --extra dev pytest tests/e2e/ --run-real-e2e --timeout=300 +``` + +## Pull Request Checklist + +- Explain the problem and the user-visible behavior change. +- List validation commands and results. +- Add or update tests when behavior changes. +- Update `README.md`, `README_zh.md`, or `CHANGELOG.md` when user-facing behavior changes. +- Include screenshots or recordings for viewer UI changes. +- Redact private data from all trace evidence. + +## Release Notes + +Published versions are documented in `CHANGELOG.md`. If a change should appear in the next release, add it under `## [Unreleased]` or the release section requested by maintainers. + +## Reporting Security Issues + +Do not open a public issue for security-sensitive reports. Contact the maintainers privately before sharing exploit details, private traces, credentials, or other sensitive material. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c61272a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 liaohch3 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd4eced --- /dev/null +++ b/README.md @@ -0,0 +1,677 @@ +# claude-tap + +[![PyPI version](https://img.shields.io/pypi/v/claude-tap.svg)](https://pypi.org/project/claude-tap/) +[![PyPI downloads](https://img.shields.io/pypi/dm/claude-tap.svg)](https://pypi.org/project/claude-tap/) +[![Python version](https://img.shields.io/pypi/pyversions/claude-tap.svg)](https://pypi.org/project/claude-tap/) +[![License](https://img.shields.io/github/license/liaohch3/claude-tap.svg)](https://github.com/liaohch3/claude-tap/blob/main/LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/liaohch3/claude-tap?style=social)](https://github.com/liaohch3/claude-tap/stargazers) +[![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg)](#contributors) + +[中文文档](README_zh.md) + +`claude-tap` is a local proxy and trace viewer for AI coding agents. Run your CLI through it, or listen to local app transcripts, then inspect the real API traffic and agent context: system prompts, conversation history, tool schemas, tool calls, streaming responses, token usage, and request diffs. + +Website: [Local AI Agent Trace Viewer](https://liaohch3.com/claude-tap/) · Guide: [How to view agent traces locally](docs/guides/agent-trace-viewer.md) + +It works with [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Codex App](https://openai.com/codex/), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Kimi CLI](https://github.com/MoonshotAI/kimi-cli), [MiMo Code](https://mimo.xiaomi.com/en/mimocode), [OpenCode](https://opencode.ai), [OpenClaw](https://github.com/openclaw/openclaw), [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent), [Hermes Agent](https://github.com/NousResearch/hermes-agent), [Cursor CLI](https://cursor.com/cli), [Qoder CLI](https://qoder.com/cli), [Antigravity CLI](https://antigravity.google/product/antigravity-cli), and [CodeBuddy CLI](https://www.codebuddy.ai). + +

+ claude-tap demo showing a real Codex trace +
+ Open a real agent run, inspect every request, and compare how context changes between turns. +

+ + + + + + + +
+ Light mode trace viewer +
+ Light viewer overview +
+ Dark mode trace viewer +
+ Dark mode for long review sessions +
+ Structured diff modal +
+ Structured diff across adjacent requests +
+ +## Built with claude-tap + + + + + + +
+ Phistory archives versioned system prompt snapshots from agent CLIs such as Claude Code, Codex, Kimi, opencode, and Pi. It uses claude-tap's capture-only prompt export to preserve raw HTTP trace evidence and generate comparison-friendly prompt snapshots. +

+ Open the prompt diff viewer · View repository +
+ + Phistory prompt diff viewer + +
+ +## Why use it + +- 👀 **See the exact context**: inspect prompts, messages, tool definitions, tool calls, tool results, reconstructed streaming responses, and token usage. +- 🔎 **Debug behavior with evidence**: compare adjacent requests and pinpoint which prompt, message, tool, or parameter changed. +- 📦 **Share one portable artifact**: each run writes a local trace session that can be exported to a self-contained HTML viewer for review or archiving. +- 🔒 **Keep traces on your machine**: no hosted dashboard is required, and common auth headers are redacted before recording. +- 🧩 **Use one workflow across clients**: trace Claude Code, Codex CLI, Codex App, Gemini CLI, Kimi CLI, MiMo Code, OpenCode, OpenClaw, Pi, Hermes Agent, Cursor CLI, Qoder CLI, and CodeBuddy. + +## Supported Clients + +| Client | Typical use | +|--------|-------------| +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Anthropic API, AWS Bedrock, Claude-compatible gateways such as DeepSeek / GLM, or local proxy upstreams such as CC Switch | +| [Codex CLI](https://github.com/openai/codex) | OpenAI API key mode or ChatGPT subscription OAuth | +| [Codex App](https://openai.com/codex/) | Local Codex App sessions imported from `CODEX_HOME` or `~/.codex`; automatic best-effort CDP WebSocket enrichment | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Google OAuth / Code Assist traffic | +| [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | Legacy kimi-cli and the newer Kimi Code CLI | +| [MiMo Code](https://mimo.xiaomi.com/en/mimocode) | MiMo Code sessions (OpenCode fork with multi-provider support) | +| [OpenCode](https://opencode.ai) | Multi-provider OpenCode sessions | +| [OpenClaw](https://github.com/openclaw/openclaw) | Multi-provider OpenClaw sessions | +| [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) | Pi sessions, including OpenAI Codex OAuth providers | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | Multi-provider Hermes TUI or gateway sessions | +| [Cursor CLI](https://cursor.com/cli) | Cursor Agent sessions plus readable local transcript import | +| [Qoder CLI](https://qoder.com/cli) | Qoder Agent sessions through forward proxy mode | +| [Antigravity CLI](https://antigravity.google/product/antigravity-cli) | Antigravity Agent sessions through forward proxy mode | +| [CodeBuddy CLI](https://www.codebuddy.ai) | Tencent CodeBuddy SaaS or internal Copilot endpoint | + +## Install + +Requires Python 3.11+ and the client you want to trace. + +```bash +# Recommended +uv tool install claude-tap + +# Or with pip +pip install claude-tap +``` + +Upgrade: `claude-tap update`, `uv tool upgrade claude-tap`, or `pip install --upgrade claude-tap` + +## Quick Start + +Run the client you want to inspect through `claude-tap`. Flags after `--` are passed to the selected client. + +```bash +# Claude Code with the live browser viewer enabled by default +claude-tap + +# Restore pre-v0.1.75 behavior: no live viewer server +claude-tap --tap-no-live + +# Codex CLI +claude-tap --tap-client codex + +# Codex App local session listener +claude-tap --tap-client codexapp + +# Gemini CLI +claude-tap --tap-client gemini -- -p "hello" + +# Kimi CLI +claude-tap --tap-client kimi + +# New Kimi Code CLI +claude-tap --tap-client kimi-code + +# MiMo Code (OpenCode fork) +claude-tap --tap-client mimo + +# Pi +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark -p "hello" + +# Cursor CLI +claude-tap --tap-client cursor -- -p --trust --model auto "hello" + +# Qoder CLI +claude-tap --tap-client qoder -- -p "hello" --permission-mode dont_ask + +# Antigravity CLI +claude-tap --tap-client agy + +# CodeBuddy CLI +claude-tap --tap-client codebuddy +``` + +
+Claude Code examples + +```bash +# Pass flags through to Claude Code +claude-tap -- --model claude-opus-4-6 +claude-tap -c # continue last conversation + +# Skip all permission prompts (auto-accept tool calls) +claude-tap -- --dangerously-skip-permissions + +# Live viewer is on by default; pass Claude flags after -- +claude-tap -- --dangerously-skip-permissions --model claude-sonnet-4-6 +``` + +`claude-tap` auto-detects custom Claude Code upstreams from `ANTHROPIC_BASE_URL`, +`ANTHROPIC_BEDROCK_BASE_URL`, or `ANTHROPIC_VERTEX_BASE_URL` in your environment +or Claude settings. Use `--tap-target` only when you want to override that +detected target. + +Local proxy upstreams are supported too: if a tool such as [CC Switch](https://github.com/farion1231/cc-switch) points Claude Code at a local `ANTHROPIC_BASE_URL`, `claude-tap` detects that value from Claude settings and records the traffic before forwarding it upstream. Use `claude-tap` in place of `claude`, such as `claude-tap -- `; no separate `--tap-client` value is needed. + +For the Claude Code VS Code extension, set `Claude Code: Claude Process Wrapper` to `claude-tap`; on Windows, use the full `claude-tap.exe` path if VS Code cannot find it. + +
+ +
+Claude Code with DeepSeek API + +Full English guide: [Claude Code with DeepSeek API](docs/guides/deepseek-claude-code.md). Simplified Chinese version: [Claude Code 搭配 DeepSeek API](docs/guides/deepseek-claude-code.zh.md). + +```bash +export ANTHROPIC_AUTH_TOKEN="" +unset ANTHROPIC_API_KEY + +export ANTHROPIC_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_OPUS_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_SUBAGENT_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_EFFORT_LEVEL=max +export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic +``` + +```bash +claude-tap -- --permission-mode bypassPermissions +``` + +`claude-tap` reads the DeepSeek upstream from `ANTHROPIC_BASE_URL`, then launches Claude Code against the local proxy. Use `--tap-target https://api.deepseek.com/anthropic` only as a manual override. + +
+ +
+Claude Code with AWS Bedrock + +`claude-tap` supports three Bedrock scenarios and auto-detects which applies: + +**Anthropic-compatible Bedrock gateway (New API or similar, no SigV4 in Claude Code)** + +```bash +export ANTHROPIC_AUTH_TOKEN="" +unset ANTHROPIC_API_KEY +export ANTHROPIC_BASE_URL="https://new-api.example.com" +export ANTHROPIC_MODEL="bedrock/claude-opus-4-6" +export ANTHROPIC_DEFAULT_OPUS_MODEL="bedrock/claude-opus-4-6" +export ANTHROPIC_DEFAULT_SONNET_MODEL="bedrock/claude-opus-4-6" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="bedrock/claude-opus-4-6" +claude-tap -- --model bedrock/claude-opus-4-6 +``` + +`claude-tap` records the normal Claude Code `/v1/messages` HTTP/SSE traffic, then +forwards it to the gateway. For model names prefixed with `bedrock/`, it removes +Claude Code beta-only request options that AWS Bedrock rejects while preserving +the captured trace. + +**Custom Bedrock gateway (company proxy, no SigV4)** + +```bash +export CLAUDE_CODE_USE_BEDROCK=1 +export ANTHROPIC_BEDROCK_BASE_URL="https://your-gateway.company.com/bedrock" +claude-tap +``` + +`claude-tap` detects the non-AWS host, redirects both `ANTHROPIC_BASE_URL` and `ANTHROPIC_BEDROCK_BASE_URL` to the local proxy, and decodes the AWS EventStream binary response format to extract token usage and model info. + +**AWS native Bedrock (SigV4-signed requests)** + +```bash +export CLAUDE_CODE_USE_BEDROCK=1 +export ANTHROPIC_BEDROCK_BASE_URL="https://bedrock-runtime.us-east-1.amazonaws.com" +export AWS_REGION="us-east-1" +claude-tap --tap-proxy-mode forward +``` + +When the endpoint is a real AWS domain (`*.amazonaws.com`), `claude-tap` does **not** rewrite `ANTHROPIC_BEDROCK_BASE_URL` to localhost — doing so would break AWS SigV4 signature validation. Use forward proxy mode (`--tap-proxy-mode forward`) to capture this traffic without modifying the signed request. + +Use `--tap-target` only as a manual override when auto-detection does not apply. + +
+ +
+Claude Code with Google Vertex AI + +`claude-tap` supports Claude Code Vertex pass-through gateways that expose the +Vertex `rawPredict`, `streamRawPredict`, and `count-tokens:rawPredict` paths. + +```bash +export CLAUDE_CODE_USE_VERTEX=1 +export CLOUD_ML_REGION="us-east5" +export ANTHROPIC_VERTEX_PROJECT_ID="your-project-id" +export ANTHROPIC_VERTEX_BASE_URL="https://your-gateway.company.com/vertex" +export CLAUDE_CODE_SKIP_VERTEX_AUTH=1 # when your gateway handles auth +claude-tap +``` + +When `CLAUDE_CODE_USE_VERTEX=1` and `ANTHROPIC_VERTEX_BASE_URL` is set, +`claude-tap` detects that upstream, redirects both `ANTHROPIC_BASE_URL` and +`ANTHROPIC_VERTEX_BASE_URL` to the local proxy, and records Vertex rawPredict +HTTP/SSE traffic. If Claude Code uses native Google Vertex without +`ANTHROPIC_VERTEX_BASE_URL`, use forward proxy mode or set the base URL +explicitly so reverse mode has a single target to forward to. + +
+ +
+Codex CLI auth modes and examples + +Codex CLI supports two authentication modes with different upstream targets: + +| Auth Mode | How to authenticate | Upstream target | Notes | +|-----------|-------------------|-----------------|-------| +| **OAuth** (ChatGPT subscription) | `codex login` | `https://chatgpt.com/backend-api/codex` | Default for ChatGPT Plus/Pro/Team users | +| **API Key** | Set `OPENAI_API_KEY` | `https://api.openai.com` (default) | Pay-per-use via OpenAI Platform | + +`claude-tap` auto-detects the Codex target from your auth state when possible. +In the default reverse-proxy mode, it launches Codex with a temporary sibling +provider whose `supports_websockets` setting is disabled. This produces one +HTTP/SSE trace record per request with the complete request context and does +not modify `~/.codex/config.toml`. + +```bash +# OAuth users (ChatGPT Plus/Pro/Team) — auto-detected after `codex login` +claude-tap --tap-client codex + +# If auto-detection cannot read your Codex auth file, specify the target explicitly +claude-tap --tap-client codex --tap-target https://chatgpt.com/backend-api/codex + +# API Key users — default OpenAI API target works out of the box +claude-tap --tap-client codex + +# With specific model +claude-tap --tap-client codex -- --model codex-mini-latest + +# Full auto-approval (skip all permission prompts) +claude-tap --tap-client codex -- --full-auto + +# OAuth + full auto; live viewer is enabled by default +claude-tap --tap-client codex -- --full-auto +``` + +
+ +
+Codex App listener examples + +Codex App sessions are imported from local JSONL files under `CODEX_HOME/sessions` or `~/.codex/sessions`. This mode does not launch Codex or create a network proxy; it keeps a claude-tap dashboard session open and appends in-progress and completed Codex App records as they appear. + +```bash +# Listen to local Codex App sessions and inspect them in the dashboard +claude-tap --tap-client codexapp + +# Use a custom Codex home directory +CODEX_HOME=/path/to/codex-home claude-tap --tap-client codexapp +``` + +`--tap-client codexapp` automatically imports the local transcript and silently tries to add CDP WebSocket evidence when a Codex App debug endpoint is available. CDP capture is a side-channel observer, not a proxy; the local session transcript remains the canonical source when the frontend does not expose model traffic through Chrome DevTools Protocol. + +
+ +
+Kimi CLI examples + +Use `--tap-client kimi` for legacy kimi-cli, or `--tap-client kimi-code` for the newer Kimi Code CLI. Both use reverse proxy mode by default. + +```bash +claude-tap --tap-client kimi +claude-tap --tap-client kimi -- --thinking +claude-tap --tap-client kimi --tap-target https://api.moonshot.ai/v1 + +claude-tap --tap-client kimi-code +claude-tap --tap-client kimi-code -- --thinking +claude-tap --tap-client kimi-code --tap-target https://api.moonshot.ai/v1 +``` + +
+ +
+Gemini CLI examples + +Gemini CLI uses forward proxy mode by default. Google OAuth / Code Assist traffic goes to several Google endpoints, so forward proxy capture is the safest default. Reverse mode remains available for API-key or Vertex-style flows that honor `GOOGLE_GEMINI_BASE_URL` or `GOOGLE_VERTEX_BASE_URL`. + +```bash +# Google OAuth / Code Assist +claude-tap --tap-client gemini -- -p "hello" + +# Live viewer is enabled by default +claude-tap --tap-client gemini -- -p "hello" + +# Reverse mode for compatible API-key / Vertex flows +claude-tap --tap-client gemini --tap-proxy-mode reverse -- -p "hello" +``` + +
+ +
+OpenCode examples + +[OpenCode](https://opencode.ai) is a multi-provider terminal AI assistant. Because it can talk to many providers, claude-tap defaults to **forward proxy** mode for opencode: it injects `HTTPS_PROXY` plus the local CA into the child process so traffic to any provider is captured. + +```bash +# Forward proxy mode — captures every provider opencode talks to (default) +claude-tap --tap-client opencode + +# Live viewer is enabled by default +claude-tap --tap-client opencode + +# Reverse mode — only works when using Anthropic provider (single ANTHROPIC_BASE_URL) +claude-tap --tap-client opencode --tap-proxy-mode reverse +``` + +
+ +
+MiMo Code examples + +[MiMo Code](https://mimo.xiaomi.com/en/mimocode) is an [OpenCode](https://opencode.ai) fork with persistent memory, subagent orchestration, and Xiaomi MiMo platform integration. claude-tap defaults to **forward proxy** mode for mimocode: it injects `HTTPS_PROXY` plus the local CA into the child process so traffic to any provider is captured. + +```bash +# Forward proxy mode — captures every provider MiMo Code talks to (default) +claude-tap --tap-client mimo + +# Live viewer is enabled by default +claude-tap --tap-client mimo + +# Reverse mode — single Anthropic provider with mimo-only disabled +claude-tap --tap-client mimo --tap-proxy-mode reverse +``` + +
+ +
+Pi examples + +[Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) is a multi-provider coding agent. claude-tap defaults to **forward proxy** mode for Pi because Pi can use subscription OAuth providers such as `openai-codex` and custom API-key providers from its model registry. + +```bash +# OpenAI Codex OAuth via Pi's openai-codex provider +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark -p "hello" + +# Live viewer is enabled by default +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark -p "hello" + +# Read-only tool capture +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark --tools bash -p "Run pwd" +``` + +Pi stores OAuth credentials in `~/.pi/agent/auth.json` after `/login`. If you keep Pi credentials in another directory, set `PI_CODING_AGENT_DIR` before launching `claude-tap`. + +
+ +
+Hermes Agent examples + +Hermes Agent is a multi-provider Python AI agent (Nous Portal, OpenRouter, NVIDIA NIM, Xiaomi MiMo, GLM, Kimi, MiniMax, Hugging Face, OpenAI, Anthropic, custom). Because it can talk to any of these providers — and `httpx` / `requests` both honor `HTTPS_PROXY` natively — claude-tap defaults to **forward proxy** mode for hermes: it injects `HTTPS_PROXY` plus the local CA into the child process so any provider is captured. + +```bash +# Interactive TUI — the recommended way for local trace capture. +claude-tap --tap-client hermes + +# Gateway mode — captures LLM calls triggered by incoming platform messages (Slack, Telegram, etc.). +# Requires a messaging platform configured in ~/.hermes/.env. +# claude-tap auto-rewrites `gateway start` → `gateway run` so the gateway runs in the +# foreground and inherits HTTPS_PROXY; without this, the daemon spawned by systemd/launchd +# would not go through the proxy and no traces would be recorded. +claude-tap --tap-client hermes -- gateway start + +# Reverse mode is opt-in and only useful when ~/.hermes is configured with an +# OpenAI-compatible provider that reads OPENAI_BASE_URL. +claude-tap --tap-client hermes --tap-proxy-mode reverse +``` + +> **Note:** Gateway mode only produces traces when a configured messaging platform (Slack, Telegram, etc.) delivers a message to the bot. Without an active platform integration, the gateway makes no LLM calls and no traces are recorded. + +
+ +
+Cursor CLI examples + +Cursor CLI uses forward proxy mode by default. Use `--model auto` on free plans, and omit `--mode ask` when you want tool calls. + +```bash +claude-tap --tap-client cursor -- -p --trust --model auto "hello" +claude-tap --tap-client cursor -- -p --trust --model auto --continue "continue" +``` + +
+ +## Guides and Integrations + +- [OpenClaw setup guide](docs/guides/OPENCLAW_README.md) for integrating `claude-tap` with OpenClaw. Simplified Chinese version: [OpenClaw 设置指南](docs/guides/OPENCLAW_README.zh.md). +- [Claude Code with DeepSeek API](docs/guides/deepseek-claude-code.md) for routing Claude Code through DeepSeek's Anthropic-compatible API. Simplified Chinese version: [Claude Code 搭配 DeepSeek API](docs/guides/deepseek-claude-code.zh.md). +- [Client support matrix](docs/support-matrix.md) for exact environment variables, proxy modes, and URL rewrite rules. + +
+Qoder CLI examples + +Qoder CLI talks to multiple Qoder endpoints, so claude-tap defaults to **forward proxy** mode for `--tap-client qoder`. + +```bash +# Browser login, PAT, or job token must be configured before launch. +qodercli login + +claude-tap --tap-client qoder -- -p "hello" --permission-mode dont_ask +``` + +
+ +
+Antigravity CLI examples + +Antigravity CLI talks to multiple Google/Antigravity endpoints, so claude-tap defaults to **forward proxy** mode for `--tap-client agy`. Its Code Assist model API also honors `CLOUD_CODE_URL`; claude-tap injects that automatically so model requests such as `/v1internal:streamGenerateContent` are captured by the same local proxy. + +On macOS, Antigravity may not honor per-process CA environment variables. claude-tap automatically trusts the local CA in your current user's login keychain on first `agy` launch. This does not use `sudo` or the System keychain, though macOS may prompt to unlock the login keychain. + +```bash +claude-tap --tap-client agy --tap-live + +# Optional: trust the CA separately before launching a forward-proxy client. +claude-tap trust-ca +``` + +
+ +
+CodeBuddy CLI examples + +CodeBuddy uses reverse proxy mode by default. claude-tap auto-detects the upstream from CodeBuddy's own login cache (`~/.codebuddy/local_storage/`), so iOA / WeChat / Google-Github / Enterprise-Domain login modes all work without any extra flag. When the cache is missing (e.g. before first login), it falls back to `https://copilot.tencent.com/v2`. + +```bash +# Auto-detected endpoint (works for all four login modes once logged in) +claude-tap --tap-client codebuddy + +# Explicit override (e.g. external SaaS or staging) +claude-tap --tap-client codebuddy --tap-target https://www.codebuddy.ai/v2 + +# Or via environment variable +CODEBUDDY_BASE_URL=https://www.codebuddy.ai/v2 claude-tap --tap-client codebuddy -- -p "Reply OK" +``` + +
+ +
+Viewer, export, and advanced options + +```bash +# Live viewer runs by default while a client runs +claude-tap + +# Disable live viewer for scripts, CI, remote shells, or old behavior +claude-tap --tap-no-live + +# Browse saved traces without launching a client +claude-tap dashboard + +# Stop the shared dashboard service +claude-tap dashboard stop + +# Build a local macOS menu bar app, then double-click it in Finder +claude-tap build-macos-app +open "dist/Claude Tap.app" + +# Build an Apple Silicon app that bundles Python and dependencies +claude-tap build-macos-app --self-contained + +# Restore Claude/Codex configs if the menu app is force-killed while monitoring +claude-tap monitor-restore + +# Regenerate a self-contained HTML viewer from JSONL or compact trace input +claude-tap export .traces/2026-02-28/trace_141557.jsonl -o trace.html + +# Export a portable compact trace bundle, then render it later. +# Compact is the default export format. +claude-tap export -o trace.ctap.json +claude-tap export trace.ctap.json -o trace.html + +# Embed the exported viewer in an iframe with reduced chrome +# trace.html?embed=1&hideHeader=1&hidePath=1&hideHistory=1&hideControls=1&density=compact&theme=light + +# Store traces in another directory, or keep fewer sessions +claude-tap --tap-output-dir ./my-traces +claude-tap --tap-max-traces 10 + +# Start only the proxy for custom setups +claude-tap --tap-no-launch --tap-port 8080 + +# Disable browser auto-open for live and generated viewers +claude-tap --tap-no-open +``` + +In proxy-only mode, start your client in another terminal and point its base URL or proxy settings at the local proxy. Use the [client support matrix](docs/support-matrix.md) for exact wiring. + +When used as VSCode Claude Code's `claudeProcessWrapper`, claude-tap honors the Claude binary path passed by the extension. + +On macOS, `claude-tap build-macos-app` creates a local `Claude Tap.app` bundle. The app runs as a menu bar item with a compact status board, Start Monitor / Stop Monitor controls, and a shortcut to the full dashboard. Start Monitor asks for confirmation, launches local reverse proxies for Claude Code and Codex CLI, then writes temporary base-URL settings into `~/.claude/settings.json` and `~/.codex/config.toml` so newly opened sessions are captured. Codex custom providers are routed through the selected provider's `base_url`; Claude Bedrock custom gateways are routed when they do not point at native AWS Bedrock endpoints. Native AWS Bedrock endpoints are left unchanged because reverse-mode URL rewriting would break SigV4 signing. Stop Monitor restores the config files byte-for-byte. If the app is force-killed, run `claude-tap monitor-restore` to restore configs and clean up monitor processes recorded by the app. + +By default the launcher points at the current checkout; pass `--installed` if `claude-tap` is installed in the Python environment used to build the app. Pass `--self-contained` to build an Apple Silicon PyInstaller bundle under `Contents/Resources` so the app does not depend on a colleague's Python installation. Ad-hoc signed builds may still require the recipient to remove quarantine or approve the app in macOS security settings. + +### CLI Options + +All flags are forwarded to the selected client, except these `--tap-*` ones: + +``` +--tap-client CLIENT Client to launch/listen to: claude (default), agy, codex, codexapp, gemini, kimi, kimi-code, mimo, opencode, openclaw, pi, hermes, cursor, qoder, or codebuddy +--tap-target URL Upstream API URL (default: auto per client) +--tap-live Start real-time viewer while the client runs (default: on) +--tap-no-live Disable the real-time viewer server (pre-v0.1.75 behavior) +--tap-live-port PORT Port for live viewer server (default: auto) +--tap-no-open Don't auto-open live or generated HTML viewers in a browser +--tap-output-dir DIR Trace output directory (default: ./.traces) +--tap-port PORT Proxy port (default: auto) +--tap-host HOST Bind address (default: 127.0.0.1, or 0.0.0.0 in --tap-no-launch mode) +--tap-no-launch Only start the proxy, don't launch client +--tap-max-traces N Max trace sessions to keep (default: 50, 0 = unlimited) +--tap-store-stream-events Persist raw SSE/WebSocket event arrays during capture so viewer/export output can show them (default: off) +--tap-proxy-mode MODE Proxy mode: reverse or forward (default: reverse for claude/codex/kimi/kimi-code/openclaw/codebuddy, forward for agy/gemini/mimo/opencode/pi/hermes/cursor/qoder; codexapp is transcript-only) +--tap-trust-ca On macOS, explicitly trust the local CA in the user login keychain before launch (agy does this automatically) +``` + +
+ +## Viewer Features + +### Trace viewer capabilities + +The viewer is a single self-contained HTML file (zero external dependencies): + +- **Structural diff** — compare consecutive requests to see exactly what changed: new/removed messages, system prompt diffs, character-level inline highlighting +- **Path filtering** — filter by API endpoint (e.g., `/v1/messages` only) +- **Model grouping** — sidebar groups requests by model, with Claude-family priority ordering +- **Token usage breakdown** — input / output / cache read / cache creation +- **Tool inspector** — expandable cards with tool name, description, and parameter schema +- **Search** — full-text search across messages, tools, prompts, and responses +- **Dark mode** — toggle light/dark themes (respects system preference) +- **Iframe embed mode** — add query parameters such as `embed=1`, `hideHeader=1`, `hidePath=1`, `hideHistory=1`, `hideControls=1`, `density=compact`, and `theme=light|dark` +- **Keyboard navigation** — `j`/`k` or arrow keys +- **Copy helpers** — one-click copy of request JSON or cURL command +- **i18n** — English, 简体中文, 日本語, 한국어, Français, العربية, Deutsch, Русский + +## Architecture + +![Architecture](docs/architecture.png) + +
+How it works + +**How it works:** + +1. `claude-tap` starts a reverse or forward proxy and spawns the selected client +2. Base URL clients are pointed at the reverse proxy; clients without base URL support use proxy/CA environment variables +3. SSE and WebSocket streams are forwarded as chunks/messages arrive with low proxy overhead +4. Each request-response pair or WebSocket session is recorded to local trace storage; raw SSE/WebSocket event arrays are omitted by default and must be captured with `--tap-store-stream-events` if you need them later in viewer/export output +5. On exit, a self-contained HTML viewer is generated +6. Live mode is enabled by default and broadcasts updates to the browser via SSE + +**Key features:** 🔒 Common auth headers auto-redacted · ⚡ Low-overhead streaming · 📦 Self-contained viewer · 🔄 Real-time live mode + +
+ +## Community + +### Ecosystem + +- [Phistory](https://github.com/WEIFENG2333/phistory) archives versioned system prompt snapshots from agent CLIs such as Claude Code, Codex, Kimi, opencode, and Pi. It uses claude-tap's capture-only prompt export to preserve raw HTTP trace evidence and generate comparison-friendly prompt snapshots. + +### Star History + + + + + + Star History Chart + + + +### Contributors + +Thanks goes to these contributors: + + + + + + + + + + + + + + + + + + + + +
liaohch3
liaohch3

💻 📖 🚧 ⚠️
BKK
BKK

💻
YoungCan-Wang
YoungCan-Wang

💻
0xkrypton
0xkrypton

💻
CYJiang
CYJiang

💻
陈展鹏
陈展鹏

📖
devtalker
devtalker

💻
Yaguang Ding
Yaguang Ding

💻
Sephy
Sephy

💻
+ + + + + + +## Contributing + +Contributions are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md). + +## License + +MIT diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..9a629ee --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`liaohch3/claude-tap` +- 原始仓库:https://github.com/liaohch3/claude-tap +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..a6c8faa --- /dev/null +++ b/README_zh.md @@ -0,0 +1,664 @@ +# claude-tap + +[![PyPI version](https://img.shields.io/pypi/v/claude-tap.svg)](https://pypi.org/project/claude-tap/) +[![PyPI downloads](https://img.shields.io/pypi/dm/claude-tap.svg)](https://pypi.org/project/claude-tap/) +[![Python version](https://img.shields.io/pypi/pyversions/claude-tap.svg)](https://pypi.org/project/claude-tap/) +[![License](https://img.shields.io/github/license/liaohch3/claude-tap.svg)](https://github.com/liaohch3/claude-tap/blob/main/LICENSE) +[![GitHub stars](https://img.shields.io/github/stars/liaohch3/claude-tap?style=social)](https://github.com/liaohch3/claude-tap/stargazers) +[![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg)](#贡献者) + +[English](README.md) + +`claude-tap` 是给 AI 编程 agent 用的本地代理和 trace 查看器。把 CLI 通过它启动,或监听本地 app transcript,就能看到真实 API 流量和 agent 上下文:system prompt、对话历史、工具 schema、工具调用、流式响应、token 用量和请求 diff。 + +网站:[本地 AI Agent Trace Viewer](https://liaohch3.com/claude-tap/) · 指南:[如何本地查看 Agent traces](docs/guides/agent-trace-viewer.zh.md) + +它支持 [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Codex CLI](https://github.com/openai/codex)、[Codex App](https://openai.com/codex/)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Kimi CLI](https://github.com/MoonshotAI/kimi-cli)、[MiMo Code](https://mimo.xiaomi.com/en/mimocode)、[OpenCode](https://opencode.ai)、[OpenClaw](https://github.com/openclaw/openclaw)、[Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent)、[Hermes Agent](https://github.com/NousResearch/hermes-agent)、[Cursor CLI](https://cursor.com/cli)、[Qoder CLI](https://qoder.com/cli)、[Antigravity CLI](https://antigravity.google/product/antigravity-cli) 和 [CodeBuddy CLI](https://www.codebuddy.ai)。 + +

+ claude-tap 演示:真实 Codex trace +
+ 打开一次真实 agent 运行,检查每个请求,并对比上下文如何在多轮之间变化。 +

+ + + + + + + +
+ 亮色模式 trace 查看器 +
+ 亮色模式总览 +
+ 暗色模式 trace 查看器 +
+ 适合长时间 review 的暗色模式 +
+ 结构化 Diff 弹窗 +
+ 相邻请求之间的结构化 Diff +
+ +## 使用 claude-tap 构建 + + + + + + +
+ Phistory 会归档 Claude Code、Codex、Kimi、opencode、Pi 等 Agent CLI 的系统提示词版本快照。它基于 claude-tap 的 capture-only prompt export 能力,保留原始 HTTP trace 证据,并生成方便阅读和对比的 prompt 快照。 +

+ 打开 prompt diff 查看器 · 查看仓库 +
+ + Phistory prompt diff 查看器 + +
+ +## 为什么用它 + +- 👀 **看见真实上下文**:检查 prompt、messages、工具定义、工具调用、工具结果、流式 chunk 和 token 用量。 +- 🔎 **用证据定位问题**:对比相邻请求,明确是哪段 prompt、消息、工具或参数发生了变化。 +- 📦 **留下可分享证据**:每次运行都会写入 JSONL trace,并生成自包含 HTML 查看器,方便 review 或归档。 +- 🔒 **数据留在本机**:不依赖云端 dashboard;常见认证 header 会在记录前自动脱敏。 +- 🧩 **覆盖主流编码客户端**:同一套流程可用于 Claude Code、Codex CLI、Codex App、Gemini CLI、Kimi CLI、MiMo Code、OpenCode、OpenClaw、Pi、Hermes Agent、Cursor CLI、Qoder CLI、Antigravity CLI 和 CodeBuddy CLI。 + +## 支持的客户端 + +| 客户端 | 典型用途 | +|--------|----------| +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Anthropic API、AWS Bedrock、DeepSeek / GLM 等 Claude 兼容网关,或 CC Switch 等本地代理上游 | +| [Codex CLI](https://github.com/openai/codex) | OpenAI API 密钥模式,或 ChatGPT 订阅 OAuth | +| [Codex App](https://openai.com/codex/) | 从 `CODEX_HOME` 或 `~/.codex` 导入本地 Codex App 会话;自动尽力补充 CDP WebSocket 证据 | +| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | Google OAuth / Code Assist 的多 Google 端点流量 | +| [Kimi CLI](https://github.com/MoonshotAI/kimi-cli) | 旧版 kimi-cli 和新版 Kimi Code CLI | +| [MiMo Code](https://mimo.xiaomi.com/en/mimocode) | MiMo Code 会话(基于 OpenCode 的多提供方 fork) | +| [OpenCode](https://opencode.ai) | 多提供方 OpenCode 会话 | +| [OpenClaw](https://github.com/openclaw/openclaw) | 多提供方 OpenClaw 会话 | +| [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) | Pi 会话,包括 OpenAI Codex OAuth 提供方 | +| [Hermes Agent](https://github.com/NousResearch/hermes-agent) | 多提供方 Hermes TUI 或 gateway 会话 | +| [Cursor CLI](https://cursor.com/cli) | Cursor Agent 会话,并导入可读的本地 transcript | +| [Qoder CLI](https://qoder.com/cli) | 通过 forward proxy 捕获 Qoder Agent 会话 | +| [Antigravity CLI](https://antigravity.google/product/antigravity-cli) | 通过 forward proxy 捕获 Antigravity Agent 会话 | +| [CodeBuddy CLI](https://www.codebuddy.ai) | 腾讯 CodeBuddy SaaS 或内部 Copilot 端点 | + +## 安装 + +需要 Python 3.11+,以及你要追踪的客户端。 + +```bash +# 推荐 +uv tool install claude-tap + +# 或用 pip +pip install claude-tap +``` + +升级: `claude-tap update`、`uv tool upgrade claude-tap` 或 `pip install --upgrade claude-tap` + +## 快速开始 + +用 `claude-tap` 启动你想观察的客户端。`--` 后面的参数会透传给所选客户端。 + +```bash +# Claude Code,默认开启浏览器实时查看器 +claude-tap + +# 恢复 v0.1.75 之前的行为:不启动实时查看器 +claude-tap --tap-no-live + +# Codex CLI +claude-tap --tap-client codex + +# Codex App 本地会话监听 +claude-tap --tap-client codexapp + +# Gemini CLI +claude-tap --tap-client gemini -- -p "hello" + +# Kimi CLI +claude-tap --tap-client kimi + +# 新版 Kimi Code CLI +claude-tap --tap-client kimi-code + +# MiMo Code(OpenCode fork) +claude-tap --tap-client mimo + +# Pi +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark -p "hello" + +# Cursor CLI +claude-tap --tap-client cursor -- -p --trust --model auto "hello" + +# Qoder CLI +claude-tap --tap-client qoder -- -p "hello" --permission-mode dont_ask + +# Antigravity CLI +claude-tap --tap-client agy + +# CodeBuddy CLI +claude-tap --tap-client codebuddy +``` + +
+Claude Code 更多示例 + +```bash +# 透传参数给 Claude Code +claude-tap -- --model claude-opus-4-6 +claude-tap -c # 继续上次对话 + +# 跳过所有权限确认(自动批准工具调用) +claude-tap -- --dangerously-skip-permissions + +# 实时查看器默认开启;-- 后面的参数透传给 Claude Code +claude-tap -- --dangerously-skip-permissions --model claude-sonnet-4-6 +``` + +`claude-tap` 会从环境变量或 Claude settings 中的 `ANTHROPIC_BASE_URL`、 +`ANTHROPIC_BEDROCK_BASE_URL` 或 `ANTHROPIC_VERTEX_BASE_URL` 自动识别自定义 Claude Code 上游;只有想手动覆盖时才需要传 `--tap-target`。 + +也支持本地代理上游:如果 [CC Switch](https://github.com/farion1231/cc-switch) 等工具把 Claude Code 指向本地 `ANTHROPIC_BASE_URL`,`claude-tap` 会从 Claude settings 中检测到该值,并在转发到上游前记录流量。用 `claude-tap` 替代 `claude` 运行,例如 `claude-tap -- `;不需要单独的 `--tap-client` 值。 + +使用 Claude Code VS Code 插件时,把 `Claude Code: Claude Process Wrapper` 设置为 `claude-tap`;如果 Windows 上 VS Code 找不到它,请填写完整的 `claude-tap.exe` 路径。 + +
+ +
+Claude Code + DeepSeek API + +完整中文指南见 [Claude Code 搭配 DeepSeek API](docs/guides/deepseek-claude-code.zh.md),英文版见 [Claude Code with DeepSeek API](docs/guides/deepseek-claude-code.md)。 + +```bash +export ANTHROPIC_AUTH_TOKEN="<你的 DeepSeek API key>" +unset ANTHROPIC_API_KEY + +export ANTHROPIC_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_OPUS_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_SUBAGENT_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_EFFORT_LEVEL=max +export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic +``` + +```bash +claude-tap -- --permission-mode bypassPermissions +``` + +`claude-tap` 会从 `ANTHROPIC_BASE_URL` 读取 DeepSeek 上游,再把 Claude Code 指向本地代理。只有手动覆盖时才需要 `--tap-target https://api.deepseek.com/anthropic`。 + +
+ +
+Claude Code + AWS Bedrock + +`claude-tap` 支持三种 Bedrock 场景,并自动检测适用哪种: + +**Anthropic 兼容 Bedrock 网关(New API 或类似网关,Claude Code 不做 SigV4)** + +```bash +export ANTHROPIC_AUTH_TOKEN="" +unset ANTHROPIC_API_KEY +export ANTHROPIC_BASE_URL="https://new-api.example.com" +export ANTHROPIC_MODEL="bedrock/claude-opus-4-6" +export ANTHROPIC_DEFAULT_OPUS_MODEL="bedrock/claude-opus-4-6" +export ANTHROPIC_DEFAULT_SONNET_MODEL="bedrock/claude-opus-4-6" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="bedrock/claude-opus-4-6" +claude-tap -- --model bedrock/claude-opus-4-6 +``` + +`claude-tap` 会记录正常的 Claude Code `/v1/messages` HTTP/SSE 流量,再转发给网关。对于以 +`bedrock/` 开头的模型名,它会在转发上游前移除 AWS Bedrock 不接受的 Claude Code beta-only 请求选项,同时保留已捕获的 trace。 + +**自定义 Bedrock 网关(公司代理,无 SigV4)** + +```bash +export CLAUDE_CODE_USE_BEDROCK=1 +export ANTHROPIC_BEDROCK_BASE_URL="https://your-gateway.company.com/bedrock" +claude-tap +``` + +`claude-tap` 检测到非 AWS 域名后,会将 `ANTHROPIC_BASE_URL` 和 `ANTHROPIC_BEDROCK_BASE_URL` 都重定向到本地代理,并解码 AWS EventStream 二进制响应格式以提取 token 用量和模型信息。 + +**AWS 原生 Bedrock(SigV4 签名请求)** + +```bash +export CLAUDE_CODE_USE_BEDROCK=1 +export ANTHROPIC_BEDROCK_BASE_URL="https://bedrock-runtime.us-east-1.amazonaws.com" +export AWS_REGION="us-east-1" +claude-tap --tap-proxy-mode forward +``` + +当端点是真实 AWS 域名(`*.amazonaws.com`)时,`claude-tap` **不会**将 `ANTHROPIC_BEDROCK_BASE_URL` 重写为 localhost — 这样做会破坏 AWS SigV4 签名验证。请使用正向代理模式(`--tap-proxy-mode forward`)来捕获此流量,而不修改已签名的请求。 + +只有手动覆盖时才需要 `--tap-target`。 + +
+ +
+Claude Code + Google Vertex AI + +`claude-tap` 支持暴露 Vertex `rawPredict`、`streamRawPredict` 和 +`count-tokens:rawPredict` 路径的 Claude Code Vertex 透传网关。 + +```bash +export CLAUDE_CODE_USE_VERTEX=1 +export CLOUD_ML_REGION="us-east5" +export ANTHROPIC_VERTEX_PROJECT_ID="your-project-id" +export ANTHROPIC_VERTEX_BASE_URL="https://your-gateway.company.com/vertex" +export CLAUDE_CODE_SKIP_VERTEX_AUTH=1 # 网关负责鉴权时使用 +claude-tap +``` + +当 `CLAUDE_CODE_USE_VERTEX=1` 且配置了 `ANTHROPIC_VERTEX_BASE_URL` 时, +`claude-tap` 会检测到该上游,将 `ANTHROPIC_BASE_URL` 和 +`ANTHROPIC_VERTEX_BASE_URL` 都重定向到本地代理,并记录 Vertex rawPredict +HTTP/SSE 流量。如果 Claude Code 直接使用 Google Vertex 原生端点且没有设置 +`ANTHROPIC_VERTEX_BASE_URL`,请使用正向代理模式,或显式设置该 base URL,让 reverse 模式有单一上游可转发。 + +
+ +
+Codex CLI 认证方式和示例 + +Codex CLI 支持两种认证方式,对应不同的上游目标: + +| 认证方式 | 如何认证 | 上游目标 | 说明 | +|---------|---------|---------|------| +| **OAuth**(ChatGPT 付费套餐) | `codex login` | `https://chatgpt.com/backend-api/codex` | ChatGPT Plus/Pro/Team 用户默认方式 | +| **API Key** | 设置 `OPENAI_API_KEY` | `https://api.openai.com`(默认) | 通过 OpenAI Platform 按量付费 | + +`claude-tap` 会尽量根据 Codex 的认证状态自动识别 target。 +在默认 reverse proxy 模式下,它会使用一个临时同级 provider 启动 Codex, +并禁用该 provider 的 `supports_websockets`。这样每个请求都会生成一条包含 +完整请求上下文的 HTTP/SSE trace,同时不会修改 `~/.codex/config.toml`。 + +```bash +# OAuth 用户(ChatGPT Plus/Pro/Team)— `codex login` 后通常会自动识别 +claude-tap --tap-client codex + +# 如果无法读取 Codex auth 文件,可以显式指定 target +claude-tap --tap-client codex --tap-target https://chatgpt.com/backend-api/codex + +# API Key 用户 — 默认 OpenAI API target 即可 +claude-tap --tap-client codex + +# 指定模型 +claude-tap --tap-client codex -- --model codex-mini-latest + +# 全自动模式(跳过所有权限确认) +claude-tap --tap-client codex -- --full-auto + +# OAuth + 全自动;实时查看器默认开启 +claude-tap --tap-client codex -- --full-auto +``` + +
+ +
+Codex App 监听示例 + +Codex App 会话会从 `CODEX_HOME/sessions` 或 `~/.codex/sessions` 下的本地 JSONL 文件导入。这个模式不会启动 Codex,也不会创建网络代理;它会保持一个 claude-tap dashboard session,并在 Codex App 运行中或完成后追加可查看的记录。 + +```bash +# 监听本地 Codex App 会话,并在 dashboard 中查看 +claude-tap --tap-client codexapp + +# 使用自定义 Codex home 目录 +CODEX_HOME=/path/to/codex-home claude-tap --tap-client codexapp +``` + +`--tap-client codexapp` 会自动导入本地 transcript,并在 Codex App debug endpoint 可用时静默补充 CDP WebSocket 证据。CDP capture 是旁路观测,不是代理;如果前端没有通过 Chrome DevTools Protocol 暴露模型流量,Codex App 复盘仍以本地 session transcript 为准。 + +
+ +
+Kimi CLI 示例 + +旧版 kimi-cli 使用 `--tap-client kimi`,新版 Kimi Code CLI 使用 `--tap-client kimi-code`。两者默认都使用 reverse proxy 模式。 + +```bash +claude-tap --tap-client kimi +claude-tap --tap-client kimi -- --thinking +claude-tap --tap-client kimi --tap-target https://api.moonshot.ai/v1 + +claude-tap --tap-client kimi-code +claude-tap --tap-client kimi-code -- --thinking +claude-tap --tap-client kimi-code --tap-target https://api.moonshot.ai/v1 +``` + +
+ +
+Gemini CLI 示例 + +Gemini CLI 默认使用 forward proxy。Google OAuth / Code Assist 流量会访问多个 Google 端点,因此 forward proxy 是更稳妥的默认抓取方式。对于会读取 `GOOGLE_GEMINI_BASE_URL` 或 `GOOGLE_VERTEX_BASE_URL` 的 API key / Vertex 类流程,仍可显式使用 reverse 模式。 + +```bash +# Google OAuth / Code Assist +claude-tap --tap-client gemini -- -p "hello" + +# 实时查看器默认开启 +claude-tap --tap-client gemini -- -p "hello" + +# API key / Vertex 兼容流程的 reverse 模式 +claude-tap --tap-client gemini --tap-proxy-mode reverse -- -p "hello" +``` + +
+ +
+OpenCode 示例 + +[OpenCode](https://opencode.ai) 是一款多 provider 的终端 AI 助手。由于它能对接多种 provider,claude-tap 默认对 opencode 使用 **forward proxy** 模式——向子进程注入 `HTTPS_PROXY` 与本地 CA,捕获它对接的任意 provider 流量。 + +```bash +# forward proxy 模式 — 捕获 opencode 对接的任意 provider(默认) +claude-tap --tap-client opencode + +# 实时查看器默认开启 +claude-tap --tap-client opencode + +# reverse 模式 — 仅在使用 Anthropic provider 时有效(单一 ANTHROPIC_BASE_URL) +claude-tap --tap-client opencode --tap-proxy-mode reverse +``` + +
+ +
+MiMo Code 示例 + +[MiMo Code](https://mimo.xiaomi.com/en/mimocode) 是 [OpenCode](https://opencode.ai) 的 fork,增加了持久化记忆、子 agent 编排和小米 MiMo 平台集成。claude-tap 默认对 mimocode 使用 **forward proxy** 模式——向子进程注入 `HTTPS_PROXY` 与本地 CA,捕获它对接的任意 provider 流量。 + +```bash +# forward proxy 模式 — 捕获 MiMo Code 对接的所有 provider(默认) +claude-tap --tap-client mimo + +# 实时查看器默认开启 +claude-tap --tap-client mimo + +# reverse 模式 — 单一 Anthropic provider 并关闭 mimo-only 模式 +claude-tap --tap-client mimo --tap-proxy-mode reverse +``` + +
+ +
+Pi 示例 + +[Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) 是一个多 provider coding agent。因为 Pi 可以使用 `openai-codex` 这类订阅 OAuth provider,也可以使用模型注册表中的自定义 API-key provider,claude-tap 默认对 Pi 使用 **forward proxy** 模式。 + +```bash +# 通过 Pi 的 openai-codex provider 使用 OpenAI Codex OAuth +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark -p "hello" + +# 实时查看器默认开启 +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark -p "hello" + +# 捕获只读工具调用 +claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark --tools bash -p "Run pwd" +``` + +Pi 在 `/login` 后会把 OAuth 凭据保存在 `~/.pi/agent/auth.json`。如果你把 Pi 凭据放在其他目录,请在启动 `claude-tap` 前设置 `PI_CODING_AGENT_DIR`。 + +
+ +
+Hermes Agent 示例 + +Hermes Agent 是基于 Python 的多 provider AI agent(Nous Portal / OpenRouter / NVIDIA NIM / 小米 MiMo / GLM / Kimi / MiniMax / Hugging Face / OpenAI / Anthropic / 自定义)。由于它能对接任意 provider,且 `httpx`、`requests` 都默认认 `HTTPS_PROXY` 环境变量,claude-tap 默认对 hermes 使用 **forward proxy** 模式——通过向子进程注入 `HTTPS_PROXY` 与本地 CA,捕获它对接的任意 provider 流量。 + +```bash +# 交互式 TUI — 本地抓 trace 的推荐方式。 +claude-tap --tap-client hermes + +# Gateway 模式 — 捕获由 Slack、Telegram 等平台消息触发的 LLM 调用。 +# 需要在 ~/.hermes/.env 中配置消息平台。 +# claude-tap 自动将 `gateway start` 改写为 `gateway run`,使 gateway 在前台运行并 +# 继承 HTTPS_PROXY;否则 systemd/launchd 启动的守护进程不会经过代理,无法抓到 trace。 +claude-tap --tap-client hermes -- gateway start + +# 反向模式仅在 ~/.hermes 配了一个读 OPENAI_BASE_URL 的 OpenAI 兼容 provider 时才有用 +claude-tap --tap-client hermes --tap-proxy-mode reverse +``` + +> **注意:** Gateway 模式只有在配置的消息平台(Slack、Telegram 等)推送消息给 bot 时才会产生 trace。若没有活跃的平台集成,gateway 不会发起 LLM 请求,也不会生成任何 trace。 + +
+ +
+Cursor CLI 示例 + +Cursor CLI 默认使用 forward proxy。免费套餐建议传 `--model auto`;需要工具调用时不要加 `--mode ask`。 + +```bash +claude-tap --tap-client cursor -- -p --trust --model auto "hello" +claude-tap --tap-client cursor -- -p --trust --model auto --continue "continue" +``` + +
+ +## 集成与指南 + +- [OpenClaw 设置指南](docs/guides/OPENCLAW_README.zh.md):在 OpenClaw 中集成 `claude-tap`。英文版见 [OpenClaw setup guide](docs/guides/OPENCLAW_README.md)。 +- [Claude Code 搭配 DeepSeek API](docs/guides/deepseek-claude-code.zh.md):让 Claude Code 走 DeepSeek 的 Anthropic 兼容 API。英文版见 [Claude Code with DeepSeek API](docs/guides/deepseek-claude-code.md)。 +- [客户端支持矩阵](docs/support-matrix.md):查看各客户端对应的环境变量、代理模式和 URL 改写规则。 + +
+Qoder CLI 示例 + +Qoder CLI 会访问多个 Qoder 端点,因此 `--tap-client qoder` 默认使用 **forward proxy** 模式。 + +```bash +# 启动前需要先配置浏览器登录、PAT 或 job token。 +qodercli login + +claude-tap --tap-client qoder -- -p "hello" --permission-mode dont_ask +``` + +
+ +
+Antigravity CLI 示例 + +Antigravity CLI 会访问多个 Google / Antigravity 端点,因此 `--tap-client agy` 默认使用 **forward proxy** 模式。它的 Code Assist 模型 API 还会读取 `CLOUD_CODE_URL`;claude-tap 会自动注入这个变量,让 `/v1internal:streamGenerateContent` 这类模型请求也进入同一个本地代理。 + +在 macOS 上,Antigravity 可能不读取进程级 CA 环境变量。首次启动 `agy` 时,claude-tap 会自动把本地 CA 信任到当前用户的 login keychain。这个操作不会使用 `sudo`,也不会写入 System keychain,但 macOS 可能要求解锁 login keychain。 + +```bash +claude-tap --tap-client agy --tap-live + +# 可选:也可以先单独信任 CA,再启动 forward proxy 客户端。 +claude-tap trust-ca +``` + +
+ +
+CodeBuddy CLI 示例 + +CodeBuddy 默认使用 reverse proxy。claude-tap 会自动从 CodeBuddy 自己的登录缓存(`~/.codebuddy/local_storage/`)识别上游地址,所以 iOA / WeChat / Google-Github / Enterprise-Domain 四种登录方式登录后都可以零参数启动。当缓存还不存在(例如首次登录前)时,会回退到 `https://copilot.tencent.com/v2`。 + +```bash +# 自动识别上游(登录后四种登录方式都适用) +claude-tap --tap-client codebuddy + +# 显式指定上游(外网 SaaS 或 staging) +claude-tap --tap-client codebuddy --tap-target https://www.codebuddy.ai/v2 + +# 或通过环境变量 +CODEBUDDY_BASE_URL=https://www.codebuddy.ai/v2 claude-tap --tap-client codebuddy -- -p "Reply OK" +``` + +
+ +
+查看器、导出和高级选项 + +```bash +# 客户端运行时默认启动实时查看器 +claude-tap + +# 脚本、CI、远程 shell 或需要旧行为时关闭实时查看器 +claude-tap --tap-no-live + +# 不启动客户端,直接浏览历史 trace +claude-tap dashboard + +# 停止共享 dashboard 服务 +claude-tap dashboard stop + +# 构建本地 macOS 菜单栏 App,然后在 Finder 中双击 +claude-tap build-macos-app +open "dist/Claude Tap.app" + +# 构建内置 Python 和依赖的 Apple Silicon App +claude-tap build-macos-app --self-contained + +# 如果菜单栏 App 在监控中被强制退出,用它恢复 Claude/Codex 配置 +claude-tap monitor-restore + +# 从已有 JSONL 或紧凑 trace 重新生成自包含 HTML 查看器 +claude-tap export .traces/2026-02-28/trace_141557.jsonl -o trace.html + +# 导出可独立搬运的压缩 trace,再按需渲染;压缩格式是默认导出格式 +claude-tap export -o trace.ctap.json +claude-tap export trace.ctap.json -o trace.html + +# 在 iframe 中嵌入导出的查看器,并减少外层 chrome +# trace.html?embed=1&hideHeader=1&hidePath=1&hideHistory=1&hideControls=1&density=compact&theme=light + +# 自定义 trace 输出目录,或限制保留数量 +claude-tap --tap-output-dir ./my-traces +claude-tap --tap-max-traces 10 + +# 只启动代理,给自定义场景使用 +claude-tap --tap-no-launch --tap-port 8080 + +# 不自动在浏览器里打开实时或生成的查看器 +claude-tap --tap-no-open +``` + +纯代理模式下,可以在另一个终端启动客户端,并把它的 base URL 或代理配置指向本地代理。具体接法见 [客户端支持矩阵](docs/support-matrix.md)。 + +作为 VSCode Claude Code 的 `claudeProcessWrapper` 使用时,claude-tap 会识别扩展传入的 Claude binary 路径并用它启动 Claude。 + +macOS 上,`claude-tap build-macos-app` 会生成本地 `Claude Tap.app`。该 App 以菜单栏图标运行,点击后显示紧凑状态看板,并提供 Start Monitor / Stop Monitor 控制和完整 dashboard 快捷入口。Start Monitor 会先请求确认,再启动 Claude Code 和 Codex CLI 的本地反向代理,并把临时 base URL 写入 `~/.claude/settings.json` 和 `~/.codex/config.toml`,之后新开的会话会被捕获。Codex 自定义 provider 会改写所选 provider 的 `base_url`;Claude Bedrock 自定义网关会在目标不是 AWS 原生 Bedrock endpoint 时被路由。AWS 原生 Bedrock endpoint 会保持不变,因为 reverse 模式改写 URL 会破坏 SigV4 签名。Stop Monitor 会按字节还原配置文件。如果 App 被强制退出,可运行 `claude-tap monitor-restore` 还原配置,并清理 App 记录的 monitor 进程。 + +默认 launcher 指向当前 checkout;如果构建时所用 Python 环境已经安装了 `claude-tap`,可加 `--installed`。加 `--self-contained` 会用 PyInstaller 构建 Apple Silicon 自包含 bundle,并放在 `Contents/Resources` 下,这样 App 不依赖同事机器上的 Python 安装。Ad-hoc 签名的构建仍可能需要接收方移除 quarantine 或在 macOS 安全设置中手动允许。 + +### CLI 选项 + +除以下 `--tap-*` 参数外,所有参数均透传给所选客户端: + +``` +--tap-client CLIENT 启动或监听的客户端: claude(默认)/ agy / codex / codexapp / gemini / kimi / kimi-code / mimo / opencode / openclaw / pi / hermes / cursor / qoder / codebuddy +--tap-target URL 上游 API 地址(默认: 根据客户端自动选择) +--tap-live 客户端运行时启动实时查看器(默认开启) +--tap-no-live 关闭实时查看器(恢复 v0.1.75 之前的行为) +--tap-live-port PORT 实时查看器端口(默认: 自动分配) +--tap-no-open 不自动在浏览器里打开实时或生成的 HTML 查看器 +--tap-output-dir DIR Trace 输出目录(默认: ./.traces) +--tap-port PORT 代理端口(默认: 自动分配) +--tap-host HOST 绑定地址(默认: 127.0.0.1,--tap-no-launch 模式下为 0.0.0.0) +--tap-no-launch 仅启动代理,不启动客户端 +--tap-max-traces N 最大保留 trace 数量(默认: 50,0 = 不限) +--tap-store-stream-events 捕获时把原始 SSE/WebSocket event 数组写入 trace 存储,以便查看器/导出结果展示(默认关闭) +--tap-proxy-mode MODE 代理模式: reverse 或 forward(默认:claude/codex/kimi/kimi-code/openclaw/codebuddy 用 reverse,agy/gemini/mimo/opencode/pi/hermes/cursor/qoder 用 forward;codexapp 是 transcript-only) +--tap-trust-ca macOS 上显式把本地 CA 信任到当前用户 login keychain(agy 会自动执行) +``` + +
+ +## 查看器功能 + +### Trace 查看器能力 + +查看器是一个自包含的 HTML 文件(零外部依赖): + +- **结构化 Diff** — 对比相邻请求的变化:新增/删除的消息、system prompt diff、字符级高亮 +- **路径过滤** — 按 API 端点筛选(如仅显示 `/v1/messages`) +- **模型分组** — 侧边栏按模型分组,并对 Claude 系列模型做优先排序 +- **Token 用量分析** — 输入 / 输出 / 缓存读取 / 缓存创建 +- **工具检查器** — 可展开的卡片,显示工具名称、描述和参数 schema +- **全文搜索** — 搜索消息、工具、prompt 和响应 +- **暗色模式** — 切换亮色/暗色主题(跟随系统偏好) +- **iframe 嵌入模式** — 添加 `embed=1`、`hideHeader=1`、`hidePath=1`、`hideHistory=1`、`hideControls=1`、`density=compact`、`theme=light|dark` 等 query 参数 +- **键盘导航** — `j`/`k` 或方向键 +- **复制助手** — 一键复制请求 JSON 或 cURL 命令 +- **多语言** — English, 简体中文, 日本語, 한국어, Français, العربية, Deutsch, Русский + +## 架构 + +![架构图](docs/architecture.png) + +
+工作原理 + +**工作原理:** + +1. `claude-tap` 启动反向代理或 forward proxy,并启动所选客户端 +2. 支持 base URL 的客户端会指向反向代理;不支持 base URL 的客户端会通过 proxy/CA 环境变量接入 +3. SSE 和 WebSocket 流会在收到 chunk/message 时实时转发,代理开销很低 +4. 每个请求-响应对或 WebSocket 会话记录到本地 trace 存储;原始 SSE/WebSocket event 数组默认不写入,如果后续需要在查看器/导出结果中展示,必须在捕获时开启 `--tap-store-stream-events` +5. 退出时生成自包含的 HTML 查看器 +6. 实时模式默认开启,并通过 SSE 向浏览器广播更新 + +**核心特性:** 🔒 常见认证 header 自动脱敏 · ⚡ 低开销流式转发 · 📦 自包含查看器 · 🔄 实时模式 + +
+ +## 社区 + +### 生态项目 + +- [Phistory](https://github.com/WEIFENG2333/phistory) 会归档 Claude Code、Codex、Kimi、opencode、Pi 等 Agent CLI 的系统提示词版本快照。它基于 claude-tap 的 capture-only prompt export 能力,保留原始 HTTP trace 证据,并生成方便阅读和对比的 prompt 快照。 + +### Star 历史 + + + + + + Star History Chart + + + +### 贡献者 + +感谢以下贡献者: + + + + + + + + + + + + + + + + + + + + +
liaohch3
liaohch3

💻 📖 🚧 ⚠️
BKK
BKK

💻
YoungCan-Wang
YoungCan-Wang

💻
0xkrypton
0xkrypton

💻
CYJiang
CYJiang

💻
陈展鹏
陈展鹏

📖
devtalker
devtalker

💻
Yaguang Ding
Yaguang Ding

💻
Sephy
Sephy

💻
+ + + + + + +## 许可证 + +MIT diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dcbf46d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +`claude-tap` intercepts and records API traffic from local AI coding clients. Security reports may involve credentials, proxy routing, generated certificates, trace files, or private prompt data. + +## Supported Versions + +Security fixes target the latest published PyPI release and the `main` branch. + +## Reporting a Vulnerability + +Please do not open a public GitHub issue for security-sensitive reports. + +Use GitHub private vulnerability reporting if it is available on this repository. If private reporting is not available, contact the maintainer privately before sharing exploit details, credentials, private traces, or reproduction data. + +Include: + +- A concise description of the issue +- Affected versions or commits +- Steps to reproduce with sanitized data +- Whether credentials, traces, local files, or generated certificates may be exposed +- Any known mitigation or workaround + +## Trace Data + +Do not attach raw `.traces/*.jsonl`, generated HTML viewers, screenshots, or recordings unless you have reviewed and redacted them. Trace files can contain prompts, tool schemas, file paths, response bodies, and other private context even when API keys are redacted. + +## Scope + +Security-sensitive areas include: + +- API key, auth token, cookie, or header handling +- Trace redaction and export behavior +- Reverse proxy and forward proxy routing +- `--tap-host`, `--tap-no-launch`, and remote binding behavior +- Generated CA certificates and per-host TLS certificates +- Generated viewer HTML that may contain private trace data + +## Public Disclosure + +Please allow maintainers time to investigate and prepare a fix before public disclosure. diff --git a/claude_tap/__init__.py b/claude_tap/__init__.py new file mode 100644 index 0000000..e6c759d --- /dev/null +++ b/claude_tap/__init__.py @@ -0,0 +1,61 @@ +"""claude-tap: Proxy to trace Claude Code API requests. + +A CLI tool that wraps Claude Code with a local proxy (reverse or forward) +to intercept and record all API requests. Useful for studying Claude Code's +Context Engineering. +""" + +from __future__ import annotations + +from claude_tap.certs import CertificateAuthority, ensure_ca +from claude_tap.cli import ( + __version__, + _build_update_command, + _detect_installer, + async_main, + dashboard_main, + main_entry, + parse_args, + parse_dashboard_args, + parse_trust_ca_args, + parse_update_args, + trust_ca_main, + update_main, +) +from claude_tap.forward_proxy import ForwardProxyServer +from claude_tap.history import cleanup_trace_sessions, delete_trace_history, migrate_legacy_traces +from claude_tap.live import LiveViewerServer +from claude_tap.proxy import filter_headers +from claude_tap.sse import SSEReassembler +from claude_tap.trace import TraceWriter +from claude_tap.trace_store import get_trace_store, reset_trace_store, resolve_db_path +from claude_tap.viewer import _generate_html_viewer + +__all__ = [ + "__version__", + "_build_update_command", + "_detect_installer", + "main_entry", + "parse_args", + "parse_dashboard_args", + "parse_trust_ca_args", + "parse_update_args", + "trust_ca_main", + "update_main", + "async_main", + "dashboard_main", + "CertificateAuthority", + "ensure_ca", + "ForwardProxyServer", + "SSEReassembler", + "TraceWriter", + "LiveViewerServer", + "filter_headers", + "_generate_html_viewer", + "cleanup_trace_sessions", + "delete_trace_history", + "migrate_legacy_traces", + "get_trace_store", + "reset_trace_store", + "resolve_db_path", +] diff --git a/claude_tap/__main__.py b/claude_tap/__main__.py new file mode 100644 index 0000000..364a052 --- /dev/null +++ b/claude_tap/__main__.py @@ -0,0 +1,11 @@ +"""Allow running as `python -m claude_tap`.""" + +from claude_tap.cli import main_entry + + +def main() -> None: + main_entry() + + +if __name__ == "__main__": + main() diff --git a/claude_tap/bedrock.py b/claude_tap/bedrock.py new file mode 100644 index 0000000..d5a705a --- /dev/null +++ b/claude_tap/bedrock.py @@ -0,0 +1,68 @@ +"""Bedrock path helpers shared by proxy and dashboard code.""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import unquote + +BEDROCK_STREAM_SUFFIXES = ("/invoke-with-response-stream", "/converse-stream") +BEDROCK_ERROR_EVENT_KEYS = frozenset( + { + "internalServerException", + "modelStreamErrorException", + "modelTimeoutException", + "serviceUnavailableException", + "throttlingException", + "validationException", + } +) + +_BEDROCK_MODEL_PATH_RE = re.compile( + r"/model/(.+)/(?:invoke|invoke-with-response-stream|messages|converse|converse-stream)(?:[?#].*)?$" +) + + +def is_bedrock_eventstream_path(path: str) -> bool: + """Return True for Bedrock routes that return AWS EventStream responses.""" + clean_path = path.split("?", 1)[0].rstrip("/") + return clean_path.endswith(BEDROCK_STREAM_SUFFIXES) + + +def bedrock_model_from_path(path: str) -> str: + """Extract the Bedrock model ID from a /model/{modelId}/... route.""" + match = _BEDROCK_MODEL_PATH_RE.search(path) + return unquote(match.group(1)) if match else "" + + +def bedrock_error_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return normalized Bedrock EventStream error events.""" + errors: list[dict[str, Any]] = [] + for event in events: + event_type = event.get("event") + if not isinstance(event_type, str) or event_type not in BEDROCK_ERROR_EVENT_KEYS: + continue + data = event.get("data") + error = {"type": event_type} + if isinstance(data, dict): + error.update(data) + elif data is not None: + error["message"] = str(data) + errors.append(error) + return errors + + +def attach_bedrock_errors(body: object, events: list[dict[str, Any]]) -> object: + """Persist Bedrock stream errors even when raw stream events are omitted.""" + errors = bedrock_error_events(events) + if not errors: + return body + + first_error = errors[0] + if isinstance(body, dict): + annotated = dict(body) + else: + annotated = {"raw_body": body} + annotated.setdefault("error", first_error) + annotated["bedrock_errors"] = errors + return annotated diff --git a/claude_tap/certs.py b/claude_tap/certs.py new file mode 100644 index 0000000..a7a3fd5 --- /dev/null +++ b/claude_tap/certs.py @@ -0,0 +1,277 @@ +"""CA and per-host certificate generation for forward proxy TLS termination. + +Generates a self-signed CA on first run and creates per-host certificates +signed by that CA. The CA cert/key are persisted to disk so they survive +restarts; host certs are cached in memory for the lifetime of the process. +""" + +from __future__ import annotations + +import datetime +import ipaddress +import logging +import ssl +import subprocess +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +log = logging.getLogger("claude-tap") + +# Default directory for CA files +_DEFAULT_CA_DIR = Path.home() / ".claude-tap" + +# CA validity: 5 years +_CA_VALIDITY_DAYS = 5 * 365 +# Host cert validity: 1 year +_HOST_VALIDITY_DAYS = 365 + + +def _generate_key() -> rsa.RSAPrivateKey: + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def ensure_ca(ca_dir: Path | None = None) -> tuple[Path, Path]: + """Ensure a CA certificate and key exist on disk. + + Returns (ca_cert_path, ca_key_path). Creates them if they don't exist. + """ + ca_dir = ca_dir or _DEFAULT_CA_DIR + ca_dir.mkdir(parents=True, exist_ok=True) + + ca_cert_path = ca_dir / "ca.pem" + ca_key_path = ca_dir / "ca-key.pem" + + if ca_cert_path.exists() and ca_key_path.exists(): + # Validate existing files are loadable + try: + _load_ca(ca_cert_path, ca_key_path) + return ca_cert_path, ca_key_path + except Exception: + log.warning("Existing CA files are invalid, regenerating") + + log.info(f"Generating new CA certificate in {ca_dir}") + key = _generate_key() + name = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, "claude-tap CA"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "claude-tap"), + ] + ) + + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=_CA_VALIDITY_DAYS)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_cert_sign=True, + crl_sign=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + ca_key_path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + # Restrict key file permissions + ca_key_path.chmod(0o600) + + ca_cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + + log.info(f"CA certificate written to {ca_cert_path}") + return ca_cert_path, ca_key_path + + +def macos_login_keychain_path() -> Path: + """Return the current user's login keychain path on modern macOS.""" + return Path.home() / "Library" / "Keychains" / "login.keychain-db" + + +def build_macos_verify_ca_command(ca_cert_path: Path, keychain_path: Path | None = None) -> list[str]: + """Build a non-mutating command that checks whether the CA is trusted for TLS.""" + keychain = keychain_path or macos_login_keychain_path() + return [ + "security", + "verify-cert", + "-c", + str(ca_cert_path), + "-p", + "ssl", + "-l", + "-L", + "-q", + "-k", + str(keychain), + ] + + +def build_macos_trust_ca_command(ca_cert_path: Path, keychain_path: Path | None = None) -> list[str]: + """Build the no-sudo command that trusts the CA in the current user's keychain.""" + keychain = keychain_path or macos_login_keychain_path() + return [ + "security", + "add-trusted-cert", + "-r", + "trustRoot", + "-p", + "ssl", + "-k", + str(keychain), + str(ca_cert_path), + ] + + +def is_macos_ca_trusted(ca_cert_path: Path, keychain_path: Path | None = None) -> bool: + """Return True when macOS already trusts the CA for TLS in the user keychain.""" + result = subprocess.run( + build_macos_verify_ca_command(ca_cert_path, keychain_path), + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + +def trust_macos_ca(ca_cert_path: Path, keychain_path: Path | None = None) -> subprocess.CompletedProcess[str]: + """Trust the CA for TLS in the current user's macOS login keychain. + + This intentionally does not use ``sudo`` or the System keychain. macOS may + still prompt for the user's login-keychain password. + """ + return subprocess.run( + build_macos_trust_ca_command(ca_cert_path, keychain_path), + capture_output=True, + text=True, + check=False, + ) + + +def _load_ca(ca_cert_path: Path, ca_key_path: Path) -> tuple[x509.Certificate, rsa.RSAPrivateKey]: + """Load CA cert and key from PEM files.""" + ca_cert = x509.load_pem_x509_certificate(ca_cert_path.read_bytes()) + ca_key = serialization.load_pem_private_key(ca_key_path.read_bytes(), password=None) + return ca_cert, ca_key # type: ignore[return-value] + + +class CertificateAuthority: + """In-memory CA that generates per-host TLS certificates. + + Caches generated host certs for the lifetime of the process. + """ + + def __init__(self, ca_cert_path: Path, ca_key_path: Path) -> None: + self._ca_cert, self._ca_key = _load_ca(ca_cert_path, ca_key_path) + self._host_cache: dict[str, tuple[bytes, bytes]] = {} + + def get_host_cert_pem(self, hostname: str) -> tuple[bytes, bytes]: + """Return (cert_pem, key_pem) for the given hostname. + + Generates and caches a new certificate signed by the CA if needed. + """ + if hostname in self._host_cache: + return self._host_cache[hostname] + + key = _generate_key() + now = datetime.datetime.now(datetime.timezone.utc) + + subject = x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, hostname), + ] + ) + + # Build SAN: use IPAddress for IP addresses, DNSName for hostnames + san_names: list[x509.GeneralName] = [] + try: + ip = ipaddress.ip_address(hostname) + san_names.append(x509.IPAddress(ip)) + except ValueError: + san_names.append(x509.DNSName(hostname)) + + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(self._ca_cert.subject) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=_HOST_VALIDITY_DAYS)) + .add_extension( + x509.SubjectAlternativeName(san_names), + critical=False, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(self._ca_key.public_key()), + critical=False, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + ) + + cert = builder.sign(self._ca_key, hashes.SHA256()) + + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + + self._host_cache[hostname] = (cert_pem, key_pem) + return cert_pem, key_pem + + def make_ssl_context(self, hostname: str) -> ssl.SSLContext: + """Create an SSL context for serving TLS as the given hostname.""" + import tempfile + + cert_pem, key_pem = self.get_host_cert_pem(hostname) + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + # Write cert and key to temp files (ssl module needs file paths) + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cf: + cf.write(cert_pem) + cert_path = cf.name + with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as kf: + kf.write(key_pem) + key_path = kf.name + + try: + ctx.load_cert_chain(cert_path, key_path) + finally: + Path(cert_path).unlink(missing_ok=True) + Path(key_path).unlink(missing_ok=True) + + return ctx diff --git a/claude_tap/cli.py b/claude_tap/cli.py new file mode 100644 index 0000000..0db2f51 --- /dev/null +++ b/claude_tap/cli.py @@ -0,0 +1,1133 @@ +"""CLI entry points for claude-tap.""" + +from __future__ import annotations + +import argparse +import asyncio +import ipaddress +import logging +import os +import shlex + +# Keep the stdlib module object available as claude_tap.cli.shutil for +# existing tests and private integrations that monkeypatch shutil.which there. +import shutil +import sqlite3 +import sys +import threading +import time +import webbrowser +from pathlib import Path +from urllib.parse import urlparse + +import aiohttp +from aiohttp import web + +from claude_tap.certs import CertificateAuthority, ensure_ca, is_macos_ca_trusted, trust_macos_ca +from claude_tap.cli_clients import ( + _CODEX_CHATGPT_TARGET, + CLIENT_CONFIGS, + TARGET_DETECTORS, + ClientConfig, + _codex_config_override_value, + _codex_config_override_values, + _codex_home, + _codex_profile_arg, + _codex_selected_provider_base_url_key, + _detect_claude_target, + _detect_codebuddy_target, + _detect_codex_target, + _extend_no_proxy, + _has_settings_arg, + _maybe_rewrite_hermes_gateway_start, + _read_codebuddy_endpoint_cache, + _read_codex_config, + _read_settings_env_base_url, + _reverse_proxy_trace_options, + _selected_codex_provider_base_url, + _settings_arg, + _toml_dotted_key_segment, + run_client, +) +from claude_tap.cli_update import ( + _build_update_command, + _detect_installer, + parse_update_args, + update_main, +) +from claude_tap.codex_app_cdp import CODEX_APP_CDP_DEFAULT_ENDPOINT, watch_codex_app_cdp +from claude_tap.codex_app_transcript import ( + CodexAppTranscriptSessionRegistry, + codex_app_sessions_dir, + watch_codex_app_transcripts_to_sessions, +) +from claude_tap.cursor_transcript import import_cursor_transcripts +from claude_tap.forward_proxy import ForwardProxyServer +from claude_tap.history import cleanup_trace_sessions, migrate_legacy_traces +from claude_tap.live import LiveViewerServer +from claude_tap.proxy import proxy_handler +from claude_tap.shared_dashboard import ( + DEFAULT_DASHBOARD_PORT, + dashboard_url, + ensure_shared_dashboard, + is_dashboard_healthy, + resolve_dashboard_port, + stop_dashboard_service, + stop_incompatible_dashboard_if_running, +) +from claude_tap.trace import TraceWriter, create_trace_writer +from claude_tap.trace_log_handler import SQLiteLogHandler +from claude_tap.trace_store import TraceStore, get_trace_store, resolve_db_path + +# Force UTF-8 + line-buffered stdout/stderr so emoji output works on Windows +# consoles (GBK/cp936) and `uv tool` doesn't fully buffer our progress prints. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace", line_buffering=True) +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") + +log = logging.getLogger("claude-tap") + + +def _create_trace_writer( + *, + store: TraceStore, + client: str, + proxy_mode: str, + metadata: dict[str, str], +) -> TraceWriter: + return create_trace_writer(store=store, client=client, proxy_mode=proxy_mode, metadata=metadata) + + +class _LazyTraceWriter: + """Create a trace session only when side-channel capture writes a record.""" + + def __init__(self, *, client: str, proxy_mode: str, metadata: dict[str, str]): + self._client = client + self._proxy_mode = proxy_mode + self._metadata = metadata + self._store = get_trace_store() + self._writer: TraceWriter | None = None + self.session_id: str | None = None + + @property + def count(self) -> int: + return self._writer.count if self._writer is not None else 0 + + async def write(self, record: dict) -> None: + await self._ensure_writer().write(record) + + async def write_next_turn(self, record: dict) -> None: + await self._ensure_writer().write_next_turn(record) + + def close(self) -> None: + if self._writer is not None: + self._writer.close() + + def get_summary(self) -> dict: + if self._writer is not None: + return self._writer.get_summary() + return { + "api_calls": 0, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_create_tokens": 0, + "models_used": {}, + "has_error": False, + } + + def _ensure_writer(self) -> TraceWriter: + if self._writer is None: + self._writer = _create_trace_writer( + store=self._store, + client=self._client, + proxy_mode=self._proxy_mode, + metadata=self._metadata, + ) + self.session_id = self._writer.session_id + return self._writer + + +try: + from importlib.metadata import version as _pkg_version + + __version__ = _pkg_version("claude-tap") +except Exception: + __version__ = "0.0.0" + +_CLI_COMPAT_EXPORTS = ( + shutil, + ClientConfig, + _CODEX_CHATGPT_TARGET, + _build_update_command, + _codex_config_override_value, + _codex_config_override_values, + _codex_home, + _codex_profile_arg, + _codex_selected_provider_base_url_key, + _detect_claude_target, + _detect_codebuddy_target, + _detect_installer, + _extend_no_proxy, + _has_settings_arg, + _maybe_rewrite_hermes_gateway_start, + _read_codebuddy_endpoint_cache, + _read_codex_config, + _read_settings_env_base_url, + _selected_codex_provider_base_url, + _settings_arg, + _toml_dotted_key_segment, + parse_update_args, +) + + +def _open_browser(url: str) -> None: + """Open URL in browser without blocking. Silently ignores failures in headless environments.""" + threading.Thread(target=lambda: webbrowser.open(url), daemon=True).start() + + +async def _is_dashboard_reusable(host: str, port: int) -> bool: + return await is_dashboard_healthy(host, port) + + +def _dashboard_stop_command(host: str, port: int) -> str: + parts = ["claude-tap", "dashboard", "stop"] + if port != DEFAULT_DASHBOARD_PORT: + parts.extend(["--tap-live-port", str(port)]) + if host != "127.0.0.1": + parts.extend(["--tap-host", host]) + return " ".join(shlex.quote(part) for part in parts) + + +_CLAUDE_EXECUTABLE_NAMES = {"claude", "claude.exe", "claude.cmd", "claude.bat"} + + +def _loopback_target_host(target: str | None) -> str | None: + """Return the host of an upstream target that resolves to the local loopback. + + Covers the whole IPv4 loopback block (127.0.0.0/8), IPv6 ::1, and the + "localhost" hostname. Returns None for remote or unparseable targets. + + A loopback upstream (e.g. a local Agent Maestro/relay) must not be routed + through a system proxy picked up via trust_env, or aiohttp tunnels the call + through the proxy and the connection is reset (ServerDisconnectedError). + """ + if not target: + return None + host = urlparse(target).hostname + if host is None: + return None + if host.lower() == "localhost": + return host + try: + if ipaddress.ip_address(host).is_loopback: + return host + except ValueError: + return None + return None + + +def _looks_like_claude_binary_path(value: str) -> bool: + if not value or value.startswith("-"): + return False + # VSCode's claudeProcessWrapper passes the bundled Claude binary path as + # argv[0]. Require a path-looking file so normal prompts/dirs named + # "claude" are not silently stripped or executed. + if "/" not in value and "\\" not in value and not (len(value) > 1 and value[1] == ":"): + return False + path = Path(value) + return path.name.lower() in _CLAUDE_EXECUTABLE_NAMES and path.is_file() + + +def _extract_wrapped_client_command(client: str, args: list[str]) -> tuple[str | None, list[str]]: + if client != "claude" or not args: + return None, args + if _looks_like_claude_binary_path(args[0]): + return args[0], args[1:] + return None, args + + +def _trust_ca_for_current_user(ca_cert_path: Path) -> int: + """Trust the forward-proxy CA in the current user's macOS login keychain.""" + if sys.platform != "darwin": + print("--tap-trust-ca is currently only supported on macOS.", file=sys.stderr) + print(f"CA certificate: {ca_cert_path}", file=sys.stderr) + return 1 + + if is_macos_ca_trusted(ca_cert_path): + print(f"🔐 CA already trusted in the macOS login keychain: {ca_cert_path}") + return 0 + + result = trust_macos_ca(ca_cert_path) + if result.returncode != 0: + details = (result.stderr or result.stdout or "").strip() + print("Error: failed to trust claude-tap CA in the macOS login keychain.", file=sys.stderr) + if details: + print(details, file=sys.stderr) + print("This command does not use sudo; macOS may require unlocking your login keychain.", file=sys.stderr) + return result.returncode or 1 + + if not is_macos_ca_trusted(ca_cert_path): + print("Error: macOS did not report the claude-tap CA as trusted after installation.", file=sys.stderr) + print(f"CA certificate: {ca_cert_path}", file=sys.stderr) + return 1 + + print(f"🔐 Trusted claude-tap CA in the current user's macOS login keychain: {ca_cert_path}") + return 0 + + +def _ensure_ca_trust_for_forward_proxy(args: argparse.Namespace, ca_cert_path: Path) -> int: + """Ensure CA trust when forward-proxy clients need macOS keychain trust.""" + if args.proxy_mode != "forward": + return 0 + + if args.trust_ca: + return _trust_ca_for_current_user(ca_cert_path) + + cfg = CLIENT_CONFIGS[args.client] + if sys.platform != "darwin" or not cfg.auto_trust_ca_macos: + return 0 + + if is_macos_ca_trusted(ca_cert_path): + return 0 + + print(f"🔐 {cfg.label} needs the claude-tap CA trusted in your macOS login keychain.") + print(" Installing for the current user only; no sudo or System keychain write is used.") + return _trust_ca_for_current_user(ca_cert_path) + + +async def async_main(args: argparse.Namespace): + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + if not args.live_viewer: + try: + migrate_legacy_traces(output_dir) + except sqlite3.Error as exc: + print( + f"claude-tap: legacy trace migration skipped because storage is unavailable ({exc})", + file=sys.stderr, + ) + + store = get_trace_store() + trace_metadata = {"client": args.client, "proxy_mode": args.proxy_mode} + cfg = CLIENT_CONFIGS[args.client] + transcript_only = cfg.transcript_only + + ca_cert_path: Path | None = None + ca_key_path: Path | None = None + if args.proxy_mode == "forward" and not transcript_only: + ca_cert_path, ca_key_path = ensure_ca() + trust_result = _ensure_ca_trust_for_forward_proxy(args, ca_cert_path) + if trust_result != 0: + return trust_result + + session_id: str | None = None + writer: TraceWriter | None = None + transcript_registry: CodexAppTranscriptSessionRegistry | None = None + cdp_writer: _LazyTraceWriter | None = None + if transcript_only: + transcript_registry = CodexAppTranscriptSessionRegistry(store=store, metadata=trace_metadata) + cdp_writer = _LazyTraceWriter(client=args.client, proxy_mode=args.proxy_mode, metadata=trace_metadata) + else: + writer = _create_trace_writer( + store=store, + client=args.client, + proxy_mode=args.proxy_mode, + metadata=trace_metadata, + ) + session_id = writer.session_id + + # Ensure the shared dashboard is running (one port for all sessions). + dashboard_url_value: str | None = None + dashboard_host = args.host + dashboard_port = resolve_dashboard_port(args.live_port) + if args.live_viewer: + try: + dashboard_url_value, spawned = await ensure_shared_dashboard( + host=dashboard_host, + port=dashboard_port, + output_dir=output_dir, + open_browser=args.open_viewer, + open_browser_fn=_open_browser, + ) + if spawned: + print(f"🌐 Dashboard: {dashboard_url_value}") + else: + print(f"🌐 Dashboard: {dashboard_url_value} (shared)") + except (RuntimeError, sqlite3.Error) as exc: + print(f"⚠️ {exc}", file=sys.stderr) + + # Proxy logs go to SQLite, not terminal (avoids polluting Claude TUI) + sqlite_handler: SQLiteLogHandler | None = None + if session_id is not None: + sqlite_handler = SQLiteLogHandler(session_id, store=store) + sqlite_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S")) + log.addHandler(sqlite_handler) + log.setLevel(logging.DEBUG) + logging.getLogger("aiohttp.access").setLevel(logging.WARNING) + aiohttp_server_log = logging.getLogger("aiohttp.server") + aiohttp_server_log.addHandler(sqlite_handler) + aiohttp_server_log.propagate = False + asyncio_log = logging.getLogger("asyncio") + asyncio_log.addHandler(sqlite_handler) + asyncio_log.propagate = False + + # Proxy clients create this lazily; transcript-only clients do not need an + # outbound upstream session. + session: aiohttp.ClientSession | None = None + + # Forward proxy mode: raw TCP server with CONNECT/TLS termination + # Reverse proxy mode: aiohttp web app (current behavior) + forward_server: ForwardProxyServer | None = None + runner: web.AppRunner | None = None + codex_app_cdp_task: asyncio.Task | None = None + exit_code = 0 + client_started_at = time.time() + capture_only = bool(getattr(args, "export_prompt", None)) + if capture_only: + print("📝 Prompt export mode: upstream calls are skipped after capture.") + try: + if transcript_only: + sessions_dir = codex_app_sessions_dir() + print(f"🔍 claude-tap v{__version__} listening for {cfg.label} sessions in {sessions_dir}") + print(" Each Codex App query is recorded as a separate dashboard trace.") + print(" Keep Codex App running; debug WebSocket evidence is added automatically when available.") + assert cdp_writer is not None + codex_app_cdp_task = asyncio.create_task( + watch_codex_app_cdp( + cdp_writer, + endpoint=getattr(args, "codexapp_cdp_endpoint", CODEX_APP_CDP_DEFAULT_ENDPOINT), + store_stream_events=args.store_stream_events, + ) + ) + else: + # Honor system proxy env (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY) + # for outbound upstream requests so users routing through Clash/VPN + # keep working. A loopback upstream (e.g. a local Agent Maestro/relay) + # must NOT be tunneled through that proxy, or aiohttp resets the + # connection (ServerDisconnectedError -> HTTP 502). Add only the + # loopback host to NO_PROXY so the bypass is per-host: remote traffic + # (including forward-mode CONNECT requests sharing this session) still + # honors the user's proxy. + loopback_host = _loopback_target_host(args.target) + if loopback_host is not None: + _extend_no_proxy(os.environ, (loopback_host,)) + session = aiohttp.ClientSession(auto_decompress=False, trust_env=True) + + if transcript_only: + print("📁 Trace sessions: one per Codex App query") + print(f"🗄️ Trace database: {resolve_db_path()}") + assert transcript_registry is not None + try: + await watch_codex_app_transcripts_to_sessions(transcript_registry, since=client_started_at) + except asyncio.CancelledError: + pass + elif args.proxy_mode == "forward": + assert ca_cert_path is not None + assert ca_key_path is not None + assert session is not None + assert writer is not None + ca = CertificateAuthority(ca_cert_path, ca_key_path) + forward_server = ForwardProxyServer( + host=args.host, + port=args.port, + ca=ca, + writer=writer, + session=session, + local_reverse_target=args.target, + local_reverse_allowed_path_prefixes=CLIENT_CONFIGS[args.client].forward_base_url_allowed_path_prefixes, + store_stream_events=args.store_stream_events, + capture_only=capture_only, + ) + actual_port = await forward_server.start() + print(f"🔍 claude-tap v{__version__} forward proxy on http://{args.host}:{actual_port}") + print(f" CA cert: {ca_cert_path}") + else: + assert session is not None + assert writer is not None + app = web.Application(client_max_size=0) # No body size limit (proxy must forward everything) + app["trace_ctx"] = { + "target_url": args.target, + "writer": writer, + "session": session, + "turn_counter": 0, + "extra_allowed_path_prefixes": tuple(args.extra_allowed_paths), + "store_stream_events": args.store_stream_events, + "capture_only": capture_only, + **_reverse_proxy_trace_options(args.client, args.target), + } + app.router.add_route("*", "/{path_info:.*}", proxy_handler) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, args.host, args.port) + await site.start() + + # Resolve actual port (site._server is a private API; fall back to args.port) + try: + actual_port = site._server.sockets[0].getsockname()[1] + except (AttributeError, IndexError, OSError): + actual_port = args.port + print(f"🔍 claude-tap v{__version__} listening on http://{args.host}:{actual_port}") + + if not transcript_only: + print(f"📁 Trace session: {session_id}") + print(f"🗄️ Trace database: {resolve_db_path()}") + + if not args.no_launch: + client_started_at = time.time() + try: + exit_code = await run_client( + actual_port, + args.claude_args, + client=args.client, + proxy_mode=args.proxy_mode, + ca_cert_path=ca_cert_path, + client_cmd=getattr(args, "client_cmd", None), + capture_only=capture_only, + ) + except asyncio.CancelledError: + pass + else: + print("\n--no-launch mode: proxy running. Press Ctrl+C to stop.") + try: + while True: + await asyncio.sleep(3600) + except asyncio.CancelledError: + pass + finally: + if forward_server: + try: + await asyncio.wait_for(forward_server.stop(), timeout=10) + except asyncio.TimeoutError: + log.warning("Timed out stopping forward proxy") + except Exception: + pass + if runner: + try: + await runner.cleanup() + except Exception: + pass + if codex_app_cdp_task: + codex_app_cdp_task.cancel() + try: + await asyncio.wait_for(codex_app_cdp_task, timeout=5) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + + # Shared dashboard runs in a detached process; nothing to stop here. + if session is not None: + try: + await asyncio.wait_for(session.close(), timeout=5) + except asyncio.TimeoutError: + log.warning("Timed out closing upstream HTTP session") + except Exception: + pass + + if args.client == "cursor" and not args.no_launch: + assert writer is not None + imported = await import_cursor_transcripts(writer, since=client_started_at) + if imported: + print(f" Cursor transcript turns: {imported}") + + if transcript_registry is not None: + transcript_registry.close() + if cdp_writer is not None: + cdp_writer.close() + if writer is not None: + writer.close() + + prompt_export_rc: int | None = None + if args.export_prompt and session_id is not None: + prompt_export_rc = _export_prompt_from_session(store, session_id, args.export_prompt) + + if args.max_traces > 0: + protected_session_ids = set(transcript_registry.session_ids) if transcript_registry is not None else set() + if cdp_writer is not None and cdp_writer.session_id: + protected_session_ids.add(cdp_writer.session_id) + try: + cleaned = cleanup_trace_sessions( + args.max_traces, + protected_session_id=session_id, + protected_session_ids=protected_session_ids or None, + ) + except sqlite3.Error as exc: + print(f"\nclaude-tap: trace cleanup skipped because storage is unavailable ({exc})", file=sys.stderr) + else: + if cleaned: + print(f"\n🧹 Cleaned up {cleaned} old trace session(s)") + + # Print summary with cost estimation + if transcript_registry is not None: + stats = transcript_registry.get_summary() + if cdp_writer is not None: + cdp_stats = cdp_writer.get_summary() + stats["api_calls"] += cdp_stats["api_calls"] + stats["input_tokens"] += cdp_stats["input_tokens"] + stats["output_tokens"] += cdp_stats["output_tokens"] + stats["cache_read_tokens"] += cdp_stats["cache_read_tokens"] + stats["cache_create_tokens"] += cdp_stats["cache_create_tokens"] + stats["has_error"] = bool(stats["has_error"] or cdp_stats["has_error"]) + for model, count in cdp_stats["models_used"].items(): + stats["models_used"][model] = stats["models_used"].get(model, 0) + count + elif writer is not None: + stats = writer.get_summary() + else: + stats = { + "api_calls": 0, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_create_tokens": 0, + "models_used": {}, + "has_error": False, + } + print("\n📊 Trace summary:") + print(f" API calls: {stats['api_calls']}") + if stats.get("trace_storage_errors"): + print(f" Trace storage errors: {stats['trace_storage_errors']}") + print(f" Dropped trace records: {stats.get('dropped_trace_records', 0)}") + if transcript_registry is not None: + print(f" Query sessions: {len(transcript_registry.session_ids)}") + + # Token breakdown + total_tokens = stats["input_tokens"] + stats["output_tokens"] + if total_tokens > 0: + print(f" Tokens: {stats['input_tokens']:,} in / {stats['output_tokens']:,} out", end="") + if stats["cache_read_tokens"] > 0: + print(f" / {stats['cache_read_tokens']:,} cache_read", end="") + if stats["cache_create_tokens"] > 0: + print(f" / {stats['cache_create_tokens']:,} cache_write", end="") + print() + + if session_id is not None: + print(f" Session: {session_id}") + print(f" Database: {resolve_db_path()}") + if dashboard_url_value: + print(f" Dashboard: {dashboard_url_value}") + print(f" Stop dashboard: {_dashboard_stop_command(dashboard_host, dashboard_port)}") + + if prompt_export_rc is not None: + if prompt_export_rc != 0: + exit_code = 1 + + return exit_code + + +def _export_prompt_from_session(store, session_id: str, output: str) -> int: + from claude_tap.prompt_snapshot import render_prompt_markdown, snapshot_from_records + + try: + text = render_prompt_markdown(snapshot_from_records(store.load_records(session_id))) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + if output == "-": + print(text, end="") + return 0 + + path = Path(output).expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + print(f"📝 Prompt snapshot: {path}") + trace_path = _prompt_trace_path(path) + trace_path.write_text(store.export_jsonl(session_id), encoding="utf-8") + print(f"🧾 Raw trace: {trace_path}") + return 0 + + +def _prompt_trace_path(prompt_path: Path) -> Path: + if prompt_path.name in {"prompt.md", "prompt.markdown", "system.md", "system.markdown"}: + return prompt_path.with_name("trace.jsonl") + return prompt_path.with_name(f"{prompt_path.stem}.trace.jsonl") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse argv, extracting ``--tap-*`` flags for ourselves and forwarding + everything else to the selected client. + """ + if argv is None: + argv = sys.argv[1:] + + tap_parser = argparse.ArgumentParser( + prog="claude-tap", + description=( + "Trace Claude Code, Codex CLI, Codex App, Gemini CLI, Kimi CLI, MiMo Code, OpenCode, OpenClaw, Pi, Hermes Agent, " + "Cursor CLI, Qoder CLI, Antigravity CLI, or CodeBuddy CLI API requests via a local proxy or transcript import. " + "All flags not listed below are forwarded to the selected client." + ), + epilog=( + "claude code:\n" + " claude-tap Basic tracing with live viewer enabled by default\n" + " claude-tap --tap-no-live Disable live viewer server/browser auto-open\n" + " claude-tap --tap-no-open Keep viewers from auto-opening in a browser\n" + " claude-tap -- --model claude-opus-4-6 Pass flags to Claude Code\n" + " claude-tap -- -c Continue last conversation\n" + " claude-tap -- --dangerously-skip-permissions Auto-accept tool calls\n" + " claude-tap -- --dangerously-skip-permissions --model claude-sonnet-4-6\n" + "\n" + "codex cli:\n" + " # Target is auto-detected from Codex auth state when possible\n" + " claude-tap --tap-client codex\n" + " # If auto-detection cannot read Codex auth, specify OAuth target explicitly\n" + " claude-tap --tap-client codex --tap-target https://chatgpt.com/backend-api/codex\n" + " # With model and full auto-approval\n" + " claude-tap --tap-client codex -- --model codex-mini-latest --full-auto\n" + "\n" + "codex app:\n" + " # Listen to local Codex App session JSONL files under CODEX_HOME / ~/.codex\n" + " claude-tap --tap-client codexapp\n" + "\n" + "kimi cli (legacy kimi-cli; uses shell KIMI_BASE_URL):\n" + " claude-tap --tap-client kimi\n" + " claude-tap --tap-client kimi -- --thinking\n" + " claude-tap --tap-client kimi --tap-target https://api.moonshot.ai/v1\n" + "\n" + "kimi-code cli (MoonshotAI/kimi-code; patches ~/.kimi-code/config.toml via sandbox):\n" + " claude-tap --tap-client kimi-code\n" + " claude-tap --tap-client kimi-code -- --thinking\n" + " claude-tap --tap-client kimi-code --tap-target https://api.moonshot.ai/v1\n" + "\n" + "gemini cli (defaults to forward proxy mode):\n" + ' claude-tap --tap-client gemini -- -p "hello"\n' + " # Reverse mode sets GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL\n" + " claude-tap --tap-client gemini --tap-proxy-mode reverse\n" + "\n" + "opencode (multi-provider; defaults to forward proxy mode):\n" + " # Forward proxy captures every provider opencode talks to\n" + " claude-tap --tap-client opencode\n" + " # Force reverse mode (single ANTHROPIC_BASE_URL provider only)\n" + " claude-tap --tap-client opencode --tap-proxy-mode reverse\n" + "\n" + "mimo (MiMo Code — OpenCode fork; defaults to forward proxy mode):\n" + " # Forward proxy captures every provider MiMo Code talks to\n" + " claude-tap --tap-client mimo\n" + " # Reverse mode — single Anthropic provider with mimo-only disabled\n" + " claude-tap --tap-client mimo --tap-proxy-mode reverse\n" + "\n" + "openclaw:\n" + " # Reads OpenClaw config and points the selected provider at the local proxy\n" + " claude-tap --tap-client openclaw -- agent\n" + "\n" + "pi (multi-provider; defaults to forward proxy mode):\n" + " # Forward proxy captures OpenAI Codex OAuth and other providers\n" + ' claude-tap --tap-client pi -- --model openai-codex/gpt-5.3-codex-spark -p "hello"\n' + " # Pi OAuth is configured with /login inside pi, or via PI_CODING_AGENT_DIR\n" + "\n" + "hermes agent (multi-provider Python agent — forward proxy default):\n" + " # Interactive TUI — captures LLM calls directly\n" + " claude-tap --tap-client hermes\n" + " # Gateway mode — captures LLM calls triggered by Slack/Telegram/etc. messages\n" + " # (requires messaging platform configured in ~/.hermes/.env)\n" + " claude-tap --tap-client hermes -- gateway start\n" + "\n" + "cursor cli (defaults to forward proxy mode):\n" + ' claude-tap --tap-client cursor -- -p --trust --model auto "hello"\n' + " # Cursor readable messages are imported from local transcripts after exit\n" + "\n" + "qoder cli (defaults to forward proxy mode):\n" + ' claude-tap --tap-client qoder -- -p "hello" --permission-mode dont_ask\n' + " # Authenticate first with `qodercli login` or QODER_PERSONAL_ACCESS_TOKEN / QODER_JOB_TOKEN\n" + "\n" + "antigravity cli (defaults to forward proxy mode):\n" + " # On macOS, claude-tap auto-trusts the local CA in your user login keychain without sudo\n" + " claude-tap --tap-client agy --tap-live\n" + "\n" + "codebuddy (reverse proxy mode):\n" + " # Auto-detects the endpoint from CodeBuddy's own login cache,\n" + " # so internal, iOA, and external users all work out of the box.\n" + " claude-tap --tap-client codebuddy\n" + " # Or override explicitly (custom/staging deployments)\n" + " claude-tap --tap-client codebuddy --tap-target https://www.codebuddy.ai/v2\n" + ' CODEBUDDY_BASE_URL=https://your-host/v2 claude-tap --tap-client codebuddy -- -p "Reply OK"\n' + "\n" + "proxy-only mode (connect from another terminal):\n" + " claude-tap --tap-no-launch --tap-port 8080\n" + " # then: ANTHROPIC_BASE_URL=http://127.0.0.1:8080 claude\n" + "\n" + "export traces:\n" + " claude-tap export -o trace.ctap.json Export compact trace bundle\n" + " claude-tap export trace.jsonl Export compact trace bundle to stdout\n" + " claude-tap export trace.jsonl --format markdown -o out.md Export to markdown\n" + " claude-tap export trace.jsonl --format prompt-md -o prompt.md Export prompt snapshot\n" + " claude-tap export trace.jsonl --format json Export as full JSON\n" + " claude-tap export trace.jsonl -o out.html Export as HTML viewer\n" + "\n" + "update:\n" + " claude-tap update Upgrade claude-tap in place\n" + " claude-tap update --installer pip Force pip-based upgrade\n" + "\n" + "dashboard:\n" + " claude-tap dashboard Browse trace history\n" + " claude-tap dashboard stop Stop the shared dashboard service\n" + " claude-tap dashboard --tap-live-port 3000 Use a fixed dashboard port\n" + "\n" + "trust local CA:\n" + " claude-tap trust-ca Trust forward-proxy CA in macOS user keychain\n" + "\n" + "monitor recovery:\n" + " claude-tap monitor-restore Restore Claude/Codex configs after a killed monitor\n" + "\n" + "homepage: https://github.com/liaohch3/claude-tap" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + tap_parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}") + + # -- Proxy options -- + proxy_group = tap_parser.add_argument_group("proxy options") + proxy_group.add_argument("--tap-port", type=int, default=0, dest="port", help="Proxy port (default: auto)") + proxy_group.add_argument( + "--tap-host", + default=None, + dest="host", + help="Bind address (default: 127.0.0.1, or 0.0.0.0 with --tap-no-launch)", + ) + proxy_group.add_argument( + "--tap-client", + choices=sorted(CLIENT_CONFIGS.keys()), + default="claude", + dest="client", + help="Client to launch (default: claude)", + ) + proxy_group.add_argument( + "--tap-target", + default=None, + dest="target", + help="Upstream API URL (default: auto-detected from auth state)", + ) + proxy_group.add_argument( + "--tap-proxy-mode", + choices=["reverse", "forward"], + default=None, + dest="proxy_mode", + help=( + "'reverse' sets provider base URL, 'forward' sets HTTPS_PROXY with CONNECT/TLS termination. " + "Default depends on the client: 'reverse' for claude/codex/kimi/kimi-code/openclaw/codebuddy, " + "'forward' for agy/gemini/mimo/opencode/pi/hermes/cursor/qoder. " + "codexapp is transcript-only and does not use this option." + ), + ) + proxy_group.add_argument( + "--tap-trust-ca", + action="store_true", + dest="trust_ca", + help=( + "On macOS, explicitly trust the forward-proxy CA in the current user's login keychain before launch " + "(no sudo; agy does this automatically when needed)" + ), + ) + proxy_group.add_argument( + "--tap-no-launch", action="store_true", dest="no_launch", help="Only start the proxy, don't launch client" + ) + proxy_group.add_argument( + "--tap-allow-path", + action="append", + default=[], + dest="extra_allowed_paths", + metavar="PREFIX", + help="Extra path prefix to allow through the proxy (can be repeated, e.g. --tap-allow-path /custom/api)", + ) + proxy_group.add_argument( + "--tap-codexapp-cdp-endpoint", + default=CODEX_APP_CDP_DEFAULT_ENDPOINT, + dest="codexapp_cdp_endpoint", + help=argparse.SUPPRESS, + ) + + # -- Viewer options -- + viewer_group = tap_parser.add_argument_group("viewer options") + viewer_group.add_argument( + "--tap-no-open", + action="store_false", + dest="open_viewer", + default=True, + help="Don't auto-open live or generated HTML viewers in a browser", + ) + viewer_group.add_argument( + "--tap-live", + action="store_true", + dest="live_viewer", + default=True, + help="Use the shared local dashboard while the client runs (default: on)", + ) + viewer_group.add_argument( + "--tap-no-live", + action="store_false", + dest="live_viewer", + help="Disable the shared dashboard (restores pre-v0.1.75 behavior)", + ) + viewer_group.add_argument( + "--tap-live-port", + type=int, + default=0, + dest="live_port", + help=f"Port for the shared dashboard (default: {DEFAULT_DASHBOARD_PORT})", + ) + + # -- Storage options -- + storage_group = tap_parser.add_argument_group("storage options") + storage_group.add_argument( + "--tap-output-dir", + default="./.traces", + dest="output_dir", + help="Legacy trace directory to import once (default: ./.traces)", + ) + storage_group.add_argument( + "--tap-max-traces", + type=int, + default=50, + dest="max_traces", + help="Max trace sessions to keep (default: 50, 0 = unlimited)", + ) + storage_group.add_argument( + "--tap-store-stream-events", + action="store_true", + dest="store_stream_events", + help="Persist raw SSE/WebSocket stream events in trace storage and viewer/export output (default: off)", + ) + storage_group.add_argument( + "--tap-export-prompt", + metavar="PATH", + default=None, + dest="export_prompt", + help=( + "Export the captured prompt surface to Markdown after this run, plus a raw trace JSONL next to it. " + "This mode records the request and returns a local success response without contacting upstream." + ), + ) + storage_group.add_argument( + "--tap-no-update-check", + action="store_true", + dest="_deprecated_no_update_check", + help=argparse.SUPPRESS, + ) + storage_group.add_argument( + "--tap-no-auto-update", + action="store_true", + dest="_deprecated_no_auto_update", + help=argparse.SUPPRESS, + ) + args, claude_args = tap_parser.parse_known_args(argv) + # Strip leading "--" separator if present (argparse leaves it in remainder) + if claude_args and claude_args[0] == "--": + claude_args = claude_args[1:] + args.client_cmd, claude_args = _extract_wrapped_client_command(args.client, claude_args) + if any(arg == "--tap-codexapp-cdp-capture" for arg in claude_args): + tap_parser.error( + "--tap-codexapp-cdp-capture was removed; Codex App CDP enrichment runs automatically " + "with --tap-client codexapp" + ) + args.claude_args = claude_args + # Default host: 0.0.0.0 in --tap-no-launch mode (proxy-only, typically remote), + # 127.0.0.1 otherwise (launching the client locally). + if args.host is None: + args.host = "0.0.0.0" if args.no_launch else "127.0.0.1" + if args.target is None: + if args.client == "codex": + args.target = _detect_codex_target(claude_args) + elif args.client == "kimi-code": + args.target = TARGET_DETECTORS["kimi-code"](claude_args) + elif args.client == "openclaw": + args.target = TARGET_DETECTORS["openclaw"](claude_args) + else: + detector = TARGET_DETECTORS.get(args.client) + args.target = detector() if detector else CLIENT_CONFIGS[args.client].default_target + if CLIENT_CONFIGS[args.client].transcript_only: + if args.proxy_mode is not None: + tap_parser.error("--tap-proxy-mode does not apply to transcript-only clients") + args.proxy_mode = CLIENT_CONFIGS[args.client].default_proxy_mode + elif args.proxy_mode is None: + args.proxy_mode = CLIENT_CONFIGS[args.client].default_proxy_mode + if args.trust_ca and CLIENT_CONFIGS[args.client].transcript_only: + tap_parser.error("--tap-trust-ca does not apply to transcript-only clients") + if args.trust_ca and args.proxy_mode != "forward": + tap_parser.error("--tap-trust-ca only applies to forward proxy mode") + if args.codexapp_cdp_endpoint != CODEX_APP_CDP_DEFAULT_ENDPOINT and args.client != "codexapp": + tap_parser.error("--tap-codexapp-cdp-endpoint only applies to --tap-client codexapp") + + # Validate --tap-allow-path prefixes + for prefix in args.extra_allowed_paths: + if not prefix: + tap_parser.error("--tap-allow-path cannot be empty") + if not prefix.startswith("/"): + tap_parser.error(f"--tap-allow-path '{prefix}' must start with '/'") + if prefix == "/": + tap_parser.error("--tap-allow-path '/' is too broad and not allowed") + if prefix.endswith("/"): + tap_parser.error(f"--tap-allow-path '{prefix}' must not end with '/' (specify exact prefix)") + + return args + + +def parse_dashboard_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse arguments for the standalone dashboard command.""" + parser = argparse.ArgumentParser( + prog="claude-tap dashboard", + description="Open a local claude-tap dashboard for browsing trace history.", + ) + parser.add_argument( + "command", + nargs="?", + choices=["stop", "quit"], + help="Use 'stop' or 'quit' to stop a running dashboard service instead of starting one", + ) + parser.add_argument( + "--tap-output-dir", + default="./.traces", + dest="output_dir", + help="Legacy trace directory to import once (default: ./.traces)", + ) + parser.add_argument( + "--tap-live-port", + type=int, + default=0, + dest="live_port", + help="Dashboard server port (default: auto)", + ) + parser.add_argument( + "--tap-host", + default="127.0.0.1", + dest="host", + help="Bind address (default: 127.0.0.1)", + ) + parser.add_argument( + "--tap-no-open", + action="store_false", + dest="open_viewer", + default=True, + help="Don't auto-open the dashboard in a browser", + ) + return parser.parse_args(argv) + + +async def dashboard_main(args: argparse.Namespace) -> int: + """Run the standalone dashboard until interrupted.""" + output_dir = Path(args.output_dir) + + host = args.host + port = resolve_dashboard_port(args.live_port) + if args.command in {"stop", "quit"}: + if not await is_dashboard_healthy(host, port, require_current_db=False): + print(f"claude-tap dashboard is not running on {dashboard_url(host, port)}") + return 1 + if not await stop_dashboard_service(host, port): + print(f"Unable to stop claude-tap dashboard on {dashboard_url(host, port)}") + return 1 + print(f"Stopped claude-tap dashboard on {dashboard_url(host, port)}") + return 0 + + output_dir.mkdir(parents=True, exist_ok=True) + + if await _is_dashboard_reusable(host, port): + migrate_legacy_traces(output_dir) + url = dashboard_url(host, port) + print(f"🌐 claude-tap dashboard already running: {url}") + print(f"🗄️ Trace database: {resolve_db_path()}") + if args.open_viewer: + _open_browser(url) + return 0 + + url = dashboard_url(host, port) + await stop_incompatible_dashboard_if_running(host, port, url) + + server = LiveViewerServer( + port=port, + host=host, + migrate_from=output_dir, + dashboard_mode=True, + ) + try: + await server.start() + except OSError: + if await _is_dashboard_reusable(host, port): + migrate_legacy_traces(output_dir) + url = dashboard_url(host, port) + print(f"🌐 claude-tap dashboard already running: {url}") + if args.open_viewer: + _open_browser(url) + return 0 + raise + print(f"🌐 claude-tap dashboard: {server.url}") + print(f"🗄️ Trace database: {resolve_db_path()}") + if output_dir.exists(): + print(f"📁 Legacy import dir: {output_dir}") + print("Press Ctrl+C to stop.") + if args.open_viewer: + _open_browser(server.url) + + try: + await server.wait_stopped() + except asyncio.CancelledError: + pass + finally: + await server.stop() + return 0 + + +# --------------------------------------------------------------------------- +def parse_trust_ca_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse arguments for the trust-ca subcommand.""" + parser = argparse.ArgumentParser( + prog="claude-tap trust-ca", + description=( + "Trust the claude-tap forward-proxy CA in the current user's macOS login keychain. " + "This does not use sudo or the System keychain." + ), + ) + return parser.parse_args(argv) + + +def trust_ca_main(argv: list[str] | None = None) -> int: + """Entry point for the trust-ca subcommand.""" + parse_trust_ca_args(argv) + ca_cert_path, _ = ensure_ca() + return _trust_ca_for_current_user(ca_cert_path) + + +def main_entry() -> None: + """Entry point for the claude-tap CLI.""" + # Check if first argument is "export" subcommand + if len(sys.argv) > 1 and sys.argv[1] == "export": + from claude_tap.export import export_main + + sys.exit(export_main(sys.argv[2:])) + + if len(sys.argv) > 1 and sys.argv[1] == "update": + sys.exit(update_main(sys.argv[2:])) + + if len(sys.argv) > 1 and sys.argv[1] == "trust-ca": + sys.exit(trust_ca_main(sys.argv[2:])) + + if len(sys.argv) > 1 and sys.argv[1] == "monitor-restore": + from claude_tap import global_inject + + global_inject.disable(terminate_processes=True) + sys.exit(0) + + if len(sys.argv) > 1 and sys.argv[1] == "macos-app": + from claude_tap import macos_app + + sys.exit(macos_app.main(sys.argv[2:])) + + if len(sys.argv) > 1 and sys.argv[1] == "build-macos-app": + from claude_tap import macos_bundle + + sys.exit(macos_bundle.main(sys.argv[2:])) + + if len(sys.argv) > 1 and sys.argv[1] == "dashboard": + args = parse_dashboard_args(sys.argv[2:]) + try: + code = asyncio.run(dashboard_main(args)) + except KeyboardInterrupt: + code = 0 + sys.exit(code) + + args = parse_args() + try: + code = asyncio.run(async_main(args)) + except KeyboardInterrupt: + code = 0 + sys.exit(code) diff --git a/claude_tap/cli_clients.py b/claude_tap/cli_clients.py new file mode 100644 index 0000000..fc31ab0 --- /dev/null +++ b/claude_tap/cli_clients.py @@ -0,0 +1,1901 @@ +"""Client launch and target detection helpers for claude-tap CLI.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import os +import re +import shutil +import signal +import sys +import tempfile +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + +_BEDROCK_HOST_RE = re.compile( + r"(^|\.)(" + r"(bedrock-runtime|bedrock-runtime-fips)" + r"\.[a-z0-9-]+\.(amazonaws\.com|amazonaws\.com\.cn|vpce\.amazonaws\.com)" + r"|bedrock-mantle\.[a-z0-9-]+\.(api\.aws|amazonaws\.com|amazonaws\.com\.cn)" + r")$" +) + + +def _is_aws_native_bedrock_url(url: str) -> bool: + """Return True if the URL points to a real AWS Bedrock endpoint (SigV4-signed). + + AWS native Bedrock endpoints match patterns like: + - bedrock-runtime.us-east-1.amazonaws.com + - bedrock-runtime-fips.us-west-2.amazonaws.com + - vpce-xxx.bedrock-runtime.us-east-1.vpce.amazonaws.com + - bedrock-mantle.us-east-1.api.aws + - bedrock-mantle.us-east-1.amazonaws.com + + Custom gateways on other AWS services (e.g. API Gateway *.execute-api.*) + or company proxies do NOT use SigV4, so rewriting their URL is safe. + """ + try: + from urllib.parse import urlparse + + host = urlparse(url).hostname or "" + except Exception: + return False + return bool(_BEDROCK_HOST_RE.search(host)) + + +def _is_truthy_env_value(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _is_claude_bedrock_enabled() -> bool: + return _is_truthy_env_value(_resolve_env_value("CLAUDE_CODE_USE_BEDROCK")) + + +def _is_claude_vertex_enabled() -> bool: + return _is_truthy_env_value(_resolve_env_value("CLAUDE_CODE_USE_VERTEX")) + + +def _should_rewrite_extra_base_url_env(env_key: str) -> bool: + current_value = _resolve_env_value(env_key) + if env_key == "ANTHROPIC_BEDROCK_BASE_URL": + if not _is_claude_bedrock_enabled() or not current_value: + return False + return not _is_aws_native_bedrock_url(current_value) + if env_key == "ANTHROPIC_VERTEX_BASE_URL": + return _is_claude_vertex_enabled() and bool(current_value) + return True + + +@dataclass(frozen=True) +class ClientConfig: + """Per-client configuration for supported AI CLI tools.""" + + cmd: str + label: str + install_url: str + base_url_env: str + base_url_suffix: str # appended to http://127.0.0.1:{port} + default_target: str + extra_base_url_envs: tuple[str, ...] = () + nesting_env_keys: tuple[str, ...] = () # env vars to clear before launch + # Some CLIs need process env duplicated into a CLI settings payload. + inject_settings_env: bool = False + # Reverse proxy URL normalization. Example: Codex OAuth receives /v1/* but + # its upstream target already points at a /codex backend that expects /*. + strip_path_prefix: str = "" + strip_path_prefix_unless_target_contains: tuple[str, ...] = () + # Default proxy mode when --tap-proxy-mode is not explicitly set. + # Multi-provider clients (e.g. hermes, opencode, pi) default to "forward" so that all + # provider traffic is captured regardless of which env var the client honors. + default_proxy_mode: str = "reverse" + # Some non-Python/non-Node macOS clients do not honor per-process CA env + # variables, so they need the forward-proxy CA in the user login keychain. + auto_trust_ca_macos: bool = False + # Some clients honor a native provider URL for the core model API but ignore + # HTTPS_PROXY for that API. In forward mode, point those env vars back at the + # local proxy and let the forward proxy bridge selected paths to target. + forward_base_url_envs: tuple[str, ...] = () + forward_base_url_allowed_path_prefixes: tuple[str, ...] = () + # Transcript-only clients are observed from local session logs instead of a + # spawned process and do not need a reverse or forward proxy. + transcript_only: bool = False + + @property + def missing_help(self) -> str: + return ( + f"\nError: '{self.cmd}' command not found in PATH.\nPlease install {self.label} first: {self.install_url}\n" + ) + + def reverse_base_url(self, port: int) -> str: + return f"http://127.0.0.1:{port}{self.base_url_suffix}" + + @property + def reverse_base_url_envs(self) -> tuple[str, ...]: + seen: set[str] = set() + env_keys: list[str] = [] + for env_key in (self.base_url_env, *self.extra_base_url_envs): + if env_key in seen: + continue + seen.add(env_key) + env_keys.append(env_key) + return tuple(env_keys) + + def reverse_base_url_env_map(self, port: int) -> dict[str, str]: + base_url = self.reverse_base_url(port) + env_map: dict[str, str] = {} + for env_key in self.reverse_base_url_envs: + if env_key in self.extra_base_url_envs and not _should_rewrite_extra_base_url_env(env_key): + continue + env_map[env_key] = base_url + return env_map + + def reverse_strip_path_prefix(self, target: str) -> str: + if not self.strip_path_prefix: + return "" + if any(marker in target for marker in self.strip_path_prefix_unless_target_contains): + return "" + return self.strip_path_prefix + + +CLIENT_CONFIGS: dict[str, ClientConfig] = { + "claude": ClientConfig( + cmd="claude", + label="Claude Code", + install_url="https://docs.anthropic.com/en/docs/claude-code", + base_url_env="ANTHROPIC_BASE_URL", + extra_base_url_envs=("ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_VERTEX_BASE_URL"), + base_url_suffix="", + default_target="https://api.anthropic.com", + nesting_env_keys=("CLAUDECODE", "CLAUDE_CODE_SSE_PORT"), + inject_settings_env=True, + ), + "codex": ClientConfig( + cmd="codex", + label="Codex CLI", + install_url="https://github.com/openai/codex", + base_url_env="OPENAI_BASE_URL", + base_url_suffix="/v1", + default_target="https://api.openai.com", + strip_path_prefix="/v1", + strip_path_prefix_unless_target_contains=("api.openai.com",), + ), + "codexapp": ClientConfig( + cmd="codex", + label="Codex App", + install_url="https://openai.com/codex", + base_url_env="CODEX_HOME", + base_url_suffix="", + default_target="codex-app://sessions", + default_proxy_mode="transcript", + transcript_only=True, + ), + "kimi": ClientConfig( + cmd="kimi", + label="Kimi Code CLI", + install_url="https://github.com/MoonshotAI/kimi-cli", + base_url_env="KIMI_BASE_URL", + base_url_suffix="", + default_target="https://api.kimi.com/coding/v1", + ), + "kimi-code": ClientConfig( + cmd="kimi", + label="Kimi Code CLI", + install_url="https://github.com/MoonshotAI/kimi-code", + base_url_env="KIMI_CODE_BASE_URL", + base_url_suffix="", + default_target="https://api.kimi.com/coding/v1", + ), + "gemini": ClientConfig( + cmd="gemini", + label="Gemini CLI", + install_url="https://github.com/google-gemini/gemini-cli", + base_url_env="GOOGLE_GEMINI_BASE_URL", + extra_base_url_envs=("GOOGLE_VERTEX_BASE_URL",), + base_url_suffix="", + default_target="https://generativelanguage.googleapis.com", + # Google OAuth / Code Assist traffic spans several Google endpoints. + # Forward mode captures that flow without assuming a single base URL. + default_proxy_mode="forward", + ), + "opencode": ClientConfig( + cmd="opencode", + label="OpenCode", + install_url="https://opencode.ai/docs/", + # opencode is multi-provider; ANTHROPIC_BASE_URL is what reverse mode + # patches when the user explicitly opts out of forward mode. Forward + # proxy is the default and captures every provider transparently. + base_url_env="ANTHROPIC_BASE_URL", + base_url_suffix="", + default_target="https://api.anthropic.com", + default_proxy_mode="forward", + ), + "mimo": ClientConfig( + cmd="mimo", + label="MiMo Code", + install_url="https://mimo.xiaomi.com/en/mimocode", + # MiMo Code is an OpenCode fork (https://github.com/XiaomiMiMo/MiMo-Code). + # It inherits the same multi-provider env vars; forward proxy is the + # natural default to capture all provider traffic transparently. + base_url_env="ANTHROPIC_BASE_URL", + base_url_suffix="", + default_target="https://api.anthropic.com", + default_proxy_mode="forward", + ), + "pi": ClientConfig( + cmd="pi", + label="Pi", + install_url="https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent", + # Pi is multi-provider and stores provider base URLs in its model + # registry/models.json rather than a single global env var. Reverse + # mode remains structurally available for custom OpenAI-compatible + # setups, but forward mode is the reliable default. + base_url_env="OPENAI_BASE_URL", + base_url_suffix="/v1", + default_target="https://api.openai.com", + default_proxy_mode="forward", + ), + "hermes": ClientConfig( + cmd="hermes", + label="Hermes Agent", + install_url="https://github.com/NousResearch/hermes-agent", + base_url_env="OPENAI_BASE_URL", + base_url_suffix="/v1", + default_target="https://api.openai.com", + # hermes is a Python 3.11+ multi-provider agent; reverse mode requires + # a user-configured OpenAI-compatible provider in ~/.hermes that honors + # OPENAI_BASE_URL. Default to forward proxy capture. + default_proxy_mode="forward", + ), + "cursor": ClientConfig( + cmd="cursor-agent", + label="Cursor CLI", + install_url="https://cursor.com/cli", + # Cursor CLI does not expose a provider base URL. Keep reverse-mode + # fields structurally valid, but default to forward proxy mode. + base_url_env="CURSOR_BASE_URL", + base_url_suffix="", + default_target="https://api2.cursor.sh", + default_proxy_mode="forward", + ), + "qoder": ClientConfig( + cmd="qodercli", + label="Qoder CLI", + install_url="https://qoder.com/cli", + # Qoder CLI talks to multiple Qoder endpoints and does not expose a + # reliable single-provider base URL override. Keep reverse-mode fields + # structurally valid, but default to forward proxy mode. + base_url_env="QODER_BASE_URL", + base_url_suffix="", + default_target="https://api2.qoder.sh", + default_proxy_mode="forward", + ), + "agy": ClientConfig( + cmd="agy", + label="Antigravity CLI", + install_url="https://antigravity.google/product/antigravity-cli", + base_url_env="CLOUD_CODE_URL", + base_url_suffix="", + default_target="https://daily-cloudcode-pa.googleapis.com", + default_proxy_mode="forward", + auto_trust_ca_macos=True, + forward_base_url_envs=("CLOUD_CODE_URL",), + forward_base_url_allowed_path_prefixes=("/v1internal",), + ), + "openclaw": ClientConfig( + cmd="openclaw", + label="OpenClaw", + install_url="https://github.com/openclaw/openclaw", + base_url_env="OPENAI_BASE_URL", + extra_base_url_envs=("ANTHROPIC_BASE_URL", "GOOGLE_GEMINI_BASE_URL", "OPENROUTER_BASE_URL", "CUSTOM_BASE_URL"), + base_url_suffix="/v1", + default_target="https://api.openai.com", + ), + "codebuddy": ClientConfig( + cmd="codebuddy", + label="CodeBuddy", + install_url="https://www.codebuddy.ai/docs/cli", + base_url_env="CODEBUDDY_BASE_URL", + base_url_suffix="", + # CodeBuddy's bundled OpenAI client appends ``/v2`` to its product + # endpoint, so the reverse-proxy upstream must include that prefix + # to hit ``/v2/chat/completions`` rather than the nginx default page. + # Users on non-Tencent deployments can override via ``--tap-target`` + # or ``CODEBUDDY_BASE_URL``. + default_target="https://copilot.tencent.com/v2", + inject_settings_env=True, + ), +} + + +async def run_client( + port: int, + extra_args: list[str], + client: str = "claude", + proxy_mode: str = "reverse", + ca_cert_path: Path | None = None, + client_cmd: str | None = None, + capture_only: bool = False, +) -> int: + cfg = CLIENT_CONFIGS[client] + + # asyncio.create_subprocess_exec uses CreateProcess on Windows, which only + # auto-appends `.exe`; resolve here so npm `.cmd`/`.bat` shims also work. + display_cmd = client_cmd or cfg.cmd + resolved_cmd = str(Path(client_cmd)) if client_cmd and Path(client_cmd).is_file() else shutil.which(display_cmd) + if resolved_cmd is None: + if client_cmd: + print(f"\nError: '{client_cmd}' command not found.\nPlease check the wrapper-provided {cfg.label} path.\n") + else: + print(cfg.missing_help) + return 1 + + env = os.environ.copy() + cleanup_paths: list[Path] = [] + + cmd_args = list(extra_args) + cmd_args = _maybe_rewrite_hermes_gateway_start(client, cmd_args) + kimi_code_sandbox: Path | None = None + kimi_code_source_home: Path | None = None + + if proxy_mode == "forward": + proxy_url = f"http://127.0.0.1:{port}" + # Set both upper/lower-case variants for tools that read one form only. + env["HTTP_PROXY"] = proxy_url + env["HTTPS_PROXY"] = proxy_url + env["ALL_PROXY"] = proxy_url + env["http_proxy"] = proxy_url + env["https_proxy"] = proxy_url + env["all_proxy"] = proxy_url + _extend_no_proxy(env, ("localhost", "127.0.0.1", "::1")) + if client == "mimo": + # MiMo defaults to mimo-only mode and ignores provider env vars unless disabled. + env["MIMOCODE_MIMO_ONLY"] = "false" + forward_base_url = cfg.reverse_base_url(port) + for env_key in cfg.forward_base_url_envs: + env[env_key] = forward_base_url + if ca_cert_path: + env["NODE_EXTRA_CA_CERTS"] = str(ca_cert_path) + # Codex is a Rust binary; NODE_EXTRA_CA_CERTS does not affect its TLS stack. + env["SSL_CERT_FILE"] = str(ca_cert_path) + env["CODEX_CA_CERTIFICATE"] = str(ca_cert_path) + # hermes is Python (httpx + requests); SSL_CERT_FILE covers httpx, + # REQUESTS_CA_BUNDLE covers the requests library. + env["REQUESTS_CA_BUNDLE"] = str(ca_cert_path) + + if cfg.inject_settings_env: + if not _has_settings_arg(cmd_args): + settings_payload: dict[str, dict[str, str]] = { + "env": { + "HTTP_PROXY": proxy_url, + "HTTPS_PROXY": proxy_url, + "ALL_PROXY": proxy_url, + "http_proxy": proxy_url, + "https_proxy": proxy_url, + "all_proxy": proxy_url, + } + } + if ca_cert_path: + settings_payload["env"]["NODE_EXTRA_CA_CERTS"] = str(ca_cert_path) + cmd_args = _settings_arg(settings_payload["env"]) + cmd_args + # Don't set reverse-mode provider-specific base URL in forward mode. + else: + if client == "kimi-code": + kimi_code_sandbox, _patched_providers, kimi_code_source_home, cmd_args = _prepare_kimi_code_reverse_sandbox( + port, cmd_args + ) + has_kimi_code_model_arg = bool(_kimi_code_model_arg(cmd_args)) + if has_kimi_code_model_arg: + env.pop("KIMI_MODEL_NAME", None) + env.pop("KIMI_MODEL_BASE_URL", None) + elif not _has_active_kimi_code_model_env(): + env.pop("KIMI_MODEL_BASE_URL", None) + reverse_env = { + "KIMI_CODE_HOME": str(kimi_code_sandbox), + "KIMI_CODE_BASE_URL": cfg.reverse_base_url(port), + "KIMI_BASE_URL": cfg.reverse_base_url(port), + } + if not has_kimi_code_model_arg and _should_proxy_kimi_code_model_env(): + reverse_env["KIMI_MODEL_BASE_URL"] = cfg.reverse_base_url(port) + elif client == "openclaw": + reverse_env = _openclaw_reverse_env(port, cmd_args) + elif capture_only and client in {"hermes", "kimi"}: + reverse_env = _multi_provider_reverse_env(port) + elif capture_only and client == "mimo": + reverse_env = _opencode_reverse_env(port) + reverse_env["MIMOCODE_MIMO_ONLY"] = "false" + elif capture_only and client == "opencode": + reverse_env = _opencode_reverse_env(port) + elif client == "mimo": + reverse_env = cfg.reverse_base_url_env_map(port) + reverse_env["MIMOCODE_MIMO_ONLY"] = "false" + else: + reverse_env = cfg.reverse_base_url_env_map(port) + cleanup_path = reverse_env.pop(_OPENCLAW_CLEANUP_ENV, None) + if cleanup_path: + cleanup_paths.append(Path(cleanup_path)) + env.update(reverse_env) + if client == "mimo": + # MiMo talks to a local HTTP server in TUI mode; preserve any existing + # NO_PROXY entries and bypass localhost the same way forward mode does. + _extend_no_proxy(env, ("localhost", "127.0.0.1", "::1")) + else: + env["NO_PROXY"] = "127.0.0.1" + if cfg.inject_settings_env and not _has_settings_arg(cmd_args): + cmd_args = _settings_arg(reverse_env) + cmd_args + if client == "codex": + cmd_args = _codex_reverse_args(cfg.reverse_base_url(port), cmd_args) + + for key in cfg.nesting_env_keys: + env.pop(key, None) + + cmd = [resolved_cmd] + cmd_args + print(f"\n🚀 Starting {cfg.label}: {' '.join([display_cmd, *cmd_args])}") + if proxy_mode == "forward": + print(f" HTTPS_PROXY=http://127.0.0.1:{port}") + for env_key in cfg.forward_base_url_envs: + print(f" {env_key}={cfg.reverse_base_url(port)}") + if ca_cert_path: + print(f" NODE_EXTRA_CA_CERTS={ca_cert_path}") + elif client == "kimi-code": + print(f" KIMI_CODE_HOME={env.get('KIMI_CODE_HOME', '')}") + print(f" KIMI_CODE_BASE_URL={env.get('KIMI_CODE_BASE_URL', '')}") + else: + for env_key, base_url in cfg.reverse_base_url_env_map(port).items(): + print(f" {env_key}={base_url}") + print() + + # Give child its own process group and make it the foreground group + # so the TUI app has full terminal control (e.g. Cmd+Delete, Ctrl+U). + use_fg = hasattr(os, "tcsetpgrp") and sys.stdin.isatty() + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + env=env, + stdin=None, + stdout=None, + stderr=None, + **({"process_group": 0} if use_fg else {}), + ) + except Exception: + for path in cleanup_paths: + try: + path.unlink(missing_ok=True) + except OSError: + pass + if client == "kimi-code" and proxy_mode == "reverse" and kimi_code_sandbox is not None: + shutil.rmtree(kimi_code_sandbox, ignore_errors=True) + raise + + if use_fg: + try: + os.tcsetpgrp(sys.stdin.fileno(), proc.pid) + except OSError: + pass + + # --- Signal handling: graceful Ctrl+C / Ctrl+Z --- + loop = asyncio.get_running_loop() + + # SIGTSTP is Unix-only; on Windows the attribute is absent. + sigtstp = getattr(signal, "SIGTSTP", None) + old_sigtstp = signal.signal(sigtstp, signal.SIG_IGN) if sigtstp is not None else None + + sigint_count = 0 + + def _handle_sigint(): + nonlocal sigint_count + sigint_count += 1 + if sigint_count == 1: + if proc.returncode is None: + proc.terminate() + print(f"\n⏳ Shutting down {cfg.label}... (Ctrl+C again to force)") + else: + if proc.returncode is None: + proc.kill() + + def _handle_sigtstp(): + if proc.returncode is None: + proc.terminate() + print(f"\n⏳ Shutting down {cfg.label}...") + + try: + loop.add_signal_handler(signal.SIGINT, _handle_sigint) + if sigtstp is not None: + loop.add_signal_handler(sigtstp, _handle_sigtstp) + except (NotImplementedError, OSError): + pass + + try: + code = await proc.wait() + finally: + for path in cleanup_paths: + try: + path.unlink(missing_ok=True) + except OSError: + pass + if ( + client == "kimi-code" + and proxy_mode == "reverse" + and kimi_code_sandbox is not None + and kimi_code_source_home is not None + ): + _merge_kimi_code_session_index(kimi_code_source_home, kimi_code_sandbox) + _persist_kimi_code_sandbox(kimi_code_source_home, kimi_code_sandbox) + _remap_kimi_code_sandbox_paths(kimi_code_source_home, kimi_code_sandbox) + shutil.rmtree(kimi_code_sandbox, ignore_errors=True) + + # Restore parent as foreground process group. + # Ignore SIGTTOU first — the parent is still in the background group + # and any terminal write (including tcsetpgrp) would suspend it. + if use_fg: + old_sigttou = signal.signal(signal.SIGTTOU, signal.SIG_IGN) + try: + os.tcsetpgrp(sys.stdin.fileno(), os.getpgrp()) + except OSError: + pass + signal.signal(signal.SIGTTOU, old_sigttou) + + # Restore original SIGTSTP handler and remove async signal handlers + if sigtstp is not None and old_sigtstp is not None: + signal.signal(sigtstp, old_sigtstp) + try: + loop.remove_signal_handler(signal.SIGINT) + except (NotImplementedError, OSError): + pass + if sigtstp is not None: + try: + loop.remove_signal_handler(sigtstp) + except (NotImplementedError, OSError): + pass + + print(f"\n📋 {cfg.label} exited with code {code}") + return code + + +_HERMES_GLOBAL_OPTS_WITH_VALUE = {"--profile", "-p"} +_HERMES_GLOBAL_BOOLEAN_OPTS = {"--ignore-user-config", "--accept-hooks"} + + +def _maybe_rewrite_hermes_gateway_start(client: str, cmd_args: list[str]) -> list[str]: + """Rewrite ``hermes [global-opts] gateway start`` to ``... gateway run``. + + Recent hermes versions delegate ``gateway start`` to systemd / launchd, + which spawn the gateway in a fresh env that does NOT inherit the + HTTPS_PROXY / CA env we inject — trace capture would silently fail. + ``gateway run`` is the foreground equivalent (it's exactly what the + systemd unit's ``ExecStart=`` invokes), so the spawned process is our + child and inherits the injected env. + + Hermes' CLI shape is ``hermes [global-options] [...]``, so the + rewrite skips any recognised leading global options before matching + ``gateway start``. + """ + if client != "hermes": + return cmd_args + i = 0 + while i < len(cmd_args): + arg = cmd_args[i] + if arg in _HERMES_GLOBAL_OPTS_WITH_VALUE and i + 1 < len(cmd_args): + i += 2 + continue + if "=" in arg and arg.split("=", 1)[0] in _HERMES_GLOBAL_OPTS_WITH_VALUE: + i += 1 + continue + if arg in _HERMES_GLOBAL_BOOLEAN_OPTS: + i += 1 + continue + break + if i + 1 < len(cmd_args) and cmd_args[i] == "gateway" and cmd_args[i + 1] == "start": + print( + "ℹ️ Rewriting `hermes gateway start` to `hermes gateway run` so the " + "gateway runs in the foreground under claude-tap. Recent hermes " + "versions delegate `gateway start` to systemd / launchd, which spawns " + "the gateway in a fresh env that does NOT inherit the proxy / CA env " + "we inject — trace capture would silently fail. Pass --tap-no-launch " + "and start the gateway yourself if you want the daemonised behaviour." + ) + return cmd_args[:i] + ["gateway", "run"] + cmd_args[i + 2 :] + return cmd_args + + +def _extend_no_proxy(env: dict[str, str], values: tuple[str, ...]) -> None: + """Append local proxy bypasses without discarding existing settings.""" + existing: list[str] = [] + for key in ("NO_PROXY", "no_proxy"): + raw = env.get(key, "") + existing.extend(part.strip() for part in raw.split(",") if part.strip()) + if "*" in existing: + env["NO_PROXY"] = "*" + env["no_proxy"] = "*" + return + + merged: list[str] = [] + seen: set[str] = set() + for value in [*existing, *values]: + lowered = value.lower() + if lowered in seen: + continue + seen.add(lowered) + merged.append(value) + + no_proxy = ",".join(merged) + env["NO_PROXY"] = no_proxy + env["no_proxy"] = no_proxy + + +def _codex_config_override_values(args: list[str]) -> list[str]: + values: list[str] = [] + i = 0 + while i < len(args): + arg = args[i] + if arg in ("-c", "--config"): + if i + 1 < len(args): + values.append(args[i + 1]) + i += 2 + continue + if arg.startswith("--config="): + values.append(arg.split("=", 1)[1]) + i += 1 + return values + + +def _codex_config_override_value(args: list[str] | None, key: str) -> object | None: + if not args: + return None + prefix = f"{key}=" + value: object | None = None + for override in _codex_config_override_values(args): + if not override.startswith(prefix): + continue + raw = override[len(prefix) :].strip() + try: + parsed = tomllib.loads(f"value = {raw}\n") + except tomllib.TOMLDecodeError: + value = raw + else: + value = parsed.get("value") + return value + + +def _codex_profile_arg(args: list[str] | None) -> str | None: + if not args: + return None + profile: str | None = None + i = 0 + while i < len(args): + arg = args[i] + if arg in ("-p", "--profile"): + if i + 1 < len(args): + profile = args[i + 1] + i += 2 + continue + if arg.startswith("--profile="): + profile = arg.split("=", 1)[1] + i += 1 + return profile.strip() if profile and profile.strip() else None + + +def _toml_dotted_key_segment(value: str) -> str: + """Return a TOML dotted-key segment for a Codex config key.""" + if value and value.isascii() and all(char.isalnum() or char in {"_", "-"} for char in value): + return value + return json.dumps(value) + + +def _codex_home() -> Path: + return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") + + +def _read_codex_config_file(config_path: Path) -> dict[str, object]: + try: + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _read_codex_config() -> dict[str, object]: + return _read_codex_config_file(_codex_home() / "config.toml") + + +def _read_codex_profile_config(profile: str | None) -> dict[str, object]: + if not profile: + return {} + return _read_codex_config_file(_codex_home() / f"{profile}.config.toml") + + +def _selected_codex_provider_base_url(args: list[str] | None = None) -> tuple[str, str] | None: + """Return the selected custom Codex provider and base URL, if configured.""" + data = _read_codex_config() + provider = _codex_config_override_value(args, "model_provider") + profile = _codex_profile_arg(args) + if profile is None: + configured_profile = _codex_config_override_value(args, "profile") + if configured_profile is None: + configured_profile = data.get("profile") + if isinstance(configured_profile, str) and configured_profile.strip(): + profile = configured_profile.strip() + + profile_data = _read_codex_profile_config(profile) + if not isinstance(provider, str): + provider = profile_data.get("model_provider") + + profiles = data.get("profiles") + if profile and isinstance(profiles, dict): + profile_config = profiles.get(profile) + if isinstance(profile_config, dict) and not isinstance(provider, str): + provider = profile_config.get("model_provider") + + if not isinstance(provider, str): + provider = data.get("model_provider") + if not isinstance(provider, str) or not provider.strip(): + return None + provider = provider.strip() + + provider_base_url_key = f"model_providers.{_toml_dotted_key_segment(provider)}.base_url" + base_url_override = _codex_config_override_value(args, provider_base_url_key) + if isinstance(base_url_override, str) and base_url_override.strip(): + return provider, base_url_override.strip() + + base_url: object | None = None + for config in (profile_data, data): + providers = config.get("model_providers") + if not isinstance(providers, dict): + continue + provider_config = providers.get(provider) + if not isinstance(provider_config, dict): + continue + base_url = provider_config.get("base_url") + if isinstance(base_url, str) and base_url.strip(): + break + if not isinstance(base_url, str) or not base_url.strip(): + return None + return provider.strip(), base_url.strip() + + +def _codex_selected_provider_base_url_key(args: list[str] | None = None) -> str | None: + selected = _selected_codex_provider_base_url(args) + if selected is None: + return None + provider, _base_url = selected + return f"model_providers.{_toml_dotted_key_segment(provider)}.base_url" + + +def _codex_reverse_args(proxy_base_url: str, args: list[str]) -> list[str]: + """Route Codex through the proxy over HTTP/SSE without changing user config.""" + provider_base_url_key = _codex_selected_provider_base_url_key(args) + if provider_base_url_key: + provider_key = provider_base_url_key.removesuffix(".base_url") + overrides = [ + f'{provider_base_url_key}="{proxy_base_url}"', + f"{provider_key}.supports_websockets=false", + ] + args = _without_config_overrides(args, {provider_base_url_key, f"{provider_key}.supports_websockets"}) + else: + provider_key = "model_providers.claude-tap-openai" + overrides = [ + 'model_provider="claude-tap-openai"', + f'{provider_key}.name="claude-tap"', + f'{provider_key}.base_url="{proxy_base_url}"', + f'{provider_key}.wire_api="responses"', + f"{provider_key}.requires_openai_auth=true", + f"{provider_key}.supports_websockets=false", + ] + args = _without_config_overrides(args, {"model_provider"}) + + injected = [item for override in overrides for item in ("-c", override)] + return injected + args + + +def _without_config_overrides(args: list[str], keys: set[str]) -> list[str]: + """Remove Codex config overrides that would bypass enforced proxy settings.""" + filtered: list[str] = [] + i = 0 + while i < len(args): + arg = args[i] + if arg in {"-c", "--config"} and i + 1 < len(args): + value = args[i + 1] + if any(value.startswith(f"{key}=") for key in keys): + i += 2 + continue + filtered.extend((arg, value)) + i += 2 + continue + if arg.startswith("--config="): + value = arg.split("=", 1)[1] + if any(value.startswith(f"{key}=") for key in keys): + i += 1 + continue + filtered.append(arg) + i += 1 + return filtered + + +def _has_settings_arg(args: list[str]) -> bool: + return any(arg == "--settings" or arg.startswith("--settings=") for arg in args) + + +def _settings_arg(env_values: dict[str, str]) -> list[str]: + settings_payload = {"env": env_values} + return ["--settings", json.dumps(settings_payload, separators=(",", ":"))] + + +_CODEX_CHATGPT_TARGET = "https://chatgpt.com/backend-api/codex" + + +def _resolve_env_value(env_key: str) -> str: + """Resolve an env key from process env or Claude settings files.""" + value = os.environ.get(env_key, "").strip() + if value: + return value + candidate_paths = ( + Path.cwd() / ".claude" / "settings.local.json", + Path.cwd() / ".claude" / "settings.json", + Path.home() / ".claude" / "settings.json", + ) + for path in candidate_paths: + found = _read_settings_env_base_url(path, env_key) + if found: + return found + return "" + + +def _read_settings_env_base_url(path: Path, env_key: str) -> str | None: + """Read a provider base URL from a Claude-style settings file.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + env = data.get("env") + if not isinstance(env, dict): + return None + value = env.get(env_key) + if not isinstance(value, str): + return None + value = value.strip() + return value or None + + +def _detect_claude_target() -> str: + """Auto-detect the upstream target Claude Code would normally use. + + Claude Code can source provider base URLs from settings files rather than + only the process environment. Mirror that behavior for custom Anthropic, + Bedrock, and Vertex gateways without forcing users to repeat + ``--tap-target``. + """ + if _is_claude_vertex_enabled(): + vertex_target = _resolve_env_value("ANTHROPIC_VERTEX_BASE_URL") + else: + vertex_target = "" + if vertex_target: + return vertex_target + + if _is_claude_bedrock_enabled(): + bedrock_target = _resolve_env_value("ANTHROPIC_BEDROCK_BASE_URL") + else: + bedrock_target = "" + if bedrock_target and not _is_aws_native_bedrock_url(bedrock_target): + return bedrock_target + + env_target = _resolve_env_value("ANTHROPIC_BASE_URL") + if env_target: + return env_target + + return CLIENT_CONFIGS["claude"].default_target + + +def _reverse_proxy_trace_options(client: str, target: str) -> dict[str, object]: + cfg = CLIENT_CONFIGS[client] + return { + "strip_path_prefix": cfg.reverse_strip_path_prefix(target), + "force_http": False, + } + + +def _detect_codex_target(args: list[str] | None = None) -> str: + """Auto-detect the correct upstream target for Codex CLI. + + Explicit provider and CLI target configuration takes precedence over the + auth-mode defaults, matching Codex's own layered config resolution. + """ + custom_provider = _selected_codex_provider_base_url(args) + if custom_provider is not None: + _provider, base_url = custom_provider + return base_url + + openai_base_url_override = _codex_config_override_value(args, "openai_base_url") + if isinstance(openai_base_url_override, str) and openai_base_url_override.strip(): + return openai_base_url_override.strip() + + codex_home = _codex_home() + auth_file = codex_home / "auth.json" + try: + data = json.loads(auth_file.read_text(encoding="utf-8")) + if isinstance(data, dict) and data.get("auth_mode") == "chatgpt": + return _CODEX_CHATGPT_TARGET + except (OSError, json.JSONDecodeError, ValueError): + pass + + env_target = os.environ.get(CLIENT_CONFIGS["codex"].base_url_env, "").strip() + if env_target: + return env_target + + data = _read_codex_config() + openai_base_url = data.get("openai_base_url") + if isinstance(openai_base_url, str) and openai_base_url.strip(): + return openai_base_url.strip() + return CLIENT_CONFIGS["codex"].default_target + + +def _detect_codebuddy_target() -> str: + """Auto-detect the upstream target CodeBuddy would normally use. + + Priority: + 1. ``CODEBUDDY_BASE_URL`` env var. + 2. ``settings.json`` env block, searched in this order: + project-local ``.codebuddy/settings{.local,}.json`` → + ``${CODEBUDDY_CONFIG_DIR}/settings.json`` (when set) → + ``~/.codebuddy/settings.json``. + 3. CodeBuddy's endpoint cache written on login (all four login modes). + 4. ``ClientConfig.default_target`` fallback. + """ + env_target = os.environ.get("CODEBUDDY_BASE_URL", "").strip() + if env_target: + return env_target + + env_key = CLIENT_CONFIGS["codebuddy"].base_url_env + config_dir = os.environ.get("CODEBUDDY_CONFIG_DIR", "").strip() + candidate_paths: list[Path] = [ + Path.cwd() / ".codebuddy" / "settings.local.json", + Path.cwd() / ".codebuddy" / "settings.json", + ] + if config_dir: + candidate_paths.append(Path(config_dir) / "settings.json") + candidate_paths.append(Path.home() / ".codebuddy" / "settings.json") + for path in candidate_paths: + target = _read_settings_env_base_url(path, env_key) + if target: + return target + + cached = _read_codebuddy_endpoint_cache() + if cached: + return cached.rstrip("/") + "/v2" + + return CLIENT_CONFIGS["codebuddy"].default_target + + +def _read_codebuddy_endpoint_cache() -> str | None: + """Return the host URL from CodeBuddy's login-time endpoint cache, or None.""" + config_dir = os.environ.get("CODEBUDDY_CONFIG_DIR", "").strip() + base = Path(config_dir) if config_dir else Path.home() / ".codebuddy" + # md5("CodeBuddy-Endpoint-Cache") — CodeBuddy's endpointCacheKey constant. + cache_file = base / "local_storage" / "entry_933d5543e80177622c17a73869c0fad7.info" + try: + value = json.loads(cache_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + return None + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +_KIMI_CODE_MANAGED_PROVIDER = "managed:kimi-code" +_KIMI_CODE_SKIP_MIGRATION_MARKER = ".skip-migration-from-kimi-cli" +_KIMI_CODE_MIGRATED_MARKER = ".migrated-to-kimi-code" +_KIMI_CODE_SANDBOX_DIR_PREFIX = "claude_tap_kimi_code_" + + +def _kimi_code_home() -> Path: + return _kimi_code_source_home() + + +def _kimi_code_source_home() -> Path: + """Persistent kimi-code data dir used when building a tap sandbox.""" + override = os.environ.get("KIMI_CODE_HOME", "").strip() + if override and _KIMI_CODE_SANDBOX_DIR_PREFIX not in override: + return Path(override).expanduser() + return Path.home() / ".kimi-code" + + +def _kimi_code_migration_already_handled(real_home: Path) -> bool: + """Mirror kimi-code detectPendingMigration suppression for the real home.""" + if (real_home / _KIMI_CODE_SKIP_MIGRATION_MARKER).is_file(): + return True + marker = Path.home() / ".kimi" / _KIMI_CODE_MIGRATED_MARKER + if not marker.is_file(): + return False + try: + data = json.loads(marker.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, ValueError): + # Unreadable marker: kimi-code treats this as "already handled". + return True + target_path = data.get("target_path") + if not isinstance(target_path, str): + return True + return Path(target_path).expanduser().resolve() == real_home.resolve() + + +def _sync_kimi_code_migration_suppression(source_home: Path, sandbox: Path) -> None: + """Copy or synthesize the skip marker so sandbox startups skip the migrate TUI.""" + skip_source = source_home / _KIMI_CODE_SKIP_MIGRATION_MARKER + skip_target = sandbox / _KIMI_CODE_SKIP_MIGRATION_MARKER + if skip_source.is_file(): + shutil.copy2(skip_source, skip_target) + return + if _kimi_code_migration_already_handled(source_home): + skip_target.write_text("", encoding="utf-8") + + +def _read_kimi_code_config(home: Path | None = None, path: Path | None = None) -> dict[str, object]: + config_path = path or (home or _kimi_code_home()) / "config.toml" + try: + text = config_path.read_text(encoding="utf-8") + data = json.loads(text) if config_path.suffix.lower() == ".json" else tomllib.loads(text) + except (OSError, json.JSONDecodeError, tomllib.TOMLDecodeError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _kimi_code_option_value(args: Sequence[str], flags: set[str]) -> str | None: + for idx, arg in enumerate(args): + if arg in flags and idx + 1 < len(args): + value = args[idx + 1].strip() + if value: + return value + for flag in flags: + prefix = f"{flag}=" + if arg.startswith(prefix): + value = arg[len(prefix) :].strip() + if value: + return value + return None + + +def _replace_kimi_code_option_value(args: Sequence[str], flags: set[str], value: str) -> list[str]: + rewritten: list[str] = [] + skip_next = False + for arg in args: + if skip_next: + rewritten.append(value) + skip_next = False + continue + if arg in flags: + rewritten.append(arg) + skip_next = True + continue + matched = False + for flag in flags: + if arg.startswith(f"{flag}="): + rewritten.append(f"{flag}={value}") + matched = True + break + if not matched: + rewritten.append(arg) + return rewritten + + +def _kimi_code_model_arg(cmd_args: Sequence[str] = ()) -> str | None: + return _kimi_code_option_value(cmd_args, {"--model", "-m"}) + + +def _kimi_code_config_file_arg(cmd_args: Sequence[str] = ()) -> str | None: + return _kimi_code_option_value(cmd_args, {"--config-file"}) + + +def _kimi_code_inline_config_arg(cmd_args: Sequence[str] = ()) -> str | None: + return _kimi_code_option_value(cmd_args, {"--config"}) + + +def _loads_kimi_code_inline_config(value: str) -> dict[str, object]: + value = value.strip() + if not value: + return {} + try: + parsed = json.loads(value) + except json.JSONDecodeError: + try: + parsed = tomllib.loads(value) + except (tomllib.TOMLDecodeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _kimi_code_config_for_args(cmd_args: Sequence[str] = ()) -> dict[str, object]: + inline_config = _kimi_code_inline_config_arg(cmd_args) + if inline_config: + return _loads_kimi_code_inline_config(inline_config) + + config_file = _kimi_code_config_file_arg(cmd_args) + if config_file: + return _read_kimi_code_config(path=Path(config_file).expanduser()) + + return _read_kimi_code_config() + + +def _has_active_kimi_code_model_env() -> bool: + return bool(os.environ.get("KIMI_MODEL_NAME", "").strip()) + + +def _should_proxy_kimi_code_model_env() -> bool: + if not _has_active_kimi_code_model_env(): + return False + if os.environ.get("KIMI_MODEL_BASE_URL", "").strip(): + return True + provider_type = os.environ.get("KIMI_MODEL_PROVIDER_TYPE", "").strip().lower() + return provider_type in {"", "kimi"} + + +def _kimi_code_provider_base_url(provider: dict[str, object]) -> str | None: + base_url = provider.get("base_url") + if isinstance(base_url, str) and base_url.strip(): + return base_url.strip() + env_table = provider.get("env") + if isinstance(env_table, dict): + fallback = env_table.get("KIMI_BASE_URL") + if isinstance(fallback, str) and fallback.strip(): + return fallback.strip() + return None + + +def _kimi_code_selected_provider_names(config: dict[str, object], cmd_args: Sequence[str] = ()) -> set[str]: + providers = config.get("providers") + if not isinstance(providers, dict): + return set() + + selected_model = _kimi_code_model_arg(cmd_args) + if not selected_model: + default_model = config.get("default_model") + if isinstance(default_model, str) and default_model.strip(): + selected_model = default_model.strip() + + models = config.get("models") + if selected_model and isinstance(models, dict): + model = models.get(selected_model) + if isinstance(model, dict): + provider_name = model.get("provider") + if isinstance(provider_name, str) and provider_name in providers: + return {provider_name} + + kimi_providers = [ + name + for name, provider in providers.items() + if isinstance(name, str) and isinstance(provider, dict) and provider.get("type") == "kimi" + ] + if len(kimi_providers) == 1: + return {kimi_providers[0]} + if _KIMI_CODE_MANAGED_PROVIDER in kimi_providers: + return {_KIMI_CODE_MANAGED_PROVIDER} + return set() + + +def _collect_kimi_code_provider_urls(config: dict[str, object], provider_names: set[str] | None = None) -> list[str]: + urls: list[str] = [] + providers = config.get("providers") + if not isinstance(providers, dict): + return urls + for name, provider in providers.items(): + if provider_names is not None and name not in provider_names: + continue + if not isinstance(provider, dict) or provider.get("type") != "kimi": + continue + base_url = _kimi_code_provider_base_url(provider) + if base_url: + urls.append(base_url) + return urls + + +def _patch_kimi_code_config_dict( + config: dict[str, object], proxy_base: str, cmd_args: Sequence[str] = () +) -> tuple[dict[str, object], list[str]]: + patched = json.loads(json.dumps(config)) + patched_providers: list[str] = [] + provider_names = _kimi_code_selected_provider_names(config, cmd_args) + + providers = patched.get("providers") + if isinstance(providers, dict): + for name, provider in providers.items(): + if not isinstance(provider, dict) or provider.get("type") != "kimi": + continue + if provider_names and name not in provider_names: + continue + provider["base_url"] = proxy_base + env_table = provider.get("env") + if isinstance(env_table, dict) and "KIMI_BASE_URL" in env_table: + env_table["KIMI_BASE_URL"] = proxy_base + patched_providers.append(str(name)) + + return patched, patched_providers + + +def _kimi_code_config_url_replacements( + config: dict[str, object], proxy_base: str, provider_names: set[str] +) -> list[tuple[str, str]]: + replacements: list[tuple[str, str]] = [] + for old_url in _collect_kimi_code_provider_urls(config, provider_names): + replacements.append((old_url, proxy_base)) + seen: set[str] = set() + ordered: list[tuple[str, str]] = [] + for old_url, new_url in sorted(replacements, key=lambda item: len(item[0]), reverse=True): + if old_url in seen: + continue + seen.add(old_url) + ordered.append((old_url, new_url)) + return ordered + + +def _replace_kimi_code_toml_url_assignments(text: str, old_url: str, new_url: str) -> str: + escaped = re.escape(old_url) + pattern = rf'(?m)^((?:base_url|KIMI_BASE_URL)\s*=\s*["\']){escaped}(["\'].*)$' + return re.sub(pattern, rf"\1{new_url}\2", text) + + +def _insert_kimi_code_provider_base_url(text: str, provider_name: str, proxy_base: str) -> str: + quoted = f'"{re.escape(provider_name)}"' + bare = re.escape(provider_name) + pattern = rf"(?m)^(\[providers\.(?:{quoted}|{bare})\]\s*(?:#.*)?\r?\n)" + replacement = rf'\1base_url = "{proxy_base}"' + "\n" + return re.sub(pattern, replacement, text, count=1) + + +def _patch_kimi_code_config_text( + source_text: str, proxy_base: str, cmd_args: Sequence[str] = () +) -> tuple[str, list[str]]: + if not source_text.strip(): + return _minimal_kimi_code_config_toml(proxy_base), [_KIMI_CODE_MANAGED_PROVIDER] + try: + config = tomllib.loads(source_text) + except (tomllib.TOMLDecodeError, ValueError): + config = {} + if not isinstance(config, dict): + config = {} + _, patched_providers = _patch_kimi_code_config_dict(config, proxy_base, cmd_args) + provider_names = set(patched_providers) + result = source_text + for old_url, new_url in _kimi_code_config_url_replacements(config, proxy_base, provider_names): + result = _replace_kimi_code_toml_url_assignments(result, old_url, new_url) + providers = config.get("providers") + if isinstance(providers, dict): + for name in provider_names: + provider = providers.get(name) + if isinstance(provider, dict) and not _kimi_code_provider_base_url(provider): + result = _insert_kimi_code_provider_base_url(result, str(name), proxy_base) + return result, patched_providers + + +def _patch_kimi_code_inline_config(value: str, proxy_base: str, cmd_args: Sequence[str] = ()) -> str: + config = _loads_kimi_code_inline_config(value) + if not config: + return value + if value.strip().startswith(("{", "[")): + patched, _ = _patch_kimi_code_config_dict(config, proxy_base, cmd_args) + return json.dumps(patched, separators=(",", ":")) + patched_text, _ = _patch_kimi_code_config_text(value, proxy_base, cmd_args) + return patched_text + + +def _minimal_kimi_code_config_toml(proxy_base: str) -> str: + return ( + 'default_model = "kimi-code/kimi-for-coding"\n' + "\n" + f'[providers."{_KIMI_CODE_MANAGED_PROVIDER}"]\n' + 'type = "kimi"\n' + f'base_url = "{proxy_base}"\n' + 'api_key = ""\n' + "\n" + '[models."kimi-code/kimi-for-coding"]\n' + f'provider = "{_KIMI_CODE_MANAGED_PROVIDER}"\n' + 'model = "kimi-for-coding"\n' + "max_context_size = 262144\n" + ) + + +def _kimi_code_config_has_launch_state(source_text: str) -> bool: + if not source_text.strip(): + return False + try: + config = tomllib.loads(source_text) + except (tomllib.TOMLDecodeError, ValueError): + return True + if not isinstance(config, dict): + return False + return any(key in config for key in ("default_model", "models", "providers")) + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def _write_kimi_code_config_metadata( + sandbox: Path, + *, + source_config: Path | None, + sandbox_config: Path, + patched_text: str, + proxy_base: str, + upstream_base: str, +) -> None: + if source_config is None: + return + metadata = { + "source_config": str(source_config.expanduser().resolve()), + "sandbox_config": str(sandbox_config), + "patched_sha256": _sha256_text(patched_text), + "proxy_base": proxy_base, + "upstream_base": upstream_base, + } + (sandbox / _KIMI_CODE_CONFIG_METADATA).write_text(json.dumps(metadata), encoding="utf-8") + + +def _persist_kimi_code_config_edits(sandbox: Path) -> None: + metadata_path = sandbox / _KIMI_CODE_CONFIG_METADATA + if not metadata_path.is_file(): + return + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return + if not isinstance(metadata, dict): + return + source_config_raw = metadata.get("source_config") + sandbox_config_raw = metadata.get("sandbox_config") + patched_sha256 = metadata.get("patched_sha256") + proxy_base = metadata.get("proxy_base") + upstream_base = metadata.get("upstream_base") + if not all(isinstance(value, str) and value for value in (source_config_raw, sandbox_config_raw, patched_sha256)): + return + sandbox_config = Path(sandbox_config_raw) + if not sandbox_config.is_file(): + return + try: + final_text = sandbox_config.read_text(encoding="utf-8") + except OSError: + return + if _sha256_text(final_text) == patched_sha256: + return + if isinstance(proxy_base, str) and isinstance(upstream_base, str) and proxy_base and upstream_base: + final_text = final_text.replace(proxy_base, upstream_base) + source_config = Path(source_config_raw) + source_config.parent.mkdir(parents=True, exist_ok=True) + source_config.write_text(final_text, encoding="utf-8") + + +_KIMI_CODE_SANDBOX_LINKS: tuple[tuple[str, bool], ...] = ( + ("oauth", True), + ("credentials", True), + ("plugins", True), + ("skills", True), + ("sessions", True), + ("AGENTS.md", False), + ("mcp.json", False), + ("tui.toml", False), +) +_KIMI_CODE_CONFIG_METADATA = ".claude-tap-config-metadata.json" + + +def _link_kimi_code_sandbox_path(source_home: Path, sandbox: Path, rel: str, *, is_dir: bool) -> None: + source = source_home / rel + target = sandbox / rel + if rel in ("oauth", "credentials") and not source.exists(): + source.mkdir(parents=True, exist_ok=True) + if not source.exists(): + return + target.parent.mkdir(parents=True, exist_ok=True) + try: + if is_dir: + target.symlink_to(source, target_is_directory=True) + else: + target.symlink_to(source) + except OSError: + if is_dir: + shutil.copytree(source, target, dirs_exist_ok=True) + else: + shutil.copy2(source, target) + + +def _persist_kimi_code_sandbox(source_home: Path, sandbox: Path) -> None: + """Copy sandbox-only auth/session files back when symlinks were unavailable.""" + _persist_kimi_code_config_edits(sandbox) + for name, is_dir in _KIMI_CODE_SANDBOX_LINKS: + path = sandbox / name + if not path.exists() or path.is_symlink(): + continue + dest = source_home / name + if is_dir: + if dest.exists(): + if dest.is_dir() and not dest.is_symlink(): + shutil.rmtree(dest) + else: + dest.unlink() + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(path, dest) + else: + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, dest) + + +_KIMI_CODE_SESSION_TEXT_SUFFIXES = frozenset({".json", ".jsonl", ".md", ".log", ".txt"}) + + +def _normalize_kimi_code_fs_path(path: str) -> str: + """Keep macOS temp paths on /var so they match KIMI_CODE_HOME join() results.""" + resolved = str(Path(path).expanduser().resolve()) + if resolved.startswith("/private/var/"): + return "/var" + resolved[len("/private/var") :] + return resolved + + +def _translate_kimi_code_home_path(path: str, old_prefix: str, new_prefix: str) -> str: + if not path: + return path + resolved = _normalize_kimi_code_fs_path(path) + old = _normalize_kimi_code_fs_path(old_prefix).rstrip("/") + new = _normalize_kimi_code_fs_path(new_prefix).rstrip("/") + if resolved == old: + return new + if resolved.startswith(old + "/"): + return new + resolved[len(old) :] + return path + + +def _iter_kimi_code_session_index_entries(path: Path) -> Iterable[dict[str, object]]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return + for line in lines: + stripped = line.strip() + if not stripped: + continue + try: + entry = json.loads(stripped) + except (json.JSONDecodeError, ValueError): + continue + if isinstance(entry, dict): + yield entry + + +def _materialize_kimi_code_session_index(source_home: Path, sandbox: Path) -> None: + """Copy session_index into the sandbox with sessionDir paths under KIMI_CODE_HOME.""" + source_index = source_home / "session_index.jsonl" + target_index = sandbox / "session_index.jsonl" + source_prefix = _normalize_kimi_code_fs_path(str(source_home)) + sandbox_prefix = _normalize_kimi_code_fs_path(str(sandbox)) + lines_out: list[str] = [] + if source_index.is_file(): + for entry in _iter_kimi_code_session_index_entries(source_index): + session_dir = entry.get("sessionDir") + if isinstance(session_dir, str): + entry["sessionDir"] = _translate_kimi_code_home_path(session_dir, source_prefix, sandbox_prefix) + lines_out.append(json.dumps(entry, ensure_ascii=False)) + target_index.parent.mkdir(parents=True, exist_ok=True) + if lines_out: + target_index.write_text("\n".join(lines_out) + "\n", encoding="utf-8") + else: + target_index.write_text("", encoding="utf-8") + + +def _merge_kimi_code_session_index(source_home: Path, sandbox: Path) -> None: + """Merge sandbox session_index updates back into the real home.""" + sandbox_index = sandbox / "session_index.jsonl" + if not sandbox_index.is_file(): + return + source_index = source_home / "session_index.jsonl" + source_prefix = _normalize_kimi_code_fs_path(str(source_home)) + sandbox_prefix = _normalize_kimi_code_fs_path(str(sandbox)) + entries: dict[str, dict[str, object]] = {} + + def ingest_index(path: Path, *, from_sandbox: bool) -> None: + for entry in _iter_kimi_code_session_index_entries(path): + session_id = entry.get("sessionId") + if not isinstance(session_id, str) or not session_id: + continue + session_dir = entry.get("sessionDir") + if isinstance(session_dir, str): + entry["sessionDir"] = ( + _translate_kimi_code_home_path(session_dir, sandbox_prefix, source_prefix) + if from_sandbox + else _normalize_kimi_code_fs_path(session_dir) + ) + entries[session_id] = entry + + if source_index.is_file(): + ingest_index(source_index, from_sandbox=False) + ingest_index(sandbox_index, from_sandbox=True) + + source_index.parent.mkdir(parents=True, exist_ok=True) + merged = "\n".join(json.dumps(entries[session_id], ensure_ascii=False) for session_id in entries) + "\n" + source_index.write_text(merged, encoding="utf-8") + + +def _kimi_code_path_prefix_variants(prefix: str) -> tuple[str, ...]: + normalized = _normalize_kimi_code_fs_path(prefix) + variants = [normalized] + if normalized.startswith("/var/"): + private_variant = "/private" + normalized + if private_variant not in variants: + variants.append(private_variant) + if prefix not in variants and prefix != normalized: + variants.append(prefix) + return tuple(sorted(variants, key=len, reverse=True)) + + +def _rewrite_kimi_code_text_paths(text: str, sandbox_prefix: str, source_prefix: str) -> str: + target_prefix = _normalize_kimi_code_fs_path(source_prefix) + rewritten = text + for old_prefix in _kimi_code_path_prefix_variants(sandbox_prefix): + rewritten = rewritten.replace(old_prefix, target_prefix) + return rewritten + + +def _remap_kimi_code_sandbox_paths(source_home: Path, sandbox: Path) -> None: + """Rewrite kimi-code session metadata that still points at the temp sandbox.""" + sandbox_prefix = _normalize_kimi_code_fs_path(str(sandbox)) + source_prefix = _normalize_kimi_code_fs_path(str(source_home)) + if sandbox_prefix == source_prefix: + return + + index_path = source_home / "session_index.jsonl" + if index_path.is_file(): + index_text = index_path.read_text(encoding="utf-8") + rewritten = _rewrite_kimi_code_text_paths(index_text, sandbox_prefix, source_prefix) + if rewritten != index_text: + index_path.write_text(rewritten, encoding="utf-8") + + sessions_root = source_home / "sessions" + if not sessions_root.is_dir(): + return + for path in sessions_root.rglob("*"): + if not path.is_file() or path.suffix not in _KIMI_CODE_SESSION_TEXT_SUFFIXES: + continue + try: + original = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + rewritten = _rewrite_kimi_code_text_paths(original, sandbox_prefix, source_prefix) + if rewritten != original: + path.write_text(rewritten, encoding="utf-8") + + +def _kimi_code_config_model_target(config: dict[str, object], model_name: str) -> str | None: + models = config.get("models") + providers = config.get("providers") + if not model_name.strip() or not isinstance(models, dict) or not isinstance(providers, dict): + return None + alias = models.get(model_name.strip()) + if not isinstance(alias, dict): + return None + provider_name = alias.get("provider") + if not isinstance(provider_name, str): + return None + provider = providers.get(provider_name) + if not isinstance(provider, dict): + return None + base_url = _kimi_code_provider_base_url(provider) + if base_url: + return base_url + if provider.get("type") == "kimi": + return CLIENT_CONFIGS["kimi-code"].default_target + return None + + +def _prepare_kimi_code_reverse_sandbox( + port: int, cmd_args: Sequence[str] = () +) -> tuple[Path, list[str], Path, list[str]]: + source_home = _kimi_code_source_home() + proxy_base = f"http://127.0.0.1:{port}" + upstream_base = _detect_kimi_code_target(cmd_args) + sandbox = Path(tempfile.mkdtemp(prefix=_KIMI_CODE_SANDBOX_DIR_PREFIX)) + patched_cmd_args = list(cmd_args) + inline_config = _kimi_code_inline_config_arg(cmd_args) + config_file_arg = _kimi_code_config_file_arg(cmd_args) + + if inline_config: + patched_inline = _patch_kimi_code_inline_config(inline_config, proxy_base, cmd_args) + patched_cmd_args = _replace_kimi_code_option_value(cmd_args, {"--config"}, patched_inline) + (sandbox / "config.toml").write_text(_minimal_kimi_code_config_toml(proxy_base), encoding="utf-8") + patched_providers = [_KIMI_CODE_MANAGED_PROVIDER] + elif config_file_arg: + source_config = Path(config_file_arg).expanduser() + target_config = sandbox / ("config.json" if source_config.suffix.lower() == ".json" else "config.toml") + try: + source_text = source_config.read_text(encoding="utf-8") + except OSError: + source_text = "" + if target_config.suffix.lower() == ".json" and source_text.strip(): + config = _read_kimi_code_config(path=source_config) + patched, patched_providers = _patch_kimi_code_config_dict(config, proxy_base, cmd_args) + target_config.write_text(json.dumps(patched, indent=2) + "\n", encoding="utf-8") + else: + patched_text, patched_providers = _patch_kimi_code_config_text(source_text, proxy_base, cmd_args) + if not patched_providers: + if _kimi_code_config_has_launch_state(source_text): + patched_text = source_text + else: + patched_text = _minimal_kimi_code_config_toml(proxy_base) + patched_providers = [_KIMI_CODE_MANAGED_PROVIDER] + target_config.write_text(patched_text, encoding="utf-8") + _write_kimi_code_config_metadata( + sandbox, + source_config=source_config, + sandbox_config=target_config, + patched_text=target_config.read_text(encoding="utf-8"), + proxy_base=proxy_base, + upstream_base=upstream_base, + ) + patched_cmd_args = _replace_kimi_code_option_value(cmd_args, {"--config-file"}, str(target_config)) + else: + source_config = source_home / "config.toml" + target_config = sandbox / "config.toml" + if source_config.is_file(): + source_text = source_config.read_text(encoding="utf-8") + patched_text, patched_providers = _patch_kimi_code_config_text(source_text, proxy_base, cmd_args) + if not patched_providers: + if _kimi_code_config_has_launch_state(source_text): + patched_text = source_text + else: + patched_text = _minimal_kimi_code_config_toml(proxy_base) + patched_providers = [_KIMI_CODE_MANAGED_PROVIDER] + target_config.write_text(patched_text, encoding="utf-8") + else: + target_config.write_text(_minimal_kimi_code_config_toml(proxy_base), encoding="utf-8") + patched_providers = [_KIMI_CODE_MANAGED_PROVIDER] + _write_kimi_code_config_metadata( + sandbox, + source_config=source_config, + sandbox_config=target_config, + patched_text=target_config.read_text(encoding="utf-8"), + proxy_base=proxy_base, + upstream_base=upstream_base, + ) + + for rel, is_dir in _KIMI_CODE_SANDBOX_LINKS: + _link_kimi_code_sandbox_path(source_home, sandbox, rel, is_dir=is_dir) + + _materialize_kimi_code_session_index(source_home, sandbox) + + _sync_kimi_code_migration_suppression(source_home, sandbox) + + return sandbox, patched_providers, source_home, patched_cmd_args + + +def _detect_kimi_code_target(cmd_args: Sequence[str] = ()) -> str: + config = _kimi_code_config_for_args(cmd_args) + model_arg = _kimi_code_model_arg(cmd_args) + if model_arg: + base_url = _kimi_code_config_model_target(config, model_arg) + if base_url: + return base_url + + env_keys = ["KIMI_BASE_URL", "KIMI_CODE_BASE_URL"] + if _has_active_kimi_code_model_env(): + env_keys.insert(0, "KIMI_MODEL_BASE_URL") + for env_key in env_keys: + base_url = os.environ.get(env_key, "").strip() + if base_url: + return base_url + + selected_model = model_arg + if not selected_model: + default_model = config.get("default_model") + if isinstance(default_model, str) and default_model.strip(): + selected_model = default_model.strip() + if isinstance(selected_model, str) and selected_model.strip(): + base_url = _kimi_code_config_model_target(config, selected_model.strip()) + if base_url: + return base_url + + providers = config.get("providers") + if isinstance(providers, dict): + managed = providers.get(_KIMI_CODE_MANAGED_PROVIDER) + if isinstance(managed, dict): + base_url = _kimi_code_provider_base_url(managed) + if base_url: + return base_url + for provider in providers.values(): + if isinstance(provider, dict) and provider.get("type") == "kimi": + base_url = _kimi_code_provider_base_url(provider) + if base_url: + return base_url + + return CLIENT_CONFIGS["kimi-code"].default_target + + +_OPENCLAW_CLEANUP_ENV = "__CLAUDE_TAP_OPENCLAW_CONFIG__" + + +def _read_openclaw_config(path: Path) -> dict | None: + if not path.is_file(): + return None + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return parsed if isinstance(parsed, dict) else None + + +def _openclaw_config_path() -> Path: + explicit = os.environ.get("OPENCLAW_CONFIG_PATH", "").strip() + if explicit: + return Path(explicit).expanduser() + state_dir = os.environ.get("OPENCLAW_STATE_DIR", "").strip() + if state_dir: + return Path(state_dir).expanduser() / "openclaw.json" + return Path.home() / ".openclaw" / "openclaw.json" + + +def _openclaw_model_arg(cmd_args: Sequence[str]) -> str | None: + for idx, arg in enumerate(cmd_args): + if arg in {"--model", "-m"} and idx + 1 < len(cmd_args): + value = cmd_args[idx + 1].strip() + if value: + return value + if arg.startswith("--model="): + value = arg.split("=", 1)[1].strip() + if value: + return value + return None + + +def _openclaw_primary_model(cfg: dict, cmd_args: Sequence[str] = ()) -> str | None: + if model_arg := _openclaw_model_arg(cmd_args): + return model_arg + agents = cfg.get("agents") + if not isinstance(agents, dict): + return None + defaults = agents.get("defaults") + if not isinstance(defaults, dict): + return None + model = defaults.get("model") + if isinstance(model, str): + return model + if isinstance(model, dict): + primary = model.get("primary") + if isinstance(primary, str): + return primary + models = defaults.get("models") + if isinstance(models, dict): + for key in models: + if isinstance(key, str): + return key + return None + + +def _openclaw_provider_proxy_url(provider: dict, proxy_url: str) -> str: + api = provider.get("api") + if not isinstance(api, str): + return f"{proxy_url}/v1" + if api.startswith("openai-"): + return f"{proxy_url}/v1" + return proxy_url + + +def _openclaw_provider_target_url(provider: dict, base_url: str) -> str: + target = base_url.strip().rstrip("/") + if _openclaw_provider_proxy_url(provider, "http://127.0.0.1:0").endswith("/v1") and target.endswith("/v1"): + return target[:-3].rstrip("/") or target + return target + + +def _openclaw_config_with_proxy(cfg: dict, proxy_url: str, cmd_args: Sequence[str] = ()) -> dict | None: + model = _openclaw_primary_model(cfg, cmd_args) + if not model or "/" not in model: + return None + provider_id = model.split("/", 1)[0] + models = cfg.get("models") + if not isinstance(models, dict): + return None + providers = models.get("providers") + if not isinstance(providers, dict): + return None + provider = providers.get(provider_id) + if not isinstance(provider, dict): + return None + patched = json.loads(json.dumps(cfg)) + patched_provider = patched["models"]["providers"][provider_id] + patched_provider["baseUrl"] = _openclaw_provider_proxy_url(provider, proxy_url) + patched_provider.pop("base_url", None) + return patched + + +def _openclaw_reverse_env(port: int, cmd_args: Sequence[str] = ()) -> dict[str, str]: + proxy_url = f"http://127.0.0.1:{port}" + cfg = _read_openclaw_config(_openclaw_config_path()) + if cfg: + patched = _openclaw_config_with_proxy(cfg, proxy_url, cmd_args) + if patched: + with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".openclaw.json", delete=False) as f: + json.dump(patched, f, indent=2) + f.write("\n") + tmp_path = f.name + return {"OPENCLAW_CONFIG_PATH": tmp_path, _OPENCLAW_CLEANUP_ENV: tmp_path} + return _openclaw_fallback_reverse_env(proxy_url, cmd_args) + + +def _openclaw_fallback_reverse_env(proxy_url: str, cmd_args: Sequence[str] = ()) -> dict[str, str]: + provider = _openclaw_fallback_provider(cmd_args) + if provider == "anthropic": + return {"ANTHROPIC_BASE_URL": proxy_url} + if provider in {"gemini", "google"}: + return {"GOOGLE_GEMINI_BASE_URL": proxy_url} + if provider == "openrouter": + return {"OPENROUTER_BASE_URL": proxy_url} + return {"OPENAI_BASE_URL": f"{proxy_url}/v1"} + + +def _openclaw_fallback_provider(cmd_args: Sequence[str] = ()) -> str: + model = _openclaw_primary_model({}, cmd_args) + if model and "/" in model: + return model.split("/", 1)[0] + for env_key, provider in ( + ("OPENAI_API_KEY", "openai"), + ("ANTHROPIC_API_KEY", "anthropic"), + ("GEMINI_API_KEY", "gemini"), + ("GOOGLE_API_KEY", "gemini"), + ("OPENROUTER_API_KEY", "openrouter"), + ): + if os.environ.get(env_key): + return provider + return "openai" + + +def _opencode_reverse_env(port: int) -> dict[str, str]: + proxy_url = f"http://127.0.0.1:{port}" + return { + "ANTHROPIC_BASE_URL": proxy_url, + "OPENAI_BASE_URL": f"{proxy_url}/v1", + "GOOGLE_GEMINI_BASE_URL": proxy_url, + } + + +def _multi_provider_reverse_env(port: int) -> dict[str, str]: + proxy_url = f"http://127.0.0.1:{port}" + return { + "KIMI_BASE_URL": proxy_url, + "MOONSHOT_BASE_URL": f"{proxy_url}/v1", + "OPENAI_BASE_URL": f"{proxy_url}/v1", + "ANTHROPIC_BASE_URL": proxy_url, + "GOOGLE_GEMINI_BASE_URL": proxy_url, + "OPENROUTER_BASE_URL": f"{proxy_url}/v1", + "CUSTOM_BASE_URL": f"{proxy_url}/v1", + } + + +def _detect_openclaw_target(cmd_args: Sequence[str] = ()) -> str: + cfg = _read_openclaw_config(_openclaw_config_path()) + if cfg: + model = _openclaw_primary_model(cfg, cmd_args) + if model and "/" in model: + provider_id = model.split("/", 1)[0] + models = cfg.get("models") + providers = models.get("providers") if isinstance(models, dict) else None + provider = providers.get(provider_id) if isinstance(providers, dict) else None + if isinstance(provider, dict): + base = provider.get("baseUrl") or provider.get("base_url") + if isinstance(base, str) and base.strip(): + return _openclaw_provider_target_url(provider, base) + for env_key, target in ( + ("OPENAI_API_KEY", "https://api.openai.com"), + ("ANTHROPIC_API_KEY", "https://api.anthropic.com"), + ("GEMINI_API_KEY", "https://generativelanguage.googleapis.com"), + ("GOOGLE_API_KEY", "https://generativelanguage.googleapis.com"), + ("OPENROUTER_API_KEY", "https://openrouter.ai/api/v1"), + ): + if os.environ.get(env_key): + return target + return CLIENT_CONFIGS["openclaw"].default_target + + +TARGET_DETECTORS = { + "claude": _detect_claude_target, + "codex": _detect_codex_target, + "codebuddy": _detect_codebuddy_target, + "kimi-code": _detect_kimi_code_target, + "openclaw": _detect_openclaw_target, +} diff --git a/claude_tap/cli_update.py b/claude_tap/cli_update.py new file mode 100644 index 0000000..f9cd503 --- /dev/null +++ b/claude_tap/cli_update.py @@ -0,0 +1,78 @@ +"""Update helpers for claude-tap CLI.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys + +from claude_tap.process_utils import windows_no_console_subprocess_kwargs + + +def _detect_installer() -> str: + """Detect whether claude-tap was installed via uv or pip.""" + exe = (sys.executable or "").lower().replace("\\", "/") + uv_tool_dir = os.environ.get("UV_TOOL_DIR", "").lower().replace("\\", "/").rstrip("/") + if uv_tool_dir and exe.startswith(f"{uv_tool_dir}/"): + return "uv" + if "/uv/data/tools/" in exe or "/uv/tools/" in exe: + return "uv" + if sys.platform != "win32" and shutil.which("uv"): + return "uv" + return "pip" + + +def _build_update_command(installer: str) -> list[str] | None: + """Build the manual self-upgrade command.""" + if installer == "uv": + uv_path = shutil.which("uv") + if uv_path is None: + return None + return [uv_path, "tool", "upgrade", "claude-tap"] + if installer == "pip": + return [sys.executable, "-m", "pip", "install", "--upgrade", "claude-tap"] + raise ValueError(f"unsupported installer: {installer}") + + +def parse_update_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse arguments for the update subcommand.""" + parser = argparse.ArgumentParser( + prog="claude-tap update", + description="Upgrade claude-tap using the detected installer.", + ) + parser.add_argument( + "--installer", + choices=["auto", "uv", "pip"], + default="auto", + help="Upgrade backend to use (default: auto-detect uv or pip)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the upgrade command without running it", + ) + return parser.parse_args(argv) + + +def update_main(argv: list[str] | None = None) -> int: + """Entry point for the update subcommand.""" + args = parse_update_args(argv) + installer = _detect_installer() if args.installer == "auto" else args.installer + cmd = _build_update_command(installer) + if cmd is None: + print("Error: 'uv' command not found. Re-run with --installer pip or install uv.", file=sys.stderr) + return 1 + + printable_cmd = " ".join(cmd) + print(f"Upgrading claude-tap with {installer}: {printable_cmd}") + if args.dry_run: + return 0 + + try: + result = subprocess.run(cmd, check=False, **windows_no_console_subprocess_kwargs()) + except OSError as exc: + print(f"Error: failed to run update command: {exc}", file=sys.stderr) + return 1 + return result.returncode diff --git a/claude_tap/codex_app_cdp.py b/claude_tap/codex_app_cdp.py new file mode 100644 index 0000000..bfd5406 --- /dev/null +++ b/claude_tap/codex_app_cdp.py @@ -0,0 +1,480 @@ +"""Codex App CDP capture for best-effort websocket trace evidence.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +import uuid +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import aiohttp + +from claude_tap.proxy import filter_headers +from claude_tap.trace import TraceWriter +from claude_tap.ws_proxy import reconstruct_ws_request_body, reconstruct_ws_response_body + +log = logging.getLogger("claude-tap") + +CODEX_APP_CDP_DEFAULT_ENDPOINT = "http://127.0.0.1:9238" +CODEX_APP_CDP_SOURCE = "codexapp-cdp" +_CDP_COMMAND_TIMEOUT = 10.0 + + +@dataclass +class _CdpTarget: + web_socket_debugger_url: str + type: str = "" + title: str = "" + url: str = "" + + +@dataclass +class _CdpSocketState: + request_id: str + url: str + created_at: float + request_headers: dict[str, str] = field(default_factory=dict) + response_headers: dict[str, str] = field(default_factory=dict) + response_status: int = 101 + pending_request_messages: deque[str] = field(default_factory=deque) + response_requests: dict[str, list[str]] = field(default_factory=dict) + response_events: dict[str, list[dict[str, Any]]] = field(default_factory=dict) + response_started_at: dict[str, float] = field(default_factory=dict) + flushed_response_ids: set[str] = field(default_factory=set) + active_response_id: str | None = None + + +def _string_headers(value: object) -> dict[str, str]: + if not isinstance(value, dict): + return {} + headers: dict[str, str] = {} + for key, header_value in value.items(): + if isinstance(key, str): + headers[key] = str(header_value) + return headers + + +def _target_from_json(value: object) -> _CdpTarget | None: + if not isinstance(value, dict): + return None + ws_url = value.get("webSocketDebuggerUrl") + if not isinstance(ws_url, str) or not ws_url: + return None + return _CdpTarget( + web_socket_debugger_url=ws_url, + type=str(value.get("type") or ""), + title=str(value.get("title") or ""), + url=str(value.get("url") or ""), + ) + + +def select_cdp_target(targets: list[dict[str, Any]]) -> str | None: + """Return the best page/webview CDP target URL from a /json target list.""" + parsed_targets = [target for target in (_target_from_json(item) for item in targets) if target is not None] + scored = [ + (score, index, target) + for index, target in enumerate(parsed_targets) + if (score := _score_cdp_target(target)) != float("-inf") + ] + scored.sort(key=lambda item: (-item[0], item[1])) + return scored[0][2].web_socket_debugger_url if scored else None + + +def _score_cdp_target(target: _CdpTarget) -> float: + target_type = target.type.lower() + title = target.title.lower() + url = target.url.lower() + haystack = f"{title} {url}" + if not target.web_socket_debugger_url: + return float("-inf") + if not haystack.strip() and not target_type: + return float("-inf") + if "devtools" in haystack: + return float("-inf") + if target_type in {"background_page", "service_worker"}: + return float("-inf") + + score = 0.0 + if target_type == "app": + score += 120 + elif target_type == "webview": + score += 100 + elif target_type == "page": + score += 80 + elif target_type == "iframe": + score += 20 + + if "codex" in haystack: + score += 120 + if url.startswith("http://localhost") or url.startswith("https://localhost"): + score += 90 + if url.startswith("file://"): + score += 60 + if url.startswith("http://127.0.0.1") or url.startswith("https://127.0.0.1"): + score += 50 + if url.startswith("about:blank"): + score -= 120 + if target.title: + score += 25 + return score + + +async def resolve_cdp_websocket_url(endpoint: str, session: aiohttp.ClientSession) -> str: + """Resolve an HTTP or WebSocket CDP endpoint to a page WebSocket URL.""" + endpoint = endpoint.strip() + if endpoint.startswith(("ws://", "wss://")): + return endpoint + base = endpoint.rstrip("/") + errors: list[str] = [] + for suffix in ("/json/list", "/json"): + try: + async with session.get(base + suffix, timeout=aiohttp.ClientTimeout(total=5)) as resp: + if resp.status < 200 or resp.status >= 300: + errors.append(f"{suffix} HTTP {resp.status}") + continue + payload = await resp.json(content_type=None) + except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc: + errors.append(f"{suffix} {exc}") + continue + + if isinstance(payload, dict): + target = _target_from_json(payload) + if target is not None: + return target.web_socket_debugger_url + payload = [payload] + if isinstance(payload, list): + ws_url = select_cdp_target(payload) + if ws_url: + return ws_url + errors.append(f"{suffix} no page target") + detail = "; ".join(errors) if errors else "no target metadata" + raise RuntimeError(f"Could not resolve Codex App CDP target from {endpoint}: {detail}") + + +class CodexAppCdpRecorder: + """Convert CDP Network websocket events into claude-tap trace records.""" + + def __init__(self, writer: TraceWriter, *, store_stream_events: bool = False, endpoint: str = ""): + self._writer = writer + self._store_stream_events = store_stream_events + self._endpoint = endpoint + self._sockets: dict[str, _CdpSocketState] = {} + + async def handle_event(self, method: str, params: dict[str, Any]) -> None: + if method == "Network.webSocketCreated": + self._handle_websocket_created(params) + elif method == "Network.webSocketWillSendHandshakeRequest": + self._handle_websocket_request_headers(params) + elif method == "Network.webSocketHandshakeResponseReceived": + self._handle_websocket_response_headers(params) + elif method == "Network.webSocketFrameSent": + self._handle_websocket_frame_sent(params) + elif method == "Network.webSocketFrameReceived": + await self._handle_websocket_frame_received(params) + elif method == "Network.webSocketClosed": + await self._handle_websocket_closed(params) + + async def flush_all(self, *, error: str | None = None) -> None: + for request_id in list(self._sockets): + await self._flush_socket(request_id, error=error) + + def _handle_websocket_created(self, params: dict[str, Any]) -> None: + request_id = _request_id(params) + url = params.get("url") + if request_id and isinstance(url, str) and url: + self._sockets[request_id] = _CdpSocketState(request_id=request_id, url=url, created_at=time.monotonic()) + + def _handle_websocket_request_headers(self, params: dict[str, Any]) -> None: + state = self._state_for_params(params) + if state is None: + return + request = params.get("request") + if isinstance(request, dict): + state.request_headers = _string_headers(request.get("headers")) + + def _handle_websocket_response_headers(self, params: dict[str, Any]) -> None: + state = self._state_for_params(params) + if state is None: + return + response = params.get("response") + if isinstance(response, dict): + status = response.get("status") + if isinstance(status, int): + state.response_status = status + state.response_headers = _string_headers(response.get("headers")) + + def _handle_websocket_frame_sent(self, params: dict[str, Any]) -> None: + state = self._state_for_params(params) + payload = _frame_payload(params) + if state is None or payload is None: + return + parsed = _json_object(payload) + if parsed is None: + return + if parsed.get("type") == "response.create" or "model" in parsed or "input" in parsed: + state.pending_request_messages.append(payload) + + async def _handle_websocket_frame_received(self, params: dict[str, Any]) -> None: + state = self._state_for_params(params) + payload = _frame_payload(params) + if state is None or payload is None: + return + event = _json_object(payload) + if event is None: + return + + response_id = _response_id_from_event(event) + if response_id is None: + response_id = state.active_response_id + if response_id is None: + return + if response_id in state.flushed_response_ids: + return + + self._ensure_response_bucket(state, response_id) + state.response_events[response_id].append(event) + state.active_response_id = response_id + + if event.get("type") in {"response.completed", "response.done"}: + await self._flush_response(state, response_id) + + async def _handle_websocket_closed(self, params: dict[str, Any]) -> None: + request_id = _request_id(params) + if request_id: + await self._flush_socket(request_id, error="CDP websocket closed before response.completed") + + def _state_for_params(self, params: dict[str, Any]) -> _CdpSocketState | None: + request_id = _request_id(params) + return self._sockets.get(request_id) if request_id else None + + def _ensure_response_bucket(self, state: _CdpSocketState, response_id: str) -> None: + if response_id not in state.response_requests: + if state.pending_request_messages: + state.response_requests[response_id] = [state.pending_request_messages.popleft()] + else: + state.response_requests[response_id] = [] + state.response_events.setdefault(response_id, []) + state.response_started_at.setdefault(response_id, time.monotonic()) + + async def _flush_socket(self, request_id: str, *, error: str | None = None) -> None: + state = self._sockets.pop(request_id, None) + if state is None: + return + for response_id in list(state.response_events): + await self._flush_response(state, response_id, error=error) + + async def _flush_response(self, state: _CdpSocketState, response_id: str, *, error: str | None = None) -> None: + request_messages = state.response_requests.pop(response_id, []) + response_events = state.response_events.pop(response_id, []) + started_at = state.response_started_at.pop(response_id, state.created_at) + if not request_messages and not response_events: + return + + record = build_cdp_websocket_record( + url=state.url, + cdp_request_id=state.request_id, + request_messages=request_messages, + response_events=response_events, + request_headers=state.request_headers, + response_headers=state.response_headers, + response_status=state.response_status, + duration_ms=max(0, int((time.monotonic() - started_at) * 1000)), + turn=self._writer.count + 1, + store_stream_events=self._store_stream_events, + endpoint=self._endpoint, + error=error, + ) + await self._writer.write_next_turn(record) + state.flushed_response_ids.add(response_id) + if state.active_response_id == response_id: + state.active_response_id = None + + +def build_cdp_websocket_record( + *, + url: str, + cdp_request_id: str, + request_messages: list[str], + response_events: list[dict[str, Any]], + request_headers: dict[str, str], + response_headers: dict[str, str], + response_status: int, + duration_ms: int, + turn: int, + store_stream_events: bool, + endpoint: str, + error: str | None = None, +) -> dict[str, Any]: + request_body = reconstruct_ws_request_body(request_messages) + response_body = reconstruct_ws_response_body(response_events) + parsed = urlsplit(url) + path = parsed.path or "/" + if parsed.query: + path += "?" + parsed.query + upstream_scheme = "https" if parsed.scheme == "wss" else "http" if parsed.scheme == "ws" else parsed.scheme + upstream_base_url = urlunsplit((upstream_scheme, parsed.netloc, "", "", "")) + + record: dict[str, Any] = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "request_id": f"codex_app_cdp_{uuid.uuid4().hex[:12]}", + "turn": turn, + "duration_ms": duration_ms, + "transport": "websocket", + "upstream_base_url": upstream_base_url, + "request": { + "method": "WEBSOCKET", + "path": path, + "headers": filter_headers(request_headers, redact_keys=True), + "body": request_body, + }, + "response": { + "status": response_status, + "headers": filter_headers(response_headers, redact_keys=True), + "body": response_body, + }, + "capture": { + "source": CODEX_APP_CDP_SOURCE, + "cdp_request_id": cdp_request_id, + }, + } + if endpoint: + record["capture"]["cdp_endpoint"] = endpoint + if store_stream_events: + request_events = [_json_object(message) or {"raw": message} for message in request_messages] + if request_events: + record["request"]["ws_events"] = request_events + if response_events: + record["response"]["ws_events"] = response_events + if error: + record["response"]["error"] = error + return record + + +async def capture_codex_app_cdp( + writer: TraceWriter, + *, + endpoint: str = CODEX_APP_CDP_DEFAULT_ENDPOINT, + store_stream_events: bool = False, +) -> None: + """Capture Codex App websocket frames from one CDP connection until it closes.""" + async with aiohttp.ClientSession() as session: + ws_url = await resolve_cdp_websocket_url(endpoint, session) + async with session.ws_connect(ws_url, heartbeat=20) as ws: + recorder = CodexAppCdpRecorder(writer, store_stream_events=store_stream_events, endpoint=endpoint) + client = _CdpClient(ws, recorder) + try: + await client.run() + finally: + await recorder.flush_all() + + +async def watch_codex_app_cdp( + writer: TraceWriter, + *, + endpoint: str = CODEX_APP_CDP_DEFAULT_ENDPOINT, + store_stream_events: bool = False, + reconnect_interval: float = 10.0, +) -> None: + """Keep reconnecting to Codex App CDP and append captured websocket records.""" + last_error: str | None = None + while True: + try: + await capture_codex_app_cdp(writer, endpoint=endpoint, store_stream_events=store_stream_events) + last_error = None + except asyncio.CancelledError: + raise + except Exception as exc: + error = str(exc) + if error != last_error: + log.debug("Codex App CDP capture unavailable: %s", exc) + last_error = error + await asyncio.sleep(reconnect_interval) + + +class _CdpClient: + def __init__(self, ws: aiohttp.ClientWebSocketResponse, recorder: CodexAppCdpRecorder): + self._ws = ws + self._recorder = recorder + self._next_id = 0 + self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {} + + async def run(self) -> None: + receiver = asyncio.create_task(self._receive_loop()) + try: + await self.send("Network.enable") + await receiver + finally: + receiver.cancel() + try: + await receiver + except asyncio.CancelledError: + pass + + async def send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + self._next_id += 1 + message_id = self._next_id + loop = asyncio.get_running_loop() + future: asyncio.Future[dict[str, Any]] = loop.create_future() + self._pending[message_id] = future + await self._ws.send_json({"id": message_id, "method": method, "params": params or {}}) + return await asyncio.wait_for(future, timeout=_CDP_COMMAND_TIMEOUT) + + async def _receive_loop(self) -> None: + async for msg in self._ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + try: + payload = json.loads(msg.data) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict): + continue + message_id = payload.get("id") + if isinstance(message_id, int) and message_id in self._pending: + future = self._pending.pop(message_id) + if not future.done(): + future.set_result(payload) + continue + method = payload.get("method") + params = payload.get("params") + if isinstance(method, str) and isinstance(params, dict): + await self._recorder.handle_event(method, params) + + +def _request_id(params: dict[str, Any]) -> str: + value = params.get("requestId") + return value if isinstance(value, str) else "" + + +def _frame_payload(params: dict[str, Any]) -> str | None: + response = params.get("response") + if not isinstance(response, dict): + return None + payload = response.get("payloadData") + return payload if isinstance(payload, str) else None + + +def _json_object(value: str) -> dict[str, Any] | None: + try: + parsed = json.loads(value) + except (json.JSONDecodeError, ValueError): + return None + return parsed if isinstance(parsed, dict) else None + + +def _response_id_from_event(event: dict[str, Any]) -> str | None: + response = event.get("response") + if isinstance(response, dict): + response_id = response.get("id") + if isinstance(response_id, str) and response_id: + return response_id + response_id = event.get("response_id") + if isinstance(response_id, str) and response_id: + return response_id + return None diff --git a/claude_tap/codex_app_transcript.py b/claude_tap/codex_app_transcript.py new file mode 100644 index 0000000..3c89985 --- /dev/null +++ b/claude_tap/codex_app_transcript.py @@ -0,0 +1,679 @@ +"""Codex App local session import for viewer-friendly trace records.""" + +from __future__ import annotations + +import asyncio +import json +import os +import time +import uuid +from collections.abc import Awaitable, Callable, Iterable +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from claude_tap.trace import TraceWriter, create_trace_writer +from claude_tap.trace_store import TraceStore, get_trace_store + +CODEX_APP_TRANSPORT = "codex-app-transcript" +CODEX_APP_TRANSCRIPT_DISCOVERY_INTERVAL = 5.0 + + +@dataclass +class _TranscriptParser: + session_id: str + model: str = "codex-app" + instructions: str = "" + tools: list[dict[str, Any]] = field(default_factory=list) + cwd: str = "" + cli_version: str = "" + source: str = "codex-app" + history_input: list[dict[str, Any]] = field(default_factory=list) + pending_tool_results: list[dict[str, Any]] = field(default_factory=list) + current_output: list[dict[str, Any]] = field(default_factory=list) + current_started_at: str | None = None + response_count: int = 0 + + def feed( + self, + rows: Iterable[dict[str, Any]], + *, + start_turn: int, + include_incomplete: bool, + ) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + + def flush( + timestamp: str | None, + usage: dict[str, int] | None = None, + *, + status: str = "completed", + ) -> None: + if not self.current_output: + self.history_input.extend(self.pending_tool_results) + self.pending_tool_results = [] + return + + self.response_count += 1 + response_id = _response_id(self.session_id, self.response_count) + request_body: dict[str, Any] = { + "type": "response.create", + "model": self.model, + "input": _json_clone(self.history_input), + } + if self.instructions: + request_body["instructions"] = self.instructions + if self.tools: + request_body["tools"] = _json_clone(self.tools) + metadata = { + "codex_app_session_id": self.session_id, + "codex_app_source": self.source, + } + if self.cwd: + metadata["cwd"] = self.cwd + request_body["metadata"] = metadata + + response_body: dict[str, Any] = { + "id": response_id, + "object": "response", + "status": status, + "model": self.model, + "output": _json_clone(self.current_output), + } + if usage: + response_body["usage"] = usage + + headers = {"x-codex-app-session-id": self.session_id} + if self.cli_version: + headers["x-codex-version"] = self.cli_version + + record: dict[str, Any] = { + "timestamp": self.current_started_at or timestamp or datetime.now(timezone.utc).isoformat(), + "request_id": f"codex_app_{uuid.uuid4().hex[:12]}", + "turn": start_turn + self.response_count - 1, + "duration_ms": 0, + "transport": CODEX_APP_TRANSPORT, + "upstream_base_url": "codex-app://sessions", + "request": { + "method": "CODEX_APP_TRANSCRIPT", + "path": "/v1/responses", + "headers": headers, + "body": request_body, + }, + "response": { + "status": 200, + "headers": {}, + "body": response_body, + }, + } + if status != "completed": + record["capture"] = {"codex_app_partial": True} + records.append(record) + self.history_input.extend(_json_clone(self.current_output)) + self.history_input.extend(self.pending_tool_results) + self.current_output = [] + self.pending_tool_results = [] + self.current_started_at = None + + for row in rows: + timestamp = row.get("timestamp") if isinstance(row.get("timestamp"), str) else None + row_type = row.get("type") + payload = row.get("payload") + if not isinstance(payload, dict): + continue + + if row_type == "session_meta": + raw_session_id = payload.get("id") + if isinstance(raw_session_id, str) and raw_session_id: + self.session_id = raw_session_id + cli_version_value = payload.get("cli_version") + if isinstance(cli_version_value, str): + self.cli_version = cli_version_value + source_value = payload.get("source") or payload.get("originator") or payload.get("thread_source") + if isinstance(source_value, str) and source_value: + self.source = source_value + self.instructions = _base_instruction_text(payload.get("base_instructions")) + self.tools = _normalize_tools(payload.get("dynamic_tools")) + cwd_value = payload.get("cwd") + if isinstance(cwd_value, str): + self.cwd = cwd_value + continue + + if row_type == "turn_context": + flush(timestamp) + model_value = payload.get("model") + if isinstance(model_value, str) and model_value: + self.model = model_value + cwd_value = payload.get("cwd") + if isinstance(cwd_value, str): + self.cwd = cwd_value + continue + + if row_type == "event_msg" and payload.get("type") == "token_count": + flush(timestamp, _usage_from_token_event(payload)) + continue + + if row_type != "response_item": + continue + + if _is_message_input(payload): + flush(timestamp) + self.history_input.append(_json_clone(payload)) + continue + if _is_call_output(payload): + self.pending_tool_results.append(_json_clone(payload)) + continue + if _is_model_output(payload): + if self.current_started_at is None: + self.current_started_at = timestamp + self.current_output.append(_json_clone(payload)) + + if include_incomplete: + flush(None, status="in_progress") + + return records + + +@dataclass +class _TranscriptCursor: + parser: _TranscriptParser + offset: int = 0 + partial_line: str = "" + imported_records: int = 0 + + +class CodexAppTranscriptSessionRegistry: + """Own one trace writer per Codex App transcript/query.""" + + def __init__( + self, + *, + store: TraceStore | None = None, + metadata: dict[str, str] | None = None, + ) -> None: + self._store = store or get_trace_store() + self._metadata = metadata or {} + self._writers: dict[Path, TraceWriter] = {} + self._session_ids: dict[Path, str] = {} + self._skip_record_counts: dict[Path, int] = {} + + @property + def session_ids(self) -> tuple[str, ...]: + return tuple(self._session_ids.values()) + + async def write_next_turn(self, transcript_path: Path, record: dict[str, Any]) -> None: + writer = self._writer_for_record(transcript_path, record) + await writer.write_next_turn(record) + + def skip_record_count(self, transcript_path: Path, record: dict[str, Any]) -> int: + self._writer_for_record(transcript_path, record) + return self._skip_record_counts.get(transcript_path, 0) + + def close(self) -> None: + for writer in self._writers.values(): + writer.close() + + def get_summary(self) -> dict[str, Any]: + summary = { + "api_calls": 0, + "input_tokens": 0, + "output_tokens": 0, + "cache_read_tokens": 0, + "cache_create_tokens": 0, + "models_used": {}, + "has_error": False, + "trace_storage_errors": 0, + "dropped_trace_records": 0, + } + models: dict[str, int] = {} + for writer in self._writers.values(): + item = writer.get_summary() + summary["api_calls"] += int(item["api_calls"]) + summary["input_tokens"] += int(item["input_tokens"]) + summary["output_tokens"] += int(item["output_tokens"]) + summary["cache_read_tokens"] += int(item["cache_read_tokens"]) + summary["cache_create_tokens"] += int(item["cache_create_tokens"]) + summary["has_error"] = bool(summary["has_error"] or item["has_error"]) + summary["trace_storage_errors"] += int(item["trace_storage_errors"]) + summary["dropped_trace_records"] += int(item["dropped_trace_records"]) + for model, count in item["models_used"].items(): + models[model] = models.get(model, 0) + count + summary["models_used"] = models + return summary + + def _writer_for_record(self, transcript_path: Path, record: dict[str, Any]) -> TraceWriter: + writer = self._writers.get(transcript_path) + if writer is not None: + return writer + + metadata = dict(self._metadata) + codex_app_session_id = _record_codex_app_session_id(record) + if codex_app_session_id: + metadata["codex_app_session_id"] = codex_app_session_id + existing_row = None + if codex_app_session_id: + existing_row = self._store.find_codex_app_session_row(codex_app_session_id) + if existing_row is not None: + session_id = existing_row["id"] + skip_record_count = self._store.count_non_partial_records(session_id) + writer = TraceWriter(session_id, metadata=metadata, store=self._store) + else: + writer = create_trace_writer( + store=self._store, + client=metadata.get("client", "codexapp"), + proxy_mode=metadata.get("proxy_mode", "transcript"), + metadata=metadata, + started_at=_record_datetime(record), + ) + session_id = writer.session_id + skip_record_count = 0 + if existing_row is not None: + writer.count = int(existing_row["record_count"] or 0) + self._writers[transcript_path] = writer + self._session_ids[transcript_path] = session_id + self._skip_record_counts[transcript_path] = skip_record_count + return writer + + +def codex_app_home(home: Path | None = None) -> Path: + """Return the Codex App home directory.""" + if home is not None: + return home / ".codex" if home.name != ".codex" else home + return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") + + +def codex_app_sessions_dir(home: Path | None = None) -> Path: + return codex_app_home(home) / "sessions" + + +def find_codex_app_transcripts(*, since: float, home: Path | None = None) -> list[Path]: + """Return Codex App session JSONL files modified at or after ``since``.""" + sessions_dir = codex_app_sessions_dir(home) + if not sessions_dir.exists(): + return [] + candidates: list[tuple[float, Path]] = [] + for path in sessions_dir.glob("**/*.jsonl"): + try: + mtime = path.stat().st_mtime + except OSError: + continue + if mtime >= since: + candidates.append((mtime, path)) + return [path for _, path in sorted(candidates, key=lambda item: item[0])] + + +def _json_clone(value: Any) -> Any: + return json.loads(json.dumps(value)) + + +def _parse_jsonl_line(line: str) -> dict[str, Any] | None: + if not line.strip(): + return None + try: + row = json.loads(line) + except json.JSONDecodeError: + return None + return row if isinstance(row, dict) else None + + +def _read_jsonl(path: Path) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return rows + for line in lines: + row = _parse_jsonl_line(line) + if row is not None: + rows.append(row) + return rows + + +def _read_new_jsonl_rows(path: Path, cursor: _TranscriptCursor) -> list[dict[str, Any]]: + try: + size = path.stat().st_size + except OSError: + return [] + if size < cursor.offset: + cursor.parser = _TranscriptParser(path.stem) + cursor.offset = 0 + cursor.partial_line = "" + + try: + with path.open("rb") as file_obj: + file_obj.seek(cursor.offset) + data = file_obj.read() + cursor.offset = file_obj.tell() + except OSError: + return [] + + if not data and not cursor.partial_line: + return [] + + text = data.decode("utf-8", errors="replace") + combined = cursor.partial_line + text + if not combined: + return [] + + lines = combined.splitlines() + cursor.partial_line = "" + if combined and not combined.endswith(("\n", "\r")) and lines: + candidate = lines[-1] + if _parse_jsonl_line(candidate) is None: + cursor.partial_line = candidate + lines = lines[:-1] + + rows: list[dict[str, Any]] = [] + for line in lines: + row = _parse_jsonl_line(line) + if row is not None: + rows.append(row) + return rows + + +def _base_instruction_text(value: object) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + text = value.get("text") + return text.strip() if isinstance(text, str) else "" + return "" + + +def _normalize_tools(value: object) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + tools: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + name = item.get("name") + if not isinstance(name, str) or not name: + continue + namespace = item.get("namespace") + tool_name = f"{namespace}.{name}" if isinstance(namespace, str) and namespace else name + tool: dict[str, Any] = {"type": "function", "name": tool_name} + description = item.get("description") + if isinstance(description, str) and description: + tool["description"] = description + input_schema = item.get("inputSchema") or item.get("input_schema") or item.get("parameters") + if isinstance(input_schema, dict): + tool["parameters"] = input_schema + tools.append(tool) + return tools + + +def _usage_from_token_event(payload: dict[str, Any]) -> dict[str, int]: + info = payload.get("info") + if not isinstance(info, dict): + return {} + raw = info.get("last_token_usage") + if not isinstance(raw, dict): + return {} + + usage: dict[str, int] = {} + field_map = { + "input_tokens": "input_tokens", + "output_tokens": "output_tokens", + "total_tokens": "total_tokens", + "cached_input_tokens": "cache_read_input_tokens", + } + for source_key, target_key in field_map.items(): + value = raw.get(source_key) + if isinstance(value, int) and value >= 0: + usage[target_key] = value + reasoning = raw.get("reasoning_output_tokens") + if isinstance(reasoning, int) and reasoning >= 0: + usage["reasoning_output_tokens"] = reasoning + return usage + + +def _is_message_input(payload: dict[str, Any]) -> bool: + return payload.get("type") == "message" and payload.get("role") in {"developer", "system", "user"} + + +def _is_message_output(payload: dict[str, Any]) -> bool: + return payload.get("type") == "message" and payload.get("role") == "assistant" + + +def _is_call_output(payload: dict[str, Any]) -> bool: + item_type = payload.get("type") + return isinstance(item_type, str) and (item_type == "tool_search_output" or item_type.endswith("_call_output")) + + +def _is_model_output(payload: dict[str, Any]) -> bool: + item_type = payload.get("type") + if _is_message_output(payload): + return True + return isinstance(item_type, str) and ( + item_type == "reasoning" or item_type == "tool_search_call" or item_type.endswith("_call") + ) + + +def _response_id(session_id: str, index: int) -> str: + return f"resp_codexapp_{session_id.replace('-', '')[:20]}_{index}" + + +def _record_codex_app_session_id(record: dict[str, Any]) -> str: + request = record.get("request") + headers = request.get("headers") if isinstance(request, dict) else {} + body = request.get("body") if isinstance(request, dict) else {} + metadata = body.get("metadata") if isinstance(body, dict) else {} + value = metadata.get("codex_app_session_id") if isinstance(metadata, dict) else None + if not isinstance(value, str) or not value: + value = headers.get("x-codex-app-session-id") if isinstance(headers, dict) else None + return value if isinstance(value, str) else "" + + +def _record_datetime(record: dict[str, Any]) -> datetime | None: + timestamp = record.get("timestamp") + if not isinstance(timestamp, str) or not timestamp: + return None + try: + parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def build_codex_app_transcript_records( + transcript_path: Path, + *, + start_turn: int, + include_incomplete: bool = True, +) -> list[dict[str, Any]]: + """Build synthetic OpenAI Responses records from a Codex App session JSONL.""" + rows = _read_jsonl(transcript_path) + if not rows: + return [] + parser = _TranscriptParser(transcript_path.stem) + return parser.feed(rows, start_turn=start_turn, include_incomplete=include_incomplete) + + +def _selected_transcript_paths( + *, + since: float, + home: Path | None, + transcript_paths: Iterable[Path] | None, +) -> list[Path]: + if transcript_paths is not None: + return list(transcript_paths) + return find_codex_app_transcripts(since=since, home=home) + + +def _cursor_for_transcript( + state: dict[Path, _TranscriptCursor], + transcript_path: Path, +) -> _TranscriptCursor: + cursor = state.get(transcript_path) + if not isinstance(cursor, _TranscriptCursor): + cursor = _TranscriptCursor(parser=_TranscriptParser(transcript_path.stem)) + state[transcript_path] = cursor + return cursor + + +def _read_next_transcript_records( + transcript_path: Path, + *, + state: dict[Path, _TranscriptCursor], + include_incomplete: bool, +) -> tuple[_TranscriptCursor, list[dict[str, Any]]]: + cursor = _cursor_for_transcript(state, transcript_path) + records = cursor.parser.feed( + _read_new_jsonl_rows(transcript_path, cursor), + start_turn=1, + include_incomplete=include_incomplete, + ) + return cursor, records + + +async def import_codex_app_transcripts( + writer: TraceWriter, + *, + since: float, + home: Path | None = None, + state: dict[Path, _TranscriptCursor] | None = None, + include_incomplete: bool = True, + transcript_paths: Iterable[Path] | None = None, +) -> int: + """Append new Codex App transcript records to the active trace.""" + imported = 0 + state = state if state is not None else {} + for transcript_path in _selected_transcript_paths(since=since, home=home, transcript_paths=transcript_paths): + _cursor, records = _read_next_transcript_records( + transcript_path, + state=state, + include_incomplete=include_incomplete, + ) + for record in records: + await writer.write_next_turn(record) + imported += 1 + return imported + + +async def import_codex_app_transcripts_to_sessions( + registry: CodexAppTranscriptSessionRegistry, + *, + since: float, + home: Path | None = None, + state: dict[Path, _TranscriptCursor] | None = None, + include_incomplete: bool = True, + transcript_paths: Iterable[Path] | None = None, +) -> int: + """Append new Codex App transcript records into one trace session per query.""" + imported = 0 + state = state if state is not None else {} + for transcript_path in _selected_transcript_paths(since=since, home=home, transcript_paths=transcript_paths): + cursor, records = _read_next_transcript_records( + transcript_path, + state=state, + include_incomplete=include_incomplete, + ) + for record in records: + skip_record_count = registry.skip_record_count(transcript_path, record) + if cursor.imported_records < skip_record_count: + cursor.imported_records += 1 + continue + await registry.write_next_turn(transcript_path, record) + cursor.imported_records += 1 + imported += 1 + return imported + + +def _discover_new_transcript_paths( + transcript_paths: list[Path], + *, + since: float, + home: Path | None, +) -> None: + known = set(transcript_paths) + for path in find_codex_app_transcripts(since=since, home=home): + if path not in known: + transcript_paths.append(path) + known.add(path) + + +async def _watch_transcript_imports( + import_paths: Callable[[list[Path], dict[Path, _TranscriptCursor]], Awaitable[int]], + *, + since: float, + home: Path | None, + poll_interval: float, + discovery_interval: float, +) -> None: + state: dict[Path, _TranscriptCursor] = {} + transcript_paths: list[Path] = [] + last_discovery = 0.0 + try: + while True: + now = time.monotonic() + if not transcript_paths or now - last_discovery >= discovery_interval: + _discover_new_transcript_paths(transcript_paths, since=since, home=home) + last_discovery = now + await import_paths(transcript_paths, state) + await asyncio.sleep(poll_interval) + except asyncio.CancelledError: + _discover_new_transcript_paths(transcript_paths, since=since, home=home) + await import_paths(transcript_paths, state) + raise + + +async def watch_codex_app_transcripts( + writer: TraceWriter, + *, + since: float, + home: Path | None = None, + poll_interval: float = 1.0, + discovery_interval: float = CODEX_APP_TRANSCRIPT_DISCOVERY_INTERVAL, +) -> None: + """Poll Codex App session files and append live transcript records.""" + + async def import_paths(paths: list[Path], state: dict[Path, _TranscriptCursor]) -> int: + return await import_codex_app_transcripts( + writer, + since=since, + home=home, + state=state, + include_incomplete=True, + transcript_paths=paths, + ) + + await _watch_transcript_imports( + import_paths, + since=since, + home=home, + poll_interval=poll_interval, + discovery_interval=discovery_interval, + ) + + +async def watch_codex_app_transcripts_to_sessions( + registry: CodexAppTranscriptSessionRegistry, + *, + since: float, + home: Path | None = None, + poll_interval: float = 1.0, + discovery_interval: float = CODEX_APP_TRANSCRIPT_DISCOVERY_INTERVAL, +) -> None: + """Poll Codex App transcripts and keep each app query in its own trace session.""" + + async def import_paths(paths: list[Path], state: dict[Path, _TranscriptCursor]) -> int: + return await import_codex_app_transcripts_to_sessions( + registry, + since=since, + home=home, + state=state, + include_incomplete=True, + transcript_paths=paths, + ) + + await _watch_transcript_imports( + import_paths, + since=since, + home=home, + poll_interval=poll_interval, + discovery_interval=discovery_interval, + ) diff --git a/claude_tap/compact_trace.py b/claude_tap/compact_trace.py new file mode 100644 index 0000000..8390eec --- /dev/null +++ b/claude_tap/compact_trace.py @@ -0,0 +1,309 @@ +"""Portable compact trace bundle helpers.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from hashlib import sha256 +from typing import Any + +COMPACT_TRACE_MARKER = "__claude_tap_compact_trace__" +COMPACT_RECORD_MARKER = "__claude_tap_compact_record__" +BLOB_REF_MARKER = "__claude_tap_blob_ref__" +BLOB_KIND_JSON = "json" +COMPACT_RECORD_VERSION = 1 +COMPACT_TRACE_VERSION = 1 +MIN_BLOB_BYTES = 512 +COMPACT_BLOB_PATHS = ( + ("request", "body", "instructions"), + ("request", "body", "tools"), + ("response", "body", "instructions"), + ("response", "body", "tools"), +) +COMPACT_ITEM_BLOB_PATHS = ( + ("request", "body", "input"), + ("request", "body", "messages"), +) + + +def dump_compact_trace(records: list[dict[str, Any]]) -> str: + """Serialize records into a portable compact trace bundle.""" + return json.dumps(build_compact_trace_bundle(records), ensure_ascii=False, separators=(",", ":")) + "\n" + + +def build_compact_trace_bundle(records: list[dict[str, Any]]) -> dict[str, Any]: + """Return a standalone compact trace bundle with inline blob dictionary.""" + blobs: dict[str, dict[str, Any]] = {} + compact_records = [_encode_compact_record(record, blobs) for record in records] + return { + COMPACT_TRACE_MARKER: { + "version": COMPACT_TRACE_VERSION, + "encoding": "json-blob-ref", + "record_count": len(compact_records), + "blob_count": len(blobs), + }, + "records": compact_records, + "blobs": blobs, + } + + +def load_compact_trace(text: str) -> list[dict[str, Any]] | None: + """Materialize a compact trace bundle, or return None when text is not one.""" + try: + value = json.loads(text) + except json.JSONDecodeError: + return None + if not is_compact_trace_bundle(value): + return None + return materialize_compact_trace_bundle(value) + + +def is_compact_trace_bundle(value: Any) -> bool: + if not isinstance(value, dict): + return False + marker = value.get(COMPACT_TRACE_MARKER) + return isinstance(marker, dict) and marker.get("version") == COMPACT_TRACE_VERSION + + +def materialize_compact_trace_bundle(bundle: dict[str, Any]) -> list[dict[str, Any]]: + """Materialize all records from a compact trace bundle.""" + if not is_compact_trace_bundle(bundle): + raise ValueError("Unsupported compact trace bundle.") + records = bundle.get("records") + blobs = bundle.get("blobs") + if not isinstance(records, list) or not isinstance(blobs, dict): + raise ValueError("Compact trace bundle must contain records and blobs.") + + materialized: list[dict[str, Any]] = [] + blob_cache: dict[str, Any] = {} + for payload in records: + record = decode_compact_record_payload(payload, lambda ref: _load_bundle_blob(ref, blobs, blob_cache)) + if isinstance(record, dict): + materialized.append(record) + return materialized + + +def decode_compact_record_payload(payload: Any, load_blob: Any) -> dict[str, Any] | None: + """Decode one compact record payload using the supplied blob loader.""" + if not isinstance(payload, dict): + return None + marker = payload.get(COMPACT_RECORD_MARKER) + if not isinstance(marker, dict): + return payload + if marker.get("version") != COMPACT_RECORD_VERSION: + raise RuntimeError(f"Unsupported compact trace record version: {marker.get('version')}") + record = payload.get("record") + refs = marker.get("refs") + if not isinstance(record, dict): + return None + ref_paths = _ref_paths_from_marker_refs(refs) + if not ref_paths: + ref_paths = _legacy_compact_ref_paths(record) + for path in ref_paths: + record = _materialize_blob_ref_path(record, path, load_blob) + return record if isinstance(record, dict) else None + + +def make_blob_ref(hash_value: str, size_bytes: int) -> dict[str, Any]: + return { + BLOB_REF_MARKER: { + "version": COMPACT_RECORD_VERSION, + "kind": BLOB_KIND_JSON, + "hash": hash_value, + "bytes": size_bytes, + } + } + + +def json_blob_payload(value: Any) -> tuple[str, int, str]: + payload_json = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + payload_bytes = payload_json.encode("utf-8") + return payload_json, len(payload_bytes), "sha256:" + sha256(payload_bytes).hexdigest() + + +def _encode_compact_record(record: dict[str, Any], blobs: dict[str, dict[str, Any]]) -> dict[str, Any]: + compact_record, refs = compact_record_blobs(record, lambda value: _store_bundle_blob(blobs, value)) + if not refs: + return compact_record + return { + COMPACT_RECORD_MARKER: { + "version": COMPACT_RECORD_VERSION, + "encoding": "json-blob-ref", + "refs": refs, + }, + "record": compact_record, + } + + +def compact_record_blobs( + record: dict[str, Any], + store_blob: Callable[[Any], dict[str, Any] | None], +) -> tuple[dict[str, Any], list[dict[str, object]]]: + """Replace large repeated record fields and list items with blob refs.""" + compact_record = record + refs: list[dict[str, object]] = [] + for path in COMPACT_BLOB_PATHS: + value = _get_path(compact_record, path) + if value is None: + continue + ref = store_blob(value) + if ref is None: + continue + compact_record = _replace_path(compact_record, path, ref) + refs.append( + { + "path": "/" + "/".join(path), + "hash": ref[BLOB_REF_MARKER]["hash"], + "bytes": ref[BLOB_REF_MARKER]["bytes"], + } + ) + for path in COMPACT_ITEM_BLOB_PATHS: + value = _get_path(compact_record, path) + if not isinstance(value, list): + continue + compact_items: list[Any] | None = None + for index, item in enumerate(value): + ref = store_blob(item) + if ref is None: + continue + if compact_items is None: + compact_items = list(value) + compact_items[index] = ref + refs.append( + { + "path": "/" + "/".join((*path, str(index))), + "hash": ref[BLOB_REF_MARKER]["hash"], + "bytes": ref[BLOB_REF_MARKER]["bytes"], + } + ) + if compact_items is not None: + compact_record = _replace_path(compact_record, path, compact_items) + return compact_record, refs + + +def _store_bundle_blob(blobs: dict[str, dict[str, Any]], value: Any) -> dict[str, Any] | None: + _payload_json, size_bytes, hash_value = json_blob_payload(value) + if size_bytes < MIN_BLOB_BYTES: + return None + blobs.setdefault(hash_value, {"kind": BLOB_KIND_JSON, "bytes": size_bytes, "payload": value}) + return make_blob_ref(hash_value, size_bytes) + + +def _load_bundle_blob(ref: dict[str, Any], blobs: dict[str, Any], blob_cache: dict[str, Any]) -> Any: + hash_value = ref["hash"] + if hash_value not in blob_cache: + blob = blobs.get(hash_value) + if not isinstance(blob, dict) or blob.get("kind") != (ref.get("kind") or BLOB_KIND_JSON): + raise KeyError(hash_value) + blob_cache[hash_value] = blob.get("payload") + return blob_cache[hash_value] + + +def _parse_ref_path(path: Any) -> tuple[str, ...] | None: + if not isinstance(path, str) or not path.startswith("/"): + return None + return tuple(part.replace("~1", "/").replace("~0", "~") for part in path.removeprefix("/").split("/")) + + +def _ref_paths_from_marker_refs(refs: Any) -> list[tuple[str, ...]]: + if not isinstance(refs, list): + return [] + paths: list[tuple[str, ...]] = [] + for ref in refs: + if not isinstance(ref, dict): + continue + path = _parse_ref_path(ref.get("path")) + if path is not None: + paths.append(path) + return paths + + +def _legacy_compact_ref_paths(record: dict[str, Any]) -> list[tuple[str, ...]]: + paths: list[tuple[str, ...]] = [] + for path in COMPACT_BLOB_PATHS: + if is_blob_ref(_get_path(record, path)): + paths.append(path) + for path in COMPACT_ITEM_BLOB_PATHS: + value = _get_path(record, path) + if not isinstance(value, list): + continue + paths.extend((*path, str(index)) for index, item in enumerate(value) if is_blob_ref(item)) + return paths + + +def _materialize_blob_ref_path(root: dict[str, Any], path: tuple[str, ...], load_blob: Any) -> dict[str, Any]: + value, changed = _replace_blob_ref_at_path(root, path, load_blob) + return value if changed and isinstance(value, dict) else root + + +def _replace_blob_ref_at_path(value: Any, path: tuple[str, ...], load_blob: Any) -> tuple[Any, bool]: + if not path: + if is_blob_ref(value): + return load_blob(value[BLOB_REF_MARKER]), True + return value, False + + key = path[0] + if isinstance(value, dict): + if key not in value: + return value, False + replacement, changed = _replace_blob_ref_at_path(value[key], path[1:], load_blob) + if not changed: + return value, False + updated = dict(value) + updated[key] = replacement + return updated, True + + if isinstance(value, list): + try: + index = int(key) + except ValueError: + return value, False + if index < 0 or index >= len(value): + return value, False + replacement, changed = _replace_blob_ref_at_path(value[index], path[1:], load_blob) + if not changed: + return value, False + updated = list(value) + updated[index] = replacement + return updated, True + + return value, False + + +def _get_path(root: dict[str, Any], path: tuple[str, ...]) -> Any: + node: Any = root + for key in path: + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + return node + + +def _replace_path(root: dict[str, Any], path: tuple[str, ...], replacement: Any) -> dict[str, Any]: + if not path: + return root + new_root = dict(root) + old_node: Any = root + new_node: dict[str, Any] = new_root + for key in path[:-1]: + child = old_node.get(key) if isinstance(old_node, dict) else None + if not isinstance(child, dict): + return root + child_copy = dict(child) + new_node[key] = child_copy + old_node = child + new_node = child_copy + new_node[path[-1]] = replacement + return new_root + + +def is_blob_ref(value: Any) -> bool: + if not isinstance(value, dict) or set(value) != {BLOB_REF_MARKER}: + return False + ref = value[BLOB_REF_MARKER] + return ( + isinstance(ref, dict) + and ref.get("version") == COMPACT_RECORD_VERSION + and ref.get("kind") == BLOB_KIND_JSON + and isinstance(ref.get("hash"), str) + ) diff --git a/claude_tap/cursor_transcript.py b/claude_tap/cursor_transcript.py new file mode 100644 index 0000000..cc937e0 --- /dev/null +++ b/claude_tap/cursor_transcript.py @@ -0,0 +1,206 @@ +"""Cursor CLI transcript import for viewer-friendly trace records.""" + +from __future__ import annotations + +import json +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from claude_tap.trace import TraceWriter + + +def _cursor_projects_dir(home: Path | None = None) -> Path: + return (home or Path.home()) / ".cursor" / "projects" + + +def _extract_content_blocks(message: object) -> list[dict]: + if not isinstance(message, dict): + return [] + content = message.get("content") + if not isinstance(content, list): + return [] + blocks: list[dict] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get("type") == "text": + text = item.get("text") + if isinstance(text, str): + blocks.append({"type": "text", "text": text}) + elif item.get("type") == "tool_use": + name = item.get("name") + if not isinstance(name, str) or not name: + name = "Tool" + tool_input = item.get("input") + if not isinstance(tool_input, dict): + tool_input = {} + block = {"type": "tool_use", "name": name, "input": tool_input} + tool_id = item.get("id") + if isinstance(tool_id, str) and tool_id: + block["id"] = tool_id + blocks.append(block) + return blocks + + +def _text_from_blocks(blocks: list[dict]) -> str: + return "\n".join( + block["text"] for block in blocks if block.get("type") == "text" and isinstance(block.get("text"), str) + ).strip() + + +def _strip_cursor_wrappers(text: str) -> str: + """Remove Cursor's timestamp/query XML wrappers from user transcript text.""" + match = re.search(r"\s*(.*?)\s*", text, flags=re.DOTALL) + if match: + return match.group(1).strip() + return re.sub(r".*?\s*", "", text, flags=re.DOTALL).strip() + + +def _load_transcript(path: Path) -> list[tuple[str, list[dict]]]: + messages: list[tuple[str, list[dict]]] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return messages + + for line in lines: + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(record, dict): + continue + role = record.get("role") + if role not in {"user", "assistant"}: + continue + blocks = _extract_content_blocks(record.get("message")) + if not blocks: + continue + if role == "user": + text = _strip_cursor_wrappers(_text_from_blocks(blocks)) + blocks = [{"type": "text", "text": text}] if text else [] + messages.append((role, blocks)) + return messages + + +def _assistant_steps(messages: list[tuple[str, list[dict]]]) -> list[tuple[str, list[dict], int, int]]: + steps: list[tuple[str, list[dict], int, int]] = [] + pending_user: str | None = None + cursor_turn = 0 + cursor_step = 0 + + for role, blocks in messages: + if role == "user": + cursor_turn += 1 + cursor_step = 0 + pending_user = _text_from_blocks(blocks) + elif role == "assistant" and pending_user is not None: + cursor_step += 1 + steps.append((pending_user, blocks or [{"type": "text", "text": ""}], cursor_turn, cursor_step)) + return steps + + +def _normalize_assistant_blocks(blocks: list[dict], *, turn_index: int) -> list[dict]: + normalized: list[dict] = [] + for index, block in enumerate(blocks, start=1): + copied = dict(block) + if copied.get("type") == "tool_use" and not copied.get("id"): + copied["id"] = f"cursor_tool_{turn_index}_{index}" + normalized.append(copied) + return normalized + + +def find_cursor_transcripts( + *, + since: float, + home: Path | None = None, +) -> list[Path]: + """Return Cursor agent transcripts modified at or after ``since``.""" + projects_dir = _cursor_projects_dir(home) + if not projects_dir.exists(): + return [] + candidates: list[tuple[float, Path]] = [] + for path in projects_dir.glob("*/agent-transcripts/*/*.jsonl"): + try: + mtime = path.stat().st_mtime + if mtime >= since: + candidates.append((mtime, path)) + except OSError: + continue + return [path for _, path in sorted(candidates, key=lambda item: item[0])] + + +def build_cursor_transcript_records( + transcript_path: Path, + *, + start_turn: int, +) -> list[dict]: + """Build Anthropic-shaped synthetic records from a Cursor transcript.""" + session_id = transcript_path.stem + messages = _load_transcript(transcript_path) + steps = _assistant_steps(messages) + records: list[dict] = [] + timestamp = datetime.now(timezone.utc).isoformat() + + for index, (user_text, assistant_blocks, cursor_turn, cursor_step) in enumerate(steps, start=1): + turn = start_turn + index - 1 + req_id = f"cursor_transcript_{uuid.uuid4().hex[:12]}" + response_content = _normalize_assistant_blocks(assistant_blocks, turn_index=index) + records.append( + { + "timestamp": timestamp, + "request_id": req_id, + "turn": turn, + "duration_ms": 0, + "transport": "cursor-transcript", + "request": { + "method": "CURSOR_TRANSCRIPT", + "path": f"/cursor/transcript/{session_id}/turn/{cursor_turn}/step/{cursor_step}", + "headers": {}, + "body": { + "model": "cursor-auto", + "cursor_turn": cursor_turn, + "cursor_step": cursor_step, + "messages": [{"role": "user", "content": user_text}], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "id": session_id, + "type": "message", + "role": "assistant", + "content": response_content, + }, + }, + } + ) + return records + + +async def import_cursor_transcripts( + writer: TraceWriter, + *, + since: float, + home: Path | None = None, +) -> int: + """Append recent Cursor transcripts to the active trace. + + Cursor CLI persists readable user/assistant messages locally, while its + network payloads are protobuf-oriented. Importing transcripts gives the + HTML viewer a readable multi-turn conversation without reverse-engineering + Cursor's private wire schema. + """ + imported = 0 + start_turn = writer.count + 1 + for transcript_path in find_cursor_transcripts(since=since, home=home): + records = build_cursor_transcript_records(transcript_path, start_turn=start_turn + imported) + for record in records: + await writer.write(record) + imported += 1 + return imported diff --git a/claude_tap/dashboard.html b/claude_tap/dashboard.html new file mode 100644 index 0000000..79d8fd9 --- /dev/null +++ b/claude_tap/dashboard.html @@ -0,0 +1,2522 @@ + + + + + + +claude-tap dashboard + + + +
+ +
+
+
+
Sessions 0
+
Records 0
+
Watching
+
+ + + +
+ +
+ Dashboard + trace history sessions +
+ +
+
+
+

Conversation Log

+ + +
+ +
+ + + +
+ + +
+
+ +
+
+
Threads
+
0
+
+
+
Traces
+
0
+
+
+
Error Rate
+
0.0%
+
+
+
Total Tokens
+
0
+
+
+ +
+ +
+ + + + + + + + + + + + + + + +
Start TimeFirst MessageTracesTokensModelAgentStatusActions
+
+ +
+
+ + +
+ + + + + + diff --git a/claude_tap/dashboard.py b/claude_tap/dashboard.py new file mode 100644 index 0000000..ec3f33f --- /dev/null +++ b/claude_tap/dashboard.py @@ -0,0 +1,1350 @@ +"""Session-first dashboard helpers backed by the local SQLite trace store.""" + +from __future__ import annotations + +import json +import re +import sqlite3 +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import parse_qsl, urlencode, urlparse, urlsplit, urlunsplit + +from claude_tap.bedrock import bedrock_model_from_path +from claude_tap.trace_store import SessionQuery, TraceStore, get_trace_store +from claude_tap.usage import normalize_usage +from claude_tap.viewer import _decode_bedrock_eventstream_events + +DASHBOARD_TEMPLATE_PATH = Path(__file__).parent / "dashboard.html" +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + +CLIENT_LABELS = { + "agy": "Antigravity", + "antigravity": "Antigravity", + "claude": "Claude Code", + "codex": "Codex", + "codexapp": "Codex App", + "cursor": "Cursor", + "gemini": "Gemini", + "hermes": "Hermes", + "kimi": "Kimi", + "kimi-code": "Kimi Code", + "mimo": "MiMo Code", + "opencode": "OpenCode", + "pi": "Pi", + "qoder": "Qoder", +} +DASHBOARD_SUMMARY_VERSION = 2 +VALID_SESSION_STATUSES = {"active", "complete", "error", "empty"} +_REDACTED_VALUE = "REDACTED" +_SENSITIVE_KEY_NAMES = { + "apikey", + "authorization", + "clientsecret", + "cookie", + "idtoken", + "password", + "passwd", + "refreshtoken", + "secret", + "secretkey", + "setcookie", + "token", + "xapikey", +} +_FORM_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-\[\]]{1,128}$") +_MAX_TEXT_REDACTION_DEPTH = 8 + + +def read_dashboard_template() -> str: + """Read the packaged dashboard HTML.""" + return DASHBOARD_TEMPLATE_PATH.read_text(encoding="utf-8") + + +def ensure_trace_store() -> TraceStore: + """Return the trace store.""" + return get_trace_store() + + +def build_session_query( + *, + date: str = "", + status: str = "", + search: str = "", + agent: str = "", +) -> SessionQuery: + """Build a SQLite-backed session query from dashboard filter values.""" + normalized_date = date if date == "legacy" or _DATE_RE.match(date) else "" + normalized_status = status if status in VALID_SESSION_STATUSES else "" + agent_clients, agent_labels = _agent_filter_values(agent) + return SessionQuery( + date=normalized_date, + status=normalized_status, + search=search.strip(), + agent_clients=agent_clients, + agent_labels=agent_labels, + ) + + +def list_trace_sessions( + current_session_id: str | None = None, + *, + live_record_count: int | None = None, + limit: int | None = None, + offset: int = 0, + query: SessionQuery | None = None, + repair_stale_summaries: bool = True, +) -> list[dict[str, Any]]: + """Return trace sessions sorted by most recent activity.""" + store = ensure_trace_store() + try: + rows = store.list_session_rows(limit=limit, offset=offset, query=query) + except (OSError, sqlite3.Error, ValueError): + return [] + + sessions: list[dict[str, Any]] = [] + for row in rows: + try: + summary = _session_summary_from_row( + store, + row, + repair_stale_summary=repair_stale_summaries, + ) + except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError): + summary = _minimal_session_summary_from_row(row) + sessions.append( + _apply_current_session_state( + summary, + current_session_id, + live_record_count=( + live_record_count + if live_record_count is not None and current_session_id and row["id"] == current_session_id + else None + ), + ) + ) + sessions.sort(key=lambda item: (_timestamp_sort_value(item.get("updated_at")), item.get("id") or ""), reverse=True) + return sessions + + +def count_trace_sessions(query: SessionQuery | None = None) -> int: + """Return the number of stored trace sessions.""" + try: + return ensure_trace_store().count_session_rows(query) + except (OSError, sqlite3.Error, ValueError): + return 0 + + +def sum_trace_session_records(query: SessionQuery | None = None) -> int: + """Return total stored records for matching trace sessions.""" + try: + return ensure_trace_store().sum_session_records(query) + except (OSError, sqlite3.Error, ValueError): + return 0 + + +def list_trace_agents( + current_session_id: str | None = None, + *, + live_record_count: int | None = None, +) -> list[dict[str, Any]]: + """Return agent buckets for the dashboard sidebar.""" + buckets: dict[str, dict[str, Any]] = {} + try: + rows = ensure_trace_store().list_agent_buckets() + except (OSError, sqlite3.Error, ValueError): + rows = [] + for row in rows: + raw_agent = str(row["agent"] or "Unknown") + label = CLIENT_LABELS.get(raw_agent.lower(), raw_agent) + key = _agent_key(label) + bucket = buckets.setdefault(key, {"key": key, "label": label, "sessions": 0, "records": 0}) + bucket["sessions"] += int(row["sessions"] or 0) + bucket["records"] += int(row["records"] or 0) + if current_session_id and live_record_count is not None: + live_row = ensure_trace_store().load_session_row(current_session_id) + if live_row is not None: + live_agent = _infer_agent( + [], {"client": live_row["client"] or "", "proxy_mode": live_row["proxy_mode"] or ""} + ) + key = _agent_key(live_agent) + bucket = buckets.get(key) + if bucket is not None: + bucket["records"] = max(int(bucket["records"] or 0), int(live_record_count or 0)) + return sorted(buckets.values(), key=lambda item: (item["label"].lower(), item["key"])) + + +def dashboard_trace_snapshot() -> dict[str, tuple[str, int, str]]: + """Return a cheap SQLite snapshot for dashboard refresh detection.""" + store = ensure_trace_store() + return store.dashboard_snapshot() + + +def load_trace_session( + session_id: str, + current_session_id: str | None = None, + record_limit: int | None = None, + record_offset: int = 0, + *, + live_record_count: int | None = None, +) -> dict[str, Any] | None: + """Load one session summary and its records by session id.""" + store = ensure_trace_store() + row = store.load_session_row(session_id) + if row is None: + return None + summary = _apply_current_session_state( + _session_summary_from_row(store, row, allow_record_scan=False), + current_session_id, + live_record_count=( + live_record_count + if live_record_count is not None and current_session_id and row["id"] == current_session_id + else None + ), + ) + summary = redact_dashboard_summary(summary) + records = redact_dashboard_records(store.load_records(session_id, limit=record_limit, offset=record_offset)) + return {"session": summary, "records": records} + + +def redact_dashboard_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return records safe for dashboard rendering without mutating stored traces.""" + return [_redact_sensitive_value(record) for record in records] + + +def redact_dashboard_summary(summary: dict[str, Any]) -> dict[str, Any]: + """Return a session summary safe for dashboard rendering.""" + return _redact_sensitive_value(summary) + + +def merge_record_into_summary( + summary: dict[str, Any] | None, + *, + row: sqlite3.Row, + record: dict[str, Any], + record_count: int, +) -> dict[str, Any]: + """Update a session summary incrementally after appending one record.""" + manifest_entry = { + "client": row["client"] or "", + "proxy_mode": row["proxy_mode"] or "", + } + if summary is None or summary.get("id") != row["id"]: + initial_summary = _summarize_session( + session_id=row["id"], + date_key=row["date_key"] or "legacy", + legacy_rel_path=row["legacy_rel_path"], + records=[record], + manifest_entry=manifest_entry, + status="active", + started_at=row["started_at"] or "", + updated_at=row["updated_at"] or "", + is_current=True, + record_count=record_count, + ) + if _is_auxiliary_status_error_record(record): + initial_summary["status"] = "active" + initial_summary["error"] = "" + return initial_summary + + summary = dict(summary) + summary["summary_version"] = DASHBOARD_SUMMARY_VERSION + usage = _record_usage(record) + summary["record_count"] = record_count + summary["turn_count"] = max(int(summary.get("turn_count") or 0), record_count) + summary["input_tokens"] = int(summary.get("input_tokens") or 0) + (usage.get("input_tokens") or 0) + summary["output_tokens"] = int(summary.get("output_tokens") or 0) + (usage.get("output_tokens") or 0) + summary["cache_read_tokens"] = int(summary.get("cache_read_tokens") or 0) + ( + usage.get("cache_read_input_tokens") or 0 + ) + summary["cache_create_tokens"] = int(summary.get("cache_create_tokens") or 0) + ( + usage.get("cache_creation_input_tokens") or 0 + ) + summary["total_tokens"] = ( + summary["input_tokens"] + + summary["output_tokens"] + + summary["cache_read_tokens"] + + summary["cache_create_tokens"] + ) + summary["duration_ms"] = int(summary.get("duration_ms") or 0) + _duration_ms(record) + model = _record_model(record) + if model: + summary["model"] = model + timestamp = _timestamp_from_record(record) + if timestamp: + summary["updated_at"] = timestamp + if not summary.get("started_at"): + summary["started_at"] = timestamp + summary["last_response"] = _last_response_preview([record]) + if not summary.get("first_user"): + summary["first_user"] = _first_user_preview([record]) + if not summary.get("agent"): + summary["agent"] = _infer_agent([record], manifest_entry) + summary["agent_key"] = _agent_key(summary["agent"]) + if _is_session_error_record(record): + summary["status"] = "error" + summary["error"] = summary.get("error") or _first_error([record]) + elif summary.get("status") != "error": + summary["status"] = "active" + return redact_dashboard_summary(summary) + + +def is_dashboard_summary_current(summary: Any, session_id: str) -> bool: + return ( + isinstance(summary, dict) + and summary.get("id") == session_id + and summary.get("summary_version") == DASHBOARD_SUMMARY_VERSION + ) + + +def build_stored_session_summary(row: sqlite3.Row, records: list[dict[str, Any]]) -> dict[str, Any]: + manifest_entry = { + "client": row["client"] or "", + "proxy_mode": row["proxy_mode"] or "", + } + return _summarize_session( + session_id=row["id"], + date_key=row["date_key"] or "legacy", + legacy_rel_path=row["legacy_rel_path"], + records=records, + manifest_entry=manifest_entry, + status=row["status"] or "complete", + started_at=row["started_at"] or "", + updated_at=row["updated_at"] or "", + is_current=row["status"] == "active", + record_count=int(row["record_count"] or len(records)), + ) + + +def build_imported_session_summary( + row: sqlite3.Row, + records: list[dict[str, Any]], + manifest_entry: dict[str, Any], +) -> dict[str, Any]: + """Build and cache a summary for a legacy import.""" + return _summarize_session( + session_id=row["id"], + date_key=row["date_key"] or "legacy", + legacy_rel_path=row["legacy_rel_path"], + records=records, + manifest_entry=manifest_entry, + status="complete", + started_at=row["started_at"] or "", + updated_at=row["updated_at"] or "", + is_current=False, + record_count=int(row["record_count"] or len(records)), + ) + + +def _session_summary_from_row( + store: TraceStore, + row: sqlite3.Row, + *, + allow_record_scan: bool = False, + repair_stale_summary: bool = True, +) -> dict[str, Any]: + summary_json = row["summary_json"] + if summary_json: + try: + cached = json.loads(summary_json) + except json.JSONDecodeError: + cached = None + if isinstance(cached, dict) and (not cached.get("id") or cached.get("id") == row["id"]): + needs_error_repair = row["status"] == "error" and not cached.get("error") + if ( + repair_stale_summary + and row["status"] != "active" + and (not is_dashboard_summary_current(cached, row["id"]) or needs_error_repair) + ): + boundary_records = store.load_boundary_records(row["id"]) + if boundary_records: + summary = _summary_from_boundary_records(row, boundary_records, cached) + store.store_summary(row["id"], summary) + return summary + return _normalize_cached_session_summary(row, cached) + + record_count = int(row["record_count"] or 0) + manifest_entry = { + "client": row["client"] or "", + "proxy_mode": row["proxy_mode"] or "", + } + if record_count == 0: + summary = _summarize_session( + session_id=row["id"], + date_key=row["date_key"] or "legacy", + legacy_rel_path=row["legacy_rel_path"], + records=[], + manifest_entry=manifest_entry, + status=row["status"] or "empty", + started_at=row["started_at"] or "", + updated_at=row["updated_at"] or "", + is_current=row["status"] == "active", + record_count=0, + ) + summary["active"] = row["status"] == "active" + if row["status"] != "active": + store.store_summary(row["id"], summary) + return redact_dashboard_summary(summary) + + if not allow_record_scan: + if row["status"] == "error": + records = store.load_records(row["id"]) + if records: + summary = _summarize_session( + session_id=row["id"], + date_key=row["date_key"] or "legacy", + legacy_rel_path=row["legacy_rel_path"], + records=records, + manifest_entry=manifest_entry, + status=row["status"] or "error", + started_at=row["started_at"] or "", + updated_at=row["updated_at"] or "", + is_current=False, + record_count=record_count, + ) + store.store_summary(row["id"], summary) + return summary + return _minimal_session_summary_from_row(row) + + records = store.load_records(row["id"]) + summary = _summarize_session( + session_id=row["id"], + date_key=row["date_key"] or "legacy", + legacy_rel_path=row["legacy_rel_path"], + records=records, + manifest_entry=manifest_entry, + status=row["status"] or "complete", + started_at=row["started_at"] or "", + updated_at=row["updated_at"] or "", + is_current=False, + record_count=record_count, + ) + summary["active"] = row["status"] == "active" + if row["status"] != "active": + store.store_summary(row["id"], summary) + return redact_dashboard_summary(summary) + + +def _minimal_session_summary_from_row(row: sqlite3.Row) -> dict[str, Any]: + record_count = int(row["record_count"] or 0) + manifest_entry = { + "client": row["client"] or "", + "proxy_mode": row["proxy_mode"] or "", + } + summary = _summarize_session( + session_id=row["id"], + date_key=row["date_key"] or "legacy", + legacy_rel_path=row["legacy_rel_path"], + records=[], + manifest_entry=manifest_entry, + status=row["status"] or ("empty" if record_count == 0 else "complete"), + started_at=row["started_at"] or "", + updated_at=row["updated_at"] or "", + is_current=row["status"] == "active", + record_count=record_count, + ) + if record_count > 0 and summary["status"] == "empty": + summary["status"] = row["status"] if row["status"] in {"active", "complete", "error"} else "complete" + return redact_dashboard_summary(summary) + + +def _summary_from_boundary_records( + row: sqlite3.Row, + records: list[dict[str, Any]], + cached: dict[str, Any], +) -> dict[str, Any]: + summary = build_stored_session_summary(row, records) + for key in ( + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_create_tokens", + "total_tokens", + "duration_ms", + "turn_count", + "model", + "error", + ): + if cached.get(key): + summary[key] = cached[key] + summary["summary_version"] = DASHBOARD_SUMMARY_VERSION + summary["record_count"] = int(row["record_count"] or summary.get("record_count") or 0) + summary["turn_count"] = max(int(summary.get("turn_count") or 0), summary["record_count"]) + return redact_dashboard_summary(summary) + + +def _normalize_cached_session_summary(row: sqlite3.Row, cached: dict[str, Any]) -> dict[str, Any]: + summary = _minimal_session_summary_from_row(row) + summary.update(cached) + summary["id"] = row["id"] + summary["summary_version"] = DASHBOARD_SUMMARY_VERSION + summary["date"] = row["date_key"] if _DATE_RE.match(row["date_key"] or "") else "legacy" + summary["legacy_rel_path"] = row["legacy_rel_path"] + summary["started_at"] = row["started_at"] or summary.get("started_at") or "" + summary["updated_at"] = row["updated_at"] or summary.get("updated_at") or summary["started_at"] + summary["active"] = row["status"] == "active" + summary["live"] = False + db_count = int(row["record_count"] or 0) + summary["record_count"] = db_count + summary["turn_count"] = max(int(summary.get("turn_count") or 0), db_count) + row_status = row["status"] or "" + if row_status == "active" and db_count > 0 and summary.get("status") != "error": + summary["status"] = "active" + elif row_status in {"active", "complete", "error", "empty"}: + summary["status"] = row_status + elif db_count == 0: + summary["status"] = "empty" + elif summary.get("status") not in {"active", "complete", "error", "empty"}: + summary["status"] = "complete" + if not summary.get("agent"): + summary["agent"] = _infer_agent([], {"client": row["client"] or "", "proxy_mode": row["proxy_mode"] or ""}) + summary["agent_key"] = _agent_key(str(summary.get("agent") or "")) + token_total = ( + int(summary.get("input_tokens") or 0) + + int(summary.get("output_tokens") or 0) + + int(summary.get("cache_read_tokens") or 0) + + int(summary.get("cache_create_tokens") or 0) + ) + summary["total_tokens"] = token_total if token_total else int(cached.get("total_tokens") or 0) + return redact_dashboard_summary(summary) + + +def _apply_current_session_state( + session: dict[str, Any], + current_session_id: str | None, + *, + live_record_count: int | None = None, +) -> dict[str, Any]: + session = dict(session) + is_current = bool(current_session_id and session.get("id") == current_session_id) + session["live"] = is_current + session["active"] = bool(session.get("active")) or is_current + if is_current: + count = int(session.get("record_count") or 0) + if live_record_count is not None: + count = max(count, live_record_count) + session["record_count"] = count + session["turn_count"] = max(int(session.get("turn_count") or 0), count) + if count > 0 and session.get("status") != "error": + session["status"] = "active" + return session + + +def _summarize_session( + *, + session_id: str, + date_key: str, + legacy_rel_path: str | None, + records: list[dict[str, Any]], + manifest_entry: dict[str, Any], + status: str, + started_at: str, + updated_at: str, + is_current: bool, + record_count: int | None = None, +) -> dict[str, Any]: + first_record = records[0] if records else {} + last_record = records[-1] if records else {} + started_at = _timestamp_from_record(first_record) or started_at or _iso_now() + updated_at = _timestamp_from_record(last_record) or updated_at or started_at + agent = _infer_agent(records, manifest_entry) + input_tokens = output_tokens = cache_read_tokens = cache_create_tokens = 0 + models: dict[str, int] = {} + duration_ms = 0 + turns: set[int] = set() + + for record in records: + usage = _record_usage(record) + input_tokens += usage.get("input_tokens") or 0 + output_tokens += usage.get("output_tokens") or 0 + cache_read_tokens += usage.get("cache_read_input_tokens") or 0 + cache_create_tokens += usage.get("cache_creation_input_tokens") or 0 + model = _record_model(record) + if model: + models[model] = models.get(model, 0) + 1 + duration_ms += _duration_ms(record) + turn = record.get("turn") + if isinstance(turn, int): + turns.add(turn) + + error_records = [record for record in records if _is_session_error_record(record)] + auxiliary_error_records = [record for record in records if _is_auxiliary_status_error_record(record)] + has_error = bool(error_records) or ( + bool(auxiliary_error_records) and not any(_is_successful_primary_record(record) for record in records) + ) + if has_error: + resolved_status = "error" + elif is_current and records: + resolved_status = "active" + elif not records: + resolved_status = "empty" + else: + resolved_status = status if status in {"active", "complete", "error", "empty"} else "complete" + + error_display_records = error_records or (auxiliary_error_records if has_error else []) + preview_records = _preview_records(records) + count = record_count if record_count is not None else len(records) + return redact_dashboard_summary( + { + "id": session_id, + "summary_version": DASHBOARD_SUMMARY_VERSION, + "date": date_key if _DATE_RE.match(date_key) else "legacy", + "agent": agent, + "agent_key": _agent_key(agent), + "status": resolved_status, + "active": is_current or status == "active", + "live": is_current, + "legacy_rel_path": legacy_rel_path, + "started_at": started_at, + "updated_at": updated_at, + "record_count": count, + "turn_count": len(turns) if turns else count, + "duration_ms": duration_ms, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cache_read_tokens": cache_read_tokens, + "cache_create_tokens": cache_create_tokens, + "total_tokens": input_tokens + output_tokens + cache_read_tokens + cache_create_tokens, + "model": _top_key(models) or _record_model(last_record) or "unknown", + "first_user": _first_user_preview(preview_records), + "last_response": _last_response_preview(preview_records), + "error": _first_error(error_display_records), + } + ) + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _timestamp_sort_value(value: object) -> float: + if not isinstance(value, str) or not value: + return 0.0 + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return 0.0 + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc).timestamp() + + +def _timestamp_from_record(record: dict[str, Any]) -> str | None: + value = record.get("timestamp") + return value if isinstance(value, str) and value else None + + +def _record_usage(record: dict[str, Any]) -> dict[str, int]: + response = record.get("response") + body = response.get("body") if isinstance(response, dict) else {} + usage = body.get("usage", {}) if isinstance(body, dict) else {} + if not usage and isinstance(body, dict): + usage = body.get("usageMetadata", {}) + if not usage: + for event in reversed(_response_events(record)): + payload = _event_payload(event) + candidate = payload.get("usage", {}) if isinstance(payload, dict) else {} + if candidate: + usage = candidate + break + if not usage: + merged_usage: dict[str, int] = {} + for event in _bedrock_events(record): + payload = event.get("data", {}) if isinstance(event, dict) else {} + candidate = payload.get("usage", {}) if isinstance(payload, dict) else {} + for key, value in candidate.items(): + if isinstance(value, int): + merged_usage[key] = max(merged_usage.get(key, 0), value) + message = payload.get("message", {}) if isinstance(payload, dict) else {} + msg_usage = message.get("usage", {}) if isinstance(message, dict) else {} + for key, value in msg_usage.items(): + if isinstance(value, int): + merged_usage[key] = max(merged_usage.get(key, 0), value) + if merged_usage: + usage = merged_usage + if not usage and isinstance(body, dict): + usage = body + return normalize_usage(usage) + + +def _record_model(record: dict[str, Any]) -> str: + request = record.get("request") + req_body = request.get("body") if isinstance(request, dict) else None + if isinstance(req_body, dict): + for key in ("model", "modelId"): + value = req_body.get(key) + if isinstance(value, str) and value: + return value + nested_request = req_body.get("request") + if isinstance(nested_request, dict): + value = nested_request.get("model") + if isinstance(value, str) and value: + return value + response = record.get("response") + resp_body = response.get("body") if isinstance(response, dict) else None + if isinstance(resp_body, dict): + value = resp_body.get("model") + if isinstance(value, str) and value: + return value + for event in _bedrock_events(record)[:3]: + data = event.get("data", {}) if isinstance(event, dict) else {} + msg = data.get("message", {}) if isinstance(data, dict) else {} + if isinstance(msg, dict): + value = msg.get("model") + if isinstance(value, str) and value: + return value + path = request.get("path") if isinstance(request, dict) else "" + if isinstance(path, str): + bedrock_model = bedrock_model_from_path(path) + if bedrock_model: + return bedrock_model + match = re.search(r"/models?/([^:?/]+)", path) + if match: + return match.group(1) + return "" + + +def _response_status(record: dict[str, Any]) -> int: + response = record.get("response") + status = response.get("status") if isinstance(response, dict) else None + return status if isinstance(status, int) else 0 + + +def _duration_ms(record: dict[str, Any]) -> int: + value = record.get("duration_ms") + return value if isinstance(value, int) else 0 + + +def _record_error(record: dict[str, Any]) -> str: + response = record.get("response") + if not isinstance(response, dict): + return "" + value = response.get("error") + return value if isinstance(value, str) else "" + + +def _first_error(records: list[dict[str, Any]]) -> str: + for record in records: + error = _record_error(record) + if error: + return _preview(error, 240) + response = record.get("response") + body = response.get("body") if isinstance(response, dict) else None + if isinstance(body, dict): + value = body.get("error") + if isinstance(value, str): + return _preview(value, 240) + if isinstance(value, dict): + message = value.get("message") + if isinstance(message, str): + return _preview(message, 240) + return "" + + +def _top_key(values: dict[str, int]) -> str: + if not values: + return "" + return max(values.items(), key=lambda item: item[1])[0] + + +def _infer_agent(records: list[dict[str, Any]], manifest_entry: dict[str, Any]) -> str: + client = manifest_entry.get("client") + if not client and isinstance(manifest_entry.get("metadata"), dict): + client = manifest_entry["metadata"].get("client") + if isinstance(client, str) and client: + return CLIENT_LABELS.get(client.lower(), client) + + for record in records: + capture = record.get("capture") + if isinstance(capture, dict): + record_client = capture.get("client") + if isinstance(record_client, str) and record_client: + return CLIENT_LABELS.get(record_client.lower(), record_client) + + sample = records[0] if records else {} + host = _record_host(sample) + path = _record_path(sample) + upstream = str(sample.get("upstream_base_url") or "") + signal = " ".join([host, path, upstream]).lower() + if "antigravity" in signal or "codeium" in signal or "v1internal:streamgeneratecontent" in signal: + return "Antigravity" + if ( + "generativelanguage.googleapis.com" in signal + or "streamgeneratecontent" in signal + or "generatecontent" in signal + ): + return "Gemini" + if "chatgpt.com/backend-api/codex" in signal or "/responses" in signal: + return "Codex" + if "api.anthropic.com" in signal or "/v1/messages" in signal: + return "Claude Code" + if "kimi" in signal or "moonshot" in signal: + return "Kimi" + if "cursor" in signal: + return "Cursor" + if "qoder" in signal: + return "Qoder" + if "opencode" in signal: + return "OpenCode" + if "mimo" in signal or "mimo.xiaomi" in signal: + return "MiMo Code" + if "hermes" in signal: + return "Hermes" + return "Unknown" + + +def _record_host(record: dict[str, Any]) -> str: + request = record.get("request") + headers = request.get("headers") if isinstance(request, dict) else {} + if isinstance(headers, dict): + for key in ("Host", "host"): + value = headers.get(key) + if isinstance(value, str): + return value + upstream = record.get("upstream_base_url") + if isinstance(upstream, str) and upstream: + return urlparse(upstream).netloc + return "" + + +def _record_path(record: dict[str, Any]) -> str: + request = record.get("request") + value = request.get("path") if isinstance(request, dict) else "" + return value if isinstance(value, str) else "" + + +def _agent_key(agent: str) -> str: + key = re.sub(r"[^a-z0-9]+", "-", agent.lower()).strip("-") + return key or "unknown" + + +def _agent_filter_values(agent_key: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + key = (agent_key or "").strip().lower() + if not key or key == "all": + return (), () + + clients: set[str] = set() + labels: set[str] = set() + for client, label in CLIENT_LABELS.items(): + if _agent_key(label) == key: + clients.add(client) + labels.add(label) + + # If no pre-defined CLIENT_LABELS matched this key, check actual DB buckets + if not clients and not labels and key != "unknown": + try: + rows = get_trace_store().list_agent_buckets() + for row in rows: + raw_agent = str(row["agent"] or "Unknown") + if _agent_key(raw_agent) == key: + labels.add(raw_agent) + clients.add(raw_agent) + except Exception: + labels.add(agent_key) + clients.add(agent_key) + + if key == "unknown": + clients.update(("", "unknown")) + labels.add("Unknown") + return tuple(sorted(clients)), tuple(sorted(labels)) + + +def _preview_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + primary = [record for record in records if _is_primary_model_record(record)] + if primary: + return primary + return [record for record in records if not _is_auxiliary_record(record)] + + +def _redact_sensitive_value(value: Any, key: str = "") -> Any: + if key and _is_sensitive_key(key): + return None if value is None else _REDACTED_VALUE + if isinstance(value, dict): + return { + str(item_key): _redact_sensitive_value(item_value, str(item_key)) for item_key, item_value in value.items() + } + if isinstance(value, list): + return [_redact_sensitive_value(item) for item in value] + if isinstance(value, str): + return _redact_sensitive_text(value) + return value + + +def _redact_sensitive_text(value: str, depth: int = 0) -> str: + stripped = value.strip() + if not stripped: + return value + if stripped[0] in "{[": + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if parsed is not None: + redacted = _redact_sensitive_value(parsed) + if redacted != parsed: + return json.dumps(redacted, ensure_ascii=False, separators=(",", ":")) + redacted_url = _redact_url_query(value, depth) + if redacted_url is not None: + return redacted_url + redacted_form = _redact_form_text(value, depth) + return value if redacted_form is None else redacted_form + + +def _redact_url_query(value: str, depth: int = 0) -> str | None: + if "?" not in value: + return None + parsed = urlsplit(value) + if not parsed.query or not _looks_like_url_or_path(value, parsed.path): + return None + redacted_query = _redact_query_string(parsed.query, depth) + if redacted_query is None: + return None + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, redacted_query, parsed.fragment)) + + +def _redact_form_text(value: str, depth: int = 0) -> str | None: + if "=" not in value: + return None + pairs = parse_qsl(value, keep_blank_values=True) + if not pairs or not all(_looks_like_form_key(item_key) for item_key, _item_value in pairs): + return None + redacted_pairs = [ + ( + item_key, + _REDACTED_VALUE if _is_sensitive_key(item_key) else _redact_nested_sensitive_text(item_value, depth), + ) + for item_key, item_value in pairs + ] + if redacted_pairs == pairs: + return None + return urlencode(redacted_pairs) + + +def _redact_query_string(query: str, depth: int = 0) -> str | None: + pairs = parse_qsl(query, keep_blank_values=True) + if not pairs: + return None + redacted_pairs = [ + ( + item_key, + _REDACTED_VALUE if _is_sensitive_key(item_key) else _redact_nested_sensitive_text(item_value, depth), + ) + for item_key, item_value in pairs + ] + if redacted_pairs == pairs: + return None + return urlencode(redacted_pairs) + + +def _looks_like_url_or_path(value: str, path: str) -> bool: + parsed = urlsplit(value) + return bool(parsed.scheme or parsed.netloc or value.startswith(("/", "?")) or ("/" in path and "=" not in path)) + + +def _looks_like_form_key(key: str) -> bool: + return bool(key and _FORM_KEY_RE.fullmatch(key)) + + +def _redact_nested_sensitive_text(value: str, depth: int) -> str: + if depth >= _MAX_TEXT_REDACTION_DEPTH or not _may_contain_sensitive_text(value): + return value + return _redact_sensitive_text(value, depth + 1) + + +def _may_contain_sensitive_text(value: str) -> bool: + normalized = re.sub(r"[^a-z0-9]", "", value.lower()) + return any(name in normalized for name in _SENSITIVE_KEY_NAMES) or any( + marker in normalized for marker in ("token", "secret", "password") + ) + + +def _is_sensitive_key(key: str) -> bool: + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + return ( + normalized in _SENSITIVE_KEY_NAMES + or normalized.endswith("token") + or normalized.endswith("secret") + or normalized.endswith("password") + ) + + +def _is_primary_model_record(record: dict[str, Any]) -> bool: + path = _record_path(record).lower() + if not path: + return False + primary_fragments = ( + "/v1/messages", + "/zen/v1/messages", + "/v1/responses", + "/responses", + "/v1/chat/completions", + "/chat/completions", + "/v1/completions", + "/completions", + "streamgeneratecontent", + "generatecontent", + ) + return any(fragment in path for fragment in primary_fragments) + + +def _is_auxiliary_record(record: dict[str, Any]) -> bool: + path = _record_path(record).lower() + if _is_model_probe_path(path): + return True + auxiliary_fragments = ( + "/token", + "oauth", + "userinfo", + "quota", + "experiments", + "admincontrols", + "features", + "register", + "manifest", + "/metrics", + "/log", + "loadcodeassist", + "fetchavailablemodels", + "fetchuserinfo", + ) + return any(fragment in path for fragment in auxiliary_fragments) + + +def _is_model_probe_path(path: str) -> bool: + clean_path = path.split("?", 1)[0].rstrip("/") + if clean_path in {"/models", "/v1/models", "/v1alpha/models", "/v1beta/models"}: + return True + match = re.fullmatch(r"/(?:v1/)?models/([^/:]+)", clean_path) + return match is not None + + +def _is_session_error_record(record: dict[str, Any]) -> bool: + if _record_error(record): + return True + status_code = _response_status(record) + return status_code >= 400 and not _is_auxiliary_record(record) + + +def _is_auxiliary_status_error_record(record: dict[str, Any]) -> bool: + status_code = _response_status(record) + return status_code >= 400 and _is_auxiliary_record(record) + + +def _is_successful_primary_record(record: dict[str, Any]) -> bool: + status_code = _response_status(record) + return 200 <= status_code < 400 and not _is_auxiliary_record(record) + + +def _first_user_preview(records: list[dict[str, Any]]) -> str: + for record in records: + request = record.get("request") + body = request.get("body") if isinstance(request, dict) else None + text = _request_user_text(body) + if text: + return _preview(text, 220) + return "" + + +def _last_response_preview(records: list[dict[str, Any]]) -> str: + for record in reversed(records): + text = _record_response_text(record) + if text: + return _preview(text, 220) + return "" + + +def _record_response_text(record: dict[str, Any]) -> str: + response = record.get("response") + body = response.get("body") if isinstance(response, dict) else None + text = _response_text(body) + if text: + return text + + for event in reversed(_response_events(record)): + payload = _event_payload(event) + text = _response_text(payload) + if text: + return text + if isinstance(event, dict): + text = _content_text(event.get("item")) or _content_text(event.get("part")) + if text: + return text + value = event.get("text") + if isinstance(value, str) and value: + return value + return "" + + +def _response_events(record: dict[str, Any]) -> list[dict[str, Any]]: + response = record.get("response") + if not isinstance(response, dict): + return [] + events = response.get("sse_events") + if isinstance(events, list) and events: + return [event for event in events if isinstance(event, dict)] + events = response.get("ws_events") + if isinstance(events, list): + return [event for event in events if isinstance(event, dict)] + return [] + + +def _bedrock_events(record: dict[str, Any]) -> list[dict[str, Any]]: + """Decode AWS Bedrock EventStream binary body into structured events.""" + response = record.get("response") + if not isinstance(response, dict): + return [] + body = response.get("body") + return _decode_bedrock_eventstream_events(body) + + +def _event_payload(event: dict[str, Any]) -> dict[str, Any]: + data = event.get("data", event) + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + return {} + if isinstance(data, dict): + response = data.get("response") + if isinstance(response, dict): + return response + return data + return {} + + +def _request_user_text(body: Any) -> str: + if isinstance(body, str): + return body + if not isinstance(body, dict): + return "" + + messages = body.get("messages") + if isinstance(messages, list): + for message in messages: + role = str(message.get("role") or "").lower() if isinstance(message, dict) else "" + if isinstance(message, dict) and role == "user": + prompt = _clean_user_content_text(message.get("content")) + if prompt: + return prompt + + text = _input_user_text(body.get("input")) + if text: + return text + + request = body.get("request") + if isinstance(request, dict): + contents = request.get("contents") + else: + contents = body.get("contents") + if isinstance(contents, list): + for content in contents: + if not isinstance(content, dict): + continue + role = str(content.get("role") or "user").lower() + if role != "user": + continue + prompt = _clean_user_content_text(content.get("parts")) + if prompt: + return prompt + + prompt = body.get("prompt") + return _clean_user_prompt_text(prompt) if isinstance(prompt, str) else "" + + +def _input_user_text(value: Any) -> str: + if isinstance(value, str): + return _clean_user_prompt_text(value) + if isinstance(value, dict): + role = str(value.get("role") or "").lower() + if role == "user": + return _clean_user_content_text(value.get("content") or value.get("text")) + return "" + if not isinstance(value, list): + return "" + + for item in value: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").lower() + if role == "user": + prompt = _clean_user_content_text(item.get("content") or item.get("text")) + if prompt: + return prompt + + for item in value: + if not isinstance(item, dict): + continue + role = str(item.get("role") or "").lower() + item_type = str(item.get("type") or "").lower() + if role or item_type in ("function_call_output", "tool_result", "reasoning"): + continue + if item_type in ("message", "input_text") or "content" in item: + prompt = _clean_user_content_text(item.get("content") or item.get("text")) + if prompt: + return prompt + return "" + + +def _clean_user_content_text(value: Any) -> str: + if isinstance(value, list): + parts = [] + for item in value: + if _is_auxiliary_user_content_block(item): + continue + text = _content_text(item) + prompt = _clean_user_prompt_text(text) + if prompt: + if re.search(r"\s*.*?\s*", text, flags=re.DOTALL | re.IGNORECASE): + return prompt + parts.append(prompt) + return "\n".join(parts).strip() + if _is_auxiliary_user_content_block(value): + return "" + return _clean_user_prompt_text(_content_text(value)) + + +def _is_auxiliary_user_content_block(value: Any) -> bool: + if not isinstance(value, dict): + return False + block_type = str(value.get("type") or "").lower() + return block_type in {"function_call_output", "tool_result"} + + +def _clean_user_prompt_text(text: str) -> str: + text = text.strip() + if not text: + return "" + if len(text) >= 2 and text[0] == text[-1] == '"': + try: + decoded = json.loads(text) + except json.JSONDecodeError: + decoded = None + if isinstance(decoded, str) and decoded: + text = decoded.strip() + + request = re.search(r"\s*(.*?)\s*", text, flags=re.DOTALL | re.IGNORECASE) + if request: + return request.group(1).strip() + + session = re.fullmatch(r"\s*(.*?)\s*", text, flags=re.DOTALL | re.IGNORECASE) + if session: + return session.group(1).strip() + + first_tag = re.match(r"^<([A-Za-z_-]+)>", text) + if first_tag and first_tag.group(1).lower() in { + "artifacts", + "additional_metadata", + "environment_context", + "session_context", + "skills", + "slash_commands", + "subagents", + "system-reminder", + "user_information", + }: + return "" + + if text.startswith("# AGENTS.md instructions") or text.startswith(""): + return "" + + return text + + +def _response_text(body: Any) -> str: + if isinstance(body, str): + return body + if not isinstance(body, dict): + return "" + + text = _content_text(body.get("content")) + if text: + return text + + candidates = body.get("candidates") + if isinstance(candidates, list): + texts = [] + for candidate in candidates: + if not isinstance(candidate, dict): + continue + content = candidate.get("content") + if isinstance(content, dict): + texts.append(_parts_text(content.get("parts"))) + text = "\n".join(part for part in texts if part).strip() + if text: + return text + + choices = body.get("choices") + if isinstance(choices, list): + texts = [] + for choice in choices: + if not isinstance(choice, dict): + continue + message = choice.get("message") or choice.get("delta") + if isinstance(message, dict): + texts.append(_content_text(message.get("content"))) + text = "\n".join(part for part in texts if part).strip() + if text: + return text + + output = body.get("output") + if isinstance(output, dict): + message = output.get("message") + if isinstance(message, dict): + text = _content_text(message.get("content")) + if text: + return text + + text = _content_text(output) + if text: + return text + + value = body.get("response") + return _content_text(value) + + +def _content_text(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, list): + parts = [] + for item in value: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + for key in ("text", "content", "output_text", "input_text"): + text = item.get(key) + if isinstance(text, str): + parts.append(text) + break + if isinstance(text, list): + parts.append(_content_text(text)) + break + else: + if item.get("type") in ("message", "assistant"): + parts.append(_content_text(item.get("content"))) + return "\n".join(part for part in parts if part).strip() + if isinstance(value, dict): + for key in ("text", "content", "output_text", "input_text"): + text = value.get(key) + if isinstance(text, (str, list, dict)): + return _content_text(text) + return "" + + +def _parts_text(value: Any) -> str: + if not isinstance(value, list): + return "" + parts = [] + for item in value: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts).strip() + + +def _preview(value: str, limit: int) -> str: + text = re.sub(r"\s+", " ", value).strip() + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "..." diff --git a/claude_tap/export.py b/claude_tap/export.py new file mode 100644 index 0000000..bb25083 --- /dev/null +++ b/claude_tap/export.py @@ -0,0 +1,367 @@ +"""Export trace JSONL files to Markdown, JSON, or HTML format.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from claude_tap.compact_trace import build_compact_trace_bundle, dump_compact_trace, is_compact_trace_bundle +from claude_tap.prompt_snapshot import render_prompt_markdown, snapshot_from_records +from claude_tap.usage import normalize_usage +from claude_tap.viewer import _generate_html_viewer_from_compact_bundle, _normalize_record_for_viewer + + +def _as_dict(value: object) -> dict: + return value if isinstance(value, dict) else {} + + +def _normalize_record_for_export(record: object) -> dict | None: + if not isinstance(record, dict): + return None + try: + normalized = json.loads(_normalize_record_for_viewer(json.dumps(record, ensure_ascii=False))) + except (TypeError, json.JSONDecodeError): + return record + return normalized if isinstance(normalized, dict) else record + + +def _normalize_records_for_export(records: list[dict]) -> list[dict]: + normalized_records: list[dict] = [] + for record in records: + normalized = _normalize_record_for_export(record) + if normalized is not None: + normalized_records.append(normalized) + return normalized_records + + +def _request_body(record: dict) -> dict: + return _as_dict(_as_dict(record.get("request")).get("body")) + + +def _response_body(record: dict) -> dict: + return _as_dict(_as_dict(record.get("response")).get("body")) + + +def _usage_from(record: dict) -> dict: + return normalize_usage(_response_body(record).get("usage")) + + +def _turn_sort_key(record: dict) -> int: + turn = record.get("turn") + return turn if isinstance(turn, int) else 0 + + +def _load_records_from_text(text: str) -> tuple[list[dict], dict | None]: + try: + parsed = json.loads(text) + except json.JSONDecodeError: + parsed = None + if is_compact_trace_bundle(parsed): + from claude_tap.compact_trace import materialize_compact_trace_bundle + + return materialize_compact_trace_bundle(parsed), parsed + + records: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(record, dict): + records.append(record) + return records, None + + +def export_main(argv: list[str] | None = None) -> int: + """Entry point for the export subcommand.""" + parser = argparse.ArgumentParser( + prog="claude-tap export", + description="Export a trace file or SQLite session to Markdown, JSON, HTML, or compact trace.", + ) + parser.add_argument("source", type=str, nargs="?", help="Path to a .jsonl trace file or a SQLite session id") + parser.add_argument( + "--session-id", + dest="session_id", + help="Export a stored SQLite session by id", + ) + parser.add_argument( + "-o", + "--output", + type=Path, + help="Output file path (default: stdout; for HTML, trace_file with .html suffix)", + ) + parser.add_argument( + "--format", + choices=["markdown", "json", "html", "compact", "prompt-md"], + default=None, + help="Output format (default: compact, except .html/.htm and prompt snapshot output suffixes)", + ) + + args = parser.parse_args(argv) + + records: list[dict] = [] + html_source_path: Path | None = None + source_session_id = args.session_id + store = None + compact_bundle: dict | None = None + + if source_session_id is None and args.source: + trace_file = Path(args.source) + if not trace_file.exists(): + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + if store.load_session_row(args.source) is not None: + source_session_id = args.source + + if source_session_id: + from claude_tap.trace_store import get_trace_store + + if store is None: + store = get_trace_store() + if store.load_session_row(source_session_id) is None: + print(f"Error: session not found: {source_session_id}", file=sys.stderr) + return 1 + for record in store.load_records(source_session_id): + normalized = _normalize_record_for_export(record) + if normalized is not None: + records.append(normalized) + html_source_path = Path(f"session-{source_session_id[:8]}.jsonl") + elif args.source: + trace_file = Path(args.source) + if not trace_file.exists(): + print(f"Error: trace file not found: {trace_file}", file=sys.stderr) + return 1 + records, compact_bundle = _load_records_from_text(trace_file.read_text(encoding="utf-8")) + html_source_path = trace_file + else: + parser.error("provide a .jsonl trace file path or --session-id") + + # Determine format + fmt = args.format + if fmt is None: + if args.output: + suffix = args.output.suffix.lower() + if suffix in {".html", ".htm"}: + fmt = "html" + elif _is_prompt_markdown_output(args.output): + fmt = "prompt-md" + else: + fmt = "compact" + else: + fmt = "compact" + + if not records: + print("Error: no valid records found in trace file", file=sys.stderr) + return 1 + + if fmt != "compact": + records.sort(key=_turn_sort_key) + + if fmt == "html": + if html_source_path is None: + print("Error: HTML export requires a JSONL source path", file=sys.stderr) + return 1 + html_path = args.output or html_source_path.with_suffix(".html") + if compact_bundle is not None: + _generate_html_viewer_from_compact_bundle( + compact_bundle, + html_path, + display_trace_path=html_source_path.absolute(), + display_html_path=html_path.absolute(), + ) + elif source_session_id: + _generate_html_viewer_from_compact_bundle( + build_compact_trace_bundle(records), + html_path, + display_trace_path=f"session:{source_session_id}", + display_html_path=html_path.absolute(), + ) + else: + _generate_html_viewer_from_compact_bundle( + build_compact_trace_bundle(_normalize_records_for_export(records)), + html_path, + display_trace_path=html_source_path.absolute(), + display_html_path=html_path.absolute(), + ) + if not html_path.exists(): + print("Error: failed to generate HTML viewer", file=sys.stderr) + return 1 + print(f"Exported {len(records)} turns to {html_path}") + return 0 + + if fmt == "compact": + if source_session_id and store is not None: + output = store.export_compact(source_session_id) + else: + output = dump_compact_trace(records) + elif fmt == "prompt-md": + try: + output = render_prompt_markdown(snapshot_from_records(records)) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + elif fmt == "json": + output = _export_json(_normalize_records_for_export(records)) + else: + output = _export_markdown(_normalize_records_for_export(records)) + + if args.output: + args.output.write_text(output, encoding="utf-8") + print(f"Exported {len(records)} turns to {args.output}") + else: + print(output) + + return 0 + + +def _is_prompt_markdown_output(path: Path) -> bool: + name = path.name.lower() + return name.endswith((".prompt.md", ".prompt.markdown", ".system.md", ".system.markdown")) + + +def _export_markdown(records: list[dict]) -> str: + """Export records as Markdown.""" + lines: list[str] = [] + lines.append("# Claude Trace Export\n") + + # Token summary + total_input = 0 + total_output = 0 + total_cache_read = 0 + total_cache_create = 0 + models: set[str] = set() + + for r in records: + usage = _usage_from(r) + total_input += usage.get("input_tokens", 0) + total_output += usage.get("output_tokens", 0) + total_cache_read += usage.get("cache_read_input_tokens", 0) + total_cache_create += usage.get("cache_creation_input_tokens", 0) + model = _request_body(r).get("model", "") + if model: + models.add(model) + + lines.append("## Summary\n") + lines.append(f"- **Turns**: {len(records)}") + lines.append(f"- **Models**: {', '.join(sorted(models)) if models else 'unknown'}") + lines.append(f"- **Input tokens**: {total_input:,}") + lines.append(f"- **Output tokens**: {total_output:,}") + if total_cache_read: + lines.append(f"- **Cache read tokens**: {total_cache_read:,}") + if total_cache_create: + lines.append(f"- **Cache create tokens**: {total_cache_create:,}") + lines.append("") + + # Each turn + for r in records: + turn = r.get("turn", "?") + req_body = _request_body(r) + resp_body = _response_body(r) + model = req_body.get("model", "unknown") + duration = r.get("duration_ms", 0) + + lines.append(f"---\n\n## Turn {turn}\n") + lines.append(f"**Model**: `{model}` | **Duration**: {duration}ms\n") + + # User messages (last message from request) + messages = req_body.get("messages", []) + if isinstance(messages, list) and messages: + last_msg = messages[-1] + if isinstance(last_msg, dict): + role = last_msg.get("role", "unknown") + lines.append(f"### {role.title()}\n") + content = last_msg.get("content", "") + if isinstance(content, str): + lines.append(content + "\n") + elif isinstance(content, list): + for block in content: + if isinstance(block, dict): + if block.get("type") == "text": + lines.append(block.get("text", "") + "\n") + elif block.get("type") == "tool_result": + lines.append(f"**Tool Result** (`{block.get('tool_use_id', '')}`)\n") + rc = block.get("content", "") + if isinstance(rc, str): + lines.append(f"```\n{rc[:2000]}\n```\n") + elif isinstance(rc, list): + for sub in rc: + if isinstance(sub, dict) and sub.get("type") == "text": + lines.append(f"```\n{sub.get('text', '')[:2000]}\n```\n") + + # Response + resp_content = resp_body.get("content", []) + if isinstance(resp_content, list) and resp_content: + lines.append("### Assistant\n") + for block in resp_content: + if isinstance(block, dict): + if block.get("type") == "text": + text = block.get("text", "") + if text.strip(): + lines.append(text + "\n") + elif block.get("type") == "tool_use": + name = block.get("name", "unknown") + inp = block.get("input", {}) + lines.append(f"**Tool Use**: `{name}`\n") + lines.append(f"```json\n{json.dumps(inp, indent=2, ensure_ascii=False)[:3000]}\n```\n") + elif block.get("type") == "thinking": + thinking = block.get("thinking", "") + if thinking.strip(): + lines.append(f"
\nThinking\n\n{thinking[:5000]}\n\n
\n") + + # Token usage + usage = normalize_usage(resp_body.get("usage")) + if usage: + parts = [] + if usage.get("input_tokens"): + parts.append(f"in={usage['input_tokens']:,}") + if usage.get("output_tokens"): + parts.append(f"out={usage['output_tokens']:,}") + if usage.get("cache_read_input_tokens"): + parts.append(f"cache_read={usage['cache_read_input_tokens']:,}") + if usage.get("cache_creation_input_tokens"): + parts.append(f"cache_create={usage['cache_creation_input_tokens']:,}") + if parts: + lines.append(f"*Tokens: {' / '.join(parts)}*\n") + + return "\n".join(lines) + + +def _export_json(records: list[dict]) -> str: + """Export records as cleaned-up JSON.""" + cleaned = [] + for r in records: + req_body = _request_body(r) + resp_body = _response_body(r) + + entry = { + "turn": r.get("turn"), + "timestamp": r.get("timestamp"), + "duration_ms": r.get("duration_ms"), + "model": req_body.get("model"), + "messages": req_body.get("messages") if isinstance(req_body.get("messages"), list) else [], + "response": { + "content": resp_body.get("content") if isinstance(resp_body.get("content"), list) else [], + "usage": _as_dict(resp_body.get("usage")), + "stop_reason": resp_body.get("stop_reason"), + }, + } + + # Include system prompt if present + system = req_body.get("system") + if system: + entry["system"] = system + + # Include tools if present + tools = req_body.get("tools") + if tools: + entry["tools"] = tools + + cleaned.append(entry) + + return json.dumps(cleaned, indent=2, ensure_ascii=False) diff --git a/claude_tap/forward_proxy.py b/claude_tap/forward_proxy.py new file mode 100644 index 0000000..e3d9d0c --- /dev/null +++ b/claude_tap/forward_proxy.py @@ -0,0 +1,1194 @@ +"""Forward proxy server with CONNECT/TLS termination. + +Implements an HTTP forward proxy that handles CONNECT tunneling with +man-in-the-middle TLS termination. This allows claude-tap to intercept +HTTPS traffic while Claude Code uses the real api.anthropic.com endpoint +(preserving OAuth authentication). + +Flow: + 1. Client sends CONNECT api.anthropic.com:443 + 2. Proxy responds 200 Connection Established + 3. Client starts TLS handshake; proxy presents a cert signed by our CA + 4. Client sends plaintext HTTP request inside the TLS tunnel + 5. Proxy reads the request, records the trace, forwards to real upstream via HTTPS + 6. Proxy returns the upstream response through the tunnel +""" + +from __future__ import annotations + +import asyncio +import base64 +import gzip +import hashlib +import json +import logging +import time +import uuid +import zlib +from collections.abc import Mapping +from urllib.parse import urlparse + +import aiohttp +from aiohttp import WSMessage, WSMsgType +from aiohttp._websocket.reader import WebSocketDataQueue, WebSocketReader +from aiohttp.http_websocket import WS_KEY, WebSocketWriter + +from claude_tap.bedrock import attach_bedrock_errors, is_bedrock_eventstream_path +from claude_tap.certs import CertificateAuthority +from claude_tap.proxy import ( + HOP_BY_HOP, + _build_record, + _parse_request_body_for_trace, + capture_only_content_type, + capture_only_response, + capture_only_stream_bytes, + filter_headers, + is_capture_only_request, + is_capture_only_streaming_request, +) +from claude_tap.sse import SSEReassembler +from claude_tap.trace import TraceWriter +from claude_tap.upstream import build_upstream_url, format_upstream_error +from claude_tap.usage import normalize_usage +from claude_tap.viewer import _decode_bedrock_eventstream_events +from claude_tap.ws_proxy import ( + _get_ws_proxy_settings, + is_prompt_bearing_ws_request_body, + reconstruct_ws_request_body, + reconstruct_ws_response_body, +) + +log = logging.getLogger("claude-tap") + +DEFAULT_TRACE_IGNORED_HOSTS = frozenset( + { + "registry.npmjs.org", + "registry.yarnpkg.com", + "registry.npmmirror.com", + "npm.pkg.github.com", + } +) +DEFAULT_TRACE_IGNORED_PATH_PREFIXES = ("/-/npm",) +DEFAULT_TRACE_IGNORED_ARCHIVE_SUFFIXES = (".tgz", ".tar.gz", ".zip") +DEFAULT_TRACE_IGNORED_BINARY_CONTENT_TYPES = frozenset( + { + "", + "application/gzip", + "application/octet-stream", + "application/x-gzip", + "application/x-tar", + "application/zip", + "binary/octet-stream", + } +) +DEFAULT_TRACE_IGNORED_PACKAGE_METADATA_CONTENT_TYPES = frozenset( + { + "application/json", + "application/vnd.npm.install-v1+json", + } +) +PACKAGE_MANAGER_USER_AGENT_MARKERS = ("npm/", "yarn/", "pnpm/", "bun/") + + +def _matches_path_prefix(path: str, prefixes: tuple[str, ...]) -> bool: + clean = path.split("?", 1)[0].rstrip("/") + return any( + clean == prefix or clean.startswith(prefix + "/") or clean.startswith(prefix + ":") for prefix in prefixes + ) + + +def _header_value(headers: Mapping[str, str], name: str) -> str: + if value := headers.get(name): + return value + lower_name = name.lower() + for key, value in headers.items(): + if key.lower() == lower_name: + return value + return "" + + +def _has_package_manager_user_agent(headers: Mapping[str, str] | None) -> bool: + if headers is None: + return False + user_agent = _header_value(headers, "User-Agent").lower() + return any(marker in user_agent for marker in PACKAGE_MANAGER_USER_AGENT_MARKERS) + + +def _should_skip_trace_record( + upstream_url: str, + path: str, + response_headers: Mapping[str, str], + request_headers: Mapping[str, str] | None = None, + method: str = "GET", +) -> bool: + """Return whether a non-model upstream response should be forwarded without persisting.""" + parsed = urlparse(upstream_url) + hostname = (parsed.hostname or "").lower() + if hostname in DEFAULT_TRACE_IGNORED_HOSTS: + return True + + clean_path = (path or parsed.path or "/").split("?", 1)[0].lower() + if _matches_path_prefix(clean_path, DEFAULT_TRACE_IGNORED_PATH_PREFIXES): + return True + + media_type = _header_value(response_headers, "Content-Type").split(";", 1)[0].strip().lower() + if clean_path.endswith(DEFAULT_TRACE_IGNORED_ARCHIVE_SUFFIXES): + return media_type in DEFAULT_TRACE_IGNORED_BINARY_CONTENT_TYPES + + if ( + method.upper() in {"GET", "HEAD"} + and _has_package_manager_user_agent(request_headers) + and media_type in DEFAULT_TRACE_IGNORED_PACKAGE_METADATA_CONTENT_TYPES + ): + return True + + return False + + +async def _read_chunked_body(reader: asyncio.StreamReader) -> bytes: + chunks: list[bytes] = [] + while True: + size_line = await asyncio.wait_for(reader.readline(), timeout=60) + if not size_line: + break + size_token = size_line.split(b";", 1)[0].strip() + try: + size = int(size_token, 16) + except ValueError: + break + if size == 0: + while True: + trailer_line = await asyncio.wait_for(reader.readline(), timeout=30) + if trailer_line in (b"\r\n", b"\n", b""): + break + break + chunks.append(await asyncio.wait_for(reader.readexactly(size), timeout=60)) + await asyncio.wait_for(reader.readexactly(2), timeout=30) + return b"".join(chunks) + + +async def _read_http_body(reader: asyncio.StreamReader, headers: dict[str, str]) -> bytes: + content_length = headers.get("Content-Length") or headers.get("content-length") + if content_length: + try: + length = int(content_length) + return await asyncio.wait_for(reader.readexactly(length), timeout=60) + except (ValueError, asyncio.IncompleteReadError, asyncio.TimeoutError): + return b"" + transfer_encoding = headers.get("Transfer-Encoding") or headers.get("transfer-encoding", "") + if "chunked" in transfer_encoding.lower(): + return await _read_chunked_body(reader) + return b"" + + +class _RawWSProtocol: + """Minimal protocol shim for aiohttp's raw WebSocket helpers.""" + + def __init__(self) -> None: + self._reading_paused = False + self._paused = False + + def pause_reading(self) -> None: + self._reading_paused = True + + def resume_reading(self) -> None: + self._reading_paused = False + + async def _drain_helper(self) -> None: + return + + +def _is_websocket_upgrade(headers: dict[str, str]) -> bool: + upgrade = headers.get("Upgrade", headers.get("upgrade", "")).lower() + if upgrade != "websocket": + return False + connection = headers.get("Connection", headers.get("connection", "")).lower() + return "upgrade" in connection + + +def _build_ws_accept(sec_key: str) -> str: + digest = hashlib.sha1(sec_key.encode("utf-8") + WS_KEY).digest() + return base64.b64encode(digest).decode("ascii") + + +class ForwardProxyServer: + """Async TCP server that acts as an HTTP forward proxy with CONNECT support.""" + + def __init__( + self, + host: str, + port: int, + ca: CertificateAuthority, + writer: TraceWriter, + session: aiohttp.ClientSession, + local_reverse_target: str | None = None, + local_reverse_allowed_path_prefixes: tuple[str, ...] = (), + store_stream_events: bool = False, + capture_only: bool = False, + ) -> None: + self.host = host + self.port = port + self._ca = ca + self._writer = writer + self._session = session + self._local_reverse_target = local_reverse_target + self._local_reverse_allowed_path_prefixes = local_reverse_allowed_path_prefixes + self._store_stream_events = store_stream_events + self._capture_only = capture_only + self._server: asyncio.Server | None = None + self._client_tasks: set[asyncio.Task] = set() + self._client_writers: set[asyncio.StreamWriter] = set() + self._turn_counter = 0 + self.actual_port: int = port + + async def start(self) -> int: + """Start the forward proxy server. Returns the actual port.""" + self._server = await asyncio.start_server(self._handle_client, self.host, self.port) + sock = self._server.sockets[0] + self.actual_port = sock.getsockname()[1] + return self.actual_port + + async def stop(self) -> None: + if self._server: + self._server.close() + await self._server.wait_closed() + for writer in list(self._client_writers): + try: + writer.close() + except Exception: + pass + tasks = [task for task in self._client_tasks if not task.done()] + for task in tasks: + task.cancel() + if tasks: + try: + await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=5) + except asyncio.TimeoutError: + log.warning("Timed out waiting for forward proxy client connections to stop") + + async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Handle an incoming client connection.""" + current_task = asyncio.current_task() + if current_task is not None: + self._client_tasks.add(current_task) + self._client_writers.add(writer) + try: + # Read the initial HTTP request line + request_line = await asyncio.wait_for(reader.readline(), timeout=30) + if not request_line: + writer.close() + return + + request_str = request_line.decode("utf-8", errors="replace").strip() + parts = request_str.split(" ") + if len(parts) < 3: + writer.close() + return + + method = parts[0].upper() + + if method == "CONNECT": + await self._handle_connect(parts[1], reader, writer) + else: + # Non-CONNECT request (plain HTTP proxy) — not expected for HTTPS + # but handle gracefully + await self._handle_plain_proxy(method, parts[1], parts[2], reader, writer) + except (ConnectionError, asyncio.TimeoutError, asyncio.CancelledError): + pass + except Exception: + log.exception("Error handling forward proxy connection") + finally: + if current_task is not None: + self._client_tasks.discard(current_task) + self._client_writers.discard(writer) + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + async def _handle_connect( + self, + authority: str, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Handle CONNECT method: TLS termination + request interception.""" + # Parse host:port + if ":" in authority: + hostname, port_str = authority.rsplit(":", 1) + try: + port = int(port_str) + except ValueError: + port = 443 + else: + hostname = authority + port = 443 + + # Read and discard remaining headers until blank line + while True: + line = await asyncio.wait_for(reader.readline(), timeout=10) + if line in (b"\r\n", b"\n", b""): + break + + # Send 200 Connection Established + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + + # TLS termination via a local loopback bounce: + # 1. Start a temporary TLS server on localhost:0 + # 2. Redirect the client to connect there (via raw socket relay) + # 3. Accept the TLS connection on the temp server + # This avoids loop.start_tls() which is unreliable on macOS Python 3.11. + ssl_ctx = self._ca.make_ssl_context(hostname) + + tls_reader_holder: list[asyncio.StreamReader] = [] + tls_writer_holder: list[asyncio.StreamWriter] = [] + connected = asyncio.Event() + + async def _accept_tls(r: asyncio.StreamReader, w: asyncio.StreamWriter) -> None: + tls_reader_holder.append(r) + tls_writer_holder.append(w) + connected.set() + + tls_server = await asyncio.start_server(_accept_tls, "127.0.0.1", 0, ssl=ssl_ctx) + tls_port = tls_server.sockets[0].getsockname()[1] + + # Relay bytes between the original client transport and the TLS server + raw_sock = writer.transport.get_extra_info("socket") + if raw_sock is None: + tls_server.close() + log.warning(f"Cannot get raw socket for {hostname}") + return + + try: + relay_r, relay_w = await asyncio.open_connection("127.0.0.1", tls_port) + except (ConnectionError, OSError) as e: + tls_server.close() + log.warning(f"Cannot connect to TLS relay for {hostname}: {e}") + return + + async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None: + try: + while True: + data = await src.read(65536) + if not data: + break + dst.write(data) + await dst.drain() + except (ConnectionError, asyncio.CancelledError): + pass + finally: + try: + dst.close() + except Exception: + pass + + # Start relaying between original client and TLS relay in background + relay_task = asyncio.create_task(_pipe(relay_r, writer)) + + # Also relay from original client to TLS relay + client_to_relay_task = asyncio.create_task(_pipe(reader, relay_w)) + + # Wait for TLS server to accept + try: + await asyncio.wait_for(connected.wait(), timeout=15) + except asyncio.TimeoutError: + log.warning(f"TLS handshake timed out for {hostname}") + relay_task.cancel() + client_to_relay_task.cancel() + tls_server.close() + return + + tls_server.close() + tls_reader = tls_reader_holder[0] + tls_writer = tls_writer_holder[0] + + # Now read HTTP requests from the TLS tunnel + try: + await self._handle_tunneled_requests(hostname, port, tls_reader, tls_writer) + finally: + try: + tls_writer.close() + await tls_writer.wait_closed() + except Exception: + pass + client_to_relay_task.cancel() + try: + await asyncio.wait_for(relay_task, timeout=1) + except (asyncio.CancelledError, asyncio.TimeoutError): # pragma: no cover - defensive shutdown timeout + relay_task.cancel() + await asyncio.gather(relay_task, client_to_relay_task, return_exceptions=True) + + async def _handle_tunneled_requests( + self, + hostname: str, + port: int, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Read HTTP requests from inside the TLS tunnel and proxy them.""" + while True: + # Read request line + try: + request_line = await asyncio.wait_for(reader.readline(), timeout=600) + except (asyncio.TimeoutError, ConnectionError): + break + if not request_line: + break + + request_str = request_line.decode("utf-8", errors="replace").strip() + if not request_str: + break + + parts = request_str.split(" ", 2) + if len(parts) < 3: + break + + method, path, _http_version = parts + + # Read headers + headers: dict[str, str] = {} + while True: + header_line = await asyncio.wait_for(reader.readline(), timeout=30) + if header_line in (b"\r\n", b"\n", b""): + break + decoded = header_line.decode("utf-8", errors="replace").strip() + if ":" in decoded: + key, value = decoded.split(":", 1) + headers[key.strip()] = value.strip() + + body = await _read_http_body(reader, headers) + + if _is_websocket_upgrade(headers): + await self._forward_websocket( + hostname=hostname, + port=port, + path=path, + headers=headers, + reader=reader, + writer=writer, + ) + break + + # Forward the request to the real upstream + upstream_url = f"https://{hostname}:{port}{path}" + await self._forward_and_record(method, path, headers, body, upstream_url, writer) + + async def _forward_and_record( + self, + method: str, + path: str, + headers: dict[str, str], + body: bytes, + upstream_url: str, + client_writer: asyncio.StreamWriter, + ) -> None: + """Forward request to upstream, record trace, send response back.""" + self._turn_counter += 1 + turn = self._turn_counter + req_id = f"req_{uuid.uuid4().hex[:12]}" + t0 = time.monotonic() + log_prefix = f"[Turn {turn}]" + + req_body = _parse_request_body_for_trace(body) + + is_streaming = is_capture_only_streaming_request(path, req_body) + + model = req_body.get("model", "") if isinstance(req_body, dict) else "" + log.info(f"{log_prefix} -> {method} {path} (model={model}, stream={is_streaming})") + + if self._capture_only and is_capture_only_request(path, req_body): + resp_body = capture_only_response(path, req_body) + response_headers = {"Content-Type": capture_only_content_type(path, is_streaming)} + duration_ms = int((time.monotonic() - t0) * 1000) + record = _build_record( + req_id, + turn, + duration_ms, + method, + path, + headers, + req_body, + 200, + response_headers, + resp_body, + ) + await self._writer.write(record) + body_bytes = ( + capture_only_stream_bytes(path, req_body) + if is_streaming + else json.dumps(resp_body, separators=(",", ":")).encode("utf-8") + ) + client_writer.write(b"HTTP/1.1 200 OK\r\n") + client_writer.write(f"Content-Type: {response_headers['Content-Type']}\r\n".encode("ascii")) + client_writer.write(f"Content-Length: {len(body_bytes)}\r\n\r\n".encode("ascii")) + client_writer.write(body_bytes) + await client_writer.drain() + log.info(f"{log_prefix} <- 200 capture-only ({duration_ms}ms, upstream skipped)") + return + + # Prepare forwarding headers + fwd_headers = filter_headers(headers) + fwd_headers.pop("Host", None) + fwd_headers.pop("host", None) + # Request identity encoding from upstream to avoid client-side zstd decode issues + # and to simplify SSE/text reconstruction. + fwd_headers["Accept-Encoding"] = "identity" + + try: + upstream_resp = await self._session.request( + method=method, + url=upstream_url, + headers=fwd_headers, + data=body, + timeout=aiohttp.ClientTimeout(total=600, sock_read=300), + ) + except Exception as exc: + duration_ms = int((time.monotonic() - t0) * 1000) + error_text = format_upstream_error(exc, target_url=upstream_url, upstream_url=upstream_url) + log.error(f"{log_prefix} upstream error: {error_text}") + error_body = error_text.encode("utf-8", errors="replace") + record = _build_record( + req_id, + turn, + duration_ms, + method, + path, + headers, + req_body, + 502, + {"Content-Type": "text/plain"}, + {"error": error_text}, + ) + await self._writer.write(record) + response_line = b"HTTP/1.1 502 Bad Gateway\r\n" + resp_headers = f"Content-Length: {len(error_body)}\r\nContent-Type: text/plain\r\n\r\n" + client_writer.write(response_line + resp_headers.encode() + error_body) + await client_writer.drain() + return + + if is_streaming and upstream_resp.status == 200: + await self._handle_streaming( + upstream_resp, + client_writer, + req_id, + turn, + t0, + method, + path, + headers, + req_body, + log_prefix, + ) + else: + await self._handle_non_streaming( + upstream_resp, + client_writer, + req_id, + turn, + t0, + method, + path, + headers, + req_body, + log_prefix, + upstream_url, + ) + + async def _handle_streaming( + self, + upstream_resp: aiohttp.ClientResponse, + client_writer: asyncio.StreamWriter, + req_id: str, + turn: int, + t0: float, + method: str, + path: str, + req_headers: dict[str, str], + req_body: dict | None, + log_prefix: str, + ) -> None: + """Handle a streaming response: forward chunks while recording SSE.""" + # Send response status line + status_line = f"HTTP/1.1 {upstream_resp.status} {upstream_resp.reason}\r\n" + client_writer.write(status_line.encode()) + + # Send response headers (filter hop-by-hop, use chunked transfer) + for key, value in upstream_resp.headers.items(): + if key.lower() not in HOP_BY_HOP: + client_writer.write(f"{key}: {value}\r\n".encode()) + client_writer.write(b"Transfer-Encoding: chunked\r\n") + client_writer.write(b"\r\n") + await client_writer.drain() + + is_bedrock_stream = is_bedrock_eventstream_path(path) + reassembler = SSEReassembler(store_events=self._store_stream_events) + raw_chunks: list[bytes] = [] + + try: + async for chunk in upstream_resp.content.iter_any(): + # Send as HTTP chunked encoding + chunk_header = f"{len(chunk):x}\r\n".encode() + client_writer.write(chunk_header + chunk + b"\r\n") + await client_writer.drain() + if is_bedrock_stream: + raw_chunks.append(chunk) + else: + reassembler.feed_bytes(chunk) + except (ConnectionError, asyncio.CancelledError): + pass + + # Send final chunk + try: + client_writer.write(b"0\r\n\r\n") + await client_writer.drain() + except (ConnectionError, Exception): + pass + + duration_ms = int((time.monotonic() - t0) * 1000) + + if is_bedrock_stream: + raw_body = b"".join(raw_chunks).decode("utf-8", errors="replace") + bedrock_events = _decode_bedrock_eventstream_events(raw_body) + for event in bedrock_events: + reassembler.add_event(event["event"], event["data"]) + reconstructed = reassembler.reconstruct() + if not reconstructed: + reconstructed = raw_body + reconstructed = attach_bedrock_errors(reconstructed, bedrock_events) + else: + reconstructed = reassembler.reconstruct() + + usage = normalize_usage(reconstructed.get("usage", {}) if isinstance(reconstructed, dict) else {}) + in_tok = usage.get("input_tokens", 0) + out_tok = usage.get("output_tokens", 0) + cache_read = usage.get("cache_read_input_tokens", 0) + cache_create = usage.get("cache_creation_input_tokens", 0) + log.info( + f"{log_prefix} <- 200 stream done ({duration_ms}ms, in={in_tok} out={out_tok}" + f" cache_read={cache_read} cache_create={cache_create})" + ) + + record = _build_record( + req_id, + turn, + duration_ms, + method, + path, + req_headers, + req_body, + upstream_resp.status, + dict(upstream_resp.headers), + reconstructed, + sse_events=reassembler.events, + ) + await self._writer.write(record) + + async def _handle_non_streaming( + self, + upstream_resp: aiohttp.ClientResponse, + client_writer: asyncio.StreamWriter, + req_id: str, + turn: int, + t0: float, + method: str, + path: str, + req_headers: dict[str, str], + req_body: dict | None, + log_prefix: str, + upstream_url: str, + ) -> None: + """Handle a non-streaming response.""" + if _should_skip_trace_record(upstream_url, path, upstream_resp.headers, req_headers, method): + total_bytes = await self._relay_unrecorded_response(upstream_resp, client_writer) + duration_ms = int((time.monotonic() - t0) * 1000) + log.info(f"{log_prefix} <- {upstream_resp.status} ({duration_ms}ms, {total_bytes} bytes, trace skipped)") + return + + resp_bytes = await upstream_resp.read() + duration_ms = int((time.monotonic() - t0) * 1000) + + # Decompress for JSON parsing + content_encoding = upstream_resp.headers.get("Content-Encoding", "").lower() + decode_bytes = resp_bytes + if resp_bytes and content_encoding in ("gzip", "deflate"): + try: + if content_encoding == "gzip": + decode_bytes = gzip.decompress(resp_bytes) + else: + decode_bytes = zlib.decompress(resp_bytes) + except Exception: + pass + + try: + resp_body = json.loads(decode_bytes) if decode_bytes else None + except (json.JSONDecodeError, ValueError): + resp_body = decode_bytes.decode("utf-8", errors="replace") if decode_bytes else None + + log.info(f"{log_prefix} <- {upstream_resp.status} ({duration_ms}ms, {len(resp_bytes)} bytes)") + + record = _build_record( + req_id, + turn, + duration_ms, + method, + path, + req_headers, + req_body, + upstream_resp.status, + dict(upstream_resp.headers), + resp_body, + ) + await self._writer.write(record) + + await self._send_buffered_response(upstream_resp, client_writer, resp_bytes) + + async def _send_buffered_response( + self, + upstream_resp: aiohttp.ClientResponse, + client_writer: asyncio.StreamWriter, + resp_bytes: bytes, + ) -> None: + status_line = f"HTTP/1.1 {upstream_resp.status} {upstream_resp.reason}\r\n" + client_writer.write(status_line.encode()) + skip_headers = HOP_BY_HOP | {"content-length"} # We set Content-Length ourselves + for key, value in upstream_resp.headers.items(): + if key.lower() not in skip_headers: + client_writer.write(f"{key}: {value}\r\n".encode()) + client_writer.write(f"Content-Length: {len(resp_bytes)}\r\n".encode()) + client_writer.write(b"\r\n") + client_writer.write(resp_bytes) + await client_writer.drain() + + async def _relay_unrecorded_response( + self, + upstream_resp: aiohttp.ClientResponse, + client_writer: asyncio.StreamWriter, + ) -> int: + total_bytes = 0 + try: + status_line = f"HTTP/1.1 {upstream_resp.status} {upstream_resp.reason}\r\n" + client_writer.write(status_line.encode()) + skip_headers = HOP_BY_HOP | {"content-length"} + content_length = _header_value(upstream_resp.headers, "Content-Length") + for key, value in upstream_resp.headers.items(): + if key.lower() not in skip_headers: + client_writer.write(f"{key}: {value}\r\n".encode()) + if content_length: + client_writer.write(f"Content-Length: {content_length}\r\n".encode()) + chunked = False + else: + client_writer.write(b"Transfer-Encoding: chunked\r\n") + chunked = True + client_writer.write(b"\r\n") + await client_writer.drain() + + async for chunk in upstream_resp.content.iter_chunked(65536): + total_bytes += len(chunk) + if chunked: + client_writer.write(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n") + else: + client_writer.write(chunk) + await client_writer.drain() + if chunked: + client_writer.write(b"0\r\n\r\n") + await client_writer.drain() + return total_bytes + finally: + upstream_resp.close() + + async def _forward_websocket( + self, + hostname: str, + port: int, + path: str, + headers: dict[str, str], + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Relay a WebSocket upgrade received inside the CONNECT tunnel.""" + self._turn_counter += 1 + turn = self._turn_counter + req_id = f"req_{uuid.uuid4().hex[:12]}" + t0 = time.monotonic() + log_prefix = f"[Turn {turn}]" + upstream_base_url = f"https://{hostname}:{port}" + upstream_ws_url = f"wss://{hostname}:{port}{path}" + + fwd_headers = filter_headers(headers) + fwd_headers.pop("Host", None) + fwd_headers.pop("host", None) + for h in list(fwd_headers.keys()): + if h.lower() in HOP_BY_HOP or h.lower().startswith("sec-websocket-"): + del fwd_headers[h] + + protocols: tuple[str, ...] = () + ws_protocol = headers.get("Sec-WebSocket-Protocol") or headers.get("sec-websocket-protocol") + if ws_protocol: + protocols = tuple(p.strip() for p in ws_protocol.split(",") if p.strip()) + + log.info(f"{log_prefix} -> WS UPGRADE {path} (upstream={upstream_ws_url})") + + if self._capture_only: + await self._capture_only_websocket( + req_id, + turn, + t0, + path, + headers, + protocols, + reader, + writer, + upstream_base_url, + log_prefix, + ) + return + + ws_connect_kwargs: dict[str, object] = {} + proxy_settings = _get_ws_proxy_settings(upstream_ws_url) if self._session.trust_env else None + if proxy_settings: + proxy_url, proxy_auth = proxy_settings + ws_connect_kwargs["proxy"] = proxy_url + if proxy_auth is not None: + ws_connect_kwargs["proxy_auth"] = proxy_auth + log.info(f"{log_prefix} -> WS upstream via proxy {proxy_url}") + + try: + upstream_ws = await self._session.ws_connect( + upstream_ws_url, + headers=fwd_headers, + protocols=protocols, + **ws_connect_kwargs, + ) + except Exception as exc: + duration_ms = int((time.monotonic() - t0) * 1000) + log.error(f"{log_prefix} upstream WS connect failed: {exc}") + error_body = str(exc).encode("utf-8", errors="replace") + writer.write( + b"HTTP/1.1 502 Bad Gateway\r\n" + + f"Content-Length: {len(error_body)}\r\n".encode() + + b"Content-Type: text/plain\r\n\r\n" + + error_body + ) + await writer.drain() + record = { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "request_id": req_id, + "turn": turn, + "duration_ms": duration_ms, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": path, + "headers": filter_headers(headers, redact_keys=True), + "body": None, + }, + "response": {"status": 502, "headers": {}, "body": None, "error": str(exc)}, + "upstream_base_url": upstream_base_url, + } + await self._writer.write(record) + return + + sec_key = headers.get("Sec-WebSocket-Key") or headers.get("sec-websocket-key") + if not sec_key: + await upstream_ws.close() + writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") + await writer.drain() + return + + response_lines = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + f"Sec-WebSocket-Accept: {_build_ws_accept(sec_key)}", + ] + if upstream_ws.protocol: + response_lines.append(f"Sec-WebSocket-Protocol: {upstream_ws.protocol}") + writer.write(("\r\n".join(response_lines) + "\r\n\r\n").encode("utf-8")) + await writer.drain() + + raw_protocol = _RawWSProtocol() + queue = WebSocketDataQueue(raw_protocol, 2**16, loop=asyncio.get_running_loop()) + ws_reader = WebSocketReader(queue, max_msg_size=0) + ws_writer = WebSocketWriter(raw_protocol, writer.transport, use_mask=False) + + client_messages: list[str] = [] + server_messages: list[str] = [] + + async def _pump_client_bytes() -> None: + try: + while True: + chunk = await reader.read(65536) + if not chunk: + break + ws_reader.feed_data(chunk) + except (ConnectionError, asyncio.CancelledError): + pass + finally: + ws_reader.feed_eof() + + async def _relay_client_to_upstream() -> None: + while True: + try: + msg: WSMessage = await queue.read() + except (asyncio.CancelledError, Exception): + break + + if msg.type == WSMsgType.TEXT: + client_messages.append(msg.data) + await upstream_ws.send_str(msg.data) + elif msg.type == WSMsgType.BINARY: + await upstream_ws.send_bytes(msg.data) + elif msg.type == WSMsgType.PING: + await upstream_ws.ping(msg.data) + elif msg.type == WSMsgType.PONG: + await upstream_ws.pong(msg.data) + elif msg.type == WSMsgType.CLOSE: + await upstream_ws.close(code=msg.data or 1000, message=msg.extra.encode("utf-8")) + break + else: + break + + async def _relay_upstream_to_client() -> None: + async for msg in upstream_ws: + if msg.type == WSMsgType.TEXT: + server_messages.append(msg.data) + await ws_writer.send_frame(msg.data.encode("utf-8"), WSMsgType.TEXT) + await writer.drain() + elif msg.type == WSMsgType.BINARY: + await ws_writer.send_frame(msg.data, WSMsgType.BINARY) + await writer.drain() + elif msg.type == WSMsgType.PING: + payload = msg.data if isinstance(msg.data, (bytes, bytearray)) else b"" + await ws_writer.send_frame(bytes(payload), WSMsgType.PING) + await writer.drain() + elif msg.type == WSMsgType.PONG: + payload = msg.data if isinstance(msg.data, (bytes, bytearray)) else b"" + await ws_writer.send_frame(bytes(payload), WSMsgType.PONG) + await writer.drain() + elif msg.type == WSMsgType.CLOSE: + await ws_writer.close(code=msg.data or 1000, message=msg.extra) + await writer.drain() + break + elif msg.type in (WSMsgType.CLOSING, WSMsgType.CLOSED, WSMsgType.ERROR): + break + + pump_task = asyncio.create_task(_pump_client_bytes()) + client_task = asyncio.create_task(_relay_client_to_upstream()) + upstream_task = asyncio.create_task(_relay_upstream_to_client()) + tasks = [pump_task, client_task, upstream_task] + + await asyncio.wait([client_task, upstream_task], return_when=asyncio.FIRST_COMPLETED) + for task in tasks: + if task.done(): + continue + task.cancel() + for task in tasks: + try: + await task + except asyncio.CancelledError: # pragma: no cover - cancellation timing is event-loop dependent + pass + except Exception as exc: # pragma: no cover - defensive relay cleanup logging + log.debug(f"{log_prefix} WS relay task ended with error: {exc}") + + if not upstream_ws.closed: + await upstream_ws.close() + try: + await ws_writer.close() + await writer.drain() + except Exception: + pass + + duration_ms = int((time.monotonic() - t0) * 1000) + request_record = { + "method": "WEBSOCKET", + "path": path, + "headers": filter_headers(headers, redact_keys=True), + "body": reconstruct_ws_request_body(client_messages), + } + response_events = [json.loads(msg) if msg.startswith("{") else {"raw": msg} for msg in server_messages] + response_record = { + "status": 101, + "headers": {}, + "body": reconstruct_ws_response_body(response_events), + } + if self._store_stream_events: + request_record["ws_events"] = [ + json.loads(msg) if msg.startswith("{") else {"raw": msg} for msg in client_messages + ] + response_record["ws_events"] = response_events + + record = { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "request_id": req_id, + "turn": turn, + "duration_ms": duration_ms, + "transport": "websocket", + "request": request_record, + "response": response_record, + "upstream_base_url": upstream_base_url, + } + await self._writer.write(record) + log.info( + f"{log_prefix} <- WS closed ({duration_ms}ms, " + f"{len(client_messages)} client→upstream, {len(server_messages)} upstream→client)" + ) + + async def _capture_only_websocket( + self, + req_id: str, + turn: int, + t0: float, + path: str, + headers: dict[str, str], + protocols: tuple[str, ...], + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + upstream_base_url: str, + log_prefix: str, + ) -> None: + sec_key = headers.get("Sec-WebSocket-Key") or headers.get("sec-websocket-key") + if not sec_key: + writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") + await writer.drain() + return + + response_lines = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + f"Sec-WebSocket-Accept: {_build_ws_accept(sec_key)}", + ] + if protocols: + response_lines.append(f"Sec-WebSocket-Protocol: {protocols[0]}") + writer.write(("\r\n".join(response_lines) + "\r\n\r\n").encode("utf-8")) + await writer.drain() + + raw_protocol = _RawWSProtocol() + queue = WebSocketDataQueue(raw_protocol, 2**16, loop=asyncio.get_running_loop()) + ws_reader = WebSocketReader(queue, max_msg_size=0) + ws_writer = WebSocketWriter(raw_protocol, writer.transport, use_mask=False) + + async def _pump_client_bytes() -> None: + try: + while True: + chunk = await reader.read(65536) + if not chunk: + break + ws_reader.feed_data(chunk) + except (ConnectionError, asyncio.CancelledError): + pass + finally: + ws_reader.feed_eof() + + client_messages: list[str] = [] + pump_task = asyncio.create_task(_pump_client_bytes()) + try: + deadline = time.monotonic() + 30 + while True: + timeout = max(0.1, deadline - time.monotonic()) + try: + msg: WSMessage = await asyncio.wait_for(queue.read(), timeout=timeout) + except (asyncio.TimeoutError, asyncio.CancelledError, Exception): + break + if msg.type == WSMsgType.TEXT: + client_messages.append(msg.data) + req_body = reconstruct_ws_request_body(client_messages) or {} + if is_prompt_bearing_ws_request_body(req_body): + break + if time.monotonic() >= deadline: + break + continue + if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING, WSMsgType.CLOSED, WSMsgType.ERROR): + break + finally: + pump_task.cancel() + try: + await pump_task + except (asyncio.CancelledError, Exception): + pass + + req_body = reconstruct_ws_request_body(client_messages) or {} + response_body = capture_only_response(path, req_body) + response_events = [ + {"type": "response.created", "response": {**response_body, "status": "in_progress"}}, + {"type": "response.completed", "response": {**response_body, "status": "completed"}}, + ] + completed_response_body = response_events[-1]["response"] + for event in response_events: + await ws_writer.send_frame(json.dumps(event, separators=(",", ":")).encode("utf-8"), WSMsgType.TEXT) + await writer.drain() + try: + await ws_writer.close() + await writer.drain() + except Exception: + pass + + duration_ms = int((time.monotonic() - t0) * 1000) + request_record = { + "method": "WEBSOCKET", + "path": path, + "headers": filter_headers(headers, redact_keys=True), + "body": req_body, + } + if self._store_stream_events: + request_record["ws_events"] = [ + json.loads(msg) if msg.startswith("{") else {"raw": msg} for msg in client_messages + ] + response_record = {"status": 101, "headers": {}, "body": completed_response_body} + if self._store_stream_events: + response_record["ws_events"] = response_events + record = { + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "request_id": req_id, + "turn": turn, + "duration_ms": duration_ms, + "transport": "websocket", + "request": request_record, + "response": response_record, + "upstream_base_url": upstream_base_url, + } + await self._writer.write(record) + log.info(f"{log_prefix} <- WS capture-only ({duration_ms}ms, upstream skipped)") + + async def _handle_plain_proxy( + self, + method: str, + url: str, + http_version: str, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Handle plain HTTP proxy requests (non-CONNECT). + + For absolute URL requests like GET http://example.com/path HTTP/1.1 + """ + # Read headers + headers: dict[str, str] = {} + while True: + line = await asyncio.wait_for(reader.readline(), timeout=10) + if line in (b"\r\n", b"\n", b""): + break + decoded = line.decode("utf-8", errors="replace").strip() + if ":" in decoded: + key, value = decoded.split(":", 1) + headers[key.strip()] = value.strip() + + body = await _read_http_body(reader, headers) + + parsed = urlparse(url) + path = parsed.path + if parsed.query: + path = f"{path}?{parsed.query}" + + if ( + not parsed.scheme + and self._local_reverse_target + and _matches_path_prefix(path, self._local_reverse_allowed_path_prefixes) + ): + upstream_url = build_upstream_url(self._local_reverse_target, path) + await self._forward_and_record(method, path, headers, body, upstream_url, writer) + return + + await self._forward_and_record(method, path, headers, body, url, writer) diff --git a/claude_tap/global_inject.py b/claude_tap/global_inject.py new file mode 100644 index 0000000..e8b51de --- /dev/null +++ b/claude_tap/global_inject.py @@ -0,0 +1,386 @@ +"""Toggle global Claude/Codex interception by editing their config files. + +``enable`` points *newly launched* Claude Code and Codex CLI sessions at the +local claude-tap reverse proxies by writing the base-URL keys into +``~/.claude/settings.json`` and ``~/.codex/config.toml``. ``disable`` restores +the originals byte-for-byte from backups taken at enable time. + +Reverse-proxy interception needs no CA cert, so this is just a base-URL edit. +Already-running sessions are unaffected (these configs are read at launch). +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import signal +import subprocess +import time +from pathlib import Path + +from claude_tap.cli_clients import CLIENT_CONFIGS, _codex_selected_provider_base_url_key + +_BACKUP_SUFFIX = ".tap-backup" + + +def _state_file() -> Path: + return Path.home() / ".claude-tap" / "monitor-state.json" + + +def _claude_settings_path() -> Path: + return Path.home() / ".claude" / "settings.json" + + +def _codex_config_path() -> Path: + return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex") / "config.toml" + + +def claude_home_exists() -> bool: + return _claude_settings_path().parent.is_dir() + + +def codex_home_exists() -> bool: + return _codex_config_path().parent.is_dir() + + +def is_active() -> bool: + """True if interception config is currently injected.""" + return _state_file().exists() + + +def recorded_proxy_processes_are_running( + *, + claude_port: int | None = None, + codex_port: int | None = None, +) -> bool: + """True if monitor state and live reverse-proxy processes are present.""" + state_file = _state_file() + if not state_file.exists(): + return False + try: + state = json.loads(state_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + processes = state.get("processes", []) if isinstance(state, dict) else [] + if not isinstance(processes, list): + return False + + if claude_port is not None and codex_port is not None: + return _proxy_port_is_running(claude_port, "claude") and _proxy_port_is_running(codex_port, "codex") + + live_roles: set[str] = set() + for entry in processes: + if not isinstance(entry, dict): + continue + role = entry.get("role") + pid = entry.get("pid") + if not isinstance(role, str) or not isinstance(pid, int) or pid <= 0: + continue + command = _monitor_process_command(pid) + if _looks_like_monitor_process(command): + live_roles.add(role) + + return {"claude proxy", "codex proxy"} <= live_roles + + +def _proxy_port_is_running(port: int, client: str) -> bool: + if port <= 0: + return False + return any( + _looks_like_proxy_process(_monitor_process_command(pid), client, port) for pid in _listening_pids_for_port(port) + ) + + +def _listening_pids_for_port(port: int) -> list[int]: + if port <= 0: + return [] + lsof = shutil.which("lsof") + if not lsof: + return [] + try: + result = subprocess.run( + [lsof, "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], + capture_output=True, + check=False, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return [] + if result.returncode not in {0, 1}: + return [] + pids: list[int] = [] + for line in result.stdout.splitlines(): + text = line.strip() + if text.isdigit(): + pids.append(int(text)) + return pids + + +def enable( + *, + claude_port: int | None = None, + codex_port: int | None = None, + processes: list[dict[str, object]] | None = None, +) -> None: + """Inject reverse-proxy base URLs for the given clients. + + Passing ``None`` for a port skips that client. Any previously-injected state + is restored first so backups always capture the user's true originals. + """ + if is_active(): + disable() + + files: list[dict[str, object]] = [] + state_file = _state_file() + try: + if claude_port is not None: + _inject_claude(_claude_settings_path(), claude_port, files) + if codex_port is not None: + _inject_codex(_codex_config_path(), codex_port, files) + + state_file.parent.mkdir(parents=True, exist_ok=True) + _write_text_atomic( + state_file, + json.dumps({"files": files, "processes": processes or []}, indent=2) + "\n", + mode=0o600, + ) + except Exception: + _restore_files(files) + state_file.unlink(missing_ok=True) + raise + + +def disable(*, terminate_processes: bool = False) -> None: + """Restore every file injected by ``enable`` and clear the state file.""" + state_file = _state_file() + if not state_file.exists(): + return + state: object = {} + try: + state = json.loads(state_file.read_text(encoding="utf-8")) + entries = state.get("files", []) if isinstance(state, dict) else [] + except (OSError, json.JSONDecodeError): + entries = [] + processes = state.get("processes", []) if isinstance(state, dict) else [] + + _restore_files(entries) + if terminate_processes and isinstance(processes, list): + _terminate_recorded_processes(processes) + + state_file.unlink(missing_ok=True) + + +def _restore_files(entries: list[object]) -> None: + for entry in entries: + if not isinstance(entry, dict): + continue + path = Path(str(entry.get("path", ""))) + if entry.get("existed"): + backup = entry.get("backup") + backup_path = Path(str(backup)) if backup else None + if backup_path and backup_path.exists(): + path.write_bytes(backup_path.read_bytes()) + backup_path.unlink() + elif path.exists(): + path.unlink() + + +def _record_backup(path: Path, files: list[dict[str, object]]) -> bool: + """Back up ``path`` if it exists, append a restore record, return existed.""" + existed = path.exists() + backup = path.with_name(path.name + _BACKUP_SUFFIX) + if existed: + shutil.copy2(path, backup) + files.append({"path": str(path), "existed": existed, "backup": str(backup) if existed else None}) + return existed + + +def _inject_claude(path: Path, port: int, files: list[dict[str, object]]) -> None: + existed = _record_backup(path, files) + data: object = {} + if existed: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + data = {} + if not isinstance(data, dict): + data = {} + env = data.get("env") + if not isinstance(env, dict): + env = {} + env.update(CLIENT_CONFIGS["claude"].reverse_base_url_env_map(port)) + data["env"] = env + path.parent.mkdir(parents=True, exist_ok=True) + _write_text_atomic(path, json.dumps(data, indent=2) + "\n", mode=_write_mode(path, existed)) + + +def _inject_codex(path: Path, port: int, files: list[dict[str, object]]) -> None: + existed = _record_backup(path, files) + text = path.read_text(encoding="utf-8") if existed else "" + new_text = _set_toml_top_level_string(text, "openai_base_url", f"http://127.0.0.1:{port}/v1") + provider_key = _codex_selected_provider_base_url_key() + if provider_key: + new_text = _set_toml_dotted_string(new_text, provider_key, f"http://127.0.0.1:{port}/v1") + path.parent.mkdir(parents=True, exist_ok=True) + _write_text_atomic(path, new_text, mode=_write_mode(path, existed)) + + +def _set_toml_top_level_string(text: str, key: str, value: str) -> str: + """Set a top-level string ``key`` in TOML text, preserving the rest. + + Replaces an existing top-level assignment, or inserts one before the first + table header (``[...]``). Top-level keys must precede any table section. + """ + new_line = f'{key} = "{value}"' + lines = text.splitlines() + + header_idx = next((i for i, ln in enumerate(lines) if ln.lstrip().startswith("[")), None) + region_end = header_idx if header_idx is not None else len(lines) + + key_re = re.compile(rf"^\s*{re.escape(key)}\s*=") + for i in range(region_end): + if key_re.match(lines[i]): + lines[i] = new_line + return "\n".join(lines) + "\n" + + lines.insert(region_end, new_line) + result = "\n".join(lines) + return result if result.endswith("\n") else result + "\n" + + +def _set_toml_dotted_string(text: str, dotted_key: str, value: str) -> str: + table, key = dotted_key.rsplit(".", 1) + lines = text.splitlines() + header_re = re.compile(r"^\s*\[([^\]]+)\]\s*(?:#.*)?$") + key_re = re.compile(rf"^\s*{re.escape(key)}\s*=") + + table_start: int | None = None + table_end = len(lines) + for i, line in enumerate(lines): + match = header_re.match(line) + if not match: + continue + if table_start is not None: + table_end = i + break + if match.group(1).strip() == table: + table_start = i + + new_line = f'{key} = "{value}"' + if table_start is None: + return _set_toml_top_level_string(text, dotted_key, value) + + for i in range(table_start + 1, table_end): + if key_re.match(lines[i]): + lines[i] = new_line + return "\n".join(lines) + "\n" + lines.insert(table_end, new_line) + return "\n".join(lines) + "\n" + + +def _write_mode(path: Path, existed: bool) -> int: + return path.stat().st_mode & 0o777 if existed and path.exists() else 0o600 + + +def _write_text_atomic(path: Path, text: str, *, mode: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}") + try: + tmp.write_text(text, encoding="utf-8") + tmp.chmod(mode) + os.replace(tmp, path) + finally: + tmp.unlink(missing_ok=True) + + +def _terminate_recorded_processes(processes: list[object]) -> None: + for entry in processes: + if not isinstance(entry, dict): + continue + pid = entry.get("pid") + if not isinstance(pid, int) or pid <= 0 or pid == os.getpid(): + continue + if not _looks_like_monitor_process(_monitor_process_command(pid)): + continue + _terminate_pid(pid) + + +def terminate_proxies_on_ports(*, claude_port: int | None = None, codex_port: int | None = None) -> None: + """SIGTERM/SIGKILL any claude-tap reverse proxy currently listening on the + given ports. + + Used on monitor stop to reap proxies the menu bar app *reused* rather than + spawned -- e.g. one left behind by a previous session's force-quit. Those + are not tracked as owned subprocesses and are absent from the state file, so + neither ``_terminate_owned_processes`` nor state-based termination reaches + them. Matching is scoped by command line and port, so only actual claude-tap + proxies on our own ports are killed.""" + for port, client in ((claude_port, "claude"), (codex_port, "codex")): + if not port: + continue + for pid in _listening_pids_for_port(port): + if pid == os.getpid(): + continue + if _looks_like_proxy_process(_monitor_process_command(pid), client, port): + _terminate_pid(pid) + + +def _terminate_pid(pid: int) -> None: + try: + os.kill(pid, signal.SIGTERM) + except OSError: + return + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if not _pid_exists(pid): + break + time.sleep(0.05) + if _pid_exists(pid): + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _monitor_process_command(pid: int) -> str: + try: + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "command="], + capture_output=True, + check=False, + text=True, + timeout=2, + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout.strip() if result.returncode == 0 else "" + + +def _looks_like_monitor_process(command: str) -> bool: + if not command: + return False + has_claude_tap = "claude_tap" in command or "claude-tap" in command + has_monitor_role = " dashboard" in command or "--tap-no-launch" in command + return has_claude_tap and has_monitor_role + + +def _looks_like_proxy_process(command: str, client: str, port: int) -> bool: + normalized = " ".join(command.split()).lower() + if not _looks_like_monitor_process(normalized): + return False + return ( + "--tap-no-launch" in normalized + and f"--tap-client {client.lower()}" in normalized + and f"--tap-port {port}" in normalized + ) + + +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + return True diff --git a/claude_tap/history.py b/claude_tap/history.py new file mode 100644 index 0000000..c72ae2b --- /dev/null +++ b/claude_tap/history.py @@ -0,0 +1,43 @@ +"""Trace history retention helpers backed by SQLite.""" + +from __future__ import annotations + +from pathlib import Path + +from claude_tap.trace_store import get_trace_store + + +def delete_trace_history( + date_key: str, + *, + protected_session_ids: set[str] | None = None, +) -> dict[str, int | str]: + """Delete stored trace sessions for a date key.""" + store = get_trace_store() + try: + return store.delete_sessions_by_date(date_key, protected_session_ids=protected_session_ids) + except ValueError as exc: + raise ValueError("Invalid date format") from exc + + +def cleanup_trace_sessions( + max_sessions: int, + *, + protected_session_id: str | None = None, + protected_session_ids: set[str] | None = None, +) -> int: + """Remove oldest trace sessions exceeding max_sessions.""" + return get_trace_store().cleanup_old_sessions( + max_sessions, + protected_session_id=protected_session_id, + protected_session_ids=protected_session_ids, + ) + + +def migrate_legacy_traces(output_dir: Path) -> int: + """Import legacy JSONL/log files from an output directory once.""" + return get_trace_store().migrate_legacy_directory(output_dir) + + +def _rel_posix(path: Path, base: Path) -> str: + return path.relative_to(base).as_posix() diff --git a/claude_tap/live.py b/claude_tap/live.py new file mode 100644 index 0000000..b4d79c5 --- /dev/null +++ b/claude_tap/live.py @@ -0,0 +1,781 @@ +"""LiveViewerServer - SSE-based real-time trace viewer.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import re +import secrets +import tempfile +from datetime import date +from pathlib import Path +from urllib.parse import quote, urlsplit + +from aiohttp import web + +from claude_tap.compact_trace import build_compact_trace_bundle +from claude_tap.dashboard import ( + build_session_query, + dashboard_trace_snapshot, + ensure_trace_store, + list_trace_agents, + list_trace_sessions, + load_trace_session, + read_dashboard_template, + redact_dashboard_summary, +) +from claude_tap.history import delete_trace_history, migrate_legacy_traces +from claude_tap.shared_dashboard import CLAUDE_TAP_VERSION, dashboard_url +from claude_tap.trace_store import get_trace_store, resolve_db_path +from claude_tap.viewer import ( + VIEWER_SCRIPT_ANCHOR, + VIEWER_TEMPLATE_PATH, + _extract_metadata_from_record, + _generate_html_viewer_from_compact_bundle, + _generate_html_viewer_from_metadata, + _normalize_record_for_viewer, + _read_viewer_template, +) + +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +DEFAULT_SESSION_PAGE_LIMIT = 100 +MAX_SESSION_PAGE_LIMIT = 500 + +_DASHBOARD_QUIT_TOKEN_HEADER = "X-Claude-Tap-Dashboard-Token" + + +def _split_host_port(value: str) -> tuple[str, int | None]: + host = value.strip() + if not host: + return "", None + if host.startswith("["): + end = host.find("]") + if end < 0: + return host, None + name = host[1:end] + rest = host[end + 1 :] + if rest.startswith(":") and rest[1:].isdigit(): + return name, int(rest[1:]) + return name, None + if host.count(":") == 1: + name, port = host.rsplit(":", 1) + if port.isdigit(): + return name, int(port) + return host, None + + +def _is_trusted_localhost(value: str | None) -> bool: + if value is None: + return False + host = value.strip().strip("[]").lower().rstrip(".") + if host == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _origin_port(origin) -> int | None: + if origin.port is not None: + return origin.port + if origin.scheme == "http": + return 80 + if origin.scheme == "https": + return 443 + return None + + +def _is_trusted_dashboard_token_request(request: web.Request) -> bool: + host, host_port = _split_host_port(request.headers.get("Host", "")) + if not _is_trusted_localhost(host): + return False + + origin_value = request.headers.get("Origin") + if not origin_value: + return True + try: + origin = urlsplit(origin_value) + except ValueError: + return False + if origin.scheme not in {"http", "https"} or not _is_trusted_localhost(origin.hostname): + return False + try: + origin_port = _origin_port(origin) + except ValueError: + return False + return host_port is None or origin_port == host_port + + +def _untrusted_dashboard_token_response() -> web.Response: + return web.json_response( + {"ok": False, "error": "Dashboard quit requires a trusted localhost Host and Origin"}, + status=403, + ) + + +def _record_limit_from_request(request: web.Request) -> int | None: + value = request.query.get("limit") + if value is None: + return None + try: + limit = int(value) + except ValueError: + return None + return max(0, limit) + + +def _record_offset_from_request(request: web.Request) -> int: + value = request.query.get("offset") + if value is None: + return 0 + try: + offset = int(value) + except ValueError: + return 0 + return max(0, offset) + + +def _session_limit_from_request(request: web.Request) -> int: + value = request.query.get("limit") + if value is None: + return DEFAULT_SESSION_PAGE_LIMIT + try: + limit = int(value) + except ValueError: + return DEFAULT_SESSION_PAGE_LIMIT + return max(1, min(MAX_SESSION_PAGE_LIMIT, limit)) + + +def _session_offset_from_request(request: web.Request) -> int: + value = request.query.get("offset") + if value is None: + return 0 + try: + offset = int(value) + except ValueError: + return 0 + return max(0, offset) + + +def _session_query_from_request(request: web.Request): + return build_session_query( + date=request.query.get("date", ""), + status=request.query.get("status", ""), + search=request.query.get("search", ""), + agent=request.query.get("agent", ""), + ) + + +class LiveViewerServer: + """HTTP server for real-time trace viewing via SSE.""" + + def __init__( + self, + session_id: str | None = None, + port: int = 0, + host: str = "127.0.0.1", + migrate_from: Path | None = None, + dashboard_mode: bool = False, + ): + self.session_id = session_id + self.port = port + self.host = host + self.migrate_from = migrate_from + self.dashboard_mode = dashboard_mode + self._sse_clients: list[web.StreamResponse] = [] + self._dashboard_clients: list[web.StreamResponse] = [] + self._records: list[dict] = [] + self._current_date: str = date.today().isoformat() + self._lock = asyncio.Lock() + self._runner: web.AppRunner | None = None + self._actual_port: int = 0 + self._shutdown_event = asyncio.Event() + self._stop_lock = asyncio.Lock() + self._dashboard_watch_task: asyncio.Task | None = None + self._dashboard_snapshot: dict[str, tuple[str, int, str]] = {} + self._dashboard_quit_token = secrets.token_urlsafe(32) + + async def start(self) -> int: + """Start the viewer server and return the actual port.""" + if self.migrate_from is not None: + migrate_legacy_traces(self.migrate_from) + + app = web.Application() + if self.dashboard_mode: + app.router.add_get("/", self._handle_dashboard_index) + else: + app.router.add_get("/", self._handle_index) + app.router.add_get("/viewer", self._handle_index) + app.router.add_get("/dashboard", self._handle_dashboard_index) + app.router.add_get("/dashboard/session/{session_id}", self._handle_dashboard_session_detail) + app.router.add_get("/dashboard/health", self._handle_dashboard_health) + app.router.add_get("/dashboard/events", self._handle_dashboard_sse) + app.router.add_post("/dashboard/quit", self._handle_dashboard_quit) + app.router.add_get("/events", self._handle_sse) + app.router.add_get("/records", self._handle_records) + app.router.add_get("/api/dates", self._handle_dates) + app.router.add_get("/api/traces/{date}", self._handle_traces_by_date) + app.router.add_delete("/api/traces/{date}", self._handle_delete_traces_by_date) + app.router.add_get("/api/agents", self._handle_agents) + app.router.add_get("/api/sessions", self._handle_sessions) + app.router.add_delete("/api/sessions", self._handle_delete_sessions) + app.router.add_delete("/api/sessions/{session_id}", self._handle_delete_session) + app.router.add_get("/api/sessions/{session_id}/records", self._handle_session_records) + app.router.add_get("/api/sessions/{session_id}/html", self._handle_session_html_compat) + app.router.add_get("/api/sessions/{session_id}/export/jsonl", self._handle_export_jsonl) + app.router.add_get("/api/sessions/{session_id}/export/compact", self._handle_export_compact) + app.router.add_get("/api/sessions/{session_id}/export/log", self._handle_export_log) + app.router.add_get("/api/sessions/{session_id}/export/html", self._handle_export_html) + + self._runner = web.AppRunner(app) + await self._runner.setup() + site = web.TCPSite(self._runner, self.host, self.port) + await site.start() + + try: + self._actual_port = site._server.sockets[0].getsockname()[1] + except (AttributeError, IndexError, OSError): + self._actual_port = self.port + + if self.dashboard_mode: + self._dashboard_snapshot = dashboard_trace_snapshot() + self._dashboard_watch_task = asyncio.create_task(self._watch_dashboard_store()) + + return self._actual_port + + async def stop(self) -> None: + """Stop the viewer server.""" + async with self._stop_lock: + if self._shutdown_event.is_set() and self._runner is None: + return + + self._shutdown_event.set() + if self._dashboard_watch_task: + self._dashboard_watch_task.cancel() + try: + await self._dashboard_watch_task + except asyncio.CancelledError: + pass + self._dashboard_watch_task = None + for client in self._sse_clients: + try: + await client.write_eof() + except Exception: + pass + self._sse_clients.clear() + for client in self._dashboard_clients: + try: + await client.write_eof() + except Exception: + pass + self._dashboard_clients.clear() + + if self._runner: + runner = self._runner + self._runner = None + await runner.cleanup() + + async def wait_stopped(self) -> None: + """Wait until the server shutdown event is set.""" + await self._shutdown_event.wait() + + async def broadcast(self, record: dict) -> None: + """Broadcast a new record to all connected SSE clients.""" + async with self._lock: + today = date.today().isoformat() + if today != self._current_date: + self._records.clear() + self._current_date = today + self._records.append(record) + + data = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + message = f"data: {data}\n\n" + + disconnected = [] + for client in self._sse_clients: + try: + await client.write(message.encode("utf-8")) + except (ConnectionError, ConnectionResetError, Exception): + disconnected.append(client) + + for client in disconnected: + self._sse_clients.remove(client) + + await self._broadcast_dashboard_event({"type": "record", "session_id": self.session_id}) + + @property + def url(self) -> str: + """Return the viewer URL.""" + return dashboard_url(self.host, self._actual_port) + + def _finalize_stale_active_sessions(self) -> None: + """Release abandoned active sessions while protecting the current writer.""" + protected = {self.session_id} if self.session_id else set() + ensure_trace_store().finalize_stale_active_sessions(protected_session_ids=protected) + + async def _handle_dashboard_index(self, request: web.Request) -> web.Response: + """Serve the session-first dashboard.""" + if session_id := request.query.get("session_id"): + raise web.HTTPFound(location=f"/dashboard/session/{quote(session_id, safe='')}") + try: + html = read_dashboard_template() + except OSError: + return web.Response(status=404, text="dashboard.html not found") + html = html.replace( + 'const CLAUDE_TAP_VERSION = "";', + f"const CLAUDE_TAP_VERSION = {json.dumps(CLAUDE_TAP_VERSION)};", + 1, + ) + if self.dashboard_mode and _is_trusted_dashboard_token_request(request): + html = html.replace( + 'const DASHBOARD_QUIT_TOKEN = "";', + f"const DASHBOARD_QUIT_TOKEN = {json.dumps(self._dashboard_quit_token)};", + 1, + ).replace( + "const DASHBOARD_CAN_STOP = false;", + "const DASHBOARD_CAN_STOP = true;", + 1, + ) + return web.Response(text=html, content_type="text/html") + + async def _handle_dashboard_session_detail(self, request: web.Request) -> web.Response: + """Serve the dashboard shell for a session detail route.""" + if ensure_trace_store().load_session_row(request.match_info["session_id"]) is None: + return web.Response(status=404, text="Session not found") + return await self._handle_dashboard_index(request) + + async def _handle_dashboard_health(self, request: web.Request) -> web.Response: + payload = { + "ok": True, + "db_path": str(resolve_db_path()), + "dashboard_mode": self.dashboard_mode, + "version": CLAUDE_TAP_VERSION, + } + if self.dashboard_mode and _is_trusted_dashboard_token_request(request): + payload["quit_token"] = self._dashboard_quit_token + return web.json_response(payload) + + async def _handle_dashboard_quit(self, request: web.Request) -> web.Response: + if not self.dashboard_mode: + return web.json_response( + {"ok": False, "error": "Dashboard quit is only available in dashboard mode"}, + status=403, + ) + if not _is_trusted_dashboard_token_request(request): + return _untrusted_dashboard_token_response() + token = request.headers.get(_DASHBOARD_QUIT_TOKEN_HEADER) + if token != self._dashboard_quit_token: + return web.json_response( + {"ok": False, "error": "Dashboard quit requires a same-origin token"}, + status=403, + ) + + async def stop_soon() -> None: + await asyncio.sleep(0.05) + await self.stop() + + asyncio.create_task(stop_soon()) + return web.json_response({"ok": True}) + + async def _handle_index(self, request: web.Request) -> web.Response: + """Serve the viewer HTML with live mode enabled.""" + if not VIEWER_TEMPLATE_PATH.exists(): + return web.Response(status=404, text="viewer.html not found") + + html = _read_viewer_template() + live_js = ( + "const LIVE_MODE = true;\nconst EMBEDDED_TRACE_DATA = [];\n" + f"const __TRACE_SESSION_ID__ = {json.dumps(self.session_id or '')};\n" + ) + html = html.replace( + VIEWER_SCRIPT_ANCHOR, + f"\n{VIEWER_SCRIPT_ANCHOR}", + 1, + ) + return web.Response(text=html, content_type="text/html") + + async def _handle_sse(self, request: web.Request) -> web.StreamResponse: + """SSE endpoint for live trace updates.""" + resp = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }, + ) + await resp.prepare(request) + + async with self._lock: + for record in self._records: + data = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + await resp.write(f"data: {data}\n\n".encode("utf-8")) + + self._sse_clients.append(resp) + + try: + while not self._shutdown_event.is_set(): + try: + await asyncio.wait_for(self._shutdown_event.wait(), timeout=30) + except asyncio.TimeoutError: + pass + if self._shutdown_event.is_set(): + break + try: + await resp.write(b": keepalive\n\n") + except (ConnectionError, ConnectionResetError, RuntimeError): + break + except asyncio.CancelledError: + pass + finally: + if resp in self._sse_clients: + self._sse_clients.remove(resp) + + return resp + + async def _handle_dashboard_sse(self, request: web.Request) -> web.StreamResponse: + """SSE endpoint for dashboard-level session updates.""" + resp = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }, + ) + await resp.prepare(request) + self._dashboard_clients.append(resp) + await self._write_dashboard_event(resp, {"type": "ready"}) + + try: + while not self._shutdown_event.is_set(): + try: + await asyncio.wait_for(self._shutdown_event.wait(), timeout=30) + except asyncio.TimeoutError: + pass + if self._shutdown_event.is_set(): + break + try: + await resp.write(b": keepalive\n\n") + except (ConnectionError, ConnectionResetError, RuntimeError): + break + except asyncio.CancelledError: + pass + finally: + if resp in self._dashboard_clients: + self._dashboard_clients.remove(resp) + + return resp + + async def _handle_records(self, request: web.Request) -> web.Response: + """Return all records as JSON array.""" + async with self._lock: + return web.json_response(self._records) + + async def _handle_dates(self, request: web.Request) -> web.Response: + """Return available trace dates (descending).""" + ensure_trace_store() + dates, has_legacy = get_trace_store().list_dates() + return web.json_response({"dates": dates, "has_legacy": has_legacy}) + + async def _handle_traces_by_date(self, request: web.Request) -> web.Response: + """Return combined trace records for a given date.""" + date_key = request.match_info["date"] + if date_key != "legacy" and not _DATE_RE.match(date_key): + return web.Response(status=400, text="Invalid date format") + + records = ensure_trace_store().load_records_for_date(date_key) + return web.json_response(records) + + async def _handle_agents(self, request: web.Request) -> web.Response: + """Return trace history agent buckets.""" + self._finalize_stale_active_sessions() + live_count = await self._current_live_record_count() + return web.json_response({"agents": list_trace_agents(self.session_id, live_record_count=live_count)}) + + async def _handle_sessions(self, request: web.Request) -> web.Response: + """Return trace history sessions.""" + self._finalize_stale_active_sessions() + live_count = await self._current_live_record_count() + offset = _session_offset_from_request(request) + limit = _session_limit_from_request(request) + query = _session_query_from_request(request) + aggregates = get_trace_store().get_session_aggregates(query) + total = aggregates["total_sessions"] + total_records = aggregates["total_records"] + total_tokens = aggregates["total_tokens"] + total_errors = aggregates["total_errors"] + sessions = list_trace_sessions( + self.session_id, + live_record_count=live_count, + limit=limit, + offset=offset, + query=query, + ) + dates, has_legacy = get_trace_store().list_dates() + return web.json_response( + { + "sessions": sessions, + "total": total, + "total_records": total_records, + "total_tokens": total_tokens, + "total_errors": total_errors, + "offset": offset, + "limit": limit, + "has_more": offset + len(sessions) < total, + "dates": dates, + "has_legacy": has_legacy, + } + ) + + async def _handle_session_records(self, request: web.Request) -> web.Response: + """Return one session's summary and records.""" + live_count = await self._current_live_record_count() + session = load_trace_session( + request.match_info["session_id"], + current_session_id=self.session_id, + record_limit=_record_limit_from_request(request), + record_offset=_record_offset_from_request(request), + live_record_count=live_count, + ) + if session is None: + return web.json_response({"error": "Session not found"}, status=404) + return web.json_response(session) + + async def _handle_session_html_compat(self, request: web.Request) -> web.Response: + return await self._session_html_response(request.match_info["session_id"]) + + async def _session_html_response(self, session_id: str) -> web.Response: + store = ensure_trace_store() + if store.load_session_row(session_id) is None: + return web.Response(status=404, text="Session not found") + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + html_path = tmp_path / f"session-{session_id[:8]}.html" + export_urls = { + "jsonl": f"/api/sessions/{quote(session_id)}/export/jsonl", + "compact": f"/api/sessions/{quote(session_id)}/export/compact", + "html": f"/api/sessions/{quote(session_id)}/export/html", + } + metadata = [ + redact_dashboard_summary(item) + for record in store.load_records(session_id) + if (item := _extract_metadata_from_record(record)) is not None + ] + _generate_html_viewer_from_metadata( + metadata, + html_path, + display_trace_path=export_urls["compact"], + display_html_path=f"/dashboard/session/{quote(session_id)}", + records_api_path=f"/api/sessions/{quote(session_id)}/records", + ) + if not html_path.exists(): + return web.Response(status=500, text="Failed to generate session viewer") + html = html_path.read_text(encoding="utf-8") + export_js = f"const __TRACE_SESSION_EXPORTS__ = {json.dumps(export_urls, separators=(',', ':'))};\n" + html = html.replace( + VIEWER_SCRIPT_ANCHOR, + f"\n{VIEWER_SCRIPT_ANCHOR}", + 1, + ) + return web.Response(text=html, content_type="text/html") + + async def _current_live_record_count(self) -> int: + async with self._lock: + return len(self._records) + + async def _handle_export_jsonl(self, request: web.Request) -> web.Response: + session_id = request.match_info["session_id"] + store = ensure_trace_store() + if store.load_session_row(session_id) is None: + return web.Response(status=404, text="Session not found") + body = store.export_jsonl(session_id) + filename = f"trace_{session_id[:8]}.jsonl" + return web.Response( + body=body, + content_type="application/x-ndjson", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + async def _handle_export_compact(self, request: web.Request) -> web.Response: + session_id = request.match_info["session_id"] + store = ensure_trace_store() + if store.load_session_row(session_id) is None: + return web.Response(status=404, text="Session not found") + body = store.export_compact(session_id) + filename = f"trace_{session_id[:8]}.ctap.json" + return web.Response( + text=body, + content_type="application/json", + charset="utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + async def _handle_export_log(self, request: web.Request) -> web.Response: + session_id = request.match_info["session_id"] + store = ensure_trace_store() + if store.load_session_row(session_id) is None: + return web.Response(status=404, text="Session not found") + body = store.export_log(session_id) + filename = f"trace_{session_id[:8]}.log" + return web.Response( + text=body, + content_type="text/plain", + charset="utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + async def _handle_delete_session(self, request: web.Request) -> web.Response: + """Delete one stored trace session.""" + session_id = request.match_info["session_id"] + self._finalize_stale_active_sessions() + store = ensure_trace_store() + row = store.load_session_row(session_id) + if row is None: + return web.json_response({"error": "Session not found"}, status=404) + if self.session_id and session_id == self.session_id: + return web.json_response({"error": "Live session cannot be deleted"}, status=409) + if (row["status"] or "") == "active": + return web.json_response({"error": "Active session cannot be deleted"}, status=409) + result = store.delete_session(session_id) + await self._broadcast_dashboard_event({"type": "refresh"}) + return web.json_response(result) + + async def _handle_delete_sessions(self, request: web.Request) -> web.Response: + """Delete multiple stored trace sessions.""" + try: + payload = await request.json() + except (json.JSONDecodeError, web.HTTPBadRequest): + return web.json_response({"error": "Invalid JSON body"}, status=400) + raw_ids = payload.get("session_ids") if isinstance(payload, dict) else None + if not isinstance(raw_ids, list): + return web.json_response({"error": "session_ids must be a list"}, status=400) + session_ids = [item for item in raw_ids if isinstance(item, str) and item] + if not session_ids: + return web.json_response({"error": "No sessions selected"}, status=400) + + self._finalize_stale_active_sessions() + store = ensure_trace_store() + deletable_ids = [] + skipped_active = [] + missing_ids = [] + for session_id in dict.fromkeys(session_ids): + row = store.load_session_row(session_id) + if row is None: + missing_ids.append(session_id) + continue + if self.session_id and session_id == self.session_id: + skipped_active.append(session_id) + continue + if (row["status"] or "") == "active": + skipped_active.append(session_id) + continue + deletable_ids.append(session_id) + + if not deletable_ids: + return web.json_response( + { + "error": "No selected sessions can be deleted", + "deleted_sessions": 0, + "deleted_records": 0, + "deleted_logs": 0, + "missing_sessions": missing_ids, + "skipped_active_sessions": skipped_active, + }, + status=409, + ) + + result = store.delete_sessions(deletable_ids) + result["missing_sessions"] = [*missing_ids, *result.get("missing_sessions", [])] + result["skipped_active_sessions"] = skipped_active + await self._broadcast_dashboard_event({"type": "refresh"}) + return web.json_response(result) + + async def _handle_export_html(self, request: web.Request) -> web.Response: + session_id = request.match_info["session_id"] + store = ensure_trace_store() + if store.load_session_row(session_id) is None: + return web.Response(status=404, text="Session not found") + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + html_path = tmp_path / f"trace_{session_id[:8]}.html" + records = [] + for record in store.load_records(session_id): + try: + normalized = json.loads(_normalize_record_for_viewer(json.dumps(record, ensure_ascii=False))) + except (TypeError, json.JSONDecodeError): + normalized = record + if isinstance(normalized, dict): + records.append(normalized) + _generate_html_viewer_from_compact_bundle( + build_compact_trace_bundle(records), + html_path, + display_trace_path=f"/api/sessions/{quote(session_id)}/export/compact", + display_html_path=f"/api/sessions/{quote(session_id)}/export/html", + ) + if not html_path.exists(): + return web.Response(status=500, text="Failed to generate session viewer") + body = html_path.read_text(encoding="utf-8") + filename = f"trace_{session_id[:8]}.html" + return web.Response( + text=body, + content_type="text/html", + charset="utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + async def _watch_dashboard_store(self) -> None: + """Poll SQLite and notify dashboard clients when history changes.""" + while not self._shutdown_event.is_set(): + try: + await asyncio.wait_for(self._shutdown_event.wait(), timeout=1) + except asyncio.TimeoutError: + pass + if self._shutdown_event.is_set(): + break + snapshot = dashboard_trace_snapshot() + if snapshot != self._dashboard_snapshot: + self._dashboard_snapshot = snapshot + await self._broadcast_dashboard_event({"type": "refresh"}) + + async def _broadcast_dashboard_event(self, payload: dict) -> None: + if not self._dashboard_clients: + return + disconnected = [] + for client in self._dashboard_clients: + try: + await self._write_dashboard_event(client, payload) + except (ConnectionError, ConnectionResetError, RuntimeError, Exception): + disconnected.append(client) + for client in disconnected: + if client in self._dashboard_clients: + self._dashboard_clients.remove(client) + + async def _write_dashboard_event(self, client: web.StreamResponse, payload: dict) -> None: + event_name = payload.get("type", "message") + data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + await client.write(f"event: {event_name}\ndata: {data}\n\n".encode("utf-8")) + + async def _handle_delete_traces_by_date(self, request: web.Request) -> web.Response: + """Delete stored trace sessions for a selected history date.""" + date_key = request.match_info["date"] + if date_key != "legacy" and not _DATE_RE.match(date_key): + return web.json_response({"error": "Invalid date format"}, status=400) + self._finalize_stale_active_sessions() + protected: set[str] = set() + force = request.query.get("force", "").lower() in {"1", "true", "yes"} + if self.session_id: + protected.add(self.session_id) + elif not force: + for row in get_trace_store().list_session_rows(): + if (row["status"] or "") == "active": + protected.add(row["id"]) + try: + result = delete_trace_history(date_key, protected_session_ids=protected) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=400) + return web.json_response(result) diff --git a/claude_tap/macos_app.py b/claude_tap/macos_app.py new file mode 100644 index 0000000..4065dab --- /dev/null +++ b/claude_tap/macos_app.py @@ -0,0 +1,840 @@ +"""macOS menu bar app for claude-tap.""" + +from __future__ import annotations + +import argparse +import asyncio +import ctypes +import os +import subprocess +import sys +import time +import webbrowser +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +from claude_tap import global_inject +from claude_tap.dashboard import list_trace_sessions +from claude_tap.shared_dashboard import ( + _sync_dashboard_healthy_for_current_db as _dashboard_is_healthy, +) +from claude_tap.shared_dashboard import dashboard_url, resolve_dashboard_port, stop_incompatible_dashboard_if_running + +_NS_VARIABLE_STATUS_ITEM_LENGTH = -1.0 +_NS_APPLICATION_ACTIVATION_POLICY_ACCESSORY = 1 +_NS_IMAGE_ONLY = 1 +_NS_ALERT_FIRST_BUTTON_RETURN = 1000 +_NS_TERMINATE_NOW = 1 +# Absolute default so the app works when launched from Finder (cwd is "/"). +_DEFAULT_OUTPUT_DIR = Path.home() / ".claude-tap" / "traces" +_CALLBACKS: list[Any] = [] +_ACTIVE_APP: MacOSMenuApp | None = None + +# Bump this string whenever the menu-bar logic changes so a debug log immediately +# reveals whether a still-running (stale in-memory) app instance is being used. +_DEBUG_BUILD_MARKER = "menubar-debug-2026-07-02a" +_DEBUG_LOG_PATH: Path | None = None + + +def _resolve_debug_log_path() -> Path | None: + """Resolve ``/dist/claude-tap-macos-debug.log`` for the checkout build.""" + try: + dist_dir = Path(__file__).resolve().parents[1] / "dist" + dist_dir.mkdir(parents=True, exist_ok=True) + return dist_dir / "claude-tap-macos-debug.log" + except OSError: + return None + + +def _enable_debug_logging() -> None: + global _DEBUG_LOG_PATH + _DEBUG_LOG_PATH = _resolve_debug_log_path() + + +def _debug_log(message: str) -> None: + """Append a timestamped line to the debug log. No-op unless logging is enabled + (only ``main`` enables it, so unit tests never write a log).""" + path = _DEBUG_LOG_PATH + if path is None: + return + try: + line = f"{datetime.now().isoformat(timespec='milliseconds')} [pid={os.getpid()}] {message}\n" + with path.open("a", encoding="utf-8") as handle: + handle.write(line) + except OSError: + pass + + +def build_dashboard_command( + *, + python_executable: str, + host: str, + port: int, + output_dir: Path, +) -> list[str]: + """Build the subprocess command used by the menu bar monitor.""" + cmd = _claude_tap_command( + python_executable, + "dashboard", + "--tap-live-port", + str(port), + "--tap-no-open", + "--tap-output-dir", + str(output_dir), + ) + if host != "127.0.0.1": + cmd.extend(["--tap-host", host]) + return cmd + + +def build_proxy_command( + *, + python_executable: str, + client: str, + host: str, + port: int, + output_dir: Path, +) -> list[str]: + """Build a standalone reverse-proxy command for a globally routed client.""" + return _claude_tap_command( + python_executable, + "--tap-no-launch", + "--tap-client", + client, + "--tap-port", + str(port), + "--tap-host", + host, + "--tap-no-live", + "--tap-output-dir", + str(output_dir), + ) + + +def _stop_incompatible_dashboard_sync(host: str, port: int, url: str) -> None: + asyncio.run(stop_incompatible_dashboard_if_running(host, port, url)) + + +def _claude_tap_command(python_executable: str, *args: str) -> list[str]: + if getattr(sys, "frozen", False): + return [python_executable, *args] + return [python_executable, "-m", "claude_tap", *args] + + +class DashboardMonitorController: + """Own the dashboard subprocess launched from the menu bar app.""" + + def __init__( + self, + *, + host: str, + port: int, + output_dir: Path, + claude_proxy_port: int | None = None, + codex_proxy_port: int | None = None, + python_executable: str = sys.executable, + popen: Callable[..., subprocess.Popen[bytes]] = subprocess.Popen, + is_healthy: Callable[[str, int], bool] = _dashboard_is_healthy, + open_browser: Callable[[str], object] = webbrowser.open, + enable_injection: Callable[..., None] = global_inject.enable, + disable_injection: Callable[[], None] = global_inject.disable, + injection_is_active: Callable[[], bool] = global_inject.is_active, + recorded_proxy_processes_are_running: Callable[..., bool] = global_inject.recorded_proxy_processes_are_running, + proxy_is_healthy: Callable[[int, str], bool] = global_inject._proxy_port_is_running, + terminate_proxies_on_ports: Callable[..., None] = global_inject.terminate_proxies_on_ports, + stop_incompatible_dashboard: Callable[[str, int, str], None] | None = None, + startup_check_delay: float = 0.15, + sleep: Callable[[float], object] = time.sleep, + ) -> None: + self.host = host + self.port = port + self.output_dir = output_dir + self.claude_proxy_port = claude_proxy_port or port + 1 + self.codex_proxy_port = codex_proxy_port or port + 2 + self.python_executable = python_executable + self._popen = popen + self._is_healthy = is_healthy + self._open_browser = open_browser + self._enable_injection = enable_injection + self._disable_injection = disable_injection + self._injection_is_active = injection_is_active + self._recorded_proxy_processes_are_running = recorded_proxy_processes_are_running + self._proxy_is_healthy = proxy_is_healthy + self._terminate_proxies_on_ports = terminate_proxies_on_ports + self._stop_incompatible_dashboard = stop_incompatible_dashboard or _stop_incompatible_dashboard_sync + self._startup_check_delay = startup_check_delay + self._sleep = sleep + self._process: subprocess.Popen[bytes] | None = None + self._proxy_processes: list[subprocess.Popen[bytes]] = [] + self._proxy_process_names: list[str] = [] + + @property + def url(self) -> str: + return dashboard_url(self.host, self.port) + + def _debug_state(self) -> str: + """Snapshot every signal the running-state logic depends on, for debugging.""" + + def _safe(fn: Callable[[], object]) -> object: + try: + return fn() + except Exception as exc: # noqa: BLE001 - debug snapshot must never raise + return f"error:{exc!r}" + + owned_process = self._process.poll() if self._process is not None else "none" + owned_proxies = [getattr(p, "pid", None) for p in self._proxy_processes] + owned_proxy_polls = [p.poll() for p in self._proxy_processes] + listeners: dict[str, object] = {} + for label, port in ( + ("dashboard", self.port), + ("claude", self.claude_proxy_port), + ("codex", self.codex_proxy_port), + ): + + def _port_listeners(port: int = port) -> object: + return { + pid: global_inject._monitor_process_command(pid) + for pid in global_inject._listening_pids_for_port(port) + } + + listeners[f"{label}:{port}"] = _safe(_port_listeners) + recorded_proxy = _safe( + lambda: self._recorded_proxy_processes_are_running( + claude_port=self.claude_proxy_port, + codex_port=self.codex_proxy_port, + ) + ) + return ( + f"injection_active={_safe(self._injection_is_active)} " + f"owned_process_poll={owned_process} owned_proxy_pids={owned_proxies} owned_proxy_polls={owned_proxy_polls} " + f"recorded_proxy={recorded_proxy} " + f"proxies_running={_safe(self._proxy_processes_are_running)} " + f"dashboard_healthy={_safe(lambda: self._is_healthy(self.host, self.port))} " + f"monitor_is_running={_safe(self._monitor_is_running)} listeners={listeners}" + ) + + def start(self) -> str: + _debug_log(f"controller.start: entry | {self._debug_state()}") + if self._monitor_is_running(): + if not self._is_healthy(self.host, self.port): + _debug_log("controller.start: monitor running but dashboard unhealthy -> repairing dashboard") + self._start_dashboard() + self._verify_dashboard_process() + _debug_log("controller.start: monitor already running -> returning url, no proxy spawn/inject") + return self.url + + try: + if self._injection_is_active(): + _debug_log("controller.start: stale injection active -> disabling before restart") + self._disable_injection() + self._start_dashboard() + self._start_proxy("claude", self.claude_proxy_port) + self._start_proxy("codex", self.codex_proxy_port) + _debug_log( + "controller.start: spawned proxies " + f"pids={[getattr(p, 'pid', None) for p in self._proxy_processes]} on " + f"claude={self.claude_proxy_port} codex={self.codex_proxy_port}" + ) + self._verify_started_processes() + self._enable_injection( + claude_port=self.claude_proxy_port, + codex_port=self.codex_proxy_port, + processes=self._monitor_process_records(), + ) + _debug_log("controller.start: injection enabled -> monitor started ok") + except Exception as exc: + _debug_log(f"controller.start: FAILED {exc!r} -> terminating owned processes + disabling injection") + self._terminate_owned_processes() + self._disable_injection() + raise + return self.url + + def stop(self) -> bool: + _debug_log(f"controller.stop: entry | {self._debug_state()}") + was_running = self._process_is_running() or self._proxy_processes_are_running() or self._injection_is_active() + if not was_running: + self._process = None + self._proxy_processes = [] + self._proxy_process_names = [] + return False + + self._disable_injection() + self._terminate_owned_processes() + # Owned proxies are gone; also reap any proxy we reused this session + # (adopted from a prior session) that we hold no Popen handle for. + self._terminate_proxies_on_ports(claude_port=self.claude_proxy_port, codex_port=self.codex_proxy_port) + _debug_log("controller.stop: reaped reused proxies on owned ports") + return True + + def _terminate_process(self, process: subprocess.Popen[bytes]) -> None: + process.terminate() + try: + process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5.0) + + def open_dashboard(self) -> None: + _debug_log("controller.open_dashboard: begin (will call start())") + self.start() + _debug_log(f"controller.open_dashboard: opening browser at {self.url}") + self._open_browser(self.url) + + def is_running(self) -> bool: + result = self._monitor_is_running() + _debug_log(f"controller.is_running -> {result} | {self._debug_state()}") + return result + + def can_stop(self) -> bool: + return self._process_is_running() or self._proxy_processes_are_running() or self._injection_is_active() + + def _process_is_running(self) -> bool: + return self._process is not None and self._process.poll() is None + + def _monitor_is_running(self) -> bool: + # A started monitor is defined by active injection plus live reverse + # proxies. The dashboard's version/db-matched HTTP health check is + # intentionally excluded here: a reused or version-mismatched dashboard + # fails that check, which would make a genuinely-running monitor read as + # stopped -- spuriously re-prompting "Start Monitor?" on Open Dashboard, + # flipping the menu label to Stopped, and triggering a proxy restart that + # collides on the ports already held by the live proxies. Whether a fresh + # dashboard needs spawning is decided separately in ``start`` via + # ``self._is_healthy``. + return self._injection_is_active() and self._proxy_processes_are_running() + + def _proxy_processes_are_running(self) -> bool: + if bool(self._proxy_processes) and all(process.poll() is None for process in self._proxy_processes): + return True + return not self._proxy_processes and self._recorded_proxy_processes_are_running( + claude_port=self.claude_proxy_port, + codex_port=self.codex_proxy_port, + ) + + def _start_dashboard(self) -> None: + if self._process is not None and self._process.poll() is not None: + self._process = None + if self._is_healthy(self.host, self.port): + _debug_log("controller.start: dashboard already healthy -> reusing existing dashboard") + return + self._stop_incompatible_dashboard(self.host, self.port, self.url) + if self._is_healthy(self.host, self.port): + _debug_log("controller.start: dashboard became healthy after stale dashboard stop") + return + _debug_log("controller.start: dashboard not healthy -> spawning dashboard subprocess") + cmd = build_dashboard_command( + python_executable=self.python_executable, + host=self.host, + port=self.port, + output_dir=self.output_dir, + ) + self._process = self._popen(cmd, **self._subprocess_kwargs()) + + def _start_proxy(self, client: str, port: int) -> None: + # Mirror the dashboard-reuse path above: if a matching proxy is already + # serving this port (e.g. one this app spawned in a prior session and + # left behind), reuse it instead of spawning a duplicate. A duplicate + # cannot bind the port, exits with code 1, and makes the whole monitor + # start fail -- the crash-loop seen in the debug log. + if self._proxy_is_healthy(port, client): + _debug_log(f"controller.start: {client} proxy already healthy on {port} -> reusing existing proxy") + return + cmd = build_proxy_command( + python_executable=self.python_executable, + client=client, + host=self.host, + port=port, + output_dir=self.output_dir, + ) + self._proxy_processes.append(self._popen(cmd, **self._subprocess_kwargs())) + self._proxy_process_names.append(client) + + def _monitor_process_records(self) -> list[dict[str, object]]: + records: list[dict[str, object]] = [] + if self._process is not None and self._process.poll() is None: + pid = getattr(self._process, "pid", None) + if isinstance(pid, int): + records.append({"pid": pid, "role": "dashboard"}) + for name, process in zip(self._proxy_process_names, self._proxy_processes, strict=True): + if process.poll() is not None: + continue + pid = getattr(process, "pid", None) + if isinstance(pid, int): + records.append({"pid": pid, "role": f"{name} proxy"}) + return records + + def _verify_started_processes(self) -> None: + if self._startup_check_delay > 0: + self._sleep(self._startup_check_delay) + + exited: list[str] = [] + if self._process is not None and self._process.poll() is not None: + exited.append(f"dashboard exited with code {self._process.returncode}") + for name, process in zip(self._proxy_process_names, self._proxy_processes, strict=True): + if process.poll() is not None: + exited.append(f"{name} proxy exited with code {process.returncode}") + if exited: + raise RuntimeError("Monitor failed to start: " + "; ".join(exited)) + + def _verify_dashboard_process(self) -> None: + if self._startup_check_delay > 0: + self._sleep(self._startup_check_delay) + if self._process is not None and self._process.poll() is not None: + raise RuntimeError(f"Monitor failed to start: dashboard exited with code {self._process.returncode}") + + def _terminate_owned_processes(self) -> None: + processes = [process for process in [self._process, *self._proxy_processes] if process is not None] + for process in processes: + if process.poll() is None: + self._terminate_process(process) + self._process = None + self._proxy_processes = [] + self._proxy_process_names = [] + + @staticmethod + def _subprocess_kwargs() -> dict[str, object]: + kwargs: dict[str, object] = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if sys.platform == "win32": + from claude_tap.process_utils import windows_no_console_subprocess_kwargs + + kwargs.update(windows_no_console_subprocess_kwargs()) + else: + kwargs["start_new_session"] = True + return kwargs + + +class MacOSMenuApp: + """Native macOS status item backed by Objective-C runtime calls.""" + + def __init__(self, controller: DashboardMonitorController, *, auto_start: bool = True) -> None: + self.controller = controller + self.auto_start = auto_start + self._objc = _ObjC() + self._app = 0 + self._delegate = 0 + self._status_item = 0 + self._target = 0 + self._status_item_view = 0 + self._session_item = 0 + self._latest_item = 0 + self._start_item = 0 + self._stop_item = 0 + + def run(self) -> int: + global _ACTIVE_APP + + _ACTIVE_APP = self + _debug_log(f"app.run: build_marker={_DEBUG_BUILD_MARKER} auto_start={self.auto_start}") + objc = self._objc + pool = objc.alloc_init("NSAutoreleasePool") + self._app = objc.msg(objc.cls("NSApplication"), "sharedApplication") + objc.msg( + self._app, + "setActivationPolicy:", + None, + [ctypes.c_long], + _NS_APPLICATION_ACTIVATION_POLICY_ACCESSORY, + ) + self._delegate = _new_app_delegate(objc) + objc.msg(self._app, "setDelegate:", None, [ctypes.c_void_p], self._delegate) + self._build_menu() + if self.auto_start: + self.start_monitor() + else: + self.refresh_menu() + objc.msg(self._app, "run") + if pool: + objc.msg(pool, "drain") + return 0 + + def start_monitor(self) -> None: + _debug_log("app.start_monitor: begin") + if not self._confirm_start_monitor(): + _debug_log("app.start_monitor: user cancelled confirmation") + self.refresh_menu() + return + try: + self.controller.start() + except Exception as exc: + _debug_log(f"app.start_monitor: controller.start raised {exc!r}") + self.refresh_menu() + self._show_error("Unable to start Claude Tap monitor", _exception_text(exc)) + return + _debug_log("app.start_monitor: controller.start returned ok") + self.refresh_menu() + + def stop_monitor(self) -> None: + _debug_log("app.stop_monitor: begin") + self.controller.stop() + self.refresh_menu() + + def open_dashboard(self) -> None: + running = self.controller.is_running() + _debug_log(f"app.open_dashboard: begin is_running={running}") + if not running: + confirmed = self._confirm_start_monitor() + _debug_log(f"app.open_dashboard: monitor not running -> confirm_dialog_result={confirmed}") + if not confirmed: + self.refresh_menu() + return + self.controller.open_dashboard() + _debug_log("app.open_dashboard: controller.open_dashboard returned") + self.refresh_menu() + + def quit(self) -> None: + self.prepare_to_quit() + self._objc.msg(self._app, "terminate:", None, [ctypes.c_void_p], None) + + def prepare_to_quit(self) -> None: + _debug_log("app.prepare_to_quit: stopping monitor before app termination") + self.controller.stop() + + def refresh_menu(self) -> None: + running = self.controller.is_running() + _debug_log(f"app.refresh_menu: label will show Monitor: {'Running' if running else 'Stopped'}") + sessions = _menu_sessions() + latest = sessions[0] if sessions else None + latest_text = _latest_session_text(latest) + + self._set_item_title(self._status_item_view, f"Monitor: {'Running' if running else 'Stopped'}") + self._set_item_title(self._session_item, f"Sessions: {len(sessions)}") + self._set_item_title(self._latest_item, latest_text) + self._set_enabled(self._start_item, not running) + self._set_enabled(self._stop_item, self.controller.can_stop()) + + def _build_menu(self) -> None: + objc = self._objc + menu = objc.alloc_init("NSMenu") + status_bar = objc.msg(objc.cls("NSStatusBar"), "systemStatusBar") + self._status_item = objc.msg( + status_bar, + "statusItemWithLength:", + ctypes.c_void_p, + [ctypes.c_double], + _NS_VARIABLE_STATUS_ITEM_LENGTH, + ) + button = objc.msg(self._status_item, "button") + self._configure_status_button(button) + + self._target = _new_menu_target(objc) + self._status_item_view = self._add_menu_item(menu, "Monitor: Stopped", None, enabled=False) + self._session_item = self._add_menu_item(menu, "Sessions: 0", None, enabled=False) + self._latest_item = self._add_menu_item(menu, "Latest: No traces yet", None, enabled=False) + self._add_separator(menu) + self._add_menu_item(menu, "Open Dashboard", "openDashboard:") + self._start_item = self._add_menu_item(menu, "Start Monitor", "startMonitor:") + self._stop_item = self._add_menu_item(menu, "Stop Monitor", "stopMonitor:") + self._add_separator(menu) + self._add_menu_item(menu, "Refresh", "refreshMenu:") + self._add_menu_item(menu, "Quit Claude Tap", "quit:") + objc.msg(self._status_item, "setMenu:", None, [ctypes.c_void_p], menu) + + def _configure_status_button(self, button: int) -> None: + objc = self._objc + image = objc.msg( + objc.cls("NSImage"), + "imageWithSystemSymbolName:accessibilityDescription:", + ctypes.c_void_p, + [ctypes.c_void_p, ctypes.c_void_p], + objc.nsstring("rectangle.grid.2x2"), + objc.nsstring("Dashboard"), + ) + if image: + objc.msg(image, "setTemplate:", None, [ctypes.c_bool], True) + objc.msg(button, "setImage:", None, [ctypes.c_void_p], image) + objc.msg(button, "setImagePosition:", None, [ctypes.c_ulong], _NS_IMAGE_ONLY) + objc.msg(button, "setTitle:", None, [ctypes.c_void_p], objc.nsstring("")) + objc.msg(button, "setToolTip:", None, [ctypes.c_void_p], objc.nsstring("Dashboard")) + + def _add_menu_item(self, menu: int, title: str, action: str | None, *, enabled: bool = True) -> int: + objc = self._objc + selector = objc.sel(action) if action else None + item = objc.msg(objc.cls("NSMenuItem"), "alloc") + item = objc.msg( + item, + "initWithTitle:action:keyEquivalent:", + ctypes.c_void_p, + [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p], + objc.nsstring(title), + selector, + objc.nsstring(""), + ) + if action: + objc.msg(item, "setTarget:", None, [ctypes.c_void_p], self._target) + objc.msg(item, "setEnabled:", None, [ctypes.c_bool], enabled) + objc.msg(menu, "addItem:", None, [ctypes.c_void_p], item) + return item + + def _add_separator(self, menu: int) -> None: + objc = self._objc + item = objc.msg(objc.cls("NSMenuItem"), "separatorItem") + objc.msg(menu, "addItem:", None, [ctypes.c_void_p], item) + + def _set_item_title(self, item: int, title: str) -> None: + self._objc.msg(item, "setTitle:", None, [ctypes.c_void_p], self._objc.nsstring(title)) + + def _set_enabled(self, item: int, enabled: bool) -> None: + self._objc.msg(item, "setEnabled:", None, [ctypes.c_bool], enabled) + + def _show_error(self, message: str, details: str) -> None: + objc = self._objc + app = objc.msg(objc.cls("NSApplication"), "sharedApplication") + objc.msg(app, "activateIgnoringOtherApps:", None, [ctypes.c_bool], True) + alert = objc.alloc_init("NSAlert") + objc.msg(alert, "setMessageText:", None, [ctypes.c_void_p], objc.nsstring(message)) + objc.msg(alert, "setInformativeText:", None, [ctypes.c_void_p], objc.nsstring(details)) + objc.msg(alert, "addButtonWithTitle:", None, [ctypes.c_void_p], objc.nsstring("OK")) + objc.msg(alert, "runModal", ctypes.c_long) + + def _confirm_start_monitor(self) -> bool: + objc = self._objc + app = objc.msg(objc.cls("NSApplication"), "sharedApplication") + objc.msg(app, "activateIgnoringOtherApps:", None, [ctypes.c_bool], True) + alert = objc.alloc_init("NSAlert") + objc.msg(alert, "setMessageText:", None, [ctypes.c_void_p], objc.nsstring("Start Claude Tap Monitor?")) + objc.msg( + alert, + "setInformativeText:", + None, + [ctypes.c_void_p], + objc.nsstring( + "This starts local dashboard/proxy processes and temporarily writes base URL settings to " + "~/.claude/settings.json and ~/.codex/config.toml. Stop Monitor restores the files; " + "claude-tap monitor-restore recovers them after a force quit." + ), + ) + objc.msg(alert, "addButtonWithTitle:", None, [ctypes.c_void_p], objc.nsstring("Start Monitor")) + objc.msg(alert, "addButtonWithTitle:", None, [ctypes.c_void_p], objc.nsstring("Cancel")) + return objc.msg(alert, "runModal", ctypes.c_long) == _NS_ALERT_FIRST_BUTTON_RETURN + + +class _ObjC: + def __init__(self) -> None: + if sys.platform != "darwin": + raise RuntimeError("The claude-tap macOS menu bar app only runs on macOS.") + self.objc = ctypes.CDLL("/usr/lib/libobjc.A.dylib") + ctypes.CDLL("/System/Library/Frameworks/Foundation.framework/Foundation") + ctypes.CDLL("/System/Library/Frameworks/AppKit.framework/AppKit") + self.objc.objc_getClass.restype = ctypes.c_void_p + self.objc.objc_getClass.argtypes = [ctypes.c_char_p] + self.objc.sel_registerName.restype = ctypes.c_void_p + self.objc.sel_registerName.argtypes = [ctypes.c_char_p] + self.objc.objc_allocateClassPair.restype = ctypes.c_void_p + self.objc.objc_allocateClassPair.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_size_t] + self.objc.class_addMethod.restype = ctypes.c_bool + self.objc.class_addMethod.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p] + self.objc.objc_registerClassPair.restype = None + self.objc.objc_registerClassPair.argtypes = [ctypes.c_void_p] + + def cls(self, name: str) -> int: + value = self.objc.objc_getClass(name.encode("utf-8")) + if not value: + raise RuntimeError(f"Objective-C class not found: {name}") + return value + + def sel(self, name: str | None) -> int | None: + if name is None: + return None + return self.objc.sel_registerName(name.encode("utf-8")) + + def msg( + self, + receiver: int, + selector: str, + restype: Any = ctypes.c_void_p, + argtypes: list[Any] | None = None, + *args: object, + ) -> Any: + send = self.objc.objc_msgSend + send.restype = restype + send.argtypes = [ctypes.c_void_p, ctypes.c_void_p, *(argtypes or [])] + return send(receiver, self.sel(selector), *args) + + def nsstring(self, value: str) -> int: + return self.msg( + self.cls("NSString"), + "stringWithUTF8String:", + ctypes.c_void_p, + [ctypes.c_char_p], + value.encode("utf-8"), + ) + + def alloc_init(self, class_name: str) -> int: + allocated = self.msg(self.cls(class_name), "alloc") + return self.msg(allocated, "init") + + +def _new_menu_target(objc: _ObjC) -> int: + class_name = "ClaudeTapMenuTarget" + target_class = objc.objc.objc_getClass(class_name.encode("utf-8")) + if not target_class: + target_class = objc.objc.objc_allocateClassPair(objc.cls("NSObject"), class_name.encode("utf-8"), 0) + for selector, callback in { + "startMonitor:": _start_monitor_callback, + "stopMonitor:": _stop_monitor_callback, + "openDashboard:": _open_dashboard_callback, + "refreshMenu:": _refresh_menu_callback, + "quit:": _quit_callback, + }.items(): + objc.objc.class_addMethod( + target_class, + objc.sel(selector), + ctypes.cast(callback, ctypes.c_void_p), + b"v@:@", + ) + objc.objc.objc_registerClassPair(target_class) + target = objc.msg(target_class, "alloc") + return objc.msg(target, "init") + + +def _new_app_delegate(objc: _ObjC) -> int: + class_name = "ClaudeTapAppDelegate" + delegate_class = objc.objc.objc_getClass(class_name.encode("utf-8")) + if not delegate_class: + delegate_class = objc.objc.objc_allocateClassPair(objc.cls("NSObject"), class_name.encode("utf-8"), 0) + objc.objc.class_addMethod( + delegate_class, + objc.sel("applicationShouldTerminate:"), + ctypes.cast(_application_should_terminate_callback, ctypes.c_void_p), + b"q@:@", + ) + objc.objc.objc_registerClassPair(delegate_class) + delegate = objc.msg(delegate_class, "alloc") + return objc.msg(delegate, "init") + + +def _callback(fn: Callable[[int, int, int], None]) -> Any: + callback_type = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p) + wrapped = callback_type(fn) + _CALLBACKS.append(wrapped) + return wrapped + + +def _terminate_callback(fn: Callable[[int, int, int], int]) -> Any: + callback_type = ctypes.CFUNCTYPE(ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p) + wrapped = callback_type(fn) + _CALLBACKS.append(wrapped) + return wrapped + + +@_terminate_callback +def _application_should_terminate_callback(_self: int, _cmd: int, _sender: int) -> int: + if _ACTIVE_APP is not None: + _ACTIVE_APP.prepare_to_quit() + return _NS_TERMINATE_NOW + + +@_callback +def _start_monitor_callback(_self: int, _cmd: int, _sender: int) -> None: + if _ACTIVE_APP is not None: + _ACTIVE_APP.start_monitor() + + +@_callback +def _stop_monitor_callback(_self: int, _cmd: int, _sender: int) -> None: + if _ACTIVE_APP is not None: + _ACTIVE_APP.stop_monitor() + + +@_callback +def _open_dashboard_callback(_self: int, _cmd: int, _sender: int) -> None: + if _ACTIVE_APP is not None: + _ACTIVE_APP.open_dashboard() + + +@_callback +def _refresh_menu_callback(_self: int, _cmd: int, _sender: int) -> None: + if _ACTIVE_APP is not None: + _ACTIVE_APP.refresh_menu() + + +@_callback +def _quit_callback(_self: int, _cmd: int, _sender: int) -> None: + if _ACTIVE_APP is not None: + _ACTIVE_APP.quit() + + +def _menu_sessions() -> list[dict[str, Any]]: + try: + return list_trace_sessions() + except Exception: + return [] + + +def _latest_session_text(session: dict[str, Any] | None) -> str: + if not session: + return "Latest: No traces yet" + agent = str(session.get("agent") or "Unknown") + record_count = int(session.get("record_count") or 0) + first_user = str(session.get("first_user") or "").strip() + if len(first_user) > 44: + first_user = first_user[:41].rstrip() + "..." + suffix = f" - {first_user}" if first_user else "" + return f"Latest: {agent} ({record_count}){suffix}" + + +def _exception_text(exc: Exception) -> str: + text = str(exc).strip() + return text or exc.__class__.__name__ + + +def parse_macos_app_args(argv: list[str] | None = None) -> argparse.Namespace: + if argv is not None: + argv = [arg for arg in argv if not arg.startswith("-psn_")] + parser = argparse.ArgumentParser( + prog="claude-tap macos-app", + description="Run the claude-tap macOS menu bar app.", + ) + parser.add_argument("--tap-output-dir", default=str(_DEFAULT_OUTPUT_DIR), dest="output_dir") + parser.add_argument("--tap-live-port", type=int, default=0, dest="live_port") + parser.add_argument("--tap-host", default="127.0.0.1", dest="host") + parser.add_argument("--tap-no-auto-start", action="store_false", default=True, dest="auto_start") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + if sys.platform != "darwin": + print("claude-tap macos-app is only supported on macOS.", file=sys.stderr) + return 1 + + _enable_debug_logging() + try: + args = parse_macos_app_args(argv) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + port = resolve_dashboard_port(args.live_port) + controller = DashboardMonitorController( + host=args.host, + port=port, + output_dir=output_dir, + ) + _debug_log( + "======== app launch ======== " + f"build_marker={_DEBUG_BUILD_MARKER} python={sys.executable} argv={argv} " + f"host={args.host} dashboard_port={port} " + f"claude_proxy_port={controller.claude_proxy_port} codex_proxy_port={controller.codex_proxy_port} " + f"output_dir={output_dir} auto_start={args.auto_start}" + ) + return MacOSMenuApp(controller, auto_start=args.auto_start).run() + except Exception: + _debug_log("main: unhandled exception -> writing crash log") + _write_crash_log() + raise + + +def _write_crash_log() -> None: + import traceback + + log_path = Path.home() / "Library" / "Logs" / "claude-tap-macos.log" + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as handle: + handle.write(traceback.format_exc()) + except OSError: + pass diff --git a/claude_tap/macos_bundle.py b/claude_tap/macos_bundle.py new file mode 100644 index 0000000..a36969b --- /dev/null +++ b/claude_tap/macos_bundle.py @@ -0,0 +1,373 @@ +"""Build a local double-clickable macOS app bundle for claude-tap.""" + +from __future__ import annotations + +import argparse +import plistlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Callable + +DEFAULT_APP_NAME = "Claude Tap" +DEFAULT_BUNDLE_ID = "dev.claude-tap.macos" +DEFAULT_EXECUTABLE_NAME = "claude-tap-macos" + + +def build_macos_app_bundle( + app_path: Path, + *, + python_executable: str | None = None, + source_root: Path | None = None, + self_contained: bool = False, + compile_launcher: Callable[[str, Path], None] | None = None, + build_frozen_executable: Callable[[Path], Path] | None = None, +) -> Path: + """Create a local .app bundle that launches the claude-tap menu bar app.""" + app_path = app_path.expanduser() + if app_path.suffix != ".app": + app_path = app_path.with_suffix(".app") + + contents_dir = app_path / "Contents" + macos_dir = contents_dir / "MacOS" + resources_dir = contents_dir / "Resources" + macos_dir.mkdir(parents=True, exist_ok=True) + resources_dir.mkdir(parents=True, exist_ok=True) + + executable_name = DEFAULT_EXECUTABLE_NAME + _write_info_plist(contents_dir / "Info.plist", executable_name=executable_name) + bundled_executable: Path | None = None + if self_contained: + bundled_executable = (build_frozen_executable or _build_pyinstaller_executable)(resources_dir) + source_root = None + + launcher_path = macos_dir / executable_name + source = _launcher_source( + python_executable=python_executable or sys.executable, + source_root=source_root, + bundled_executable=bundled_executable.relative_to(resources_dir) if bundled_executable else None, + ) + (compile_launcher or _compile_native_launcher)(source, launcher_path) + launcher_path.chmod(launcher_path.stat().st_mode | 0o755) + _ad_hoc_sign_app(app_path) + return app_path + + +def parse_build_macos_app_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="claude-tap build-macos-app", + description="Build a local double-clickable macOS app bundle for claude-tap.", + ) + parser.add_argument( + "--output", + default=str(Path("dist") / "Claude Tap.app"), + help="Output .app path (default: dist/Claude Tap.app)", + ) + parser.add_argument( + "--installed", + action="store_true", + help="Do not add the current source checkout to PYTHONPATH in the launcher.", + ) + parser.add_argument( + "--self-contained", + action="store_true", + help="Bundle Python and dependencies with PyInstaller (Apple Silicon only).", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_build_macos_app_args(argv) + source_root = None if args.installed or args.self_contained else Path(__file__).resolve().parents[1] + app_path = build_macos_app_bundle( + Path(args.output), + python_executable=sys.executable, + source_root=source_root, + self_contained=args.self_contained, + ) + print(f"Built macOS app: {app_path}") + return 0 + + +def _write_info_plist(path: Path, *, executable_name: str) -> None: + info = { + "CFBundleDevelopmentRegion": "en", + "CFBundleExecutable": executable_name, + "CFBundleIdentifier": DEFAULT_BUNDLE_ID, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": DEFAULT_APP_NAME, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": "1", + "LSMinimumSystemVersion": "11.0", + "LSUIElement": True, + "NSHighResolutionCapable": True, + } + path.write_bytes(plistlib.dumps(info, sort_keys=True)) + + +def _launcher_source( + *, + python_executable: str, + source_root: Path | None, + bundled_executable: Path | None = None, +) -> str: + source_root_literal = _c_string_literal(str(source_root)) if source_root is not None else "NULL" + if bundled_executable is not None: + return _bundled_launcher_source(bundled_executable) + return f"""#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +int main(int argc, char **argv) {{ + const char *python = {_c_string_literal(python_executable)}; + const char *source_root = {source_root_literal}; + const char *existing_path = getenv("PATH"); + const char *prefix_path = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + const char *existing_pythonpath = getenv("PYTHONPATH"); + + if (existing_path && existing_path[0]) {{ + size_t path_len = strlen(prefix_path) + strlen(existing_path) + 2; + char *path_value = malloc(path_len); + if (!path_value) return 126; + snprintf(path_value, path_len, "%s:%s", prefix_path, existing_path); + setenv("PATH", path_value, 1); + free(path_value); + }} else {{ + setenv("PATH", prefix_path, 1); + }} + + if (source_root && source_root[0]) {{ + size_t pythonpath_len = strlen(source_root) + 1; + if (existing_pythonpath && existing_pythonpath[0]) {{ + pythonpath_len += strlen(existing_pythonpath) + 1; + }} + char *pythonpath_value = malloc(pythonpath_len); + if (!pythonpath_value) return 126; + if (existing_pythonpath && existing_pythonpath[0]) {{ + snprintf(pythonpath_value, pythonpath_len, "%s:%s", source_root, existing_pythonpath); + }} else {{ + snprintf(pythonpath_value, pythonpath_len, "%s", source_root); + }} + setenv("PYTHONPATH", pythonpath_value, 1); + free(pythonpath_value); + }} + + char **child_argv = calloc((size_t)argc + 4, sizeof(char *)); + if (!child_argv) return 126; + child_argv[0] = (char *)python; + child_argv[1] = "-m"; + child_argv[2] = "claude_tap"; + child_argv[3] = "macos-app"; + for (int i = 1; i < argc; i++) {{ + child_argv[i + 3] = argv[i]; + }} + child_argv[argc + 3] = NULL; + + // Spawn Python as a child and wait, instead of execv replacing this process. + // LaunchServices ties the app's menu-bar/GUI identity to this bundle + // executable; replacing it via execv makes the status item fail to appear. + pid_t pid; + int spawn_result = posix_spawn(&pid, python, NULL, NULL, child_argv, environ); + free(child_argv); + if (spawn_result != 0) {{ + errno = spawn_result; + perror("claude-tap macOS launcher"); + return 127; + }} + + int status = 0; + while (waitpid(pid, &status, 0) < 0) {{ + if (errno != EINTR) {{ + perror("claude-tap macOS launcher"); + return 127; + }} + }} + if (WIFEXITED(status)) {{ + return WEXITSTATUS(status); + }} + return 128; +}} +""" + + +def _bundled_launcher_source(bundled_executable: Path) -> str: + bundled_relative_path = Path("..") / "Resources" / bundled_executable + return f"""#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; + +int main(int argc, char **argv) {{ + char executable_path[PATH_MAX]; + uint32_t executable_size = sizeof(executable_path); + if (_NSGetExecutablePath(executable_path, &executable_size) != 0) return 126; + + char executable_dir[PATH_MAX]; + snprintf(executable_dir, sizeof(executable_dir), "%s", executable_path); + char *macos_dir = dirname(executable_dir); + + char python_path[PATH_MAX]; + const char *bundled_executable = {_c_string_literal(str(bundled_relative_path))}; + if (snprintf(python_path, sizeof(python_path), "%s/%s", macos_dir, bundled_executable) >= (int)sizeof(python_path)) {{ + return 126; + }} + const char *python = python_path; + + const char *existing_path = getenv("PATH"); + const char *prefix_path = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; + if (existing_path && existing_path[0]) {{ + size_t path_len = strlen(prefix_path) + strlen(existing_path) + 2; + char *path_value = malloc(path_len); + if (!path_value) return 126; + snprintf(path_value, path_len, "%s:%s", prefix_path, existing_path); + setenv("PATH", path_value, 1); + free(path_value); + }} else {{ + setenv("PATH", prefix_path, 1); + }} + + char **child_argv = calloc((size_t)argc + 2, sizeof(char *)); + if (!child_argv) return 126; + child_argv[0] = (char *)python; + child_argv[1] = "macos-app"; + for (int i = 1; i < argc; i++) {{ + child_argv[i + 1] = argv[i]; + }} + child_argv[argc + 1] = NULL; + + pid_t pid; + int spawn_result = posix_spawn(&pid, python, NULL, NULL, child_argv, environ); + free(child_argv); + if (spawn_result != 0) {{ + errno = spawn_result; + perror("claude-tap macOS launcher"); + return 127; + }} + + int status = 0; + while (waitpid(pid, &status, 0) < 0) {{ + if (errno != EINTR) {{ + perror("claude-tap macOS launcher"); + return 127; + }} + }} + if (WIFEXITED(status)) {{ + return WEXITSTATUS(status); + }} + return 128; +}} +""" + + +def _build_pyinstaller_executable(resources_dir: Path) -> Path: + with tempfile.TemporaryDirectory(prefix="claude-tap-pyinstaller-") as tmp: + tmp_dir = Path(tmp) + entrypoint = tmp_dir / "claude_tap_entry.py" + entrypoint.write_text( + "from claude_tap.cli import main_entry\n\nif __name__ == '__main__':\n main_entry()\n", + encoding="utf-8", + ) + result = subprocess.run( + [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--onedir", + "--name", + "claude-tap", + "--target-architecture", + "arm64", + "--distpath", + str(resources_dir), + "--workpath", + str(tmp_dir / "build"), + "--specpath", + str(tmp_dir), + "--collect-data", + "claude_tap", + str(entrypoint), + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + details = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"Failed to build self-contained Claude Tap executable with PyInstaller: {details}") + + executable = resources_dir / "claude-tap" / "claude-tap" + if not executable.exists(): + raise RuntimeError(f"PyInstaller did not create expected executable: {executable}") + executable.chmod(executable.stat().st_mode | 0o755) + return executable + + +def _compile_native_launcher(source: str, output_path: Path) -> None: + compiler = _native_compiler() + if compiler is None: + raise RuntimeError("Building Claude Tap.app requires clang or cc. Install Xcode Command Line Tools first.") + + source_path = output_path.with_suffix(".c") + source_path.write_text(source, encoding="utf-8") + try: + result = subprocess.run( + [compiler, str(source_path), "-o", str(output_path)], + check=False, + capture_output=True, + text=True, + ) + finally: + source_path.unlink(missing_ok=True) + if result.returncode != 0: + details = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"Failed to compile Claude Tap.app launcher: {details}") + + +def _native_compiler() -> str | None: + return shutil.which("cc") or shutil.which("clang") + + +def _ad_hoc_sign_app(app_path: Path) -> None: + codesign = shutil.which("codesign") + if not codesign: + return + subprocess.run( + [codesign, "--force", "--sign", "-", str(app_path)], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def _c_string_literal(value: str) -> str: + chunks: list[str] = ['"'] + for byte in value.encode("utf-8"): + if byte == 34: + chunks.append('\\"') + elif byte == 92: + chunks.append("\\\\") + elif 32 <= byte <= 126: + chunks.append(chr(byte)) + else: + chunks.append(f"\\x{byte:02x}") + chunks.append('"') + return "".join(chunks) diff --git a/claude_tap/process_utils.py b/claude_tap/process_utils.py new file mode 100644 index 0000000..9f29231 --- /dev/null +++ b/claude_tap/process_utils.py @@ -0,0 +1,31 @@ +"""Subprocess helpers shared by CLI background tasks.""" + +from __future__ import annotations + +import subprocess +import sys +from typing import Any + +_CREATE_NO_WINDOW = 0x08000000 +_CREATE_NEW_PROCESS_GROUP = 0x00000200 +_STARTF_USESHOWWINDOW = 0x00000001 +_SW_HIDE = 0 + + +def windows_no_console_subprocess_kwargs() -> dict[str, Any]: + """Return Windows-only Popen kwargs for hidden background processes.""" + if sys.platform != "win32": + return {} + + kwargs: dict[str, Any] = { + "stdin": subprocess.DEVNULL, + "creationflags": getattr(subprocess, "CREATE_NO_WINDOW", _CREATE_NO_WINDOW) + | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", _CREATE_NEW_PROCESS_GROUP), + } + startupinfo_cls = getattr(subprocess, "STARTUPINFO", None) + if startupinfo_cls is not None: + startupinfo = startupinfo_cls() + startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", _STARTF_USESHOWWINDOW) + startupinfo.wShowWindow = getattr(subprocess, "SW_HIDE", _SW_HIDE) + kwargs["startupinfo"] = startupinfo + return kwargs diff --git a/claude_tap/prompt_snapshot.py b/claude_tap/prompt_snapshot.py new file mode 100644 index 0000000..24ee06b --- /dev/null +++ b/claude_tap/prompt_snapshot.py @@ -0,0 +1,537 @@ +"""Prompt snapshot extraction from trace records. + +The proxy records each provider's native request body. This module keeps a +small provider-aware normalization layer for downstream tools that want the +prompt surface rather than the full traffic trace. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class PromptTool: + name: str + description: str = "" + schema: dict[str, Any] = field(default_factory=dict) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PromptSnapshot: + provider: str + model: str + system_prompt: str = "" + developer_prompt: str = "" + user_message: str = "" + tools: tuple[PromptTool, ...] = () + turn: int | None = None + request_id: str = "" + path: str = "" + upstream_base_url: str = "" + captured_at: str = "" + raw_request_body: dict[str, Any] = field(default_factory=dict) + + +def snapshot_from_records(records: list[dict[str, Any]]) -> PromptSnapshot: + """Select the best prompt-bearing request and normalize it. + + Preference is intentionally simple and explainable: choose generation + requests with explicit prompt material and the largest tool surface. This + picks Claude Code's tool-bearing `/v1/messages` call and Codex's + `/v1/responses` call while ignoring lightweight probes. + """ + + candidates: list[tuple[int, dict[str, Any]]] = [] + for record in records: + body = _request_body(record) + if not body: + continue + provider = infer_provider(record) + if provider == "unknown": + continue + candidates.append((_score_record(record, provider), record)) + + if not candidates: + raise ValueError("no prompt-bearing request found in trace") + + _score, record = max(candidates, key=lambda item: item[0]) + provider = infer_provider(record) + if provider == "anthropic": + return _anthropic_snapshot(record) + if provider == "openai": + return _openai_snapshot(record) + if provider == "gemini": + return _gemini_snapshot(record) + raise ValueError("no prompt-bearing request found in trace") + + +def infer_provider(record: dict[str, Any]) -> str: + """Infer provider protocol from the trace path and request body.""" + + req = record.get("request") if isinstance(record.get("request"), dict) else {} + path = str(req.get("path") or "").split("?", 1)[0] + body = _request_body(record) + + if path.startswith("/v1/messages") or path.startswith("/v1/complete"): + return "anthropic" + if path.startswith("/v1/responses") or path.startswith("/responses"): + return "openai" + if path.startswith(("/v1/chat/completions", "/chat/completions", "/v1/completions", "/completions")): + return "openai" + if path.startswith("/v1internal"): + return "gemini" if _looks_like_gemini_body(body) else "unknown" + if path.startswith("/model/"): + return "anthropic" + if "/models/" in path or path.startswith(("/v1beta/models", "/v1/models")): + return "gemini" + + if "messages" in body and ("system" in body or "anthropic_version" in body): + return "anthropic" + if "instructions" in body or "input" in body: + return "openai" + if _looks_like_gemini_body(body): + return "gemini" + return "unknown" + + +def render_prompt_markdown(snapshot: PromptSnapshot) -> str: + """Render a normalized prompt snapshot as comparison-oriented Markdown. + + Volatile capture metadata such as request IDs, timestamps, upstream URLs, + and turn numbers belongs in trace/meta files. The prompt Markdown is meant + to diff cleanly across CLI versions, so it contains only prompt-bearing + content and tool definitions. + """ + + lines: list[str] = [] + + _append_section(lines, "System Prompt", snapshot.system_prompt) + _append_section(lines, "Developer Prompt", snapshot.developer_prompt) + _append_section(lines, "User Message", snapshot.user_message) + + lines.append("# Tools") + lines.append("") + if not snapshot.tools: + lines.append("_No tools captured._") + lines.append("") + for tool in sorted(snapshot.tools, key=lambda t: t.name): + lines.append(f"## {tool.name or 'unnamed_tool'}") + lines.append("") + if tool.description: + lines.append(_indent_markdown_headers(tool.description, levels=2)) + lines.append("") + schema = tool.schema if tool.schema else tool.raw + lines.append("```json") + lines.append(json.dumps(schema, indent=2, ensure_ascii=False)) + lines.append("```") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def _indent_markdown_headers(text: str, *, levels: int = 1) -> str: + prefix = "#" * levels + return "\n".join(f"{prefix}{line}" if line.startswith("#") else line for line in text.splitlines()) + + +def _score_record(record: dict[str, Any], provider: str) -> int: + body = _request_body(record) + tools = _tools_for_provider(provider, body) + system_text, developer_text, user_text = _prompt_text_for_provider(provider, body) + path = str((record.get("request") or {}).get("path") or "") + + score = len(tools) * 10 + if system_text: + score += 100 + if developer_text: + score += 60 + if user_text: + score += 20 + if path.endswith("/models") or "/models?" in path: + score -= 200 + return score + + +def _anthropic_snapshot(record: dict[str, Any]) -> PromptSnapshot: + body = _request_body(record) + system_prompt, _developer_prompt, user_message = _prompt_text_for_provider("anthropic", body) + tools = tuple(_anthropic_tools_from_body(body)) + return _base_snapshot( + record, + provider="anthropic", + model=str(body.get("model") or ""), + system_prompt=system_prompt, + user_message=user_message, + tools=tools, + ) + + +def _openai_snapshot(record: dict[str, Any]) -> PromptSnapshot: + body = _request_body(record) + system_prompt, developer_prompt, user_message = _prompt_text_for_provider("openai", body) + tools = tuple(_openai_tools(body.get("tools"))) + return _base_snapshot( + record, + provider="openai", + model=str(body.get("model") or ""), + system_prompt=system_prompt, + developer_prompt=developer_prompt, + user_message=user_message, + tools=tools, + ) + + +def _gemini_snapshot(record: dict[str, Any]) -> PromptSnapshot: + body = _request_body(record) + system_prompt, developer_prompt, user_message = _prompt_text_for_provider("gemini", body) + tools = tuple(_gemini_tools(body.get("tools"))) + model = str(body.get("model") or _gemini_model_from_path(str((record.get("request") or {}).get("path") or ""))) + return _base_snapshot( + record, + provider="gemini", + model=model, + system_prompt=system_prompt, + developer_prompt=developer_prompt, + user_message=user_message, + tools=tools, + ) + + +def _base_snapshot( + record: dict[str, Any], + *, + provider: str, + model: str, + system_prompt: str = "", + developer_prompt: str = "", + user_message: str = "", + tools: tuple[PromptTool, ...] = (), +) -> PromptSnapshot: + req = record.get("request") if isinstance(record.get("request"), dict) else {} + return PromptSnapshot( + provider=provider, + model=model, + system_prompt=system_prompt, + developer_prompt=developer_prompt, + user_message=user_message, + tools=tools, + turn=record.get("turn") if isinstance(record.get("turn"), int) else None, + request_id=str(record.get("request_id") or ""), + path=str(req.get("path") or ""), + upstream_base_url=str(record.get("upstream_base_url") or ""), + captured_at=str(record.get("timestamp") or ""), + raw_request_body=_request_body(record), + ) + + +def _request_body(record: dict[str, Any]) -> dict[str, Any]: + req = record.get("request") if isinstance(record.get("request"), dict) else {} + candidates: list[dict[str, Any]] = [] + body = req.get("body") + if isinstance(body, dict): + candidates.append(body) + ws_events = req.get("ws_events") + if isinstance(ws_events, list): + candidates.extend(event for event in ws_events if isinstance(event, dict)) + if not candidates: + return {} + + return max((_prompt_body(candidate) for candidate in candidates), key=_prompt_body_score) + + +def _prompt_body(body: dict[str, Any]) -> dict[str, Any]: + nested = body.get("request") + if isinstance(nested, dict) and _looks_like_gemini_body(nested): + return nested + return body + + +def _prompt_body_score(body: dict[str, Any]) -> int: + score = 0 + for key, weight in ( + ("system", 100), + ("instructions", 100), + ("system_instruction", 100), + ("systemInstruction", 100), + ("messages", 40), + ("input", 40), + ("contents", 40), + ("prompt", 40), + ("tools", 20), + ): + if key in body: + score += weight + tools = body.get("tools") + if isinstance(tools, list): + score += len(tools) + return score + + +def _looks_like_gemini_body(body: dict[str, Any]) -> bool: + return any(key in body for key in ("contents", "system_instruction", "systemInstruction")) + + +def _tools_for_provider(provider: str, body: dict[str, Any]) -> list[PromptTool]: + if provider == "anthropic": + return _anthropic_tools_from_body(body) + if provider == "openai": + return _openai_tools(body.get("tools")) + if provider == "gemini": + return _gemini_tools(body.get("tools")) + return [] + + +def _prompt_text_for_provider(provider: str, body: dict[str, Any]) -> tuple[str, str, str]: + legacy_prompt = body.get("prompt") if isinstance(body.get("prompt"), str) else "" + if provider == "anthropic": + return ( + _anthropic_system_text(body.get("system")), + "", + _join_text([_messages_text(body.get("messages"), {"user"}), legacy_prompt]), + ) + if provider == "openai": + input_value = body.get("input") + messages = body.get("messages") + developer = _join_text( + [ + _input_text(input_value, {"developer"}), + _messages_text(messages, {"developer"}), + ] + ) + system = _join_text( + [ + str(body.get("instructions") or ""), + _input_text(input_value, {"system"}), + _messages_text(messages, {"system"}), + ] + ) + user = _join_text( + [ + _input_text(input_value, {"user"}), + _messages_text(messages, {"user"}), + legacy_prompt, + ] + ) + return (system, developer, user) + if provider == "gemini": + return ( + _gemini_parts_text(body.get("system_instruction") or body.get("systemInstruction")), + _contents_text(body.get("contents"), {"developer", "system"}), + _contents_text(body.get("contents"), {"user"}), + ) + return ("", "", "") + + +def _anthropic_system_text(system: Any) -> str: + if isinstance(system, str): + return system + if isinstance(system, list): + return _join_text(_content_text(item) for item in system) + return "" + + +def _messages_text(messages: Any, roles: set[str]) -> str: + if not isinstance(messages, list): + return "" + parts = [] + for msg in messages: + if isinstance(msg, dict) and msg.get("role") in roles: + parts.append(_content_text(msg.get("content"))) + return _join_text(parts) + + +def _input_text(input_value: Any, roles: set[str]) -> str: + if isinstance(input_value, str): + return input_value if "user" in roles else "" + if not isinstance(input_value, list): + return "" + parts: list[str] = [] + for item in input_value: + if not isinstance(item, dict) or item.get("role") not in roles: + continue + parts.append(_content_text(item.get("content"))) + return _join_text(parts) + + +def _contents_text(contents: Any, roles: set[str]) -> str: + if not isinstance(contents, list): + return "" + parts: list[str] = [] + for item in contents: + if not isinstance(item, dict): + continue + role = item.get("role") or "user" + if role not in roles: + continue + parts.append(_gemini_parts_text(item)) + return _join_text(parts) + + +def _content_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, dict): + if isinstance(content.get("text"), str): + return content["text"] + if isinstance(content.get("input_text"), str): + return content["input_text"] + if "parts" in content: + return _gemini_parts_text(content) + return "" + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict): + for key in ("text", "input_text", "output_text"): + if isinstance(block.get(key), str): + parts.append(block[key]) + break + else: + nested = block.get("content") + if isinstance(nested, (str, list, dict)): + text = _content_text(nested) + if text: + parts.append(text) + return _join_text(parts) + + +def _gemini_parts_text(value: Any) -> str: + if not isinstance(value, dict): + return "" + parts = value.get("parts") + if not isinstance(parts, list): + return "" + return _join_text( + part.get("text") for part in parts if isinstance(part, dict) and isinstance(part.get("text"), str) + ) + + +def _anthropic_tools_from_body(body: dict[str, Any]) -> list[PromptTool]: + tools = _anthropic_tools(body.get("tools")) + tool_config = body.get("toolConfig") + if isinstance(tool_config, dict): + tools.extend(_bedrock_tool_config_tools(tool_config.get("tools"))) + return tools + + +def _anthropic_tools(tools: Any) -> list[PromptTool]: + if not isinstance(tools, list): + return [] + out: list[PromptTool] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + out.append( + PromptTool( + name=str(tool.get("name") or tool.get("type") or ""), + description=str(tool.get("description") or ""), + schema=tool.get("input_schema") if isinstance(tool.get("input_schema"), dict) else {}, + raw=tool, + ) + ) + return out + + +def _bedrock_tool_config_tools(tools: Any) -> list[PromptTool]: + if not isinstance(tools, list): + return [] + out: list[PromptTool] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + spec = tool.get("toolSpec") + if not isinstance(spec, dict): + continue + input_schema = spec.get("inputSchema") + schema = input_schema.get("json") if isinstance(input_schema, dict) else {} + out.append( + PromptTool( + name=str(spec.get("name") or ""), + description=str(spec.get("description") or ""), + schema=schema if isinstance(schema, dict) else {}, + raw=tool, + ) + ) + return out + + +def _openai_tools(tools: Any) -> list[PromptTool]: + if not isinstance(tools, list): + return [] + out: list[PromptTool] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + if isinstance(tool.get("function"), dict): + fn = tool["function"] + raw = tool + else: + fn = tool + raw = tool + schema = fn.get("parameters") if isinstance(fn.get("parameters"), dict) else {} + if not schema and isinstance(fn.get("input_schema"), dict): + schema = fn["input_schema"] + out.append( + PromptTool( + name=str(fn.get("name") or fn.get("type") or tool.get("type") or ""), + description=str(fn.get("description") or ""), + schema=schema, + raw=raw, + ) + ) + return out + + +def _gemini_tools(tools: Any) -> list[PromptTool]: + if not isinstance(tools, list): + return [] + out: list[PromptTool] = [] + for tool in tools: + if not isinstance(tool, dict): + continue + declarations = tool.get("function_declarations") or tool.get("functionDeclarations") + if isinstance(declarations, list): + for decl in declarations: + if not isinstance(decl, dict): + continue + params = decl.get("parameters") if isinstance(decl.get("parameters"), dict) else {} + out.append( + PromptTool( + name=str(decl.get("name") or ""), + description=str(decl.get("description") or ""), + schema=params, + raw=decl, + ) + ) + else: + out.append(PromptTool(name=str(tool.get("name") or tool.get("type") or ""), raw=tool)) + return out + + +def _gemini_model_from_path(path: str) -> str: + marker = "/models/" + if marker not in path: + return "" + tail = path.split(marker, 1)[1].split("?", 1)[0] + return tail.split(":", 1)[0] + + +def _join_text(parts: Any) -> str: + return "\n\n".join(str(part).strip() for part in parts if isinstance(part, str) and part.strip()) + + +def _append_section(lines: list[str], title: str, text: str) -> None: + if not text: + return + lines.append(f"# {title}") + lines.append("") + lines.append(_indent_markdown_headers(text)) + lines.append("") diff --git a/claude_tap/proxy.py b/claude_tap/proxy.py new file mode 100644 index 0000000..b09922d --- /dev/null +++ b/claude_tap/proxy.py @@ -0,0 +1,880 @@ +"""Proxy handler – forward requests to upstream API and record traces.""" + +from __future__ import annotations + +import asyncio +import gzip +import hashlib +import json +import logging +import re +import struct +import time +import uuid +import zlib +from datetime import datetime, timezone + +import aiohttp +from aiohttp import web +from yarl import URL + +from claude_tap.bedrock import attach_bedrock_errors, bedrock_model_from_path, is_bedrock_eventstream_path +from claude_tap.sse import SSEReassembler +from claude_tap.trace import TraceWriter +from claude_tap.upstream import build_upstream_url, format_upstream_error +from claude_tap.usage import normalize_usage +from claude_tap.viewer import _decode_bedrock_eventstream_events + +log = logging.getLogger("claude-tap") + +# --------------------------------------------------------------------------- +# Header helpers +# --------------------------------------------------------------------------- + +HOP_BY_HOP = frozenset( + { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + } +) + +SENSITIVE_HEADER_KEYS = frozenset( + { + "authorization", + "cookie", + "set-cookie", + "set-cookie2", + "x-api-key", + "x-amz-security-token", + # Qoder/Cosy runtime headers can carry account, machine, or token-derived + # identifiers and must not be persisted in trace evidence. + "cosy-key", + "cosy-machinetoken", + "cosy-machine-token", + "cosy-machineid", + "cosy-machine-id", + "cosy-machinetype", + "cosy-machine-type", + "cosy-user", + } +) +PREFIX_REDACTED_HEADER_KEYS = frozenset({"authorization", "x-api-key"}) + + +def filter_headers(headers: dict[str, str], *, redact_keys: bool = False) -> dict[str, str]: + """Filter hop-by-hop headers and optionally redact sensitive values.""" + out: dict[str, str] = {} + for k, v in headers.items(): + key = k.lower() + if key in HOP_BY_HOP: + continue + if redact_keys and key in SENSITIVE_HEADER_KEYS: + out[k] = v[:12] + "..." if key in PREFIX_REDACTED_HEADER_KEYS and len(v) > 12 else "***" + else: + out[k] = v + return out + + +def _parse_request_body_for_trace(body: bytes) -> object: + """Parse a request body for trace storage without mutating upstream bytes.""" + if not body: + return None + + try: + parsed = json.loads(body) + except (json.JSONDecodeError, ValueError): + return body.decode("utf-8", errors="replace") + + if isinstance(parsed, str): + try: + inner = json.loads(parsed) + except (json.JSONDecodeError, ValueError): + return parsed + if isinstance(inner, dict): + return inner + + return parsed + + +# --------------------------------------------------------------------------- +# Path allowlist – only forward requests to known API endpoints. +# Scanners / crawlers hitting the proxy with paths like /etc/passwd, /swagger, +# /metrics etc. are rejected with 404 without forwarding or recording. +# --------------------------------------------------------------------------- + +ALLOWED_PATH_PREFIXES: tuple[str, ...] = ( + # Anthropic API (Claude Code) + "/v1/messages", + "/v1/complete", + # AWS Bedrock API (Claude Code via Bedrock) + "/model", + # OpenAI API (Codex CLI) + "/v1/responses", + "/v1/chat/completions", + "/v1/completions", + "/v1/models", + "/v1/embeddings", + "/v1/files", + # OpenAI Responses API (after strip_path_prefix removes /v1) + "/responses", + "/chat/completions", + "/completions", + "/models", + "/embeddings", + "/files", + # Gemini API + "/v1beta/models", + "/v1alpha/models", + # Google Code Assist / Antigravity internal API + "/v1internal", + # Kimi Code auxiliary APIs (when users proxy Kimi Code services explicitly) + "/search", + "/fetch", + "/usages", + "/feedback", +) + +_VERTEX_ANTHROPIC_RAW_PREDICT_RE = re.compile( + r"^/v1/projects/[^/]+/locations/[^/]+/publishers/anthropic/models/[^/]+" + r"(?::(?:rawPredict|streamRawPredict)|/count-tokens:rawPredict)$" +) + + +def _is_vertex_anthropic_raw_predict_path(clean_path: str) -> bool: + return bool(_VERTEX_ANTHROPIC_RAW_PREDICT_RE.fullmatch(clean_path.rstrip("/"))) + + +def _is_allowed_path(path: str, extra_prefixes: tuple[str, ...] = ()) -> bool: + """Check whether the request path matches a known API endpoint.""" + clean = path.split("?", 1)[0].rstrip("/") + if _is_vertex_anthropic_raw_predict_path(clean): + return True + prefixes = ALLOWED_PATH_PREFIXES + extra_prefixes + return any( + clean == prefix or clean.startswith(prefix + "/") or clean.startswith(prefix + ":") for prefix in prefixes + ) + + +_ANTHROPIC_METADATA_USER_ID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") +_BEDROCK_GATEWAY_UNSUPPORTED_BODY_FIELDS = frozenset({"context_management", "output_config"}) + + +def _is_deepseek_anthropic_target(target: str) -> bool: + """Return True for DeepSeek's Anthropic-compatible API target.""" + try: + url = URL(target) + except ValueError: + return False + return url.host == "api.deepseek.com" and url.path.rstrip("/") == "/anthropic" + + +def _normalize_request_body_for_upstream(req_body: dict, target: str) -> dict: + """Apply narrow upstream compatibility fixes without changing default Anthropic behavior.""" + normalized_body = _normalize_bedrock_gateway_body(req_body) + if normalized_body is not req_body: + req_body = normalized_body + + if not _is_deepseek_anthropic_target(target): + return req_body + + metadata = req_body.get("metadata") + if not isinstance(metadata, dict): + return req_body + + user_id = metadata.get("user_id") + if not isinstance(user_id, str) or _ANTHROPIC_METADATA_USER_ID_PATTERN.fullmatch(user_id): + return req_body + + normalized_body = dict(req_body) + normalized_metadata = dict(metadata) + digest = hashlib.sha256(user_id.encode("utf-8")).hexdigest()[:24] + normalized_metadata["user_id"] = f"claude_tap_{digest}" + normalized_body["metadata"] = normalized_metadata + return normalized_body + + +def _normalize_bedrock_gateway_body(req_body: dict) -> dict: + if not _is_bedrock_gateway_request(req_body): + return req_body + + normalized_body: dict | None = None + for key in _BEDROCK_GATEWAY_UNSUPPORTED_BODY_FIELDS: + if key in req_body: + normalized_body = dict(req_body) if normalized_body is None else normalized_body + normalized_body.pop(key, None) + + body = normalized_body if normalized_body is not None else req_body + thinking = body.get("thinking") + if isinstance(thinking, dict) and thinking.get("type") == "adaptive": + normalized_body = dict(body) if normalized_body is None else normalized_body + normalized_body.pop("thinking", None) + + return normalized_body if normalized_body is not None else req_body + + +def _is_bedrock_gateway_request(req_body: object) -> bool: + """Return True when an Anthropic-compatible gateway routes by a Bedrock model prefix.""" + if not isinstance(req_body, dict): + return False + model = req_body.get("model") + return isinstance(model, str) and model.startswith("bedrock/") + + +def _drop_header(headers: dict[str, str], header_name: str) -> None: + target = header_name.lower() + for key in list(headers): + if key.lower() == target: + del headers[key] + + +def _drop_query_param(raw_path: str, param_name: str) -> str: + path, separator, query = raw_path.partition("?") + if not separator: + return raw_path + kept = [part for part in query.split("&") if part.split("=", 1)[0] != param_name] + if not kept: + return path + return f"{path}?{'&'.join(kept)}" + + +def is_capture_only_request(path: str, req_body: object) -> bool: + """Return whether capture-only mode should short-circuit this request. + + Forward proxy clients may make unrelated HTTPS calls during startup. Prompt + export mode should only synthesize model API responses; everything else can + continue upstream and be filtered by the normal trace-skip rules. + """ + + clean_path = path.split("?", 1)[0] + if clean_path.startswith(("/v1/embeddings", "/embeddings", "/v1/files", "/files")): + return False + if _is_vertex_anthropic_raw_predict_path(clean_path): + return True + if clean_path.startswith( + ( + "/v1/messages", + "/v1/complete", + "/model/", + "/v1/responses", + "/responses", + "/v1/chat/completions", + "/chat/completions", + "/v1/completions", + "/completions", + "/v1/models", + "/models", + "/v1beta/models", + "/v1alpha/models", + ) + ): + return True + if clean_path.startswith(("/v1internal:", "/v1internal/")): + return "generatecontent" in clean_path.lower() + if isinstance(req_body, dict) and isinstance(req_body.get("request"), dict): + return is_capture_only_request(path, req_body["request"]) + return isinstance(req_body, dict) and any( + key in req_body for key in ("system", "messages", "instructions", "input", "contents", "system_instruction") + ) + + +def is_capture_only_streaming_request(path: str, req_body: object) -> bool: + """Return whether a captured request expects a streaming response by path or body.""" + + if is_bedrock_eventstream_path(path): + return True + if _is_vertex_anthropic_raw_predict_path(path.split("?", 1)[0]) and ":streamRawPredict" in path: + return True + if "streamGenerateContent" in path: + return True + return isinstance(req_body, dict) and bool(req_body.get("stream", False)) + + +def capture_only_content_type(path: str, is_streaming: bool) -> str: + if is_bedrock_eventstream_path(path): + return "application/vnd.amazon.eventstream" + if is_streaming: + return "text/event-stream" + return "application/json" + + +def capture_only_response(path: str, req_body: object) -> dict: + """Return a protocol-shaped success response without contacting upstream.""" + model = req_body.get("model", "claude-tap-capture") if isinstance(req_body, dict) else "claude-tap-capture" + clean_path = path.split("?", 1)[0] + if clean_path.startswith("/model/") and clean_path.rstrip("/").endswith("/converse"): + return { + "output": {"message": {"role": "assistant", "content": [{"text": "captured"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0}, + } + if _is_vertex_anthropic_raw_predict_path(clean_path) and clean_path.endswith("/count-tokens:rawPredict"): + return {"input_tokens": 0} + if clean_path.startswith("/v1/complete"): + return _capture_only_anthropic_completion_response(model) + if clean_path.startswith(("/v1/messages", "/model/")) or _is_vertex_anthropic_raw_predict_path(clean_path): + return { + "id": "msg_claude_tap_capture", + "type": "message", + "role": "assistant", + "model": model, + "content": [{"type": "text", "text": "captured"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 0, "output_tokens": 0}, + } + if clean_path.startswith(("/v1internal:", "/v1internal/")): + return _capture_only_gemini_generation_response() + if clean_path in {"/v1/models", "/models"}: + return {"object": "list", "data": [{"id": str(model), "object": "model"}]} + if clean_path.startswith(("/v1/models/", "/models/")): + model_id = clean_path.rsplit("/", 1)[-1] or str(model) + return {"id": model_id, "object": "model", "created": 0, "owned_by": "claude-tap"} + if clean_path.startswith(("/v1beta/models", "/v1alpha/models")): + if ":" not in clean_path.rsplit("/", 1)[-1]: + return _capture_only_gemini_model_response(clean_path, model) + return _capture_only_gemini_generation_response() + if clean_path.startswith(("/v1/completions", "/completions")): + return { + "id": "cmpl_claude_tap_capture", + "object": "text_completion", + "created": 0, + "model": model, + "choices": [{"index": 0, "text": "captured", "finish_reason": "stop"}], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + if "chat/completions" in clean_path: + return { + "id": "chatcmpl_claude_tap_capture", + "object": "chat.completion", + "created": 0, + "model": model, + "choices": [{"index": 0, "message": {"role": "assistant", "content": "captured"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + return { + "id": "resp_claude_tap_capture", + "object": "response", + "created_at": 0, + "model": model, + "status": "completed", + "output": [{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "captured"}]}], + "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}, + } + + +def _capture_only_anthropic_completion_response(model: object) -> dict: + return { + "id": "compl_claude_tap_capture", + "type": "completion", + "model": model, + "completion": "captured", + "stop_reason": "stop_sequence", + } + + +def _capture_only_gemini_generation_response() -> dict: + return { + "candidates": [ + { + "content": {"role": "model", "parts": [{"text": "captured"}]}, + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": {"promptTokenCount": 0, "candidatesTokenCount": 0, "totalTokenCount": 0}, + } + + +def _capture_only_gemini_model_response(clean_path: str, model: object) -> dict: + if clean_path in {"/v1beta/models", "/v1alpha/models"}: + model_name = f"models/{model}" + return {"models": [_capture_only_gemini_model(model_name)]} + + model_id = clean_path.rsplit("/", 1)[-1] or str(model) + model_name = model_id if model_id.startswith("models/") else f"models/{model_id}" + return _capture_only_gemini_model(model_name) + + +def _capture_only_gemini_model(model_name: str) -> dict: + model_id = model_name.rsplit("/", 1)[-1] + return { + "name": model_name, + "version": model_id, + "displayName": model_id, + "supportedGenerationMethods": ["generateContent", "streamGenerateContent"], + } + + +def capture_only_stream_bytes(path: str, req_body: object) -> bytes: + """Return a small provider-shaped SSE response for streaming capture-only requests.""" + + resp_body = capture_only_response(path, req_body) + clean_path = path.split("?", 1)[0] + if is_bedrock_eventstream_path(path): + return _capture_only_bedrock_eventstream_bytes(path) + if clean_path.startswith("/v1/complete"): + chunk = {**resp_body, "stop_reason": None} + done = {**resp_body, "completion": "", "stop_reason": "stop_sequence"} + return ( + f"data: {json.dumps(chunk, separators=(',', ':'))}\n\ndata: {json.dumps(done, separators=(',', ':'))}\n\n" + ).encode("utf-8") + if clean_path.startswith("/v1/messages"): + return _capture_only_anthropic_message_stream_bytes(resp_body) + if _is_vertex_anthropic_raw_predict_path(clean_path) and clean_path.endswith(":streamRawPredict"): + return _capture_only_anthropic_message_stream_bytes(resp_body) + if clean_path.startswith(("/v1/completions", "/completions")): + chunk = { + "id": resp_body["id"], + "object": "text_completion", + "created": 0, + "model": resp_body["model"], + "choices": [{"index": 0, "text": "captured", "finish_reason": None}], + } + done = { + "id": resp_body["id"], + "object": "text_completion", + "created": 0, + "model": resp_body["model"], + "choices": [{"index": 0, "text": "", "finish_reason": "stop"}], + } + return ( + f"data: {json.dumps(chunk, separators=(',', ':'))}\n\n" + f"data: {json.dumps(done, separators=(',', ':'))}\n\n" + "data: [DONE]\n\n" + ).encode("utf-8") + if "chat/completions" in clean_path: + chunk = { + "id": resp_body["id"], + "object": "chat.completion.chunk", + "created": 0, + "model": resp_body["model"], + "choices": [{"index": 0, "delta": {"content": "captured"}, "finish_reason": None}], + } + done = { + "id": resp_body["id"], + "object": "chat.completion.chunk", + "created": 0, + "model": resp_body["model"], + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + return ( + f"data: {json.dumps(chunk, separators=(',', ':'))}\n\n" + f"data: {json.dumps(done, separators=(',', ':'))}\n\n" + "data: [DONE]\n\n" + ).encode("utf-8") + if clean_path.startswith(("/v1beta/models", "/v1alpha/models", "/v1internal:", "/v1internal/")): + return f"data: {json.dumps(resp_body, separators=(',', ':'))}\n\n".encode("utf-8") + + created = {"type": "response.created", "response": {**resp_body, "status": "in_progress"}} + completed = {"type": "response.completed", "response": {**resp_body, "status": "completed"}} + return ( + f"data: {json.dumps(created, separators=(',', ':'))}\n\n" + f"data: {json.dumps(completed, separators=(',', ':'))}\n\n" + "data: [DONE]\n\n" + ).encode("utf-8") + + +def _capture_only_anthropic_message_stream_bytes(resp_body: dict) -> bytes: + events = [ + ("message_start", {"type": "message_start", "message": resp_body}), + ( + "content_block_start", + {"type": "content_block_start", "index": 0, "content_block": resp_body["content"][0]}, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn"}}), + ("message_stop", {"type": "message_stop"}), + ] + return b"".join( + f"event: {event}\ndata: {json.dumps(payload, separators=(',', ':'))}\n\n".encode("utf-8") + for event, payload in events + ) + + +def _capture_only_bedrock_eventstream_bytes(path: str) -> bytes: + model = bedrock_model_from_path(path) or "claude-tap-capture" + if path.split("?", 1)[0].rstrip("/").endswith("/converse-stream"): + events = [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "captured"}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0}}}, + ] + return b"".join(_capture_only_bedrock_frame(event, next(iter(event))) for event in events) + else: + events = [ + { + "type": "message_start", + "message": { + "id": "msg_claude_tap_capture", + "type": "message", + "role": "assistant", + "model": model, + "content": [], + "usage": {"input_tokens": 0, "output_tokens": 0}, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "captured"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 0}}, + {"type": "message_stop"}, + ] + return b"".join(_capture_only_bedrock_frame(event) for event in events) + + +def _capture_only_bedrock_frame(payload: dict, event_type: str = "chunk") -> bytes: + payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + headers = b"".join( + _bedrock_eventstream_header(name, value) + for name, value in ( + (":message-type", "event"), + (":event-type", event_type), + (":content-type", "application/json"), + ) + ) + prelude = struct.pack("!II", 16 + len(headers) + len(payload_bytes), len(headers)) + prelude_crc = struct.pack("!I", zlib.crc32(prelude) & 0xFFFFFFFF) + message = prelude + prelude_crc + headers + payload_bytes + return message + struct.pack("!I", zlib.crc32(message) & 0xFFFFFFFF) + + +def _bedrock_eventstream_header(name: str, value: str) -> bytes: + name_bytes = name.encode("utf-8") + value_bytes = value.encode("utf-8") + return bytes([len(name_bytes)]) + name_bytes + b"\x07" + struct.pack("!H", len(value_bytes)) + value_bytes + + +# --------------------------------------------------------------------------- +# Proxy handler +# --------------------------------------------------------------------------- + + +async def proxy_handler(request: web.Request) -> web.StreamResponse: + # Reject requests to unknown paths (scanner/crawler protection) + ctx: dict = request.app["trace_ctx"] + extra_prefixes: tuple[str, ...] = ctx.get("extra_allowed_path_prefixes", ()) + if not _is_allowed_path(request.path, extra_prefixes): + log.debug(f"Blocked non-API path: {request.method} {request.path}") + return web.Response(status=404, text="Not Found") + + # Detect WebSocket upgrade and route to WS proxy. + if request.headers.get("Upgrade", "").lower() == "websocket": + if ctx.get("force_http"): + log.info(f"Rejecting WebSocket upgrade on {request.path} (force_http); client will fallback to HTTP") + return web.Response(status=426, text="Upgrade Required") + from claude_tap.ws_proxy import _handle_websocket + + return await _handle_websocket(request) + + target: str = ctx["target_url"] + writer: TraceWriter = ctx["writer"] + session: aiohttp.ClientSession = ctx["session"] + + # Strip path prefix (e.g. /v1) for codex client so that + # /v1/responses -> target + /responses + strip_prefix: str = ctx.get("strip_path_prefix", "") + fwd_path = request.raw_path + if strip_prefix and fwd_path.startswith(strip_prefix): + fwd_path = fwd_path[len(strip_prefix) :] or "/" + + # aiohttp auto-decompresses request bodies (gzip/deflate/zstd), so + # request.read() returns plain bytes even when Content-Encoding is set. + body = await request.read() + + fwd_headers = filter_headers(request.headers) + fwd_headers.pop("Host", None) + # Strip Content-Encoding since aiohttp already decompressed the body; + # also remove stale Content-Length (aiohttp client will recompute it). + req_content_encoding = request.headers.get("Content-Encoding", "").lower() + if req_content_encoding in ("zstd", "gzip", "deflate", "br"): + for key in list(fwd_headers.keys()): + if key.lower() in ("content-encoding", "content-length"): + del fwd_headers[key] + + req_id = f"req_{uuid.uuid4().hex[:12]}" + t0 = time.monotonic() + + req_body = _parse_request_body_for_trace(body) + trace_req_body = req_body + upstream_req_body = req_body + + upstream_body = body + if isinstance(req_body, dict): + normalized_req_body = _normalize_request_body_for_upstream(req_body, target) + if normalized_req_body is not req_body: + upstream_req_body = normalized_req_body + upstream_body = json.dumps(upstream_req_body, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + for key in list(fwd_headers.keys()): + if key.lower() == "content-length": + del fwd_headers[key] + + if _is_bedrock_gateway_request(upstream_req_body): + _drop_header(fwd_headers, "anthropic-beta") + fwd_path = _drop_query_param(fwd_path, "beta") + + upstream_url = build_upstream_url(target, fwd_path) + + is_streaming = is_capture_only_streaming_request(request.raw_path, trace_req_body) + + ctx["turn_counter"] = ctx.get("turn_counter", 0) + 1 + turn = ctx["turn_counter"] + + model = trace_req_body.get("model", "") if isinstance(trace_req_body, dict) else "" + log_prefix = f"[Turn {turn}]" + log.info( + f"{log_prefix} → {request.method} {request.path} (model={model}, stream={is_streaming}, upstream={upstream_url})" + ) + + if ctx.get("capture_only") and is_capture_only_request(request.raw_path, trace_req_body): + resp_body = capture_only_response(request.raw_path, trace_req_body) + content_type = capture_only_content_type(request.raw_path, is_streaming) + response_headers = {"Content-Type": content_type} + duration_ms = int((time.monotonic() - t0) * 1000) + record = _build_record( + req_id, + turn, + duration_ms, + request.method, + request.raw_path, + request.headers, + trace_req_body, + 200, + response_headers, + resp_body, + upstream_base_url=target, + ) + await writer.write(record) + log.info(f"{log_prefix} ← 200 capture-only ({duration_ms}ms, upstream skipped)") + if is_streaming: + return web.Response( + body=capture_only_stream_bytes(request.raw_path, trace_req_body), content_type=content_type + ) + return web.json_response(resp_body) + + # Request identity encoding from upstream to avoid client-side zstd decode issues + # and to simplify SSE/text reconstruction. + fwd_headers["Accept-Encoding"] = "identity" + + try: + upstream_resp = await session.request( + method=request.method, + url=upstream_url, + headers=fwd_headers, + data=upstream_body, + timeout=aiohttp.ClientTimeout(total=600, sock_read=300), + ) + except Exception as exc: + error_text = format_upstream_error(exc, target_url=target, upstream_url=upstream_url) + log.error( + f"{log_prefix} upstream error while requesting {upstream_url}: {error_text} " + f"-- Check that the target ({target}) is reachable." + ) + return web.Response(status=502, text=error_text) + + if is_streaming and upstream_resp.status == 200: + resp_body = await _handle_streaming( + request, + upstream_resp, + req_id, + turn, + t0, + trace_req_body, + writer, + log_prefix, + upstream_base_url=target, + store_stream_events=bool(ctx.get("store_stream_events", False)), + ) + return resp_body + + return await _handle_non_streaming( + request, + upstream_resp, + req_id, + turn, + t0, + trace_req_body, + writer, + log_prefix, + upstream_base_url=target, + ) + + +async def _handle_streaming( + request: web.Request, + upstream_resp: aiohttp.ClientResponse, + req_id: str, + turn: int, + t0: float, + req_body, + writer: TraceWriter, + log_prefix: str, + upstream_base_url: str, + store_stream_events: bool, +) -> web.StreamResponse: + resp = web.StreamResponse( + status=upstream_resp.status, + headers={k: v for k, v in upstream_resp.headers.items() if k.lower() not in HOP_BY_HOP}, + ) + await resp.prepare(request) + + is_bedrock_stream = is_bedrock_eventstream_path(request.raw_path) + reassembler = SSEReassembler(store_events=store_stream_events) + raw_chunks: list[bytes] = [] + + try: + async for chunk in upstream_resp.content.iter_any(): + await resp.write(chunk) + if is_bedrock_stream: + raw_chunks.append(chunk) + else: + reassembler.feed_bytes(chunk) + except (ConnectionError, asyncio.CancelledError): + pass + + try: + await resp.write_eof() + except (ConnectionError, ConnectionResetError, Exception): + pass + + duration_ms = int((time.monotonic() - t0) * 1000) + + if is_bedrock_stream: + raw_body = b"".join(raw_chunks).decode("utf-8", errors="replace") + bedrock_events = _decode_bedrock_eventstream_events(raw_body) + for event in bedrock_events: + reassembler.add_event(event["event"], event["data"]) + reconstructed = reassembler.reconstruct() + if not reconstructed: + reconstructed = raw_body + reconstructed = attach_bedrock_errors(reconstructed, bedrock_events) + else: + reconstructed = reassembler.reconstruct() + + usage = normalize_usage(reconstructed.get("usage", {}) if isinstance(reconstructed, dict) else {}) + in_tok = usage.get("input_tokens", 0) + out_tok = usage.get("output_tokens", 0) + cache_read = usage.get("cache_read_input_tokens", 0) + cache_create = usage.get("cache_creation_input_tokens", 0) + log.info( + f"{log_prefix} ← 200 stream done ({duration_ms}ms, " + f"in={in_tok} out={out_tok} cache_read={cache_read} cache_create={cache_create})" + ) + + record = _build_record( + req_id, + turn, + duration_ms, + request.method, + request.raw_path, + request.headers, + req_body, + upstream_resp.status, + upstream_resp.headers, + reconstructed, + sse_events=reassembler.events, + upstream_base_url=upstream_base_url, + ) + await writer.write(record) + + return resp + + +async def _handle_non_streaming( + request: web.Request, + upstream_resp: aiohttp.ClientResponse, + req_id: str, + turn: int, + t0: float, + req_body, + writer: TraceWriter, + log_prefix: str, + upstream_base_url: str, +) -> web.Response: + resp_bytes = await upstream_resp.read() + duration_ms = int((time.monotonic() - t0) * 1000) + + # Decompress for JSON parsing (raw bytes are forwarded as-is to client) + content_encoding = upstream_resp.headers.get("Content-Encoding", "").lower() + decode_bytes = resp_bytes + if resp_bytes and content_encoding in ("gzip", "deflate"): + try: + if content_encoding == "gzip": + decode_bytes = gzip.decompress(resp_bytes) + else: + decode_bytes = zlib.decompress(resp_bytes) + except Exception: + pass + + try: + resp_body = json.loads(decode_bytes) if decode_bytes else None + except (json.JSONDecodeError, ValueError): + resp_body = decode_bytes.decode("utf-8", errors="replace") if decode_bytes else None + + log.info(f"{log_prefix} ← {upstream_resp.status} ({duration_ms}ms, {len(resp_bytes)} bytes)") + + record = _build_record( + req_id, + turn, + duration_ms, + request.method, + request.raw_path, + request.headers, + req_body, + upstream_resp.status, + upstream_resp.headers, + resp_body, + upstream_base_url=upstream_base_url, + ) + await writer.write(record) + + return web.Response( + status=upstream_resp.status, + headers={k: v for k, v in upstream_resp.headers.items() if k.lower() not in HOP_BY_HOP}, + body=resp_bytes, + ) + + +def _build_record( + req_id: str, + turn: int, + duration_ms: int, + method: str, + path_qs: str, + req_headers: dict, + req_body: dict | None, + status: int, + resp_headers: dict, + resp_body: dict | None, + sse_events: list[dict] | None = None, + upstream_base_url: str | None = None, +) -> dict: + """Build a trace record for a single API call.""" + record: dict = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "request_id": req_id, + "turn": turn, + "duration_ms": duration_ms, + "request": { + "method": method, + "path": path_qs, + "headers": filter_headers(req_headers, redact_keys=True), + "body": req_body, + }, + "response": { + "status": status, + "headers": filter_headers(resp_headers, redact_keys=True), + "body": resp_body, + }, + } + if sse_events: + record["response"]["sse_events"] = sse_events + if upstream_base_url: + record["upstream_base_url"] = upstream_base_url + return record diff --git a/claude_tap/py.typed b/claude_tap/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/claude_tap/shared_dashboard.py b/claude_tap/shared_dashboard.py new file mode 100644 index 0000000..26408a9 --- /dev/null +++ b/claude_tap/shared_dashboard.py @@ -0,0 +1,464 @@ +"""Shared local dashboard process used by concurrent claude-tap sessions.""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import os +import re +import shutil +import signal +import subprocess +import sys +import time +import urllib.error +import urllib.request +from contextlib import contextmanager +from importlib.metadata import version as _pkg_version +from pathlib import Path + +import aiohttp + +from claude_tap.process_utils import windows_no_console_subprocess_kwargs +from claude_tap.trace_store import resolve_db_path + +DEFAULT_DASHBOARD_PORT = 19527 +_DASHBOARD_HEALTH_TIMEOUT = 1.5 +_DASHBOARD_SESSIONS_HEALTH_TIMEOUT = 3.0 +_DASHBOARD_QUIT_TIMEOUT = 2.0 +_DASHBOARD_LOCK_NAME = "dashboard.lock" +_DASHBOARD_QUIT_TOKEN_HEADER = "X-Claude-Tap-Dashboard-Token" + +try: + CLAUDE_TAP_VERSION = _pkg_version("claude-tap") +except Exception: + CLAUDE_TAP_VERSION = "0.0.0" + + +def resolve_dashboard_port(explicit: int | None = None) -> int: + """Return the shared dashboard port (fixed default unless overridden).""" + if explicit is not None and explicit > 0: + return explicit + override = os.environ.get("CLOUDTAP_DASHBOARD_PORT", "").strip() + if override.isdigit() and int(override) > 0: + return int(override) + return DEFAULT_DASHBOARD_PORT + + +def dashboard_connect_host(host: str) -> str: + """Return the local address clients should use for a dashboard bind host.""" + normalized_host = host.strip() + bare_host = normalized_host.strip("[]") + try: + address = ipaddress.ip_address(bare_host) + except ValueError: + return normalized_host or "127.0.0.1" + if address.is_unspecified: + return "::1" if address.version == 6 else "127.0.0.1" + return bare_host + + +def dashboard_url(host: str, port: int) -> str: + normalized_host = dashboard_connect_host(host) + if ":" in normalized_host and not normalized_host.startswith("["): + normalized_host = f"[{normalized_host}]" + return f"http://{normalized_host}:{port}" + + +_LOCAL_DASHBOARD_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def _dashboard_lock_path() -> Path: + return resolve_db_path().parent / _DASHBOARD_LOCK_NAME + + +@contextmanager +def _dashboard_spawn_lock(): + path = _dashboard_lock_path() + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as lock_file: + if sys.platform == "win32": + import msvcrt + + msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1) + try: + yield + finally: + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +async def _dashboard_get_status(url: str, *, timeout_seconds: float) -> int | None: + timeout = aiohttp.ClientTimeout(total=timeout_seconds) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as resp: + return resp.status + except (aiohttp.ClientError, asyncio.TimeoutError, OSError): + return None + + +async def _dashboard_get_status_and_payload( + url: str, + *, + timeout_seconds: float, +) -> tuple[int | None, dict | None]: + timeout = aiohttp.ClientTimeout(total=timeout_seconds) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(url) as resp: + payload = None + if resp.status == 200: + try: + body = await resp.json(content_type=None) + except (aiohttp.ClientError, json.JSONDecodeError, UnicodeDecodeError): + body = None + if isinstance(body, dict): + payload = body + return resp.status, payload + except (aiohttp.ClientError, asyncio.TimeoutError, OSError): + return None, None + + +def _dashboard_health_matches_current_instance(payload: dict | None) -> bool: + return bool( + payload + and payload.get("ok") is True + and payload.get("db_path") == str(resolve_db_path()) + and payload.get("version") == CLAUDE_TAP_VERSION + ) + + +def _sync_dashboard_healthy_for_current_db(host: str, port: int) -> bool: + url = f"{dashboard_url(host, port)}/dashboard/health" + try: + with _LOCAL_DASHBOARD_OPENER.open(url, timeout=_DASHBOARD_HEALTH_TIMEOUT) as resp: + if resp.status != 200: + return False + payload = json.loads(resp.read().decode("utf-8")) + except (OSError, TimeoutError, urllib.error.URLError, json.JSONDecodeError, UnicodeDecodeError): + return False + return _dashboard_health_matches_current_instance(payload if isinstance(payload, dict) else None) + + +def _spawn_dashboard_subprocess_if_needed(host: str, port: int, output_dir: Path) -> bool: + with _dashboard_spawn_lock(): + if _sync_dashboard_healthy_for_current_db(host, port): + return False + _spawn_dashboard_subprocess(host, port, output_dir) + return True + + +def _migrate_legacy_traces(output_dir: Path) -> None: + from claude_tap.history import migrate_legacy_traces + + migrate_legacy_traces(output_dir) + + +async def is_dashboard_healthy(host: str, port: int, *, require_current_db: bool = True) -> bool: + """Return True when the shared dashboard responds to a cheap health check.""" + base_url = dashboard_url(host, port) + status, payload = await _dashboard_get_status_and_payload( + f"{base_url}/dashboard/health", + timeout_seconds=_DASHBOARD_HEALTH_TIMEOUT, + ) + if status == 200: + return not require_current_db or _dashboard_health_matches_current_instance(payload) + if status not in {404, 405}: + return False + + fallback_status = await _dashboard_get_status( + f"{base_url}/api/sessions", + timeout_seconds=_DASHBOARD_SESSIONS_HEALTH_TIMEOUT, + ) + return fallback_status == 200 and not require_current_db + + +async def is_legacy_dashboard_healthy(host: str, port: int) -> bool: + """Return True for pre-health-route dashboards that still serve sessions.""" + base_url = dashboard_url(host, port) + status, _ = await _dashboard_get_status_and_payload( + f"{base_url}/dashboard/health", + timeout_seconds=_DASHBOARD_HEALTH_TIMEOUT, + ) + if status not in {404, 405}: + return False + fallback_status = await _dashboard_get_status( + f"{base_url}/api/sessions", + timeout_seconds=_DASHBOARD_SESSIONS_HEALTH_TIMEOUT, + ) + return fallback_status == 200 + + +async def wait_for_dashboard_healthy( + host: str, + port: int, + *, + timeout: float = 8.0, + interval: float = 0.1, +) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if await is_dashboard_healthy(host, port): + return True + await asyncio.sleep(interval) + return False + + +async def stop_shared_dashboard(host: str, port: int) -> bool: + """Ask a running shared dashboard service to stop and wait until it is gone.""" + base_url = dashboard_url(host, port) + timeout = aiohttp.ClientTimeout(total=_DASHBOARD_QUIT_TIMEOUT) + status, payload = await _dashboard_get_status_and_payload( + f"{base_url}/dashboard/health", + timeout_seconds=_DASHBOARD_HEALTH_TIMEOUT, + ) + if status != 200 or not isinstance(payload, dict): + return False + quit_token = payload.get("quit_token") + if not isinstance(quit_token, str) or not quit_token: + return False + + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post( + f"{base_url}/dashboard/quit", + headers={_DASHBOARD_QUIT_TOKEN_HEADER: quit_token}, + ) as resp: + if resp.status != 200: + return False + except (aiohttp.ClientError, asyncio.TimeoutError, OSError): + return False + + return await wait_for_dashboard_stopped(host, port) + + +async def stop_dashboard_service(host: str, port: int) -> bool: + """Stop a current dashboard, including older dashboards without a quit endpoint.""" + if await stop_shared_dashboard(host, port): + return True + if not await is_legacy_dashboard_healthy(host, port): + return False + return await stop_legacy_dashboard_process(host, port) + + +async def stop_legacy_dashboard_process(host: str, port: int) -> bool: + pids = await asyncio.to_thread(_dashboard_listening_pids_for_port, port) + if not pids: + return False + terminated = await asyncio.to_thread(_terminate_legacy_dashboard_pids, pids, port) + return terminated and await wait_for_dashboard_stopped(host, port) + + +def _dashboard_listening_pids_for_port(port: int) -> list[int]: + if port <= 0: + return [] + pids: set[int] = set() + lsof = shutil.which("lsof") + if lsof: + try: + result = subprocess.run( + [lsof, "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], + check=False, + capture_output=True, + text=True, + timeout=2.0, + ) + except (OSError, subprocess.TimeoutExpired): + result = None + if result and result.returncode in {0, 1}: + for line in result.stdout.splitlines(): + if line.strip().isdigit(): + pids.add(int(line.strip())) + + if pids: + return sorted(pids) + + ss = shutil.which("ss") + if ss: + try: + result = subprocess.run( + [ss, "-ltnp", f"sport = :{port}"], + check=False, + capture_output=True, + text=True, + timeout=2.0, + ) + except (OSError, subprocess.TimeoutExpired): + result = None + if result and result.returncode == 0: + for match in re.finditer(r"pid=(\d+)", result.stdout): + pids.add(int(match.group(1))) + + return sorted(pids) + + +def _dashboard_process_command(pid: int) -> str: + if pid <= 0: + return "" + if sys.platform.startswith("linux"): + try: + parts = [ + part.decode("utf-8", errors="replace") + for part in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0") + if part + ] + except OSError: + parts = [] + if parts: + return " ".join(parts) + + ps = shutil.which("ps") + if not ps: + return "" + try: + result = subprocess.run( + [ps, "-p", str(pid), "-o", "command="], + check=False, + capture_output=True, + text=True, + timeout=2.0, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def _looks_like_legacy_dashboard_command(command: str, port: int) -> bool: + normalized = " ".join(command.split()) + lower = normalized.lower() + if "claude_tap" not in lower and "claude-tap" not in lower: + return False + if not re.search(r"(^|\s)dashboard($|\s)", lower): + return False + if "--tap-live-port" in lower and str(port) not in lower: + return False + return True + + +def _terminate_legacy_dashboard_pids(pids: list[int], port: int) -> bool: + terminated = False + for pid in pids: + if pid == os.getpid(): + continue + command = _dashboard_process_command(pid) + if not _looks_like_legacy_dashboard_command(command, port): + continue + try: + os.kill(pid, signal.SIGTERM) + except OSError: + continue + terminated = True + return terminated + + +async def stop_incompatible_dashboard_if_running(host: str, port: int, url: str) -> None: + """Stop a dashboard that is listening on the shared port but cannot be reused. + + The common case is an older claude-tap dashboard left running after the CLI + was upgraded. Reusing it would keep serving the old packaged HTML/JS. + """ + if not await is_dashboard_healthy(host, port, require_current_db=False): + return + if await stop_dashboard_service(host, port): + return + raise RuntimeError( + "A different or outdated claude-tap dashboard is already running on " + f"{url}. Stop it first with `claude-tap dashboard stop --tap-live-port {port}`. " + "If that fails, terminate the old dashboard process manually." + ) + + +async def wait_for_dashboard_stopped( + host: str, + port: int, + *, + timeout: float = 5.0, + interval: float = 0.1, +) -> bool: + """Return True once no dashboard responds on the configured endpoint.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not await is_dashboard_healthy(host, port, require_current_db=False): + return True + await asyncio.sleep(interval) + return False + + +def _spawn_dashboard_subprocess(host: str, port: int, output_dir: Path) -> subprocess.Popen[bytes]: + cmd = [ + _dashboard_python_executable(), + "-m", + "claude_tap", + "dashboard", + "--tap-live-port", + str(port), + "--tap-no-open", + "--tap-output-dir", + str(output_dir), + ] + if host and host != "127.0.0.1": + cmd.extend(["--tap-host", host]) + + kwargs: dict = { + "stdin": subprocess.DEVNULL, + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if sys.platform == "win32": + kwargs.update(windows_no_console_subprocess_kwargs()) + else: + kwargs["start_new_session"] = True + return subprocess.Popen(cmd, **kwargs) + + +def _dashboard_python_executable() -> str: + if sys.platform != "win32": + return sys.executable + + executable = Path(sys.executable) + if executable.name.lower() != "python.exe": + return sys.executable + + pythonw = executable.with_name("pythonw.exe") + if pythonw.exists(): + return str(pythonw) + return sys.executable + + +async def ensure_shared_dashboard( + *, + host: str, + port: int, + output_dir: Path, + open_browser: bool, + open_browser_fn, +) -> tuple[str, bool]: + """Ensure the shared dashboard is running; return (url, spawned_by_caller).""" + url = dashboard_url(host, port) + if await is_dashboard_healthy(host, port): + _migrate_legacy_traces(output_dir) + return url, False + + await stop_incompatible_dashboard_if_running(host, port, url) + + spawned = await asyncio.to_thread(_spawn_dashboard_subprocess_if_needed, host, port, output_dir) + if spawned: + if not await wait_for_dashboard_healthy(host, port): + raise RuntimeError(f"Failed to start shared dashboard on {url}") + else: + _migrate_legacy_traces(output_dir) + + if open_browser and spawned: + open_browser_fn(url) + return url, spawned diff --git a/claude_tap/sse.py b/claude_tap/sse.py new file mode 100644 index 0000000..dd4ebdd --- /dev/null +++ b/claude_tap/sse.py @@ -0,0 +1,575 @@ +"""SSEReassembler – parse SSE bytes and reconstruct the full API response.""" + +from __future__ import annotations + +import copy +import json + +from claude_tap.usage import normalize_usage + + +class SSEReassembler: + """Parse raw SSE bytes and reconstruct the full API response object + by accumulating streaming events into a complete message snapshot.""" + + def __init__(self, *, store_events: bool = True): + self._store_events = store_events + self.events: list[dict] = [] + self._buf = b"" + self._current_event: str | None = None + self._current_data_lines: list[str] = [] + self._snapshot: dict | None = None + + def feed_bytes(self, chunk: bytes): + self._buf += chunk + while b"\n" in self._buf: + line, self._buf = self._buf.split(b"\n", 1) + self._feed_line(line.decode("utf-8", errors="replace")) + + def _feed_line(self, line: str): + line = line.rstrip("\r") + if line.startswith("event:"): + self._current_event = line[len("event:") :].strip() + self._current_data_lines = [] + elif line.startswith("data:"): + self._current_data_lines.append(line[len("data:") :].strip()) + elif line == "": + # Emit on blank line if we have an explicit event: header (Anthropic / + # OpenAI Responses) OR accumulated data: lines without a header + # (OpenAI Chat Completions uses bare "data: {...}" frames). + if self._current_event is not None or self._current_data_lines: + raw_data = "\n".join(self._current_data_lines) + # Skip OpenAI Chat Completions terminator "[DONE]" — it's a + # protocol sentinel, not a payload, and would otherwise show up + # as a noisy non-JSON event in the trace. + if raw_data == "[DONE]" and self._current_event is None: + self._current_event = None + self._current_data_lines = [] + return + try: + data = json.loads(raw_data) + except (json.JSONDecodeError, ValueError): + data = raw_data + # Default event type for bare data: frames (OpenAI Chat + # Completions). Snapshot reconstruction stays a no-op for + # these — the events themselves are preserved in the trace. + event_type = self._current_event or "message" + self.add_event(event_type, data) + self._current_event = None + self._current_data_lines = [] + + def add_event(self, event_type: str, data) -> None: + """Append an already-parsed stream event and update the snapshot.""" + if self._store_events: + self.events.append({"event": event_type, "data": data}) + self._accumulate(event_type, data) + + def _accumulate(self, event_type: str, data) -> None: + """Accumulate an SSE event into the message snapshot. + + This replaces the anthropic SDK's accumulate_event() with a simple + manual implementation that handles the Anthropic streaming protocol. + """ + if not isinstance(data, dict): + return + try: + gemini_chunk = self._gemini_chunk_payload(data) if event_type == "message" else None + + if event_type == "message_start": + self._snapshot = copy.deepcopy(data.get("message", {})) + elif event_type == "response.created": + response = data.get("response") + if isinstance(response, dict): + self._snapshot = copy.deepcopy(response) + elif event_type in ("response.output_item.added", "response.output_item.done"): + # The Codex/ChatGPT backend streams each output item here and + # sends response.completed with an EMPTY output array, so the + # items have to be accumulated rather than read off the + # terminal event. Must run before the `_snapshot is None` + # guard since output_item.added can be the first event. + self._accumulate_responses_output_item(data) + elif event_type == "response.output_text.delta": + self._accumulate_responses_output_text(data) + elif event_type in ( + "response.completed", + "response.done", + "response.incomplete", + "response.failed", + ): + # incomplete (e.g. max_output_tokens) and failed carry the same + # {"response": {...}} shape as completed, with the final status, + # usage and error / incomplete_details — merge them the same way. + self._merge_responses_terminal(data) + elif event_type == "response.error": + self._record_responses_error(data) + elif gemini_chunk is not None: + # Gemini streamGenerateContent uses bare `data: {...}` frames + # with no `event:` header. Accumulate them before the generic + # `_snapshot is None` guard so forward-proxy captures retain + # assistant text and usage even when raw stream events are not + # stored. + self._accumulate_gemini_chunk(gemini_chunk) + elif event_type == "message" and "choices" in data: + # OpenAI Chat Completions chunk — must run before the + # `_snapshot is None` guard below, since the accumulator + # initializes its own snapshot on the first chunk. + self._accumulate_chat_completion_chunk(data) + elif self._snapshot is None: + return + elif event_type == "content_block_start": + block = copy.deepcopy(data.get("content_block", {})) + if "content" not in self._snapshot: + self._snapshot["content"] = [] + idx = data.get("index", len(self._snapshot["content"])) + # Extend content list if needed + while len(self._snapshot["content"]) <= idx: + self._snapshot["content"].append({}) + self._snapshot["content"][idx] = block + elif event_type == "content_block_delta": + idx = data.get("index", 0) + delta = data.get("delta", {}) + block = self._content_block_for_delta(idx, delta) + if block is not None: + if delta.get("type") == "text_delta": + block["text"] = block.get("text", "") + delta.get("text", "") + elif delta.get("type") == "thinking_delta": + block["thinking"] = block.get("thinking", "") + delta.get("thinking", "") + if delta.get("signature"): + block["signature"] = delta["signature"] + elif delta.get("type") == "input_json_delta": + block["_partial_json"] = block.get("_partial_json", "") + delta.get("partial_json", "") + elif event_type == "content_block_stop": + idx = data.get("index", 0) + if idx < len(self._snapshot.get("content", [])): + block = self._snapshot["content"][idx] + if "_partial_json" in block: + try: + block["input"] = json.loads(block["_partial_json"]) + except (json.JSONDecodeError, ValueError): + pass + del block["_partial_json"] + elif event_type == "message_delta": + delta = data.get("delta", {}) + for k, v in delta.items(): + self._snapshot[k] = v + usage = data.get("usage", {}) + if usage: + if "usage" not in self._snapshot: + self._snapshot["usage"] = {} + self._snapshot["usage"].update(normalize_usage(usage)) + except Exception: + pass + + def _ensure_responses_output(self) -> list: + """Return the snapshot's OpenAI Responses `output` list, creating the + snapshot and/or the list if a streaming output item arrives before + response.created.""" + if self._snapshot is None: + self._snapshot = {"output": []} + output = self._snapshot.get("output") + if not isinstance(output, list): + output = [] + self._snapshot["output"] = output + return output + + def _accumulate_responses_output_item(self, data: dict) -> None: + """Place an OpenAI Responses output item into the snapshot at its + output_index. response.output_item.done carries the complete item and + is authoritative; response.output_item.added seeds a placeholder that + text deltas can append to.""" + item = data.get("item") + if not isinstance(item, dict): + return + output = self._ensure_responses_output() + idx = data.get("output_index") + if not isinstance(idx, int) or idx < 0: + idx = len(output) + while len(output) <= idx: + output.append({}) + output[idx] = copy.deepcopy(item) + + def _accumulate_responses_output_text(self, data: dict) -> None: + """Append a response.output_text.delta to the in-progress message item + so partial/truncated streams still retain their text even if the + terminal response.output_item.done never arrives.""" + delta = data.get("delta") + if not isinstance(delta, str) or not delta: + return + output = self._ensure_responses_output() + idx = data.get("output_index") + if not isinstance(idx, int) or idx < 0: + idx = len(output) - 1 + if idx < 0 or idx >= len(output): + return + item = output[idx] + if not isinstance(item, dict): + return + content = item.get("content") + if not isinstance(content, list): + content = [] + item["content"] = content + part = next((c for c in content if isinstance(c, dict) and c.get("type") == "output_text"), None) + if part is None: + part = {"type": "output_text", "text": ""} + content.append(part) + part["text"] = (part.get("text") or "") + delta + + def _merge_responses_terminal(self, data: dict) -> None: + """Apply a terminal response.completed / response.done event. + + The terminal `response` object carries the final status and usage but + the Codex/ChatGPT backend leaves its `output` empty (the items were + streamed via response.output_item.* events), so preserve the output + already accumulated in the snapshot when the terminal one is empty.""" + response = data.get("response") + if not isinstance(response, dict): + self._snapshot = copy.deepcopy(data) + return + response = copy.deepcopy(response) + accumulated = self._snapshot.get("output") if isinstance(self._snapshot, dict) else None + if not response.get("output") and isinstance(accumulated, list) and accumulated: + response["output"] = accumulated + self._snapshot = response + + def _record_responses_error(self, data: dict) -> None: + """Capture a stream-level response.error event. It carries no response + object, so attach the error to the snapshot without discarding output + already accumulated from response.output_item.* events.""" + if self._snapshot is None: + self._snapshot = {"output": []} + error = {k: data[k] for k in ("code", "message", "param") if k in data} + self._snapshot["error"] = error or copy.deepcopy(data) + # The stream errored out, so promote a still-running status to failed + # but never override a status a terminal event already set. + if self._snapshot.get("status") in (None, "", "queued", "in_progress"): + self._snapshot["status"] = "failed" + + def _content_block_for_delta(self, idx: int, delta: dict) -> dict | None: + if not isinstance(idx, int) or idx < 0: + idx = 0 + if "content" not in self._snapshot: + self._snapshot["content"] = [] + content = self._snapshot["content"] + if not isinstance(content, list): + content = [] + self._snapshot["content"] = content + while len(content) <= idx: + content.append(self._empty_content_block_for_delta(delta)) + block = content[idx] + if not isinstance(block, dict): + block = self._empty_content_block_for_delta(delta) + content[idx] = block + if not block: + block.update(self._empty_content_block_for_delta(delta)) + return block + + def _empty_content_block_for_delta(self, delta: dict) -> dict: + if delta.get("type") == "thinking_delta": + return {"type": "thinking", "thinking": ""} + if delta.get("type") == "input_json_delta": + return {"type": "tool_use", "id": "", "name": "", "input": {}} + return {"type": "text", "text": ""} + + def _accumulate_chat_completion_chunk(self, data: dict) -> None: + choices = data.get("choices") or [] + usage = data.get("usage") + + # Some providers send a final usage-only chunk: {"choices": [], + # "usage": {...}}. Process the usage and bail out — there is no + # delta body to apply. + if not isinstance(choices, list) or not choices: + if isinstance(usage, dict) and self._snapshot is not None: + self._merge_chat_completion_usage(usage) + return + + choice = choices[0] if isinstance(choices[0], dict) else {} + delta = choice.get("delta") or {} + finish_reason = choice.get("finish_reason") + choice_usage = choice.get("usage") + + if self._snapshot is None: + self._snapshot = { + "id": data.get("id", ""), + "object": "chat.completion", + "model": data.get("model", ""), + "choices": [ + { + "index": 0, + "message": {"role": delta.get("role") or "assistant", "content": ""}, + "finish_reason": None, + } + ], + # Anthropic-shape mirror so the viewer's renderResponseContent + # picks it up without schema-specific code. content normally + # contains the leading text block; thinking and tool_use blocks + # are mirrored in as needed. + "content": [{"type": "text", "text": ""}], + } + + msg = self._snapshot["choices"][0]["message"] + text_block = self._chat_completion_text_block() + + if isinstance(delta.get("role"), str) and delta["role"]: + msg["role"] = delta["role"] + reasoning_details = self._merge_chat_completion_reasoning_details(msg, delta.get("reasoning_details")) + if reasoning_details: + self._mirror_reasoning_to_content(reasoning_details) + else: + for reasoning_key in ("reasoning_content", "reasoning"): + if isinstance(delta.get(reasoning_key), str) and delta[reasoning_key]: + msg[reasoning_key] = (msg.get(reasoning_key) or "") + delta[reasoning_key] + self._mirror_reasoning_to_content(msg[reasoning_key]) + if isinstance(delta.get("content"), str) and delta["content"]: + msg["content"] = (msg.get("content") or "") + delta["content"] + text_block["text"] = (text_block.get("text") or "") + delta["content"] + + # Tool calls arrive as indexed deltas: {"index": 0, "id":?, "type":?, + # "function": {"name":?, "arguments":?}}. Each field accumulates by + # concatenation — most providers stream `arguments` token by token. + for tc_delta in delta.get("tool_calls") or []: + if not isinstance(tc_delta, dict): + continue + idx = tc_delta.get("index", 0) + tool_calls = msg.setdefault("tool_calls", []) + while len(tool_calls) <= idx: + tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}}) + existing = tool_calls[idx] + if isinstance(tc_delta.get("id"), str): + existing["id"] = tc_delta["id"] + if isinstance(tc_delta.get("type"), str): + existing["type"] = tc_delta["type"] + fn_delta = tc_delta.get("function") or {} + if isinstance(fn_delta, dict): + fn = existing.setdefault("function", {"name": "", "arguments": ""}) + if isinstance(fn_delta.get("name"), str): + fn["name"] = (fn.get("name") or "") + fn_delta["name"] + if isinstance(fn_delta.get("arguments"), str): + fn["arguments"] = (fn.get("arguments") or "") + fn_delta["arguments"] + # Mirror this tool call into the Anthropic-shape `content` array + # so the viewer (which only reads body.content) can render + # tool-only responses, and so the sidebar's response_tool_names + # extractor sees the call. + self._mirror_tool_call_to_content(idx, existing) + + if finish_reason: + self._snapshot["choices"][0]["finish_reason"] = finish_reason + + if isinstance(usage, dict): + self._merge_chat_completion_usage(usage) + if isinstance(choice_usage, dict): + self._merge_chat_completion_usage(choice_usage) + + def _gemini_chunk_payload(self, data: dict) -> dict | None: + if self._is_gemini_chunk(data): + return data + response = data.get("response") + if isinstance(response, dict) and self._is_gemini_chunk(response): + return response + return None + + def _is_gemini_chunk(self, data: dict) -> bool: + return "candidates" in data or "usageMetadata" in data + + def _accumulate_gemini_chunk(self, data: dict) -> None: + if self._snapshot is None or not isinstance(self._snapshot.get("candidates"), list): + self._snapshot = {"candidates": []} + + for key, value in data.items(): + if key in {"candidates", "usageMetadata"}: + continue + self._snapshot[key] = copy.deepcopy(value) + + candidates = data.get("candidates") + if isinstance(candidates, list): + for position, candidate in enumerate(candidates): + if isinstance(candidate, dict): + self._merge_gemini_candidate(position, candidate) + + usage = data.get("usageMetadata") + if isinstance(usage, dict): + self._snapshot["usageMetadata"] = copy.deepcopy(usage) + self._snapshot["usage"] = normalize_usage(usage) + + self._snapshot["content"] = self._gemini_content_blocks() + + def _merge_gemini_candidate(self, position: int, candidate: dict) -> None: + idx = candidate.get("index") + if not isinstance(idx, int) or idx < 0: + idx = position + + candidates = self._snapshot["candidates"] + while len(candidates) <= idx: + candidates.append({}) + + target = candidates[idx] + if not isinstance(target, dict): + target = {} + candidates[idx] = target + + for key, value in candidate.items(): + if key == "content" and isinstance(value, dict): + self._merge_gemini_candidate_content(target, value) + else: + target[key] = copy.deepcopy(value) + + def _merge_gemini_candidate_content(self, candidate: dict, incoming: dict) -> None: + content = candidate.get("content") + if not isinstance(content, dict): + content = {} + candidate["content"] = content + + for key, value in incoming.items(): + if key == "parts": + continue + content[key] = copy.deepcopy(value) + + parts = content.get("parts") + if not isinstance(parts, list): + parts = [] + content["parts"] = parts + + for part in incoming.get("parts") or []: + if isinstance(part, dict): + self._append_gemini_part(parts, part) + + def _append_gemini_part(self, parts: list, part: dict) -> None: + if self._is_mergeable_gemini_text_part(part): + previous = parts[-1] if parts else None + if ( + isinstance(previous, dict) + and isinstance(previous.get("text"), str) + and self._is_mergeable_gemini_text_part(previous) + and previous.get("thought") == part.get("thought") + ): + previous["text"] += part["text"] + return + parts.append(copy.deepcopy(part)) + + def _is_mergeable_gemini_text_part(self, part: dict) -> bool: + if not isinstance(part.get("text"), str): + return False + return set(part).issubset({"text", "thought"}) + + def _gemini_content_blocks(self) -> list[dict]: + content: list[dict] = [] + for candidate in self._snapshot.get("candidates") or []: + if not isinstance(candidate, dict): + continue + candidate_content = candidate.get("content") + if not isinstance(candidate_content, dict): + continue + for part in candidate_content.get("parts") or []: + if not isinstance(part, dict): + continue + if isinstance(part.get("text"), str) and part["text"].strip(): + if part.get("thought") is True: + self._append_mergeable_content_block(content, {"type": "thinking", "thinking": part["text"]}) + else: + self._append_mergeable_content_block(content, {"type": "text", "text": part["text"]}) + call = part.get("functionCall") + if isinstance(call, dict): + content.append( + { + "type": "tool_use", + "id": call.get("id", ""), + "name": call.get("name", "tool_use"), + "input": call.get("args") if isinstance(call.get("args"), dict) else {}, + } + ) + return content + + def _append_mergeable_content_block(self, content: list[dict], block: dict) -> None: + previous = content[-1] if content else None + if isinstance(previous, dict) and previous.get("type") == block.get("type"): + if block.get("type") == "thinking": + previous["thinking"] = f"{previous.get('thinking', '')}{block.get('thinking', '')}" + return + if block.get("type") == "text": + previous["text"] = f"{previous.get('text', '')}{block.get('text', '')}" + return + content.append(block) + + def _mirror_tool_call_to_content(self, idx: int, tc: dict) -> None: + """Sync one accumulated tool_call into the `content` array as a + tool_use block. content always has a text block, and may also have a + leading thinking block, so tool_use blocks live after those mirrors.""" + content = self._snapshot["content"] + offset = 1 + (1 if self._chat_completion_thinking_block(create=False) is not None else 0) + target = idx + offset + while len(content) <= target: + content.append({"type": "tool_use", "id": "", "name": "", "input": {}}) + block = content[target] + if tc.get("id"): + block["id"] = tc["id"] + fn = tc.get("function") or {} + if fn.get("name"): + block["name"] = fn["name"] + args_str = fn.get("arguments", "") + if args_str: + try: + block["input"] = json.loads(args_str) + except (json.JSONDecodeError, ValueError): + # Arguments are still streaming; leave the previously parsed + # input (or {}) until a complete JSON arrives. + pass + + def _chat_completion_text_block(self) -> dict: + content = self._snapshot["content"] + for block in content: + if block.get("type") == "text": + return block + block = {"type": "text", "text": ""} + content.append(block) + return block + + def _chat_completion_thinking_block(self, *, create: bool) -> dict | None: + content = self._snapshot["content"] + for block in content: + if block.get("type") == "thinking": + return block + if not create: + return None + block = {"type": "thinking", "thinking": ""} + content.insert(0, block) + return block + + def _mirror_reasoning_to_content(self, reasoning: str) -> None: + block = self._chat_completion_thinking_block(create=True) + if block is not None: + block["thinking"] = reasoning + + def _merge_chat_completion_reasoning_details(self, msg: dict, details) -> str: + """Merge MiniMax reasoning_details buffers and return display text.""" + if not isinstance(details, list): + return "" + existing = msg.setdefault("reasoning_details", []) + if not isinstance(existing, list): + existing = [] + msg["reasoning_details"] = existing + + for fallback_index, detail in enumerate(details): + if not isinstance(detail, dict): + continue + index = detail.get("index", fallback_index) + if not isinstance(index, int) or index < 0: + index = fallback_index + while len(existing) <= index: + existing.append({}) + existing[index] = copy.deepcopy(detail) + + texts = [ + detail["text"] + for detail in existing + if isinstance(detail, dict) and isinstance(detail.get("text"), str) and detail["text"] + ] + return "\n\n".join(texts) + + def _merge_chat_completion_usage(self, usage: dict) -> None: + """Merge an OpenAI-shape usage dict into the snapshot, exposing both + prompt/completion and input/output token names for downstream code.""" + self._snapshot["usage"] = normalize_usage(usage) + + def reconstruct(self) -> dict | None: + if self._snapshot is None: + return None + return self._snapshot diff --git a/claude_tap/trace.py b/claude_tap/trace.py new file mode 100644 index 0000000..a50696b --- /dev/null +++ b/claude_tap/trace.py @@ -0,0 +1,174 @@ +"""TraceWriter – async SQLite writer with statistics.""" + +from __future__ import annotations + +import asyncio +import re +import sqlite3 +import sys +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +from claude_tap.trace_store import TraceStore, get_trace_store +from claude_tap.usage import normalize_usage + +if TYPE_CHECKING: + from claude_tap.live import LiveViewerServer + + +class TraceWriter: + """Writes trace records to the local SQLite store and accumulates statistics.""" + + def __init__( + self, + session_id: str, + live_server: "LiveViewerServer | None" = None, + metadata: dict[str, str] | None = None, + store: TraceStore | None = None, + ): + self.session_id = session_id + self._lock = asyncio.Lock() + self.count = 0 + self.total_input_tokens = 0 + self.total_output_tokens = 0 + self.total_cache_read_tokens = 0 + self.total_cache_create_tokens = 0 + self.models_used: dict[str, int] = {} + self._live_server = live_server + self._metadata = metadata or {} + self._store = store or get_trace_store() + self._has_error = False + self._has_auxiliary_status_probe_error = False + self._has_primary_success = False + self.storage_error_count = 0 + self.dropped_trace_records = 0 + self._startup_storage_error: sqlite3.Error | None = None + self._storage_warning_emitted = False + + async def write(self, record: dict) -> None: + """Write a record and update statistics.""" + async with self._lock: + self._write_locked(record) + + if self._live_server: + await self._live_server.broadcast(record) + + async def write_next_turn(self, record: dict) -> None: + """Assign the next trace turn under the writer lock, then write the record.""" + async with self._lock: + record["turn"] = self.count + 1 + self._write_locked(record) + + if self._live_server: + await self._live_server.broadcast(record) + + def _write_locked(self, record: dict) -> None: + if self._metadata: + capture = record.get("capture") if isinstance(record.get("capture"), dict) else {} + record["capture"] = {**self._metadata, **capture} + try: + self._store.append_record(self.session_id, record) + except sqlite3.Error as exc: + self.dropped_trace_records += 1 + self._record_storage_error(exc) + self.count += 1 + self._update_stats(record) + + def close(self) -> None: + """Finalize the active session in SQLite.""" + if self._startup_storage_error is not None: + return + summary = self.get_summary() + try: + self._store.finalize_session(self.session_id, summary) + except sqlite3.Error as exc: + self._record_storage_error(exc) + + def record_startup_storage_error(self, exc: sqlite3.Error) -> None: + """Record that SQLite could not create the initial session row.""" + self._startup_storage_error = exc + self._record_storage_error(exc) + + def _record_storage_error(self, exc: sqlite3.Error) -> None: + self.storage_error_count += 1 + if self._storage_warning_emitted: + return + self._storage_warning_emitted = True + sys.stderr.write(f"claude-tap: trace storage failed; continuing without blocking proxy ({exc})\n") + + def _update_stats(self, record: dict) -> None: + req_body = record.get("request", {}).get("body", {}) + model = req_body.get("model", "unknown") if isinstance(req_body, dict) else "unknown" + self.models_used[model] = self.models_used.get(model, 0) + 1 + + resp_body = record.get("response", {}).get("body", {}) + usage = resp_body.get("usage", {}) if isinstance(resp_body, dict) else {} + if not usage and isinstance(resp_body, dict): + usage = resp_body + usage = normalize_usage(usage) + + self.total_input_tokens += usage.get("input_tokens", 0) + self.total_output_tokens += usage.get("output_tokens", 0) + self.total_cache_read_tokens += usage.get("cache_read_input_tokens", 0) + self.total_cache_create_tokens += usage.get("cache_creation_input_tokens", 0) + + response = record.get("response") + if isinstance(response, dict): + status = response.get("status") + if isinstance(status, int): + is_probe = _is_auxiliary_status_probe(record) + if status >= 400: + if is_probe: + self._has_auxiliary_status_probe_error = True + else: + self._has_error = True + elif status >= 200 and not is_probe: + self._has_primary_success = True + if isinstance(response.get("error"), str) and response["error"]: + self._has_error = True + + def get_summary(self) -> dict: + """Return a summary of the trace statistics.""" + has_error = self._has_error or (self._has_auxiliary_status_probe_error and not self._has_primary_success) + return { + "api_calls": self.count, + "input_tokens": self.total_input_tokens, + "output_tokens": self.total_output_tokens, + "cache_read_tokens": self.total_cache_read_tokens, + "cache_create_tokens": self.total_cache_create_tokens, + "models_used": self.models_used, + "has_error": has_error, + "trace_storage_errors": self.storage_error_count, + "dropped_trace_records": self.dropped_trace_records, + } + + +def create_trace_writer( + *, + store: TraceStore, + client: str, + proxy_mode: str, + metadata: dict[str, str], + started_at: datetime | None = None, +) -> TraceWriter: + """Create a writer without letting unavailable trace storage block the client.""" + try: + session_id = store.create_session(client=client, proxy_mode=proxy_mode, started_at=started_at) + except sqlite3.Error as exc: + writer = TraceWriter(str(uuid.uuid4()), live_server=None, metadata=metadata, store=store) + writer.record_startup_storage_error(exc) + return writer + return TraceWriter(session_id, live_server=None, metadata=metadata, store=store) + + +def _is_auxiliary_status_probe(record: dict) -> bool: + request = record.get("request") + path = request.get("path") if isinstance(request, dict) else "" + if not isinstance(path, str): + return False + clean_path = path.lower().split("?", 1)[0].rstrip("/") + if clean_path in {"/models", "/v1/models", "/v1alpha/models", "/v1beta/models"}: + return True + match = re.fullmatch(r"/(?:v1/)?models/([^/:]+)", clean_path) + return match is not None diff --git a/claude_tap/trace_log_handler.py b/claude_tap/trace_log_handler.py new file mode 100644 index 0000000..2b1b434 --- /dev/null +++ b/claude_tap/trace_log_handler.py @@ -0,0 +1,37 @@ +"""Logging handler that persists proxy logs into SQLite.""" + +from __future__ import annotations + +import logging +import sqlite3 +from datetime import datetime, timezone + +from claude_tap.trace_store import TraceStore, get_trace_store + + +class SQLiteLogHandler(logging.Handler): + """Write log records to the active trace session.""" + + def __init__(self, session_id: str, store: TraceStore | None = None): + super().__init__() + self.session_id = session_id + self._store = store or get_trace_store() + + def emit(self, record: logging.LogRecord) -> None: + try: + message = record.getMessage() + formatter = self.formatter or logging.Formatter() + if record.exc_info: + message = f"{message}\n{formatter.formatException(record.exc_info)}" + if record.stack_info: + message = f"{message}\n{formatter.formatStack(record.stack_info)}" + self._store.append_log( + self.session_id, + message, + level=record.levelname, + logged_at=datetime.fromtimestamp(record.created, tz=timezone.utc).strftime("%H:%M:%S"), + ) + except sqlite3.Error: + return + except Exception: + self.handleError(record) diff --git a/claude_tap/trace_store.py b/claude_tap/trace_store.py new file mode 100644 index 0000000..a8a4c55 --- /dev/null +++ b/claude_tap/trace_store.py @@ -0,0 +1,1543 @@ +"""SQLite-backed trace storage (single local database).""" + +from __future__ import annotations + +import json +import os +import re +import sqlite3 +import threading +import time +import uuid +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from hashlib import sha256 +from pathlib import Path +from typing import Any, Iterator + +from claude_tap.compact_trace import ( + BLOB_KIND_JSON, + COMPACT_RECORD_MARKER, + COMPACT_RECORD_VERSION, + MIN_BLOB_BYTES, + compact_record_blobs, + decode_compact_record_payload, + dump_compact_trace, + json_blob_payload, + make_blob_ref, +) + +DB_FILENAME = "traces.sqlite3" +SCHEMA_VERSION = 4 +SQLITE_BUSY_TIMEOUT_MS = 1000 +WRITE_LOCK_TIMEOUT_SECONDS = 1.0 +WRITE_LOCK_RETRY_SECONDS = 0.01 +WRITE_LOCK_SUFFIX = ".write.lock" +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +STALE_ACTIVE_SESSION_AFTER = timedelta(hours=24) + + +@dataclass(frozen=True) +class SessionQuery: + """Session-list filters that can be applied directly in SQLite.""" + + date: str = "" + status: str = "" + search: str = "" + agent_clients: tuple[str, ...] = () + agent_labels: tuple[str, ...] = () + + +_store: TraceStore | None = None +_store_lock = threading.Lock() + + +def resolve_db_path() -> Path: + """Return the canonical local trace database path.""" + override = os.environ.get("CLOUDTAP_DB", "").strip() + if override: + return Path(override).expanduser().resolve() + xdg_data = os.environ.get("XDG_DATA_HOME", "").strip() + if xdg_data: + base = Path(xdg_data).expanduser() / "claude-tap" + else: + base = Path.home() / ".local" / "share" / "claude-tap" + return (base / DB_FILENAME).resolve() + + +def get_trace_store() -> TraceStore: + """Return the process-wide TraceStore singleton.""" + global _store + with _store_lock: + if _store is None: + _store = TraceStore(resolve_db_path()) + return _store + + +def reset_trace_store() -> None: + """Clear the process-wide TraceStore singleton (for tests).""" + global _store + with _store_lock: + if _store is not None: + _store.close() + _store = None + + +class TraceStore: + """Persist trace sessions, API records, and proxy logs in SQLite.""" + + def __init__(self, db_path: Path): + self.db_path = db_path.resolve() + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._write_lock_path = self.db_path.with_name(f"{self.db_path.name}{WRITE_LOCK_SUFFIX}") + self._schema_ready = False + self._schema_lock = threading.Lock() + self._write_lock = threading.Lock() + self._tls = threading.local() + + def create_session( + self, + *, + client: str = "", + proxy_mode: str = "", + started_at: datetime | None = None, + ) -> str: + """Create a new active trace session and return its id.""" + session_id = str(uuid.uuid4()) + now = started_at or datetime.now(timezone.utc) + started_at_iso = now.isoformat() + date_key = now.astimezone().date().isoformat() + with self._write_access() as conn: + conn.execute( + """ + INSERT INTO sessions ( + id, started_at, updated_at, date_key, client, proxy_mode, status, record_count + ) + VALUES (?, ?, ?, ?, ?, ?, 'active', 0) + """, + (session_id, started_at_iso, started_at_iso, date_key, client, proxy_mode), + ) + conn.commit() + return session_id + + def append_record(self, session_id: str, record: dict[str, Any]) -> None: + """Append one API trace record to a session.""" + with self._write_access() as conn: + next_index = self._next_record_index(conn, session_id) + updated_at = _str_or_none(record.get("timestamp")) or datetime.now(timezone.utc).isoformat() + payload_json = self._encode_record(conn, session_id, record) + conn.execute( + """ + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + VALUES (?, ?, ?, ?, ?) + """, + ( + session_id, + next_index, + _int_or_none(record.get("turn")), + _str_or_none(record.get("timestamp")), + payload_json, + ), + ) + conn.execute( + """ + UPDATE sessions + SET updated_at = ?, record_count = record_count + 1, status = 'active' + WHERE id = ? + """, + (updated_at, session_id), + ) + count_row = conn.execute( + "SELECT record_count FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + record_count = int(count_row["record_count"]) if count_row is not None else next_index + self._refresh_summary_after_append(conn, session_id, record, record_count) + conn.commit() + + def append_log( + self, + session_id: str, + message: str, + *, + level: str = "INFO", + logged_at: str | None = None, + ) -> None: + """Append one proxy log line to a session.""" + with self._write_access() as conn: + row = conn.execute( + "SELECT COALESCE(MAX(line_no), 0) + 1 AS next_line FROM proxy_logs WHERE session_id = ?", + (session_id,), + ).fetchone() + line_no = int(row["next_line"]) + conn.execute( + """ + INSERT INTO proxy_logs (session_id, line_no, logged_at, level, message) + VALUES (?, ?, ?, ?, ?) + """, + (session_id, line_no, logged_at, level, message), + ) + conn.execute( + """ + UPDATE sessions + SET updated_at = ?, status = 'active' + WHERE id = ? + """, + (datetime.now(timezone.utc).isoformat(), session_id), + ) + conn.commit() + + def finalize_session(self, session_id: str, summary: dict[str, Any] | None = None) -> None: + """Mark a session complete and persist its summary.""" + with self._write_access() as conn: + row = conn.execute( + "SELECT status, summary_json FROM sessions WHERE id = ?", + (session_id,), + ).fetchone() + if row is None: + return + status = "complete" + if summary: + record_count = summary.get("api_calls", 0) + if record_count == 0: + status = "empty" + elif summary.get("has_error"): + status = "error" + + existing_summary = None + if row["summary_json"]: + try: + existing_summary = json.loads(row["summary_json"]) + except json.JSONDecodeError: + pass + + updated_at = datetime.now(timezone.utc).isoformat() + if isinstance(existing_summary, dict): + final_summary = { + **existing_summary, + **(summary or {}), + "status": status, + "id": session_id, + "updated_at": updated_at, + } + summary_json_str = json.dumps(final_summary, ensure_ascii=False, separators=(",", ":")) + else: + summary_json_str = json.dumps(summary, ensure_ascii=False, separators=(",", ":")) if summary else None + + conn.execute( + """ + UPDATE sessions + SET status = ?, summary_json = ?, updated_at = ? + WHERE id = ? + """, + ( + status, + summary_json_str, + updated_at, + session_id, + ), + ) + conn.commit() + + def load_session_row(self, session_id: str) -> sqlite3.Row | None: + with self._read_connect() as conn: + return conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + + def find_codex_app_session_row(self, codex_app_session_id: str) -> sqlite3.Row | None: + codex_app_session_id = codex_app_session_id.strip() + if not codex_app_session_id: + return None + with self._read_connect() as conn: + rows = conn.execute( + """ + SELECT s.* + FROM sessions s + WHERE LOWER(COALESCE(s.client, '')) = 'codexapp' + ORDER BY s.record_count DESC, + COALESCE(julianday(s.updated_at), 0) DESC, + COALESCE(julianday(s.started_at), 0) DESC, + s.id DESC + """ + ).fetchall() + for row in rows: + first_record = conn.execute( + """ + SELECT payload_json + FROM records + WHERE session_id = ? + ORDER BY record_index + LIMIT 1 + """, + (row["id"],), + ).fetchone() + if first_record is None: + continue + payload_json = first_record["payload_json"] + if isinstance(payload_json, str) and codex_app_session_id in payload_json: + return row + return None + + def count_non_partial_records(self, session_id: str) -> int: + with self._read_connect() as conn: + row = conn.execute( + """ + SELECT COUNT(*) AS count + FROM records + WHERE session_id = ? + AND payload_json NOT LIKE '%codex_app_partial%' + """, + (session_id,), + ).fetchone() + return int(row["count"] or 0) if row is not None else 0 + + def list_session_rows( + self, + *, + limit: int | None = None, + offset: int = 0, + query: SessionQuery | None = None, + ) -> list[sqlite3.Row]: + offset = max(0, offset) + limit_sql = "" + where_sql, params = self._session_where(query) + if limit is not None: + limit_sql = " LIMIT ? OFFSET ?" + params.extend([max(0, limit), offset]) + with self._read_connect() as conn: + return conn.execute( + f""" + SELECT * FROM sessions + {where_sql} + ORDER BY COALESCE(julianday(updated_at), 0) DESC, + COALESCE(julianday(started_at), 0) DESC, + id DESC + {limit_sql} + """, + params, + ).fetchall() + + def count_session_rows(self, query: SessionQuery | None = None) -> int: + where_sql, params = self._session_where(query) + with self._read_connect() as conn: + row = conn.execute(f"SELECT COUNT(*) AS count FROM sessions {where_sql}", params).fetchone() + return int(row["count"] or 0) if row is not None else 0 + + def sum_session_records(self, query: SessionQuery | None = None) -> int: + where_sql, params = self._session_where(query) + with self._read_connect() as conn: + row = conn.execute( + f"SELECT COALESCE(SUM(record_count), 0) AS total FROM sessions {where_sql}", params + ).fetchone() + return int(row["total"] or 0) if row is not None else 0 + + def get_session_aggregates(self, query: SessionQuery | None = None) -> dict[str, Any]: + where_sql, params = self._session_where(query) + with self._read_connect() as conn: + row = conn.execute( + f""" + SELECT + COUNT(*) AS total_sessions, + COALESCE(SUM(record_count), 0) AS total_records, + COALESCE(SUM(CAST(json_extract(summary_json, '$.total_tokens') AS INTEGER)), 0) AS total_tokens, + COALESCE(SUM(CASE WHEN status = 'error' OR (status = 'active' AND json_valid(summary_json) AND json_extract(summary_json, '$.status') = 'error') THEN 1 ELSE 0 END), 0) AS total_errors + FROM sessions + {where_sql} + """, + params, + ).fetchone() + return { + "total_sessions": int(row["total_sessions"] or 0) if row else 0, + "total_records": int(row["total_records"] or 0) if row else 0, + "total_tokens": int(row["total_tokens"] or 0) if row else 0, + "total_errors": int(row["total_errors"] or 0) if row else 0, + } + + def list_agent_buckets(self) -> list[sqlite3.Row]: + """Return session counts grouped by stored agent signal without loading records.""" + agent_expr = self._agent_label_expr() + with self._read_connect() as conn: + return conn.execute( + f""" + SELECT + {agent_expr} AS agent, + COUNT(*) AS sessions, + COALESCE(SUM(record_count), 0) AS records + FROM sessions + GROUP BY agent + ORDER BY LOWER(agent), agent + """ + ).fetchall() + + def delete_sessions(self, session_ids: list[str]) -> dict[str, int | list[str]]: + """Delete multiple trace sessions and their dependent records/logs.""" + unique_ids = list(dict.fromkeys(session_id for session_id in session_ids if session_id)) + if not unique_ids: + return { + "deleted_sessions": 0, + "deleted_records": 0, + "deleted_logs": 0, + "missing_sessions": [], + } + placeholders = ",".join("?" * len(unique_ids)) + with self._write_access() as conn: + rows = conn.execute( + f"SELECT id FROM sessions WHERE id IN ({placeholders})", + unique_ids, + ).fetchall() + existing_ids = [row["id"] for row in rows] + missing_ids = [session_id for session_id in unique_ids if session_id not in set(existing_ids)] + if not existing_ids: + return { + "deleted_sessions": 0, + "deleted_records": 0, + "deleted_logs": 0, + "missing_sessions": missing_ids, + } + existing_placeholders = ",".join("?" * len(existing_ids)) + record_row = conn.execute( + f"SELECT COUNT(*) AS count FROM records WHERE session_id IN ({existing_placeholders})", + existing_ids, + ).fetchone() + log_row = conn.execute( + f"SELECT COUNT(*) AS count FROM proxy_logs WHERE session_id IN ({existing_placeholders})", + existing_ids, + ).fetchone() + deleted_records = int(record_row["count"] or 0) if record_row is not None else 0 + deleted_logs = int(log_row["count"] or 0) if log_row is not None else 0 + conn.execute(f"DELETE FROM sessions WHERE id IN ({existing_placeholders})", existing_ids) + conn.commit() + return { + "deleted_sessions": len(existing_ids), + "deleted_records": deleted_records, + "deleted_logs": deleted_logs, + "missing_sessions": missing_ids, + } + + def finalize_stale_active_sessions( + self, + *, + protected_session_ids: set[str] | None = None, + now: datetime | None = None, + ) -> int: + """Mark stale active sessions complete so abandoned traces can be managed.""" + protected = protected_session_ids or set() + cutoff = (now or datetime.now(timezone.utc)) - STALE_ACTIVE_SESSION_AFTER + with self._write_access() as conn: + rows = conn.execute( + """ + SELECT id, record_count, summary_json + FROM sessions + WHERE status = 'active' + AND COALESCE(julianday(updated_at), 0) <= julianday(?) + """, + (cutoff.isoformat(),), + ).fetchall() + updated = 0 + for row in rows: + session_id = row["id"] + if session_id in protected: + continue + status = _stale_active_final_status(row) + summary_json = _stale_active_summary_json(row["summary_json"], session_id, status) + conn.execute( + """ + UPDATE sessions + SET status = ?, summary_json = ? + WHERE id = ? + """, + (status, summary_json, session_id), + ) + updated += 1 + if updated: + conn.commit() + return updated + + def load_records( + self, + session_id: str, + limit: int | None = None, + offset: int = 0, + ) -> list[dict[str, Any]]: + with self._read_connect() as conn: + return self._load_records_with_connection( + conn, + session_id, + limit=limit, + offset=offset, + ) + + def _load_records_with_connection( + self, + conn: sqlite3.Connection, + session_id: str, + *, + limit: int | None = None, + offset: int = 0, + ) -> list[dict[str, Any]]: + offset = max(0, offset) + params: list[object] = [session_id] + limit_sql = "" + if limit is not None: + limit_sql = " LIMIT ? OFFSET ?" + params.extend([max(0, limit), offset]) + elif offset: + limit_sql = " LIMIT -1 OFFSET ?" + params.append(offset) + rows = conn.execute( + f""" + SELECT session_id, payload_json + FROM records + WHERE session_id = ? + ORDER BY record_index + {limit_sql} + """, + params, + ).fetchall() + return self._rows_to_records(conn, rows) + + def load_boundary_records(self, session_id: str) -> list[dict[str, Any]]: + """Load the first and last records for a session without reading everything.""" + with self._read_connect() as conn: + first = conn.execute( + """ + SELECT session_id, payload_json + FROM records + WHERE session_id = ? + ORDER BY record_index + LIMIT 1 + """, + (session_id,), + ).fetchone() + last = conn.execute( + """ + SELECT session_id, payload_json + FROM records + WHERE session_id = ? + ORDER BY record_index DESC + LIMIT 1 + """, + (session_id,), + ).fetchone() + if first is None: + return [] + if last is None or first["payload_json"] == last["payload_json"]: + return self._rows_to_records(conn, [first]) + return self._rows_to_records(conn, [first, last]) + + def load_records_for_date(self, date_key: str) -> list[dict[str, Any]]: + """Load all records for sessions on a given date in one query.""" + with self._read_connect() as conn: + if date_key == "legacy": + rows = conn.execute( + """ + SELECT r.session_id, r.payload_json + FROM records r + INNER JOIN sessions s ON s.id = r.session_id + WHERE s.date_key = 'legacy' OR s.legacy_rel_path NOT LIKE '%/%' + ORDER BY s.started_at ASC, r.record_index ASC + """ + ).fetchall() + elif _DATE_RE.match(date_key): + rows = conn.execute( + """ + SELECT r.session_id, r.payload_json + FROM records r + INNER JOIN sessions s ON s.id = r.session_id + WHERE s.date_key = ? + ORDER BY s.started_at ASC, r.record_index ASC + """, + (date_key,), + ).fetchall() + else: + raise ValueError("Invalid date format") + return self._rows_to_records(conn, rows) + + def load_logs(self, session_id: str) -> list[dict[str, str]]: + with self._read_connect() as conn: + rows = conn.execute( + """ + SELECT logged_at, level, message + FROM proxy_logs + WHERE session_id = ? + ORDER BY line_no + """, + (session_id,), + ).fetchall() + return [ + { + "logged_at": row["logged_at"] or "", + "level": row["level"] or "", + "message": row["message"] or "", + } + for row in rows + ] + + def export_jsonl(self, session_id: str) -> str: + records = self.load_records(session_id) + return "\n".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) for record in records) + ( + "\n" if records else "" + ) + + def export_compact(self, session_id: str) -> str: + records = self.load_records(session_id) + return dump_compact_trace(records) + + def export_log(self, session_id: str) -> str: + lines = [] + for entry in self.load_logs(session_id): + timestamp = entry["logged_at"] + message = entry["message"] + if timestamp: + lines.append(f"{timestamp} {message}") + else: + lines.append(message) + return "\n".join(lines) + ("\n" if lines else "") + + def store_summary(self, session_id: str, summary: dict[str, Any]) -> None: + with self._write_access() as conn: + conn.execute( + """ + UPDATE sessions + SET summary_json = ?, updated_at = ?, status = ? + WHERE id = ? + """, + ( + json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + summary.get("updated_at") or datetime.now(timezone.utc).isoformat(), + summary.get("status") or "complete", + session_id, + ), + ) + conn.commit() + + def dashboard_snapshot(self) -> dict[str, tuple[str, int, str]]: + """Return session_id -> (updated_at, record_count, status) for change detection.""" + snapshot: dict[str, tuple[str, int, str]] = {} + with self._read_connect() as conn: + rows = conn.execute( + """ + SELECT id, updated_at, record_count, status + FROM sessions + """ + ).fetchall() + for row in rows: + snapshot[row["id"]] = ( + row["updated_at"] or "", + int(row["record_count"] or 0), + row["status"] or "", + ) + return snapshot + + def list_dates(self) -> tuple[list[str], bool]: + dates: set[str] = set() + has_legacy = False + with self._read_connect() as conn: + for row in conn.execute("SELECT DISTINCT date_key FROM sessions").fetchall(): + date_key = row["date_key"] or "" + if _DATE_RE.match(date_key): + dates.add(date_key) + elif date_key == "legacy": + has_legacy = True + dates.add(datetime.now().date().isoformat()) + return sorted(dates, reverse=True), has_legacy + + def delete_sessions_by_date( + self, date_key: str, *, protected_session_ids: set[str] | None = None + ) -> dict[str, int | str]: + protected = protected_session_ids or set() + with self._write_access() as conn: + if date_key == "legacy": + rows = conn.execute( + "SELECT id FROM sessions WHERE date_key = 'legacy' OR legacy_rel_path NOT LIKE '%/%'" + ).fetchall() + elif _DATE_RE.match(date_key): + rows = conn.execute("SELECT id FROM sessions WHERE date_key = ?", (date_key,)).fetchall() + else: + raise ValueError("Invalid date format") + + to_delete = [row["id"] for row in rows if row["id"] not in protected] + skipped = [row["id"] for row in rows if row["id"] in protected] + if to_delete: + placeholders = ",".join("?" * len(to_delete)) + conn.execute(f"DELETE FROM sessions WHERE id IN ({placeholders})", to_delete) + conn.commit() + return { + "date": date_key, + "deleted_sessions": len(to_delete), + "deleted_files": len(to_delete), + "skipped_sessions": len(skipped), + "skipped_files": len(skipped), + } + + def delete_session(self, session_id: str) -> dict[str, int | str]: + """Delete one trace session and its dependent records/logs.""" + with self._write_access() as conn: + row = conn.execute("SELECT id FROM sessions WHERE id = ?", (session_id,)).fetchone() + if row is None: + return { + "session_id": session_id, + "deleted_sessions": 0, + "deleted_records": 0, + "deleted_logs": 0, + } + record_row = conn.execute( + "SELECT COUNT(*) AS count FROM records WHERE session_id = ?", + (session_id,), + ).fetchone() + log_row = conn.execute( + "SELECT COUNT(*) AS count FROM proxy_logs WHERE session_id = ?", + (session_id,), + ).fetchone() + deleted_records = int(record_row["count"] or 0) if record_row is not None else 0 + deleted_logs = int(log_row["count"] or 0) if log_row is not None else 0 + conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + conn.commit() + return { + "session_id": session_id, + "deleted_sessions": 1, + "deleted_records": deleted_records, + "deleted_logs": deleted_logs, + } + + def cleanup_old_sessions( + self, + max_sessions: int, + *, + protected_session_id: str | None = None, + protected_session_ids: set[str] | None = None, + ) -> int: + if max_sessions <= 0: + return 0 + protected = set(protected_session_ids or set()) + if protected_session_id: + protected.add(protected_session_id) + with self._write_access() as conn: + rows = conn.execute( + """ + SELECT id, status, updated_at, started_at + FROM sessions + ORDER BY COALESCE(julianday(started_at), 0) ASC, + started_at ASC, + id ASC + """ + ).fetchall() + if len(rows) <= max_sessions: + return 0 + target_remove = len(rows) - max_sessions + now = datetime.now(timezone.utc) + to_remove = [] + for row in rows: + if row["id"] in protected: + continue + if row["status"] == "active" and not _is_stale_active_session(row["updated_at"], now): + continue + to_remove.append(row["id"]) + if len(to_remove) >= target_remove: + break + if not to_remove: + return 0 + placeholders = ",".join("?" * len(to_remove)) + conn.execute(f"DELETE FROM sessions WHERE id IN ({placeholders})", to_remove) + conn.commit() + return len(to_remove) + + def migrate_legacy_directory(self, output_dir: Path) -> int: + """Import legacy JSONL/log files from a directory tree.""" + output_dir = output_dir.resolve() + if not output_dir.is_dir(): + return 0 + + imported = 0 + legacy_source_key = _legacy_source_key(output_dir) + manifest_entries = _manifest_entries_by_rel_path(output_dir) + for trace_path in sorted(output_dir.glob("**/trace_*.jsonl")): + rel_path = trace_path.relative_to(output_dir).as_posix() + if self._legacy_session_exists(legacy_source_key, rel_path): + continue + records = _read_jsonl_file(trace_path) + log_path = trace_path.with_suffix(".log") + logs = _read_log_file(log_path) if log_path.is_file() else [] + manifest_entry = manifest_entries.get(rel_path, {}) + session_id = self._import_legacy_session( + legacy_source_key=legacy_source_key, + rel_path=rel_path, + trace_path=trace_path, + records=records, + logs=logs, + manifest_entry=manifest_entry, + ) + if session_id: + imported += 1 + + return imported + + def _import_legacy_session( + self, + *, + legacy_source_key: str, + rel_path: str, + trace_path: Path, + records: list[dict[str, Any]], + logs: list[str], + manifest_entry: dict[str, Any], + ) -> str | None: + session_id = str(uuid.uuid4()) + stat = trace_path.stat() + started_at = _legacy_started_at(trace_path, records, manifest_entry, stat.st_mtime) + date_key = trace_path.parent.name if _DATE_RE.match(trace_path.parent.name) else "legacy" + client = "" + proxy_mode = "" + if isinstance(manifest_entry.get("client"), str): + client = manifest_entry["client"] + if isinstance(manifest_entry.get("proxy_mode"), str): + proxy_mode = manifest_entry["proxy_mode"] + if not client and records: + capture = records[0].get("capture") + if isinstance(capture, dict): + client = str(capture.get("client") or "") + proxy_mode = str(capture.get("proxy_mode") or "") + + with self._write_access() as conn: + try: + conn.execute( + """ + INSERT INTO sessions ( + id, started_at, updated_at, date_key, client, proxy_mode, + status, record_count, legacy_source_key, legacy_rel_path + ) + VALUES (?, ?, ?, ?, ?, ?, 'complete', ?, ?, ?) + """, + ( + session_id, + started_at, + started_at, + date_key, + client, + proxy_mode, + len(records), + legacy_source_key, + rel_path, + ), + ) + except sqlite3.IntegrityError: + conn.rollback() + row = conn.execute( + "SELECT 1 FROM sessions WHERE legacy_source_key = ? AND legacy_rel_path = ? LIMIT 1", + (legacy_source_key, rel_path), + ).fetchone() + if row is not None: + return None + raise + conn.executemany( + """ + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + VALUES (?, ?, ?, ?, ?) + """, + [ + ( + session_id, + index, + _int_or_none(record.get("turn")), + _str_or_none(record.get("timestamp")), + self._encode_record(conn, session_id, record), + ) + for index, record in enumerate(records, start=1) + ], + ) + conn.executemany( + """ + INSERT INTO proxy_logs (session_id, line_no, logged_at, level, message) + VALUES (?, ?, ?, ?, ?) + """, + [ + (session_id, index, _parse_log_timestamp(line), "INFO", _parse_log_message(line)) + for index, line in enumerate(logs, start=1) + ], + ) + row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if row is not None: + from claude_tap.dashboard import build_imported_session_summary + + summary = build_imported_session_summary(row, records, manifest_entry) + conn.execute( + """ + UPDATE sessions + SET summary_json = ?, status = ? + WHERE id = ? + """, + ( + json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + summary.get("status") or "complete", + session_id, + ), + ) + conn.commit() + return session_id + + def _legacy_session_exists(self, legacy_source_key: str, rel_path: str) -> bool: + with self._read_connect() as conn: + row = conn.execute( + "SELECT 1 FROM sessions WHERE legacy_source_key = ? AND legacy_rel_path = ? LIMIT 1", + (legacy_source_key, rel_path), + ).fetchone() + return row is not None + + def _migration_done(self, marker: str) -> bool: + with self._read_connect() as conn: + row = conn.execute( + "SELECT value FROM migration_state WHERE key = ?", + (marker,), + ).fetchone() + return row is not None + + def _mark_migration_done(self, marker: str) -> None: + with self._write_access() as conn: + conn.execute( + """ + INSERT INTO migration_state (key, value) + VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """, + (marker, datetime.now(timezone.utc).isoformat()), + ) + conn.commit() + + def _refresh_summary_after_append( + self, + conn: sqlite3.Connection, + session_id: str, + record: dict[str, Any], + record_count: int, + ) -> None: + from claude_tap.dashboard import ( + build_stored_session_summary, + is_dashboard_summary_current, + merge_record_into_summary, + ) + + row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if row is None: + return + existing = None + if row["summary_json"]: + try: + existing = json.loads(row["summary_json"]) + except json.JSONDecodeError: + existing = None + if existing is not None and not is_dashboard_summary_current(existing, session_id): + summary = build_stored_session_summary(row, self._load_records_with_connection(conn, session_id)) + else: + summary = merge_record_into_summary( + existing, + row=row, + record=record, + record_count=record_count, + ) + conn.execute( + """ + UPDATE sessions + SET summary_json = ? + WHERE id = ? + """, + ( + json.dumps(summary, ensure_ascii=False, separators=(",", ":")), + session_id, + ), + ) + + def _open_connection(self, *, enable_wal: bool = True) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, timeout=SQLITE_BUSY_TIMEOUT_MS / 1000) + conn.row_factory = sqlite3.Row + conn.execute(f"PRAGMA busy_timeout = {SQLITE_BUSY_TIMEOUT_MS}") + conn.execute("PRAGMA foreign_keys = ON") + if enable_wal: + conn.execute("PRAGMA journal_mode = WAL") + return conn + + def _connect(self) -> sqlite3.Connection: + conn = getattr(self._tls, "conn", None) + if conn is None: + conn = self._open_connection() + try: + self._ensure_schema_once(conn) + except Exception: + conn.close() + raise + self._tls.conn = conn + return conn + + @contextmanager + def _write_access(self) -> Iterator[sqlite3.Connection]: + with self._write_lock: + with self._process_write_lock(): + conn = self._connect() + try: + yield conn + except sqlite3.Error: + _rollback_if_needed(conn) + raise + + @contextmanager + def _process_write_lock(self) -> Iterator[None]: + try: + lock_file = self._write_lock_path.open("a+b") + except OSError as exc: + raise sqlite3.OperationalError(f"trace write lock unavailable: {exc}") from exc + + with lock_file: + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write(b"\0") + lock_file.flush() + deadline = time.monotonic() + WRITE_LOCK_TIMEOUT_SECONDS + while True: + try: + _try_lock_file_exclusive(lock_file) + break + except OSError as exc: + if time.monotonic() >= deadline: + raise sqlite3.OperationalError(f"trace write lock unavailable: {exc}") from exc + time.sleep(WRITE_LOCK_RETRY_SECONDS) + try: + yield + finally: + try: + _unlock_file(lock_file) + except OSError: + pass + + @contextmanager + def _read_connect(self) -> Iterator[sqlite3.Connection]: + self._ensure_schema_for_read() + conn = self._open_connection(enable_wal=False) + try: + yield conn + finally: + conn.close() + + def _ensure_schema_for_read(self) -> None: + if self._schema_ready: + return + with self._write_lock: + with self._process_write_lock(): + if self._schema_ready: + return + conn = self._open_connection() + try: + self._ensure_schema_once(conn) + finally: + conn.close() + + def close(self) -> None: + """Close the thread-local SQLite connection.""" + conn = getattr(self._tls, "conn", None) + if conn is not None: + conn.close() + self._tls.conn = None + + def _ensure_schema_once(self, conn: sqlite3.Connection) -> None: + with self._schema_lock: + if self._schema_ready: + return + self._ensure_schema(conn) + conn.commit() + self._schema_ready = True + + def _ensure_schema(self, conn: sqlite3.Connection) -> None: + current = conn.execute("PRAGMA user_version").fetchone()[0] + if current == 0: + self._create_v4_schema(conn) + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + return + if current == 2: + self._migrate_v2_to_v3(conn) + current = 3 + if current == 3: + self._migrate_v3_to_v4(conn) + return + if current != SCHEMA_VERSION: + raise RuntimeError(f"Unsupported trace database schema version {current}; expected {SCHEMA_VERSION}.") + self._create_v4_schema(conn) + + def _create_v3_schema(self, conn: sqlite3.Connection) -> None: + self._create_v3_tables(conn) + self._create_v3_indexes(conn) + + def _create_v4_schema(self, conn: sqlite3.Connection) -> None: + self._create_v3_tables(conn) + self._create_v4_tables(conn) + self._create_v3_indexes(conn) + + def _migrate_v2_to_v3(self, conn: sqlite3.Connection) -> None: + suffix = uuid.uuid4().hex + sessions_v2 = f"sessions_v2_{suffix}" + records_v2 = f"records_v2_{suffix}" + proxy_logs_v2 = f"proxy_logs_v2_{suffix}" + if conn.in_transaction: + conn.commit() + conn.execute("PRAGMA foreign_keys = OFF") + try: + conn.execute("BEGIN") + conn.execute(f"ALTER TABLE sessions RENAME TO {sessions_v2}") + conn.execute(f"ALTER TABLE records RENAME TO {records_v2}") + conn.execute(f"ALTER TABLE proxy_logs RENAME TO {proxy_logs_v2}") + self._create_v3_tables(conn) + conn.execute( + f""" + INSERT INTO sessions ( + id, started_at, updated_at, date_key, client, proxy_mode, + status, record_count, summary_json, legacy_source_key, legacy_rel_path + ) + SELECT + id, started_at, updated_at, date_key, client, proxy_mode, + status, record_count, summary_json, '', legacy_rel_path + FROM {sessions_v2} + """ + ) + conn.execute( + f""" + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + SELECT session_id, record_index, turn, timestamp, payload_json + FROM {records_v2} + """ + ) + conn.execute( + f""" + INSERT INTO proxy_logs (session_id, line_no, logged_at, level, message) + SELECT session_id, line_no, logged_at, level, message + FROM {proxy_logs_v2} + """ + ) + conn.execute(f"DROP TABLE {proxy_logs_v2}") + conn.execute(f"DROP TABLE {records_v2}") + conn.execute(f"DROP TABLE {sessions_v2}") + self._create_v3_indexes(conn) + conn.execute("PRAGMA user_version = 3") + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.execute("PRAGMA foreign_keys = ON") + + def _migrate_v3_to_v4(self, conn: sqlite3.Connection) -> None: + self._create_v4_tables(conn) + conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + conn.commit() + + def _create_v3_tables(self, conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + date_key TEXT NOT NULL, + client TEXT NOT NULL DEFAULT '', + proxy_mode TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + record_count INTEGER NOT NULL DEFAULT 0, + summary_json TEXT, + legacy_source_key TEXT NOT NULL DEFAULT '', + legacy_rel_path TEXT + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS records ( + session_id TEXT NOT NULL, + record_index INTEGER NOT NULL, + turn INTEGER, + timestamp TEXT, + payload_json TEXT NOT NULL, + PRIMARY KEY (session_id, record_index), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS proxy_logs ( + session_id TEXT NOT NULL, + line_no INTEGER NOT NULL, + logged_at TEXT, + level TEXT, + message TEXT NOT NULL, + PRIMARY KEY (session_id, line_no), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS migration_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """ + ) + + def _create_v4_tables(self, conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS record_blobs ( + session_id TEXT NOT NULL, + hash TEXT NOT NULL, + kind TEXT NOT NULL, + payload_json TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (session_id, hash), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ) + """ + ) + + def _create_v3_indexes(self, conn: sqlite3.Connection) -> None: + conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_date_key ON sessions(date_key)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_records_session_id ON records(session_id)") + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_legacy_source_rel_path + ON sessions(legacy_source_key, legacy_rel_path) + WHERE legacy_rel_path IS NOT NULL + """ + ) + + def _next_record_index(self, conn: sqlite3.Connection, session_id: str) -> int: + row = conn.execute( + "SELECT COALESCE(MAX(record_index), 0) + 1 AS next_index FROM records WHERE session_id = ?", + (session_id,), + ).fetchone() + return int(row["next_index"]) + + @staticmethod + def _agent_label_expr() -> str: + return """ + COALESCE( + NULLIF( + CASE + WHEN json_valid(summary_json) + THEN json_extract(summary_json, '$.agent') + ELSE '' + END, + '' + ), + NULLIF(client, ''), + 'Unknown' + ) + """ + + @staticmethod + def _summary_agent_lower_expr() -> str: + return """ + LOWER( + CASE + WHEN json_valid(summary_json) + THEN COALESCE(json_extract(summary_json, '$.agent'), '') + ELSE '' + END + ) + """ + + @staticmethod + def _escape_like(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _session_where(self, query: SessionQuery | None) -> tuple[str, list[object]]: + if query is None: + return "", [] + + clauses: list[str] = [] + params: list[object] = [] + if query.date: + if query.date == "legacy": + clauses.append("(date_key = 'legacy' OR legacy_rel_path NOT LIKE '%/%')") + elif _DATE_RE.match(query.date): + clauses.append("date_key = ?") + params.append(query.date) + + if query.status: + if query.status == "error": + clauses.append( + "(status = 'error' OR (status = 'active' AND json_valid(summary_json) AND json_extract(summary_json, '$.status') = 'error'))" + ) + elif query.status == "active": + clauses.append( + "(status = 'active' AND (NOT json_valid(summary_json) OR json_extract(summary_json, '$.status') IS NULL OR json_extract(summary_json, '$.status') != 'error'))" + ) + else: + clauses.append("status = ?") + params.append(query.status) + + if query.agent_clients or query.agent_labels: + agent_clauses: list[str] = [] + if query.agent_clients: + placeholders = ",".join("?" * len(query.agent_clients)) + agent_clauses.append(f"LOWER(COALESCE(client, '')) IN ({placeholders})") + params.extend(client.lower() for client in query.agent_clients) + if query.agent_labels: + placeholders = ",".join("?" * len(query.agent_labels)) + summary_agent_expr = self._summary_agent_lower_expr() + agent_clauses.append(f"{summary_agent_expr} IN ({placeholders})") + params.extend(label.lower() for label in query.agent_labels) + clauses.append(f"({' OR '.join(agent_clauses)})") + + search = query.search.strip().lower() + if search: + pattern = f"%{self._escape_like(search)}%" + search_clauses = [ + "LOWER(COALESCE(id, '')) LIKE ? ESCAPE '\\'", + "LOWER(COALESCE(date_key, '')) LIKE ? ESCAPE '\\'", + "LOWER(COALESCE(client, '')) LIKE ? ESCAPE '\\'", + "LOWER(COALESCE(proxy_mode, '')) LIKE ? ESCAPE '\\'", + "LOWER(COALESCE(status, '')) LIKE ? ESCAPE '\\'", + "LOWER(COALESCE(legacy_rel_path, '')) LIKE ? ESCAPE '\\'", + "LOWER(COALESCE(summary_json, '')) LIKE ? ESCAPE '\\'", + "id IN (SELECT session_id FROM records WHERE LOWER(payload_json) LIKE ? ESCAPE '\\')", + "id IN (SELECT session_id FROM record_blobs WHERE LOWER(payload_json) LIKE ? ESCAPE '\\')", + ] + clauses.append(f"({' OR '.join(search_clauses)})") + params.extend([pattern] * len(search_clauses)) + + if not clauses: + return "", [] + return f"WHERE {' AND '.join(clauses)}", params + + def _encode_record(self, conn: sqlite3.Connection, session_id: str, record: dict[str, Any]) -> str: + compact_record, refs = compact_record_blobs( + record, lambda value: self._store_json_blob(conn, session_id, value) + ) + payload: dict[str, Any] = compact_record + if refs: + payload = { + COMPACT_RECORD_MARKER: { + "version": COMPACT_RECORD_VERSION, + "encoding": "json-blob-ref", + "refs": refs, + }, + "record": compact_record, + } + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + def _store_json_blob(self, conn: sqlite3.Connection, session_id: str, value: Any) -> dict[str, Any] | None: + payload_json, size_bytes, hash_value = json_blob_payload(value) + if size_bytes < MIN_BLOB_BYTES: + return None + conn.execute( + """ + INSERT OR IGNORE INTO record_blobs (session_id, hash, kind, payload_json, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + (session_id, hash_value, BLOB_KIND_JSON, payload_json, size_bytes, datetime.now(timezone.utc).isoformat()), + ) + return make_blob_ref(hash_value, size_bytes) + + def _rows_to_records(self, conn: sqlite3.Connection, rows: list[sqlite3.Row]) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + blob_cache: dict[tuple[str, str], Any] = {} + for row in rows: + try: + record = self._decode_record_payload(conn, row["session_id"], row["payload_json"], blob_cache) + except (json.JSONDecodeError, KeyError): + continue + if isinstance(record, dict): + records.append(record) + return records + + def _decode_record_payload( + self, + conn: sqlite3.Connection, + session_id: str, + payload_json: str, + blob_cache: dict[tuple[str, str], Any], + ) -> dict[str, Any] | None: + payload = json.loads(payload_json) + return decode_compact_record_payload( + payload, + lambda ref: self._load_record_blob(conn, session_id, ref, blob_cache), + ) + + def _load_record_blob( + self, + conn: sqlite3.Connection, + session_id: str, + ref: dict[str, Any], + blob_cache: dict[tuple[str, str], Any], + ) -> Any: + hash_value = ref["hash"] + cache_key = (session_id, hash_value) + if cache_key not in blob_cache: + row = conn.execute( + "SELECT payload_json FROM record_blobs WHERE session_id = ? AND hash = ? AND kind = ?", + (session_id, hash_value, ref.get("kind") or BLOB_KIND_JSON), + ).fetchone() + if row is None: + raise KeyError(hash_value) + blob_cache[cache_key] = json.loads(row["payload_json"]) + return blob_cache[cache_key] + + +def _try_lock_file_exclusive(lock_file: Any) -> None: + lock_file.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + return + + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def _unlock_file(lock_file: Any) -> None: + lock_file.seek(0) + if os.name == "nt": + import msvcrt + + msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1) + return + + import fcntl + + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +def _rollback_if_needed(conn: sqlite3.Connection) -> None: + try: + if conn.in_transaction: + conn.rollback() + except sqlite3.Error: + pass + + +def _legacy_source_key(output_dir: Path) -> str: + return sha256(str(output_dir.resolve()).encode("utf-8")).hexdigest()[:16] + + +def _read_jsonl_file(path: Path) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return records + for line in lines: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(record, dict): + records.append(record) + return records + + +def _read_log_file(path: Path) -> list[str]: + try: + return [line for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + except OSError: + return [] + + +def _manifest_entries_by_rel_path(output_dir: Path) -> dict[str, dict[str, Any]]: + manifest_path = output_dir / ".cloudtap-manifest.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(manifest, dict): + return {} + entries: dict[str, dict[str, Any]] = {} + for entry in manifest.get("traces", []): + if not isinstance(entry, dict): + continue + for file_name in entry.get("files", []): + if isinstance(file_name, str): + entries[file_name.replace("\\", "/")] = entry + return entries + + +def _manifest_entry_for_rel_path(output_dir: Path, rel_path: str) -> dict[str, Any]: + return _manifest_entries_by_rel_path(output_dir).get(rel_path, {}) + + +def _legacy_started_at( + trace_path: Path, + records: list[dict[str, Any]], + manifest_entry: dict[str, Any], + mtime: float, +) -> str: + if records: + timestamp = records[0].get("timestamp") + if isinstance(timestamp, str) and timestamp: + return timestamp + created_at = manifest_entry.get("created_at") + if isinstance(created_at, str) and created_at: + return created_at + return datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() + + +def _parse_log_timestamp(line: str) -> str | None: + match = re.match(r"^(\d{2}:\d{2}:\d{2})\s", line) + return match.group(1) if match else None + + +def _parse_log_message(line: str) -> str: + match = re.match(r"^\d{2}:\d{2}:\d{2}\s+(.*)$", line) + return match.group(1) if match else line + + +def _parse_iso_datetime(value: object) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _is_stale_active_session(updated_at: object, now: datetime) -> bool: + updated = _parse_iso_datetime(updated_at) + return updated is not None and updated <= now - STALE_ACTIVE_SESSION_AFTER + + +def _stale_active_final_status(row: sqlite3.Row) -> str: + try: + summary = json.loads(row["summary_json"] or "{}") + except json.JSONDecodeError: + summary = {} + if isinstance(summary, dict) and summary.get("status") == "error": + return "error" + return "empty" if int(row["record_count"] or 0) == 0 else "complete" + + +def _stale_active_summary_json(summary_json: object, session_id: str, status: str) -> str | None: + if not summary_json: + return None + try: + summary = json.loads(str(summary_json)) + except json.JSONDecodeError: + return str(summary_json) + if not isinstance(summary, dict): + return str(summary_json) + summary["id"] = session_id + summary["status"] = status + summary["active"] = False + return json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + + +def _int_or_none(value: object) -> int | None: + return value if isinstance(value, int) else None + + +def _str_or_none(value: object) -> str | None: + return value if isinstance(value, str) else None diff --git a/claude_tap/upstream.py b/claude_tap/upstream.py new file mode 100644 index 0000000..fc6382a --- /dev/null +++ b/claude_tap/upstream.py @@ -0,0 +1,119 @@ +"""Helpers for upstream URL construction and connection diagnostics.""" + +from __future__ import annotations + +import ssl +from urllib.parse import urlsplit, urlunsplit + +KNOWN_UPSTREAM_ENDPOINT_PATHS = ( + "/v1/chat/completions", + "/chat/completions", + "/v1/messages", + "/messages", + "/v1/responses", + "/responses", + "/v1/completions", + "/completions", +) + + +def build_upstream_url(target_url: str, forward_path: str) -> str: + """Join a configured upstream target with a forwarded request path. + + Some users pass a complete request endpoint such as + ``https://gateway.example/v1/messages`` to ``--tap-target``. Avoid turning + a client request for ``/v1/messages`` into ``/v1/messages/v1/messages``. + """ + + target = urlsplit(target_url) + request_path, request_query = _split_forward_path(forward_path) + path = _join_without_duplicate_endpoint(target.path, request_path) + query = request_query or target.query + return urlunsplit((target.scheme, target.netloc, path, query, target.fragment)) + + +def format_upstream_error(exc: BaseException, *, target_url: str, upstream_url: str) -> str: + """Return a user-facing upstream connection error with actionable context.""" + + base = str(exc) or exc.__class__.__name__ + if not is_ssl_certificate_error(exc): + return base + + safe_target = _redact_url_userinfo(target_url) + safe_upstream = _redact_url_userinfo(upstream_url) + return ( + f"{base}\n\n" + "claude-tap could not verify the upstream TLS certificate. This commonly happens when " + "a corporate proxy, private gateway, or local network tool presents a certificate that " + "Python/aiohttp does not trust.\n\n" + "Set SSL_CERT_FILE to a CA bundle that trusts that proxy or gateway, then retry. On " + "macOS with the system CA bundle this is often:\n" + " SSL_CERT_FILE=/etc/ssl/cert.pem claude-tap --tap-live --tap-target \n\n" + "Also make sure --tap-target is the provider base URL, not a full request endpoint such " + "as /v1/messages.\n" + f"Configured target: {safe_target}\n" + f"Upstream URL: {safe_upstream}" + ) + + +def is_ssl_certificate_error(exc: BaseException) -> bool: + """Return whether an exception chain contains a TLS certificate verify failure.""" + + current: BaseException | None = exc + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, ssl.SSLCertVerificationError): + return True + cert_error = getattr(current, "certificate_error", None) + if isinstance(cert_error, ssl.SSLCertVerificationError): + return True + current = current.__cause__ or current.__context__ + + text = f"{exc.__class__.__name__}: {exc}" + return ( + "SSLCertVerificationError" in text or "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text + ) + + +def _split_forward_path(forward_path: str) -> tuple[str, str]: + path, sep, query = forward_path.partition("?") + if not path: + path = "/" + elif not path.startswith("/"): + path = f"/{path}" + return path, query if sep else "" + + +def _join_without_duplicate_endpoint(target_path: str, request_path: str) -> str: + clean_target = target_path.rstrip("/") + clean_request = request_path if request_path.startswith("/") else f"/{request_path}" + + for endpoint in KNOWN_UPSTREAM_ENDPOINT_PATHS: + if not clean_target.endswith(endpoint): + continue + if clean_request == endpoint or clean_request.startswith(f"{endpoint}/"): + base = clean_target[: -len(endpoint)].rstrip("/") + return f"{base}{clean_request}" or "/" + short_endpoint = _without_version_prefix(endpoint) + if short_endpoint and (clean_request == short_endpoint or clean_request.startswith(f"{short_endpoint}/")): + remainder = clean_request[len(short_endpoint) :] + return f"{clean_target}{remainder}" or "/" + + if clean_request == "/": + return clean_target or "/" + return f"{clean_target}/{clean_request.lstrip('/')}" if clean_target else clean_request + + +def _without_version_prefix(endpoint: str) -> str | None: + if endpoint.startswith("/v1/"): + return endpoint[3:] + return None + + +def _redact_url_userinfo(url: str) -> str: + parsed = urlsplit(url) + if "@" not in parsed.netloc: + return url + host_part = parsed.netloc.rsplit("@", 1)[1] + return urlunsplit((parsed.scheme, f"***@{host_part}", parsed.path, parsed.query, parsed.fragment)) diff --git a/claude_tap/usage.py b/claude_tap/usage.py new file mode 100644 index 0000000..5567412 --- /dev/null +++ b/claude_tap/usage.py @@ -0,0 +1,57 @@ +"""Token usage normalization helpers.""" + +from __future__ import annotations + + +def _missing_or_zero(value: object) -> bool: + return value is None or value == 0 + + +def normalize_usage(usage: object) -> dict: + """Return usage with provider-specific token fields mapped to shared names.""" + if not isinstance(usage, dict): + return {} + + normalized = {k: v for k, v in usage.items() if v is not None} + + input_tokens = normalized.get("input_tokens") + output_tokens = normalized.get("output_tokens") + if _missing_or_zero(input_tokens) and usage.get("prompt_tokens"): + normalized["input_tokens"] = usage["prompt_tokens"] + if _missing_or_zero(normalized.get("input_tokens")) and usage.get("promptTokenCount"): + normalized["input_tokens"] = usage["promptTokenCount"] + if _missing_or_zero(normalized.get("input_tokens")) and usage.get("inputTokens"): + normalized["input_tokens"] = usage["inputTokens"] + if _missing_or_zero(output_tokens) and usage.get("completion_tokens"): + normalized["output_tokens"] = usage["completion_tokens"] + if _missing_or_zero(normalized.get("output_tokens")) and usage.get("candidatesTokenCount"): + normalized["output_tokens"] = usage["candidatesTokenCount"] + if _missing_or_zero(normalized.get("output_tokens")) and usage.get("outputTokens"): + normalized["output_tokens"] = usage["outputTokens"] + if _missing_or_zero(normalized.get("total_tokens")) and usage.get("totalTokens"): + normalized["total_tokens"] = usage["totalTokens"] + if _missing_or_zero(normalized.get("total_tokens")) and usage.get("totalTokenCount"): + normalized["total_tokens"] = usage["totalTokenCount"] + + if "cache_read_input_tokens" not in normalized: + cached = usage.get("cached_tokens") + if cached is None: + cached = usage.get("cachedContentTokenCount") + if cached is None: + cached = usage.get("cacheReadInputTokens") + if cached is None: + for details_key in ("input_tokens_details", "prompt_tokens_details"): + details = usage.get(details_key) + if isinstance(details, dict): + cached = details.get("cached_tokens") + if cached is not None: + break + if cached is not None: + normalized["cache_read_input_tokens"] = cached + + if "cache_creation_input_tokens" not in normalized: + cache_write = usage.get("cacheWriteInputTokens") + if cache_write is not None: + normalized["cache_creation_input_tokens"] = cache_write + + return normalized diff --git a/claude_tap/viewer.html b/claude_tap/viewer.html new file mode 100644 index 0000000..c8257b8 --- /dev/null +++ b/claude_tap/viewer.html @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + +claude-tap Viewer + + + + +
+ +
+ + + + + +
+ +
+
+ +
+ +
+
+
+

Load Trace File

+

Drag & drop a .jsonl trace file, or click to browse

+ + +
+
+ + + + +
+ + + + diff --git a/claude_tap/viewer.py b/claude_tap/viewer.py new file mode 100644 index 0000000..a8dd002 --- /dev/null +++ b/claude_tap/viewer.py @@ -0,0 +1,1220 @@ +"""HTML viewer generation – embed JSONL data into a self-contained HTML file.""" + +from __future__ import annotations + +import base64 +import json +import re +from importlib.metadata import version as _pkg_version +from pathlib import Path + +from claude_tap.compact_trace import COMPACT_TRACE_MARKER, build_compact_trace_bundle, is_compact_trace_bundle +from claude_tap.sse import SSEReassembler +from claude_tap.usage import normalize_usage + +try: + CLAUDE_TAP_VERSION = _pkg_version("claude-tap") +except Exception: + CLAUDE_TAP_VERSION = "0.0.0" + +# Threshold: traces with more entries than this use lazy mode +LAZY_THRESHOLD = 50 +VIEWER_TEMPLATE_PATH = Path(__file__).parent / "viewer.html" +VIEWER_ASSETS_DIR = Path(__file__).parent / "viewer_assets" +VIEWER_CSS_PATH = VIEWER_ASSETS_DIR / "viewer.css" +VIEWER_JS_PATHS = ( + VIEWER_ASSETS_DIR / "state.js", + VIEWER_ASSETS_DIR / "responses.js", + VIEWER_ASSETS_DIR / "lazy_loading.js", + VIEWER_ASSETS_DIR / "i18n_ui.js", + VIEWER_ASSETS_DIR / "live_bootstrap.js", + VIEWER_ASSETS_DIR / "filters_search.js", + VIEWER_ASSETS_DIR / "sidebar.js", + VIEWER_ASSETS_DIR / "detail_trace.js", + VIEWER_ASSETS_DIR / "renderers.js", + VIEWER_ASSETS_DIR / "sections_json.js", + VIEWER_ASSETS_DIR / "diff.js", + VIEWER_ASSETS_DIR / "utilities_mobile.js", +) +VIEWER_I18N_PATH = Path(__file__).parent / "viewer_i18n.json" +VIEWER_STYLE_TEMPLATE_ANCHOR = "" +VIEWER_SCRIPT_TEMPLATE_ANCHOR = "" +VIEWER_SCRIPT_ANCHOR = "\n", + 1, + ) + if VIEWER_SCRIPT_ANCHOR not in html: + raise ValueError("viewer asset script is missing the main script anchor.") + return html + + +def _iter_response_events(resp: dict) -> list[dict]: + """Return stream events from SSE or WebSocket traces.""" + if not isinstance(resp, dict): + return [] + events = resp.get("sse_events") + if isinstance(events, list) and events: + return events + events = resp.get("ws_events") + if isinstance(events, list): + return events + return [] + + +def _iter_request_events(req: dict) -> list[dict]: + """Return request-side WebSocket events when a raw trace stores them.""" + if not isinstance(req, dict): + return [] + events = req.get("ws_events") + if isinstance(events, list): + return events + return [] + + +def _event_type(event: dict) -> str: + if not isinstance(event, dict): + return "" + value = event.get("event") or event.get("type") + return value if isinstance(value, str) else "" + + +def _event_payload(event: dict) -> dict | None: + if not isinstance(event, dict): + return None + payload = event.get("data", event) + if isinstance(payload, str): + try: + payload = json.loads(payload) + except (json.JSONDecodeError, TypeError): + return None + return payload if isinstance(payload, dict) else None + + +def _first_bool(*values: object) -> bool | None: + for value in values: + if isinstance(value, bool): + return value + return None + + +def _response_payload_from_event(event: dict) -> dict: + data = _event_payload(event) + if not isinstance(data, dict): + return {} + response = data.get("response") + if isinstance(response, dict): + return response + return data + + +def _last_response_payload_for_event(events: list[dict], event_type: str) -> dict: + for event in reversed(events): + if _event_type(event) == event_type: + return _response_payload_from_event(event) + return {} + + +def _response_output_count_from_events(events: list[dict]) -> int: + completed = _last_response_payload_for_event(events, "response.completed") + output = completed.get("output") + if isinstance(output, list): + return len(output) + return sum(1 for event in events if _event_type(event) == "response.output_item.done") + + +def _decode_bedrock_eventstream_events(body: object) -> list[dict]: + """Extract normalized stream events from a decoded AWS EventStream body. + + Bedrock streaming responses are binary AWS EventStream frames. Legacy + traces may contain those bytes decoded as text with invalid frame bytes + replaced, but the JSON payloads inside the frames remain intact. + """ + error_event_keys = { + "internalServerException", + "modelStreamErrorException", + "modelTimeoutException", + "serviceUnavailableException", + "throttlingException", + "validationException", + } + if not isinstance(body, str): + return [] + stream_event_keys = ( + "bytes", + "chunk", + "type", + "messageStart", + "contentBlockStart", + "contentBlockDelta", + "contentBlockStop", + "messageStop", + "metadata", + *error_event_keys, + ) + if not any(f'"{key}"' in body for key in stream_event_keys): + return [] + + def _converse_event_payload(payload: dict) -> tuple[str | None, dict | None]: + message_start = payload.get("messageStart") + if isinstance(message_start, dict): + return "message_start", { + "message": { + "type": "message", + "role": message_start.get("role") or "assistant", + "content": [], + } + } + + block_start = payload.get("contentBlockStart") + if isinstance(block_start, dict): + start = block_start.get("start") if isinstance(block_start.get("start"), dict) else {} + tool_use = start.get("toolUse") if isinstance(start, dict) else None + block: dict = {} + if isinstance(tool_use, dict): + block = { + "type": "tool_use", + "id": tool_use.get("toolUseId", ""), + "name": tool_use.get("name", ""), + "input": {}, + } + return "content_block_start", { + "index": block_start.get("contentBlockIndex", payload.get("contentBlockIndex", 0)), + "content_block": block, + } + + block_delta = payload.get("contentBlockDelta") + if isinstance(block_delta, dict): + delta = block_delta.get("delta") if isinstance(block_delta.get("delta"), dict) else {} + normalized_delta: dict = {} + if isinstance(delta.get("text"), str): + normalized_delta = {"type": "text_delta", "text": delta["text"]} + elif isinstance(delta.get("reasoningContent"), dict): + reasoning = delta["reasoningContent"] + text = reasoning.get("text") if isinstance(reasoning.get("text"), str) else "" + normalized_delta = {"type": "thinking_delta", "thinking": text} + signature = reasoning.get("signature") if isinstance(reasoning.get("signature"), str) else "" + if signature: + normalized_delta["signature"] = signature + elif isinstance(delta.get("toolUse"), dict): + tool_delta = delta["toolUse"] + partial = tool_delta.get("input") if isinstance(tool_delta.get("input"), str) else "" + normalized_delta = {"type": "input_json_delta", "partial_json": partial} + if normalized_delta: + return "content_block_delta", { + "index": block_delta.get("contentBlockIndex", payload.get("contentBlockIndex", 0)), + "delta": normalized_delta, + } + + block_stop = payload.get("contentBlockStop") + if isinstance(block_stop, dict): + return "content_block_stop", { + "index": block_stop.get("contentBlockIndex", payload.get("contentBlockIndex", 0)), + } + + message_stop = payload.get("messageStop") + if isinstance(message_stop, dict): + return "message_delta", { + "delta": {"stop_reason": message_stop.get("stopReason")}, + } + + metadata = payload.get("metadata") + if isinstance(metadata, dict) and isinstance(metadata.get("usage"), dict): + return "message_delta", {"usage": metadata["usage"]} + + return None, None + + def _event_payload_from_frame(frame: dict) -> tuple[str | None, dict | None]: + encoded = frame.get("bytes") + if not isinstance(encoded, str): + chunk = frame.get("chunk") + if isinstance(chunk, dict): + encoded = chunk.get("bytes") + if isinstance(encoded, str): + try: + payload_bytes = base64.b64decode(encoded, validate=True) + payload = json.loads(payload_bytes) + except (ValueError, json.JSONDecodeError): + return None, None + if not isinstance(payload, dict): + return None, None + + event_type = payload.get("type") + if isinstance(event_type, str) and event_type: + return event_type, payload + event_type, event_payload = _converse_event_payload(payload) + if event_type and event_payload: + return event_type, event_payload + for event_type in error_event_keys: + event_payload = payload.get(event_type) + if isinstance(event_payload, dict): + return event_type, event_payload + return None, None + + event_type = frame.get("type") + if isinstance(event_type, str) and event_type: + return event_type, frame + event_type, event_payload = _converse_event_payload(frame) + if event_type and event_payload: + return event_type, event_payload + + for event_type in error_event_keys: + payload = frame.get(event_type) + if isinstance(payload, dict): + return event_type, payload + return None, None + + events: list[dict] = [] + decoder = json.JSONDecoder() + pos = 0 + while True: + start = body.find('{"', pos) + if start < 0: + break + try: + frame, end = decoder.raw_decode(body[start:]) + except json.JSONDecodeError: + pos = start + 1 + continue + pos = start + end + + if not isinstance(frame, dict): + continue + event_type, payload = _event_payload_from_frame(frame) + if event_type and payload: + events.append({"event": event_type, "data": payload}) + + return events + + +def _normalize_record_for_viewer(record_json: str) -> str: + """Normalize trace variants into the shape expected by viewer.html.""" + try: + record = json.loads(record_json) + except (json.JSONDecodeError, TypeError): + return record_json + if not isinstance(record, dict): + return record_json + + response = record.get("response") + if not isinstance(response, dict): + return record_json + + events = _decode_bedrock_eventstream_events(response.get("body")) + if not events: + return record_json + + reassembler = SSEReassembler() + for event in events: + reassembler.add_event(event["event"], event["data"]) + + reconstructed = reassembler.reconstruct() + if reconstructed: + response["body"] = reconstructed + response.setdefault("sse_events", events) + + return json.dumps(record, ensure_ascii=False, separators=(",", ":")) + + +def _parse_function_call_arguments(arguments: object) -> object: + if isinstance(arguments, str): + try: + return json.loads(arguments) + except (json.JSONDecodeError, TypeError): + return arguments + if arguments is None: + return {} + return arguments + + +def _parse_sse_data_frames(body: object) -> list[dict]: + if not isinstance(body, str) or "data:" not in body: + return [] + + events: list[dict] = [] + data_lines: list[str] = [] + for line in body.splitlines(): + if line.startswith("data:"): + data_lines.append(line[len("data:") :].strip()) + continue + if line.strip(): + continue + if not data_lines: + continue + raw_data = "\n".join(data_lines) + data_lines = [] + if raw_data == "[DONE]": + continue + try: + data = json.loads(raw_data) + except (json.JSONDecodeError, TypeError): + data = raw_data + events.append({"event": "message", "data": data}) + + if data_lines: + raw_data = "\n".join(data_lines) + if raw_data != "[DONE]": + try: + data = json.loads(raw_data) + except (json.JSONDecodeError, TypeError): + data = raw_data + events.append({"event": "message", "data": data}) + + return events + + +def _looks_like_gemini_request(value: object) -> bool: + return isinstance(value, dict) and ( + isinstance(value.get("contents"), list) or isinstance(value.get("systemInstruction"), dict) + ) + + +def _gemini_request(body: dict) -> dict: + req = body.get("request") + if _looks_like_gemini_request(req): + return req + return body if _looks_like_gemini_request(body) else {} + + +def _is_gemini_request_body(body: dict) -> bool: + req = _gemini_request(body) + return _looks_like_gemini_request(req) + + +def _model_from_path(path: object) -> str: + if not isinstance(path, str): + return "" + match = re.search(r"/models?/([^:?/]+)", path) + return match.group(1) if match else "" + + +def _gemini_text_from_parts(parts: object) -> str: + if not isinstance(parts, list): + return "" + return "\n".join( + part.get("text", "") for part in parts if isinstance(part, dict) and isinstance(part.get("text"), str) + ) + + +def _extract_gemini_system(body: dict) -> str: + instr = _gemini_request(body).get("systemInstruction") + if not isinstance(instr, dict): + return "" + return _gemini_text_from_parts(instr.get("parts")).strip() + + +def _gemini_function_response_content(resp: dict) -> str: + payload = resp.get("response") + if isinstance(payload, dict) and "output" in payload: + output = payload["output"] + else: + output = payload + if isinstance(output, str): + return output + return json.dumps(output, ensure_ascii=False) + + +def _gemini_part_blocks(part: dict) -> list[dict]: + blocks: list[dict] = [] + text = part.get("text") + if isinstance(text, str) and text.strip(): + if part.get("thought") is True: + blocks.append({"type": "thinking", "thinking": text}) + else: + blocks.append({"type": "text", "text": text}) + + call = part.get("functionCall") + if isinstance(call, dict): + blocks.append( + { + "type": "tool_use", + "id": call.get("id", ""), + "name": call.get("name", "tool_use"), + "input": call.get("args") if isinstance(call.get("args"), dict) else {}, + } + ) + + response = part.get("functionResponse") + if isinstance(response, dict): + blocks.append( + { + "type": "tool_result", + "tool_use_id": response.get("id") or response.get("name", ""), + "content": _gemini_function_response_content(response), + } + ) + return blocks + + +def _gemini_role(role: object) -> str: + if role == "model": + return "assistant" + return role if isinstance(role, str) and role else "user" + + +def _extract_gemini_request_messages(body: dict) -> list[dict]: + contents = _gemini_request(body).get("contents") + if not isinstance(contents, list): + return [] + + messages: list[dict] = [] + for content in contents: + if not isinstance(content, dict): + continue + blocks: list[dict] = [] + for part in content.get("parts") or []: + if isinstance(part, dict): + blocks.extend(_gemini_part_blocks(part)) + if not blocks: + continue + role = _gemini_role(content.get("role")) + if all(block.get("type") == "tool_result" for block in blocks): + role = "tool" + messages.append({"role": role, "content": blocks}) + return messages + + +def _extract_gemini_tools(body: dict) -> list[dict]: + tools = _gemini_request(body).get("tools") + if not isinstance(tools, list): + return [] + + normalized: list[dict] = [] + for tool_group in tools: + if not isinstance(tool_group, dict): + continue + declarations = tool_group.get("functionDeclarations") + if not isinstance(declarations, list): + continue + for decl in declarations: + if not isinstance(decl, dict): + continue + normalized.append( + { + "name": decl.get("name", ""), + "description": decl.get("description", ""), + "input_schema": decl.get("parametersJsonSchema") or decl.get("parameters") or {}, + } + ) + return normalized + + +def _gemini_payloads_from_response_body(body: object) -> list[dict]: + if isinstance(body, str): + return [event["data"] for event in _parse_sse_data_frames(body) if isinstance(event.get("data"), dict)] + if isinstance(body, dict): + return [body] + return [] + + +def _extract_gemini_response_output(body: object) -> dict | None: + payloads = _gemini_payloads_from_response_body(body) + content: list[dict] = [] + + def append_mergeable_block(block: dict[str, str]) -> None: + previous = content[-1] if content else None + if previous and previous.get("type") == block.get("type"): + if block.get("type") == "thinking": + previous["thinking"] = f"{previous.get('thinking', '')}{block.get('thinking', '')}" + return + if block.get("type") in {"text", "input_text", "output_text"}: + previous["text"] = f"{previous.get('text', '')}{block.get('text', '')}" + return + content.append(block) + + def append_text(text: str) -> None: + if not text.strip(): + return + append_mergeable_block({"type": "text", "text": text}) + + for payload in payloads: + response = payload.get("response") if isinstance(payload.get("response"), dict) else payload + candidates = response.get("candidates") if isinstance(response, dict) else None + if not isinstance(candidates, list): + continue + for candidate in candidates: + if not isinstance(candidate, dict): + continue + candidate_content = candidate.get("content") + if not isinstance(candidate_content, dict): + continue + for part in candidate_content.get("parts") or []: + if not isinstance(part, dict): + continue + if isinstance(part.get("text"), str): + if part.get("thought") is True: + thinking = part["text"] + if thinking.strip(): + append_mergeable_block({"type": "thinking", "thinking": thinking}) + else: + append_text(part["text"]) + call = part.get("functionCall") + if isinstance(call, dict): + content.append( + { + "type": "tool_use", + "id": call.get("id", ""), + "name": call.get("name", "tool_use"), + "input": call.get("args") if isinstance(call.get("args"), dict) else {}, + } + ) + + return {"content": content} if content else None + + +def _extract_gemini_response_usage(body: object) -> dict: + usage: dict = {} + for payload in _gemini_payloads_from_response_body(body): + response = payload.get("response") if isinstance(payload.get("response"), dict) else payload + if not isinstance(response, dict): + continue + event_usage = response.get("usageMetadata") + if isinstance(event_usage, dict): + usage = event_usage + return normalize_usage(usage) + + +def _extract_gemini_response_tool_names(body: object) -> list[str]: + output = _extract_gemini_response_output(body) + if not output: + return [] + return [block.get("name", "") for block in output["content"] if block.get("type") == "tool_use"] + + +def _tool_search_output_content(item: dict) -> str: + names: list[str] = [] + tools = item.get("tools") + if isinstance(tools, list): + for namespace in tools: + if not isinstance(namespace, dict): + continue + namespace_name = namespace.get("name") + if isinstance(namespace_name, str) and namespace_name: + names.append(namespace_name) + nested_tools = namespace.get("tools") + if isinstance(nested_tools, list): + for tool in nested_tools: + if not isinstance(tool, dict): + continue + tool_name = tool.get("name") + if isinstance(tool_name, str) and tool_name: + if isinstance(namespace_name, str) and namespace_name: + names.append(f"{namespace_name}.{tool_name}") + else: + names.append(tool_name) + if names: + return "tool_search_output\n" + "\n".join(names) + if isinstance(tools, list): + return json.dumps(tools, ensure_ascii=False) + return json.dumps(item, ensure_ascii=False) + + +def _response_call_tool_name(item: dict) -> str: + item_type = item.get("type") + if item_type == "tool_search_call": + return "tool_search" + item_name = item.get("name") + if isinstance(item_name, str) and item_name: + return item_name + if isinstance(item_type, str) and item_type.endswith("_call"): + return item_type[: -len("_call")] + return "" + + +def _is_response_call_item(item: dict) -> bool: + item_type = item.get("type") + return isinstance(item_type, str) and item_type.endswith("_call") + + +def _response_call_input(item: dict) -> object: + if "arguments" in item: + return _parse_function_call_arguments(item.get("arguments")) + return { + key: value for key, value in item.items() if key not in {"id", "type", "status", "call_id", "name", "execution"} + } + + +def _is_response_tool_result_item(item: dict) -> bool: + item_type = item.get("type") + return item_type == "tool_search_output" or (isinstance(item_type, str) and item_type.endswith("_call_output")) + + +def _response_tool_result_content(item: dict) -> str: + if item.get("type") == "tool_search_output": + return _tool_search_output_content(item) + if "output" in item: + output = item.get("output") + if isinstance(output, str): + return output + return json.dumps(output, ensure_ascii=False) + return json.dumps( + {key: value for key, value in item.items() if key not in {"id", "type", "status", "call_id", "execution"}}, + ensure_ascii=False, + ) + + +def _extract_request_messages(body: dict) -> list[dict]: + if not isinstance(body, dict): + return [] + msgs = body.get("messages") + if isinstance(msgs, list) and msgs: + return [msg for msg in msgs if isinstance(msg, dict)] + + if _is_gemini_request_body(body): + return _extract_gemini_request_messages(body) + + inp = body.get("input") + if not isinstance(inp, list): + return [] + + normalized = [] + for item in inp: + if not isinstance(item, dict): + continue + item_type = item.get("type") + if _is_response_call_item(item): + normalized.append( + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "name": _response_call_tool_name(item), + "input": _response_call_input(item), + } + ], + } + ) + continue + if _is_response_tool_result_item(item): + normalized.append({"role": "tool", "content": _response_tool_result_content(item)}) + continue + if item_type not in (None, "message") and "role" not in item: + continue + role = item.get("role") + if not isinstance(role, str) or not role: + continue + normalized.append({"role": role, "content": item.get("content")}) + return normalized + + +def _extract_response_tool_names(output: list) -> list[str]: + names: list[str] = [] + if not isinstance(output, list): + return names + for item in output: + if not isinstance(item, dict): + continue + if item.get("type") == "message": + for c in item.get("content") or []: + if isinstance(c, dict) and c.get("type") == "tool_use": + names.append(c.get("name", "")) + elif _is_response_call_item(item): + names.append(_response_call_tool_name(item)) + return names + + +def _extract_response_tool_names_from_output_item_events(events: list[dict]) -> list[str]: + names: list[str] = [] + for ev in events: + if _event_type(ev) != "response.output_item.done": + continue + data = _event_payload(ev) + if not isinstance(data, dict): + continue + item = data.get("item") + if isinstance(item, dict): + names.extend(_extract_response_tool_names([item])) + return names + + +def _dict_or_empty(value: object) -> dict: + return value if isinstance(value, dict) else {} + + +def _tool_display_name(tool: dict) -> str: + for value in ( + tool.get("name"), + (tool.get("function") or {}).get("name") if isinstance(tool.get("function"), dict) else None, + tool.get("id"), + tool.get("type"), + ): + if isinstance(value, str) and value: + return value + return "" + + +def _clean_session_user_text(text: str) -> str: + value = text.strip() + if not value: + return "" + if len(value) >= 2 and value[0] == value[-1] == '"': + try: + decoded = json.loads(value) + except json.JSONDecodeError: + decoded = None + if isinstance(decoded, str) and decoded.strip(): + value = decoded.strip() + + request = re.search(r"\s*(.*?)\s*", value, flags=re.DOTALL | re.IGNORECASE) + if request: + return request.group(1).strip() + + codex_request = re.search( + r"^#+\s*My request for Codex:\s*(.*?)\s*$", + value, + flags=re.DOTALL | re.IGNORECASE | re.MULTILINE, + ) + if codex_request: + return codex_request.group(1).strip() + + session = re.fullmatch(r"\s*(.*?)\s*", value, flags=re.DOTALL | re.IGNORECASE) + if session: + return session.group(1).strip() + + first_tag = re.match(r"^<([A-Za-z_-]+)", value) + if first_tag and first_tag.group(1).lower() in { + "artifacts", + "codex_internal_context", + "environment_context", + "local-command-caveat", + "session_context", + "skills", + "slash_commands", + "subagents", + "system-reminder", + "user_information", + }: + return "" + + if value.startswith(("# AGENTS.md instructions", "")): + return "" + if value.startswith("# Files mentioned by the user:"): + return "" + if re.match(r"^]*)?>$", value, flags=re.IGNORECASE): + return "" + if re.match(r"^\[SUGGESTION MODE:", value, flags=re.IGNORECASE): + return "" + if re.match(r"^(web page content|page content|网页内容)\s*[::]", value, flags=re.IGNORECASE): + return "" + if re.match(r"^\[Image:\s*source:", value, flags=re.IGNORECASE): + return "" + return re.sub(r"^\[Image #\d+\]\s*", "", value, flags=re.IGNORECASE).strip() + + +def _session_text_from_content(content: object) -> str: + if content is None: + return "" + if isinstance(content, str): + return _clean_session_user_text(content) + if isinstance(content, dict): + item_type = content.get("type") + if item_type in {"tool_result", "function_call_output"}: + return "" + for key in ("text", "output"): + value = content.get(key) + if isinstance(value, str): + cleaned = _clean_session_user_text(value) + if cleaned: + return cleaned + if "content" in content: + return _session_text_from_content(content.get("content")) + return "" + if isinstance(content, list): + for item in content: + text = _session_text_from_content(item) + if text: + return text + return "" + + +def _is_tool_result_only_message(message: dict) -> bool: + content = message.get("content") + if not isinstance(content, list) or not content: + return False + return all( + isinstance(block, dict) and block.get("type") in {"tool_result", "function_call_output"} for block in content + ) + + +def _first_user_text(messages: list[dict]) -> str: + for message in messages: + if message.get("role") != "user" or _is_tool_result_only_message(message): + continue + text = _session_text_from_content(message.get("content")) + if text: + return text + return "" + + +def _latest_user_text(messages: list[dict]) -> str: + for message in reversed(messages): + if message.get("role") != "user" or _is_tool_result_only_message(message): + continue + text = _session_text_from_content(message.get("content")) + if text: + return text + return "" + + +def _extract_metadata(record_json: str) -> dict | None: + """Extract sidebar-relevant metadata from a raw JSON record string.""" + try: + r = json.loads(record_json) + except (json.JSONDecodeError, TypeError): + return None + return _extract_metadata_from_record(r) + + +def _extract_metadata_from_record(r: dict) -> dict | None: + """Extract sidebar metadata from a raw record without embedding full payloads. + + The returned dict contains only fields needed for sidebar rendering, + filtering, and search previews. + """ + if not isinstance(r, dict): + return None + + req = _dict_or_empty(r.get("request")) + body = _dict_or_empty(req.get("body")) + resp = _dict_or_empty(r.get("response")) + raw_resp_body = resp.get("body") + resp_body = _dict_or_empty(raw_resp_body) + request_events = _iter_request_events(req) + stream_events = _iter_response_events(resp) + if not stream_events: + stream_events = _parse_sse_data_frames(raw_resp_body) + created_response = _last_response_payload_for_event(stream_events, "response.created") + completed_response = _last_response_payload_for_event(stream_events, "response.completed") + request_event_bodies = [_event_payload(event) for event in request_events] + response_output = resp_body.get("output") + response_output_count = ( + len(response_output) if isinstance(response_output, list) else _response_output_count_from_events(stream_events) + ) + + # Token usage — from response.body.usage or terminal stream event + usage = resp_body.get("usage") or _extract_gemini_response_usage(raw_resp_body) or {} + if not usage: + for ev in reversed(stream_events): + if _event_type(ev) != "response.completed": + continue + data = _event_payload(ev) + if isinstance(data, dict): + usage = (data.get("response") or {}).get("usage") or {} + if usage: + break + usage = normalize_usage(usage) + + # System prompt hint (first 200 chars) + sys_text = "" + if isinstance(body.get("system"), str): + sys_text = body["system"] + elif isinstance(body.get("system"), list): + parts = [] + for s in body["system"]: + if isinstance(s, str): + parts.append(s) + elif isinstance(s, dict): + parts.append(s.get("text", "")) + sys_text = "\n".join(parts) + elif isinstance(body.get("instructions"), str): + sys_text = body["instructions"] + elif _is_gemini_request_body(body): + sys_text = _extract_gemini_system(body) + + # Messages + msgs = _extract_request_messages(body) + + metadata = _dict_or_empty(body.get("metadata")) + headers = _dict_or_empty(req.get("headers")) + codex_app_session_id = metadata.get("codex_app_session_id") or headers.get("x-codex-app-session-id") + if not isinstance(codex_app_session_id, str): + codex_app_session_id = "" + + # Tool names from request + tools = _extract_gemini_tools(body) or body.get("tools") or [] + tool_names = [_tool_display_name(t) for t in tools if isinstance(t, dict)] + + # Response tool names (tool_use blocks in response content) + response_tool_names = [] + # Try response.body.content first + rc = resp_body.get("content") or [] + if rc: + for block in rc: + if isinstance(block, dict) and block.get("type") == "tool_use": + response_tool_names.append(block.get("name", "")) + else: + response_tool_names.extend(_extract_response_tool_names(resp_body.get("output") or [])) + if not response_tool_names: + response_tool_names.extend(_extract_response_tool_names_from_output_item_events(stream_events)) + if not response_tool_names: + response_tool_names.extend(_extract_gemini_response_tool_names(raw_resp_body)) + if not response_tool_names: + for ev in reversed(stream_events): + if _event_type(ev) != "response.completed": + continue + data = _event_payload(ev) + if isinstance(data, dict): + response_tool_names.extend( + _extract_response_tool_names((data.get("response") or {}).get("output") or []) + ) + break + + # Error info + error_msg = "" + err_obj = resp_body.get("error") + if isinstance(err_obj, dict): + error_msg = err_obj.get("message", "") + + return { + "turn": r.get("turn"), + "request_id": r.get("request_id", ""), + "timestamp": r.get("timestamp", ""), + "duration_ms": r.get("duration_ms", 0), + "transport": r.get("transport", ""), + "method": req.get("method", ""), + "path": req.get("path", ""), + "model": body.get("model", "") or _model_from_path(req.get("path", "")), + "request_generate": _first_bool( + body.get("generate"), + *(event_body.get("generate") for event_body in request_event_bodies if isinstance(event_body, dict)), + created_response.get("generate"), + ), + "response_generate": _first_bool( + resp_body.get("generate"), + completed_response.get("generate"), + created_response.get("generate"), + ), + "response_output_count": response_output_count, + "status": resp.get("status", 0), + "error_message": error_msg, + "input_tokens": usage.get("input_tokens", 0), + "output_tokens": usage.get("output_tokens", 0), + "cache_read_input_tokens": usage.get("cache_read_input_tokens", 0), + "cache_creation_input_tokens": usage.get("cache_creation_input_tokens", 0), + "has_system": bool(sys_text), + "message_count": len(msgs), + "session_user_text": _latest_user_text(msgs) or _first_user_text(msgs), + "codex_app_session_id": codex_app_session_id, + "sys_hint": sys_text[:200], + "tool_names": tool_names, + "response_tool_names": response_tool_names, + } + + +def _generate_html_viewer( + trace_path: Path, + html_path: Path, + *, + display_trace_path: str | Path | None = None, + display_html_path: str | Path | None = None, +) -> None: + """Read viewer.html template, embed JSONL data, write self-contained HTML.""" + if trace_path.exists(): + text = trace_path.read_text(encoding="utf-8") + try: + parsed = json.loads(text) + except json.JSONDecodeError: + parsed = None + if is_compact_trace_bundle(parsed): + _generate_html_viewer_from_compact_bundle( + parsed, + html_path, + display_trace_path=display_trace_path if display_trace_path is not None else trace_path.absolute(), + display_html_path=display_html_path if display_html_path is not None else html_path.absolute(), + ) + return + + records: list[dict] = [] + if trace_path.exists(): + with open(trace_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + record = json.loads(_normalize_record_for_viewer(line)) + except json.JSONDecodeError: + continue + if isinstance(record, dict): + records.append(record) + _generate_html_viewer_from_compact_bundle( + build_compact_trace_bundle(records), + html_path, + display_trace_path=display_trace_path if display_trace_path is not None else trace_path.absolute(), + display_html_path=display_html_path if display_html_path is not None else html_path.absolute(), + ) + + +def _generate_html_viewer_from_compact_bundle( + compact_bundle: dict, + html_path: Path, + *, + display_trace_path: str | Path, + display_html_path: str | Path, +) -> None: + """Write a self-contained HTML viewer that embeds compact trace data.""" + if not VIEWER_TEMPLATE_PATH.exists(): + return + if not is_compact_trace_bundle(compact_bundle): + raise ValueError(f"Expected {COMPACT_TRACE_MARKER} compact trace bundle.") + + trace_path_label = str(display_trace_path) + html_path_label = str(display_html_path) + compact_js = json.dumps(compact_bundle, ensure_ascii=False, separators=(",", ":")).replace("\n{data_js}\n{VIEWER_SCRIPT_ANCHOR}", + 1, + ) + html_path.write_text(html, encoding="utf-8") + + +def _generate_html_viewer_from_metadata( + metadata: list[dict], + html_path: Path, + *, + display_trace_path: str | Path, + display_html_path: str | Path, + records_api_path: str | Path, +) -> None: + """Write an online viewer that fetches full records on demand.""" + if not VIEWER_TEMPLATE_PATH.exists(): + return + + trace_path_label = str(display_trace_path) + html_path_label = str(display_html_path) + records_api_label = str(records_api_path) + meta_js = json.dumps(metadata, ensure_ascii=False, separators=(",", ":")).replace("\n{data_js}\n{VIEWER_SCRIPT_ANCHOR}", + 1, + ) + html_path.write_text(html, encoding="utf-8") + + +def _generate_html_viewer_from_records( + record_json_lines: list[str], + html_path: Path, + *, + display_trace_path: str | Path, + display_html_path: str | Path, +) -> None: + """Write a self-contained HTML viewer from already-loaded JSON records.""" + if not VIEWER_TEMPLATE_PATH.exists(): + return + + # Escape / ; without this, the browser closes the data block early + # and renders the captured HTML as page content. JSON's \/ is a valid + # escape for /, so the parsed JSON value is unchanged. + records = [rec.replace(" LAZY_THRESHOLD + + if use_lazy: + # Extract metadata for sidebar rendering + meta_list = [] + for rec in records: + meta = _extract_metadata(rec) + if meta is not None: + meta_list.append(meta) + + meta_js = json.dumps(meta_list, separators=(",", ":")) + + raw_lines = "\n".join(records) + + data_js = ( + f"const EMBEDDED_TRACE_META = {meta_js};\n" + f"const __TRACE_JSONL_PATH__ = {jsonl_path_js};\n" + f"const __TRACE_HTML_PATH__ = {html_path_js};\n" + f"const __CLAUDE_TAP_VERSION__ = {version_js};\n" + ) + + html = _read_viewer_template() + # Inject data script + raw JSONL block before the main \n" + f'\n' + f"{VIEWER_SCRIPT_ANCHOR}", + 1, + ) + else: + # Small trace: inline all data as before + data_js = ( + "const EMBEDDED_TRACE_DATA = [\n" + ",\n".join(records) + "\n];\n" + f"const __TRACE_JSONL_PATH__ = {jsonl_path_js};\n" + f"const __TRACE_HTML_PATH__ = {html_path_js};\n" + f"const __CLAUDE_TAP_VERSION__ = {version_js};\n" + ) + + html = _read_viewer_template() + html = html.replace( + VIEWER_SCRIPT_ANCHOR, + f"\n{VIEWER_SCRIPT_ANCHOR}", + 1, + ) + + html_path.write_text(html, encoding="utf-8") diff --git a/claude_tap/viewer_assets/__init__.py b/claude_tap/viewer_assets/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/claude_tap/viewer_assets/__init__.py @@ -0,0 +1 @@ + diff --git a/claude_tap/viewer_assets/detail_trace.js b/claude_tap/viewer_assets/detail_trace.js new file mode 100644 index 0000000..4b2cb59 --- /dev/null +++ b/claude_tap/viewer_assets/detail_trace.js @@ -0,0 +1,304 @@ + +/* ─── Detail ─── */ +// Persist section collapse state across turn switches. +// Key: section title text, Value: true = open, false = collapsed. +const sectionCollapseState = {}; +let detailViewMode = 'default'; +let traceFormatMode = 'json'; +let detailLoadToken = 0; + +function saveSectionStates() { + const d = $('#detail'); + if (!d) return; + d.querySelectorAll('.section').forEach(sec => { + const titleEl = sec.querySelector('.section-header .title'); + const bodyEl = sec.querySelector('.section-body'); + if (titleEl && bodyEl) { + sectionCollapseState[titleEl.textContent] = bodyEl.classList.contains('open'); + } + }); +} + +function restoreSectionStates() { + const d = $('#detail'); + if (!d) return; + d.querySelectorAll('.section').forEach(sec => { + const titleEl = sec.querySelector('.section-header .title'); + const bodyEl = sec.querySelector('.section-body'); + const chevron = sec.querySelector('.chevron'); + if (!titleEl || !bodyEl || !chevron) return; + const key = titleEl.textContent; + if (key in sectionCollapseState) { + const shouldBeOpen = sectionCollapseState[key]; + if (shouldBeOpen && !bodyEl.classList.contains('open')) { + bodyEl.classList.add('open'); + chevron.classList.add('open'); + } else if (!shouldBeOpen && bodyEl.classList.contains('open')) { + bodyEl.classList.remove('open'); + chevron.classList.remove('open'); + } + } + }); +} + +function setDetailViewMode(mode) { + detailViewMode = mode; + const entry = filtered[activeIdx]; + if (!entry) return; + renderDetailForEntry(entry); +} + +function detailTabButton(mode, label) { + const active = detailViewMode === mode; + return ``; +} + +function renderDetailViewTabs() { + return `
+ ${detailTabButton('default', t('tab_default'))} + ${detailTabButton('trace', t('tab_trace'))} +
`; +} + +function setTraceFormatMode(mode) { + if (!['json', 'yaml', 'pretty'].includes(mode)) return; + traceFormatMode = mode; + const entry = filtered[activeIdx]; + if (!entry) return; + renderDetailForEntry(entry); +} + +function renderTraceFormatControls() { + const modes = [ + ['json', t('format_json')], + ['yaml', t('format_yaml')], + ['pretty', t('format_pretty')], + ]; + return ``; +} + +async function renderDetailForEntry(entry) { + if (!shouldFetchRemoteEntry(entry)) { + renderDetail(resolveEntryForDetail(entry)); + return; + } + const token = ++detailLoadToken; + currentDetailRequestId = entry.request_id; + currentDetailEntryKey = entryStableKey(entry); + $('#detail').innerHTML = '
'; + try { + const resolved = await resolveEntryForDetailAsync(entry); + if (token !== detailLoadToken) return; + renderDetail(resolved); + } catch (err) { + console.error('Failed to load trace record:', err); + if (token !== detailLoadToken) return; + renderDetail(resolveEntryForDetail(entry)); + } +} + +function renderDetail(e) { + saveSectionStates(); + currentDetailRequestId = e.request_id; + currentDetailEntryKey = entryStableKey(e); + const d = $('#detail'); + const reqBody = e.request?.body, respBody = e.response?.body, usage = getUsage(e); + const statusCode = getResponseStatus(e); + const isError = statusCode >= 400; + const errorMessage = getResponseErrorMessage(e); + let html = ''; + + if (isError) { + html += `
HTTP ${statusCode}
${esc(errorMessage)}
`; + } + const continuation = getResponsesContinuationInfo(e); + if (continuation) html += renderResponsesContinuationNotice(continuation); + + const sysPrompt = extractSystem(reqBody); + const sysBlocks = extractSystemBlocks(reqBody); + const respOutput = getResponseOutput(e); + const respPayload = getResponsePayload(e); + const requestTools = getRequestTools(reqBody); + const tools = requestTools.length ? requestTools : getRequestTools(respPayload); + const msgs = getMessages(reqBody); + const contextOnly = shouldRenderRequestContext(e, reqBody, msgs, respOutput); + const streamEvents = getResponseEvents(e); + html += renderDetailViewTabs(); + + if (detailViewMode === 'default') { + const actionBarHtml = `
+ + + +
`; + const toolsSection = tools && tools.length + ? section(t('section_tools'), renderTools(tools), false, null, tools.length + ' ' + t('badge_tools')) + : ''; + const systemSection = sysPrompt ? section(t('section_system'), renderSystemPrompt(sysBlocks, sysPrompt), true, sysPrompt) : ''; + const messagesSection = msgs && msgs.length + ? section(contextOnly ? t('section_context') : t('section_messages'), renderMessages(msgs), true, null, msgs.length + ' ' + t('badge_messages')) + : ''; + const responseSection = respOutput?.content || contextOnly + ? section(t('section_response'), renderResponseContent(respOutput, contextOnly), true) + : ''; + const streamSection = streamEvents.length + ? section(t('section_sse'), renderSSEEvents(streamEvents), false, null, streamEvents.length + ' ' + t('badge_events')) + : ''; + const jsonSection = section(t('section_json'), `
${renderJSONTree(e)}
`, false, JSON.stringify(e, null, 2)); + html += actionBarHtml; + if (usage) html += renderTokenUsage(usage); + html += toolsSection + systemSection + messagesSection + responseSection; + if (streamEvents.length) html += streamSection; + html += jsonSection; + } else if (detailViewMode === 'trace') { + html += renderTraceDetail(e, { reqBody, sysPrompt, msgs, tools, respOutput, contextOnly, streamEvents, usage }); + } + + d.innerHTML = html; + bindSections(d); + restoreSectionStates(); + if (globalSearchState.open && globalSearchState.query) { + const target = getTargetForGlobalMatch(globalSearchState.currentMatch); + const localIndex = target && target.entryKey === entryStableKey(e) ? target.localIndex : 0; + applyGlobalSearchHighlights(localIndex); + } +} + +function renderTraceBlock(title, payload, badge = '') { + const badgeHtml = badge ? `${esc(badge)}` : ''; + const copyText = tracePayloadText(payload); + const copyBtn = ``; + return `
${esc(title)}${badgeHtml}${copyBtn}
${renderTracePayload(payload)}
`; +} + +function renderTraceDetail(entry, ctx) { + const inputPayload = { + system: ctx.sysPrompt || undefined, + messages: ctx.msgs || [], + tools: ctx.tools || [], + }; + const responsePayload = { + status: getResponseStatus(entry), + output: ctx.respOutput?.content || [], + usage: ctx.usage || {}, + body: getResponsePayload(entry) || {}, + }; + const metadata = { + request_id: entry.request_id || '', + display_turn: entry.display_turn ?? '', + capture_turn: entry.capture_turn ?? entry.turn ?? '', + turn: entry.turn ?? '', + record_index: entry.record_index ?? '', + websocket_response_index: entry.websocket_response_index ?? '', + duration_ms: entry.duration_ms ?? 0, + transport: entry.transport || 'http', + upstream_base_url: entry.upstream_base_url || '', + method: entry.request?.method || '', + path: entry.request?.path || '', + model: ctx.reqBody?.model || '', + status: getResponseStatus(entry), + }; + + let html = renderTraceFormatControls(); + if (ctx.usage) html += renderTokenUsage(ctx.usage); + html += '
'; + html += renderTraceBlock( + t('tok_input'), + inputPayload, + `${(ctx.msgs || []).length} ${t('badge_messages')}` + ); + html += renderTraceBlock( + t('tok_output'), + responsePayload, + `${getResponseStatus(entry)}` + ); + if (ctx.streamEvents.length) { + html += renderTraceBlock( + t('section_sse'), + ctx.streamEvents, + `${ctx.streamEvents.length} ${t('badge_events')}` + ); + } + html += renderTraceBlock( + t('section_metadata'), + metadata + ); + html += '
'; + return html; +} + +function renderTracePayload(payload) { + if (traceFormatMode === 'pretty') return `
${renderTracePrettyValue(payload)}
`; + const text = traceFormatMode === 'yaml' ? toTraceYaml(payload) : JSON.stringify(payload, null, 2); + return `
${esc(text)}
`; +} + +function tracePayloadText(payload) { + return traceFormatMode === 'yaml' ? toTraceYaml(payload) : JSON.stringify(payload, null, 2); +} + +function isTraceScalar(value) { + return value === null || value === undefined || typeof value !== 'object'; +} + +function yamlKey(key) { + return /^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? key : JSON.stringify(key); +} + +function yamlScalar(value, indent) { + if (value === null || value === undefined) return 'null'; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + const str = String(value); + if (str === '') return '""'; + if (str.includes('\n')) { + const pad = ' '.repeat(indent + 2); + return `|\n${str.split('\n').map(line => pad + line).join('\n')}`; + } + if (/^[A-Za-z0-9_./:@+-]+$/.test(str) && !/^(true|false|null|yes|no|on|off)$/i.test(str)) return str; + return JSON.stringify(str); +} + +function toTraceYaml(value, indent = 0) { + const pad = ' '.repeat(indent); + if (isTraceScalar(value)) return pad + yamlScalar(value, indent); + if (Array.isArray(value)) { + if (!value.length) return pad + '[]'; + return value.map(item => { + if (isTraceScalar(item)) return `${pad}- ${yamlScalar(item, indent)}`; + return `${pad}-\n${toTraceYaml(item, indent + 2)}`; + }).join('\n'); + } + const keys = Object.keys(value).filter(key => value[key] !== undefined); + if (!keys.length) return pad + '{}'; + return keys.map(key => { + const item = value[key]; + if (isTraceScalar(item)) return `${pad}${yamlKey(key)}: ${yamlScalar(item, indent)}`; + return `${pad}${yamlKey(key)}:\n${toTraceYaml(item, indent + 2)}`; + }).join('\n'); +} + +function renderTracePrettyScalar(value) { + if (value === null || value === undefined) return `${value === undefined ? 'undefined' : 'null'}`; + if (typeof value === 'number' || typeof value === 'boolean') return `${esc(String(value))}`; + const str = String(value); + if (str.length > 90 || str.includes('\n')) return `
${esc(str)}
`; + return `${esc(JSON.stringify(str))}`; +} + +function renderTracePrettyValue(value, depth = 0) { + if (isTraceScalar(value)) return renderTracePrettyScalar(value); + if (Array.isArray(value)) { + if (!value.length) return '
[]
'; + return `
${value.map((item, index) => `
#${index}${renderTracePrettyValue(item, depth + 1)}
`).join('')}
`; + } + const keys = Object.keys(value).filter(key => value[key] !== undefined); + if (!keys.length) return '
{}
'; + return `
${keys.map(key => { + const item = value[key]; + if (isTraceScalar(item)) return `
${esc(key)}${renderTracePrettyScalar(item)}
`; + return `
${esc(key)}
${renderTracePrettyValue(item, depth + 1)}
`; + }).join('')}
`; +} diff --git a/claude_tap/viewer_assets/diff.js b/claude_tap/viewer_assets/diff.js new file mode 100644 index 0000000..5124afe --- /dev/null +++ b/claude_tap/viewer_assets/diff.js @@ -0,0 +1,595 @@ + +/* ─── Diff ─── */ +function isMainTurn(e) { + const b = e?.request?.body; + if (!b) return false; + const hasSys = (b.system && (typeof b.system === 'string' ? b.system.length > 0 : b.system.length > 0)) + || (typeof b.instructions === 'string' && b.instructions.length > 0) + || !!geminiSystemInstruction(b); + const msgs = getMessages(b); + return hasSys || msgs.length > 1; +} + +function _msgHash(msg) { + let c = msg?.content; + // Strip cache_control from content items (Claude Code adds/removes these between turns) + if (Array.isArray(c)) { + c = c.map(item => { + if (item && typeof item === 'object' && 'cache_control' in item) { + const { cache_control, ...rest } = item; + return rest; + } + return item; + }); + } + const text = typeof c === 'string' ? c : JSON.stringify(c || ''); + // Simple hash: role + first 500 chars of content (200 was too short for Claude Code + // subagents that share long system-reminder prefixes but differ in task content) + return (msg?.role || '') + ':' + text.slice(0, 500); +} + +function _getMsgHashes(entry) { + const resolved = resolveEntryForDetail(entry); + const msgs = getMessages(resolved?.request?.body); + return msgs.map(_msgHash); +} + +function _isPrefixOf(shorter, longer) { + if (shorter.length === 0 || longer.length < shorter.length) return false; + for (let i = 0; i < shorter.length; i++) { + if (shorter[i] !== longer[i]) return false; + } + return true; +} + +function responseIdForDiff(entry) { + const resolved = resolveEntryForDetail(entry); + return getResponsePayload(resolved)?.id || resolved?.response?.body?.id || ''; +} + +function previousResponseIdForDiff(entry) { + const resolved = resolveEntryForDetail(entry); + return resolved?.request?.body?.previous_response_id || getResponsePayload(resolved)?.previous_response_id || ''; +} + +function codexThreadKey(entry) { + const resolved = resolveEntryForDetail(entry); + const metadata = resolved?.request?.body?.client_metadata || {}; + let turnMetadata = metadata['x-codex-turn-metadata']; + if (typeof turnMetadata === 'string') { + try { turnMetadata = JSON.parse(turnMetadata); } catch(e) { turnMetadata = null; } + } + if (!turnMetadata || typeof turnMetadata !== 'object') return ''; + const threadId = turnMetadata.thread_id || ''; + const sessionId = turnMetadata.session_id || ''; + if (!threadId && !sessionId) return ''; + return `${sessionId}:${threadId}`; +} + +function findPrevByResponseId(idx) { + const previousId = previousResponseIdForDiff(filtered[idx]); + if (!previousId) return -1; + for (let i = idx - 1; i >= 0; i--) { + if (responseIdForDiff(filtered[i]) === previousId) return i; + } + return -1; +} + +function findPrevByCodexThread(idx) { + const key = codexThreadKey(filtered[idx]); + if (!key) return -1; + for (let i = idx - 1; i >= 0; i--) { + if (codexThreadKey(filtered[i]) === key) return i; + } + return -1; +} + +function findNextByResponseId(idx) { + const currentId = responseIdForDiff(filtered[idx]); + if (!currentId) return -1; + for (let i = idx + 1; i < filtered.length; i++) { + if (previousResponseIdForDiff(filtered[i]) === currentId) return i; + } + return -1; +} + +function findNextByCodexThread(idx) { + const key = codexThreadKey(filtered[idx]); + if (!key) return -1; + for (let i = idx + 1; i < filtered.length; i++) { + if (codexThreadKey(filtered[i]) === key) return i; + } + return -1; +} + +function findPrevSameModel(idx) { + const target = filtered[idx]; + const targetHashes = _getMsgHashes(target); + + // Strategy 1: exact Responses state link when the previous response is visible. + const linkedIdx = findPrevByResponseId(idx); + if (linkedIdx >= 0) return { idx: linkedIdx, isFallback: false }; + + // Strategy 2: Codex WebSocket turns can contain hidden generate=false + // prefetch responses. When previous_response_id points at one of those + // hidden frames, use the nearest visible entry in the same Codex thread. + const codexThreadIdx = findPrevByCodexThread(idx); + if (codexThreadIdx >= 0) return { idx: codexThreadIdx, isFallback: false }; + + // Strategy 3: find the best prefix match (longest prefix) + let bestIdx = -1; + let bestLen = 0; + for (let i = idx - 1; i >= 0; i--) { + const candidateHashes = _getMsgHashes(filtered[i]); + if (candidateHashes.length > 0 && _isPrefixOf(candidateHashes, targetHashes)) { + if (candidateHashes.length > bestLen) { + bestLen = candidateHashes.length; + bestIdx = i; + } + } + } + if (bestIdx >= 0) return { idx: bestIdx, isFallback: false }; + + // Strategy 4: fallback to same model + isMainTurn (original behavior) + const model = target?.request?.body?.model; + const main = isMainTurn(target); + for (let i = idx - 1; i >= 0; i--) { + if (filtered[i]?.request?.body?.model === model && isMainTurn(filtered[i]) === main) + return { idx: i, isFallback: true }; + } + return { idx: -1, isFallback: false }; +} + +function showDiff(btn) { + showDiffForIdx(activeIdx, btn); +} + +function findNextSameModel(idx) { + const current = filtered[idx]; + const currentHashes = _getMsgHashes(current); + + const linkedIdx = findNextByResponseId(idx); + if (linkedIdx >= 0) return linkedIdx; + + const codexThreadIdx = findNextByCodexThread(idx); + if (codexThreadIdx >= 0) return codexThreadIdx; + + // Strategy 3: find the next entry whose messages start with current's messages as prefix + let bestIdx = -1; + let bestLen = Infinity; + for (let i = idx + 1; i < filtered.length; i++) { + const candidateHashes = _getMsgHashes(filtered[i]); + if (currentHashes.length > 0 && _isPrefixOf(currentHashes, candidateHashes)) { + // Pick the closest (smallest) extension + if (candidateHashes.length < bestLen) { + bestLen = candidateHashes.length; + bestIdx = i; + } + } + } + if (bestIdx >= 0) return bestIdx; + + // Strategy 2: fallback to same model + isMainTurn + const model = current?.request?.body?.model; + const main = isMainTurn(current); + for (let i = idx + 1; i < filtered.length; i++) { + if (filtered[i]?.request?.body?.model === model && isMainTurn(filtered[i]) === main) return i; + } + return -1; +} + +function _buildDiffTargetOptions(curIdx) { + // Collect all previous entries grouped by model for the dropdown + const options = []; // { label, filteredIdx } + const modelGroups = {}; // model -> [{label, filteredIdx}] + for (let i = curIdx - 1; i >= 0; i--) { + const e = filtered[i]; + const model = e?.request?.body?.model || 'unknown'; + const turn = displayTurnLabel(e); + if (!modelGroups[model]) modelGroups[model] = []; + modelGroups[model].push({ label: `${t('turn')} ${turn}`, filteredIdx: i, model }); + } + // Flatten: for each model group (sorted by first appearance), add entries + const seenModels = []; + for (let i = curIdx - 1; i >= 0; i--) { + const model = filtered[i]?.request?.body?.model || 'unknown'; + if (!seenModels.includes(model)) seenModels.push(model); + } + for (const model of seenModels) { + for (const item of modelGroups[model] || []) { + options.push(item); + } + } + return options; +} + +function showDiffForIdx(curIdx, triggerBtn, manualPrevIdx) { + const prevResult = manualPrevIdx !== undefined + ? { idx: manualPrevIdx, isFallback: false } + : findPrevSameModel(curIdx); + const prevIdx = prevResult.idx; + const isFallback = prevResult.isFallback; + + if (prevIdx < 0) { + if (triggerBtn) { + const orig = triggerBtn.innerHTML; + triggerBtn.textContent = t('no_prev'); + setTimeout(() => triggerBtn.innerHTML = orig, 1500); + } + return; + } + // Remove existing overlay if any + document.querySelector('.diff-overlay')?.remove(); + + const prevEntry = resolveEntryForDetail(filtered[prevIdx]); + const curEntry = resolveEntryForDetail(filtered[curIdx]); + const oldBody = prevEntry.request?.body || {}; + const newBody = curEntry.request?.body || {}; + const diff = structuralDiff(oldBody, newBody); + const html = renderStructuralDiff(diff); + + // Check if prev/next diff pairs exist + const hasPrev = findPrevSameModel(prevIdx).idx >= 0; + const nextChainIdx = findNextSameModel(curIdx); + const hasNext = nextChainIdx >= 0 && findPrevSameModel(nextChainIdx).idx >= 0; + + // Build dropdown options for manual selection + const targetOptions = _buildDiffTargetOptions(curIdx); + const optionsHtml = targetOptions.map(opt => { + const selected = opt.filteredIdx === prevIdx ? ' selected' : ''; + const modelShort = (opt.model || '').replace('claude-', '').replace(/-\d{8}$/, ''); + return ``; + }).join(''); + const autoMark = manualPrevIdx === undefined && !isFallback ? ` [${t('diff_select_auto')}]` : ''; + const selectHtml = targetOptions.length > 0 + ? `
${t('diff_select_target')}
` + : ''; + + const warningHtml = isFallback + ? `
⚠️${t('diff_fallback_warning')}
` + : ''; + + const overlay = document.createElement('div'); + overlay.className = 'diff-overlay'; + overlay.innerHTML = `
+
+ + ${t('turn')} ${displayTurnLabel(filtered[prevIdx])} → ${t('turn')} ${displayTurnLabel(filtered[curIdx])} + + ${selectHtml} + +
+ ${warningHtml} +
${html}
+
`; + + // Dynamic nav button updater — recalculates from current filtered state + function updateNavButtons() { + const prevBtn = overlay.querySelector('.diff-nav-prev'); + const nextBtn = overlay.querySelector('.diff-nav-next'); + if (!prevBtn || !nextBtn) return; + prevBtn.disabled = findPrevSameModel(prevIdx).idx < 0; + const ni = findNextSameModel(curIdx); + nextBtn.disabled = !(ni >= 0 && findPrevSameModel(ni).idx >= 0); + } + updateNavButtons(); + + // In live mode, periodically refresh button state as filtered[] changes + let navInterval = null; + if (typeof LIVE_MODE !== 'undefined' && LIVE_MODE) { + navInterval = setInterval(updateNavButtons, 500); + } + + const close = () => { + if (navInterval) clearInterval(navInterval); + overlay.remove(); + document.removeEventListener('keydown', escHandler); + }; + overlay.querySelector('.diff-close').onclick = close; + overlay.onclick = e => { if (e.target === overlay) close(); }; + // Navigate to prev/next diff pair + overlay.querySelector('.diff-nav-prev').onclick = () => { + updateNavButtons(); + if (!overlay.querySelector('.diff-nav-prev').disabled) { + close(); + selectEntry(filtered.indexOf(filtered[prevIdx])); + showDiffForIdx(prevIdx); + } + }; + overlay.querySelector('.diff-nav-next').onclick = () => { + updateNavButtons(); + const nextIdx = findNextSameModel(curIdx); + if (nextIdx >= 0 && !overlay.querySelector('.diff-nav-next').disabled) { + close(); + selectEntry(filtered.indexOf(filtered[nextIdx])); + showDiffForIdx(nextIdx); + } + }; + // Manual target selection dropdown + const dropdown = overlay.querySelector('.diff-target-dropdown'); + if (dropdown) { + dropdown.onchange = () => { + const selectedIdx = parseInt(dropdown.value, 10); + showDiffForIdx(curIdx, null, selectedIdx); + }; + } + document.body.appendChild(overlay); + const escHandler = e => { + if (e.key === 'Escape') close(); + if (e.key === 'ArrowLeft') { overlay.querySelector('.diff-nav-prev').click(); } + if (e.key === 'ArrowRight') { overlay.querySelector('.diff-nav-next').click(); } + }; + document.addEventListener('keydown', escHandler); +} + +function msgContentEqual(a, b) { + // Compare by role + text representation, ignoring metadata like cache_control + return a.role === b.role && msgToText(a) === msgToText(b); +} + +function msgToText(m) { + const c = m.content; + if (typeof c === 'string') return c; + if (!Array.isArray(c)) return JSON.stringify(c, null, 2); + return c.map(b => { + if (b.type === 'text' || b.type === 'input_text' || b.type === 'output_text') return b.text || ''; + if (b.type === 'thinking') return '[thinking]\n' + (b.thinking || ''); + if (b.type === 'tool_use') return '[tool_use: ' + (b.name || '') + ']\n' + JSON.stringify(b.input, null, 2); + if (b.type === 'tool_result') { + const rc = b.content; + if (typeof rc === 'string') return '[tool_result]\n' + rc; + if (Array.isArray(rc)) return '[tool_result]\n' + rc.map(x => x.type === 'text' ? x.text : JSON.stringify(x)).join('\n'); + return '[tool_result]\n' + JSON.stringify(b, null, 2); + } + return JSON.stringify(b, null, 2); + }).join('\n'); +} + +function structuralDiff(oldB, newB) { + const d = { unchangedMsgs: 0, newMsgs: [], removedMsgs: [], modifiedMsgs: [], + systemChanged: false, oldSystemLen: 0, newSystemLen: 0, oldSystemText: '', newSystemText: '', + toolsChanged: false, oldToolCount: 0, newToolCount: 0, + addedTools: [], removedTools: [], addedToolDetails: [], removedToolDetails: [], fieldChanges: [] }; + // Messages — compare by role+content (ignore cache_control etc.) + const om = getMessages(oldB), nm = getMessages(newB); + // Common prefix + let common = 0; + for (let i = 0; i < Math.min(om.length, nm.length); i++) { + if (msgContentEqual(om[i], nm[i])) common++; else break; + } + d.unchangedMsgs = common; + // Common suffix + let suffix = 0; + for (let i = 0; i < Math.min(om.length - common, nm.length - common); i++) { + if (msgContentEqual(om[om.length - 1 - i], nm[nm.length - 1 - i])) suffix++; else break; + } + const oldTail = om.slice(common, om.length - suffix); + const newTail = nm.slice(common, nm.length - suffix); + d.suffixMsgs = suffix; + // Try to pair changed messages by role + let oi = 0, ni = 0; + while (oi < oldTail.length && ni < newTail.length) { + if (oldTail[oi].role === newTail[ni].role) { + if (msgContentEqual(oldTail[oi], newTail[ni])) { d.unchangedMsgs++; } + else { d.modifiedMsgs.push({ old: oldTail[oi], new: newTail[ni] }); } + oi++; ni++; + } else if (oi + 1 < oldTail.length && oldTail[oi + 1].role === newTail[ni].role) { + d.removedMsgs.push(oldTail[oi]); oi++; + } else if (ni + 1 < newTail.length && oldTail[oi].role === newTail[ni + 1].role) { + d.newMsgs.push(newTail[ni]); ni++; + } else { + d.removedMsgs.push(oldTail[oi]); d.newMsgs.push(newTail[ni]); + oi++; ni++; + } + } + while (oi < oldTail.length) { d.removedMsgs.push(oldTail[oi]); oi++; } + while (ni < newTail.length) { d.newMsgs.push(newTail[ni]); ni++; } + // System + const oldSys = extractSystem(oldB) || '', newSys = extractSystem(newB) || ''; + d.systemChanged = oldSys !== newSys; + d.oldSystemLen = oldSys.length; + d.newSystemLen = newSys.length; + d.oldSystemText = oldSys; + d.newSystemText = newSys; + // Tools — find added/removed by name + const oldToolEntries = getRequestTools(oldB); + const newToolEntries = getRequestTools(newB); + const oldTools = oldToolEntries.map(toolDisplayName); + const newTools = newToolEntries.map(toolDisplayName); + const oldToolMap = new Map(oldToolEntries.map(tool => [toolDisplayName(tool), tool])); + const newToolMap = new Map(newToolEntries.map(tool => [toolDisplayName(tool), tool])); + const oldSet = new Set(oldTools), newSet = new Set(newTools); + d.addedTools = newTools.filter(n => !oldSet.has(n)); + d.removedTools = oldTools.filter(n => !newSet.has(n)); + d.addedToolDetails = d.addedTools.map(name => newToolMap.get(name)).filter(Boolean); + d.removedToolDetails = d.removedTools.map(name => oldToolMap.get(name)).filter(Boolean); + d.toolsChanged = d.addedTools.length > 0 || d.removedTools.length > 0 || oldTools.length !== newTools.length; + d.oldToolCount = oldTools.length; + d.newToolCount = newTools.length; + // Other fields + const skip = new Set(['messages', 'system', 'tools', 'input', 'instructions']); + const allKeys = new Set([...Object.keys(oldB), ...Object.keys(newB)]); + for (const k of allKeys) { + if (skip.has(k)) continue; + const ov = JSON.stringify(oldB[k]), nv = JSON.stringify(newB[k]); + if (ov !== nv) d.fieldChanges.push({ key: k, oldVal: oldB[k], newVal: newB[k], added: ov === undefined, removed: nv === undefined }); + } + return d; +} + +function normalizeDiffValue(value) { + if (typeof value === 'string') { + const trimmed = value.trim(); + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { return normalizeDiffValue(JSON.parse(trimmed)); } catch(e) { return value; } + } + return value; + } + if (Array.isArray(value)) return value.map(normalizeDiffValue); + if (value && typeof value === 'object') { + const normalized = {}; + for (const [key, child] of Object.entries(value)) normalized[key] = normalizeDiffValue(child); + return normalized; + } + return value; +} + +function formatDiffValue(value) { + if (value === undefined) return ''; + const normalized = normalizeDiffValue(value); + return typeof normalized === 'string' ? normalized : JSON.stringify(normalized, null, 2); +} + +function renderParamChange(f) { + const oldText = formatDiffValue(f.oldVal); + const newText = formatDiffValue(f.newVal); + const badgeClass = f.added ? 'add' : f.removed ? 'del' : 'change'; + const badgeText = f.added ? t('diff_added') : f.removed ? t('diff_removed') : t('diff_changed'); + return `
${esc(f.key)}${badgeText}
${renderLineDiff(oldText, newText)}
`; +} + +function renderDiffToolDetail(tool, badgeClass, badgeText) { + const name = toolDisplayName(tool) || 'unknown'; + const desc = toolDescription(tool); + const nested = Array.isArray(tool?.tools) && tool.tools.length + ? `
${esc(tool.tools.length + ' ' + t('badge_tools'))}
    ${tool.tools.map(child => `
  • ${esc(toolDisplayName(child) || 'unknown')}${toolDescription(child) ? ` - ${esc(toolDescription(child).split('\n')[0])}` : ''}
  • `).join('')}
` + : ''; + return `
${esc(name)}${badgeText}
${desc ? `
${esc(desc)}
` : ''}${nested}
${esc(JSON.stringify(tool, null, 2))}
`; +} + +function renderStructuralDiff(d) { + let html = ''; + // ── Messages ── + const totalNew = d.newMsgs.length, totalRm = d.removedMsgs.length, totalMod = d.modifiedMsgs.length; + const badges = []; + if (totalNew > 0) badges.push(`+${totalNew} ${t('diff_new')}`); + if (totalRm > 0) badges.push(`-${totalRm} ${t('diff_removed')}`); + if (totalMod > 0) badges.push(`${totalMod} ${t('diff_changed')}`); + if (!totalNew && !totalRm && !totalMod) badges.push(`${t('diff_no_change')}`); + html += `
${t('section_messages')} ${badges.join(' ')}
`; + if (d.unchangedMsgs > 0) { + html += `
${d.unchangedMsgs} ${t('diff_unchanged')} (${t('diff_msg_range')}${d.unchangedMsgs})
`; + } + d.removedMsgs.forEach(m => { + const role = m.role || 'unknown'; + const cls = role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role === 'tool' ? 'tool_result' : 'system'; + html += `
${esc(role)}
${renderContent(m.content, role)}
`; + }); + d.modifiedMsgs.forEach(pair => { + const role = pair.old.role || 'unknown'; + const cls = role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role === 'tool' ? 'tool_result' : 'system'; + const oldText = msgToText(pair.old), newText = msgToText(pair.new); + html += `
${esc(role)} ${t('diff_changed')}
${renderLineDiff(oldText, newText)}
`; + }); + d.newMsgs.forEach(m => { + const role = m.role || 'unknown'; + const cls = role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role === 'tool' ? 'tool_result' : 'system'; + html += `
${esc(role)}
${renderContent(m.content, role)}
`; + }); + if (d.suffixMsgs > 0) { + html += `
${d.suffixMsgs} ${t('diff_unchanged')} (${t('diff_trailing')})
`; + } + if (totalNew === 0 && totalRm === 0 && totalMod === 0 && d.unchangedMsgs === 0) html += `
${t('no_messages')}
`; + html += `
`; + + // ── Parameters ── + if (d.fieldChanges.length > 0) { + html += `
${t('diff_params')} ${d.fieldChanges.length} ${t('diff_changed')}
`; + html += d.fieldChanges.map(renderParamChange).join(''); + html += `
`; + } + + // ── System Prompt ── + if (d.systemChanged) { + const lenDiff = d.newSystemLen - d.oldSystemLen; + const lenStr = lenDiff > 0 ? `+${lenDiff}` : `${lenDiff}`; + html += `
${t('diff_system')} ${t('diff_changed')} (${fmtChars(d.oldSystemLen)} → ${fmtChars(d.newSystemLen)}, ${lenStr} ${t('diff_chars')})
`; + html += `
${renderLineDiff(d.oldSystemText, d.newSystemText)}
`; + } else { + html += `
${t('diff_system')} ${fmtChars(d.newSystemLen)}, ${t('diff_unchanged_lbl')}
`; + } + + // ── Tools ── + if (d.toolsChanged) { + html += `
${t('diff_tools')} ${d.oldToolCount} → ${d.newToolCount}
`; + if (d.addedTools.length || d.removedTools.length) { + html += `
`; + d.addedToolDetails.forEach(tool => { html += renderDiffToolDetail(tool, 'add', t('diff_added')); }); + d.removedToolDetails.forEach(tool => { html += renderDiffToolDetail(tool, 'del', t('diff_removed')); }); + html += `
`; + } + html += `
`; + } else { + html += `
${t('diff_tools')} ${d.newToolCount} ${t('diff_tools_unchanged')}
`; + } + return html; +} + +function lineDiff(oldText, newText) { + const ol = oldText.split('\n'), nl = newText.split('\n'); + let pre = 0; + while (pre < ol.length && pre < nl.length && ol[pre] === nl[pre]) pre++; + let suf = 0; + while (suf < ol.length - pre && suf < nl.length - pre && ol[ol.length - 1 - suf] === nl[nl.length - 1 - suf]) suf++; + const result = []; + const addCtx = (start, end, lines) => { + const count = end - start; + if (count <= 0) return; + if (count <= 4) { for (let i = start; i < end; i++) result.push({ type: 'ctx', text: lines[i] }); } + else { result.push({ type: 'ctx', text: lines[start] }); result.push({ type: 'ctx', text: lines[start + 1] }); result.push({ type: 'fold', count: count - 4 }); result.push({ type: 'ctx', text: lines[end - 2] }); result.push({ type: 'ctx', text: lines[end - 1] }); } + }; + addCtx(0, pre, ol); + const oldEnd = ol.length - suf, newEnd = nl.length - suf; + const dels = [], adds = []; + for (let i = pre; i < oldEnd; i++) dels.push(ol[i]); + for (let i = pre; i < newEnd; i++) adds.push(nl[i]); + const paired = Math.min(dels.length, adds.length); + for (let i = 0; i < paired; i++) result.push({ type: 'change', oldText: dels[i], newText: adds[i] }); + for (let i = paired; i < dels.length; i++) result.push({ type: 'del', text: dels[i] }); + for (let i = paired; i < adds.length; i++) result.push({ type: 'add', text: adds[i] }); + addCtx(ol.length - suf, ol.length, ol); + return result; +} + +function charHighlight(text, hiStart, hiEnd, hiClass) { + if (hiStart >= hiEnd || hiStart >= text.length) return esc(text); + return esc(text.substring(0, hiStart)) + `${esc(text.substring(hiStart, hiEnd))}` + esc(text.substring(hiEnd)); +} + +function renderLineDiff(oldText, newText) { + const lines = lineDiff(oldText, newText); + let html = '
'; + html += '
OLD
NEW
'; + for (const ln of lines) { + if (ln.type === 'fold') { + html += `
... ${ln.count} lines ...
`; + continue; + } + if (ln.type === 'ctx') { + html += `
${esc(ln.text)}
`; + html += `
${esc(ln.text)}
`; + } else if (ln.type === 'change') { + const o = ln.oldText, n = ln.newText; + let cp = 0; + while (cp < o.length && cp < n.length && o[cp] === n[cp]) cp++; + let cs = 0; + while (cs < o.length - cp && cs < n.length - cp && o[o.length - 1 - cs] === n[n.length - 1 - cs]) cs++; + html += `
${charHighlight(o, cp, o.length - cs, 'sys-diff-del-hi')}
`; + html += `
${charHighlight(n, cp, n.length - cs, 'sys-diff-add-hi')}
`; + } else if (ln.type === 'del') { + html += `
${esc(ln.text)}
`; + html += `
`; + } else if (ln.type === 'add') { + html += `
`; + html += `
${esc(ln.text)}
`; + } + } + html += '
'; + return html; +} + +function truncJson(v) { + if (v === undefined) return ''; + const s = typeof v === 'string' ? v : JSON.stringify(v); + return s.length > 80 ? s.substring(0, 77) + '...' : s; +} diff --git a/claude_tap/viewer_assets/filters_search.js b/claude_tap/viewer_assets/filters_search.js new file mode 100644 index 0000000..5673de4 --- /dev/null +++ b/claude_tap/viewer_assets/filters_search.js @@ -0,0 +1,709 @@ + +/* ─── Path & filter ─── */ +function getPath(e) { return (e.request?.path || '/unknown').replace(/\?.*$/, ''); } + +function renderApp(preserveDetail) { + $('#drop-zone').style.display = 'none'; + $('#sidebar-wrap').style.display = 'flex'; + $('#search-bar').style.display = ''; + $('#sidebar-sort').style.display = ''; + $('#position-indicator').style.display = ''; + $('#sidebar').style.display = ''; + $('#detail').style.display = ''; + $('#stats').style.display = ''; + $('#path-filter').style.display = ''; + const pathCounts = {}; + entries.filter(isNavigableTraceEntry).forEach(e => { const p = getPath(e); pathCounts[p] = (pathCounts[p] || 0) + 1; }); + const paths = Object.keys(pathCounts).sort(); + if (activePaths.size === 0) { + /* If the trace has main conversation paths, hide auxiliary setup calls by default. */ + const primaryPaths = paths.filter(isPathPrimary); + if (primaryPaths.length > 0) primaryPaths.forEach(p => activePaths.add(p)); + else paths.forEach(p => activePaths.add(p)); + } + renderPathFilter(paths, pathCounts); + renderTracePathBar(); + applyFilter(preserveDetail); +} + +function renderTracePathBar() { + const bar = $('#trace-path-bar'); + if (!TRACE_JSONL_PATH && !TRACE_HTML_PATH) return; + const copyIcon = ``; + let html = ''; + if (TRACE_JSONL_PATH) { + html += `JSONL${esc(TRACE_JSONL_PATH)}`; + } + if (TRACE_JSONL_PATH && TRACE_HTML_PATH) html += ''; + if (TRACE_HTML_PATH) { + html += `HTML${esc(TRACE_HTML_PATH)}`; + } + bar.innerHTML = html; + bar.querySelectorAll('.tp-copy').forEach(btn => { + btn.addEventListener('click', () => copyPath(btn.dataset.copyPath || '', btn)); + }); + bar.style.display = 'flex'; +} +function copyPath(text, btn) { + copyToClipboard(text, btn, '✓'); +} + +function getResponseStatus(e) { + return Number(e?.response?.status || 0); +} + +function getResponseErrorMessage(e) { + const message = e?.response?.body?.error?.message; + if (typeof message === 'string' && message.trim()) return message.trim(); + return 'Unknown error'; +} + +function onCopySuccess(btn, copiedLabel = null) { + if (!btn) return; + const orig = btn.innerHTML; + btn.textContent = copiedLabel || t('copied'); + setTimeout(() => { btn.innerHTML = orig; }, 1500); +} + +function fallbackCopyText(text) { + return new Promise((resolve, reject) => { + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + ta.setSelectionRange(0, ta.value.length); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (ok) resolve(); + else reject(new Error('execCommand(copy) failed')); + } catch (err) { + reject(err); + } + }); +} + +function writeClipboardText(text) { + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + return navigator.clipboard.writeText(text).catch(() => fallbackCopyText(text)); + } + return fallbackCopyText(text); +} + +function copyToClipboard(text, btn, copiedLabel = null) { + writeClipboardText(text).then(() => { + onCopySuccess(btn, copiedLabel); + }).catch(err => { + console.error('Clipboard copy failed:', err); + if (!btn) return; + const orig = btn.innerHTML; + btn.textContent = '!'; + setTimeout(() => { btn.innerHTML = orig; }, 1500); + }); +} + +let pathFilterExpanded = false; + +/* + * Path priority tiers: + * primary – core AI conversation endpoints, always visible + * secondary – useful auxiliary APIs (MCP, models, token counting), collapsed behind "+N more" + * noise – plugin manifests, bot APIs, asset downloads, version checks — hidden by default + */ +const PRIMARY_PATH_PREFIXES = ['/v1/messages', '/v1/responses', '/backend-api/codex/responses', '/v1/chat/completions', '/v1/completions', '/v1beta/models', '/v1alpha/models', '/v1internal:generateContent', '/v1internal:streamGenerateContent']; +const SECONDARY_PATH_PREFIXES = ['/v1/mcp', '/v1/models', '/v1/embeddings', '/v1/files', '/responses', '/models', '/chat/completions', '/completions', '/files', '/search', '/fetch', '/usages', '/feedback']; +function isBedrockInvokePath(p) { + return p.startsWith('/model/') && (p.endsWith('/invoke') || p.endsWith('/invoke-with-response-stream')); +} +function pathTier(p) { + if (isBedrockInvokePath(p)) return 0; + if (PRIMARY_PATH_PREFIXES.some(pfx => p.startsWith(pfx))) return 0; + if (SECONDARY_PATH_PREFIXES.some(pfx => p.startsWith(pfx))) return 1; + return 2; +} +function isPathPrimary(p) { return pathTier(p) === 0; } + +function renderPathFilter(paths, pathCounts) { + const c = $('#path-filter'); c.innerHTML = ''; + const buckets = [[], [], []]; /* primary, secondary, noise */ + paths.forEach(p => buckets[pathTier(p)].push(p)); + buckets.forEach(b => b.sort((a, bb) => pathCounts[bb] - pathCounts[a])); + + /* Always show primary */ + buckets[0].forEach(p => c.appendChild(makeFilterChip(p, pathCounts[p]))); + + const hiddenPaths = [...buckets[1], ...buckets[2]]; + if (hiddenPaths.length > 0) { + if (pathFilterExpanded) { + buckets[1].forEach(p => c.appendChild(makeFilterChip(p, pathCounts[p]))); + buckets[2].forEach(p => c.appendChild(makeFilterChip(p, pathCounts[p]))); + } + const toggle = document.createElement('button'); + toggle.className = 'filter-chip-toggle'; + toggle.textContent = pathFilterExpanded ? t('filter_less') : `+${hiddenPaths.length} ${t('filter_more')}`; + toggle.onclick = () => { pathFilterExpanded = !pathFilterExpanded; renderPathFilter(paths, pathCounts); }; + c.appendChild(toggle); + } +} + +function makeFilterChip(p, count) { + const chip = document.createElement('button'); + chip.className = 'filter-chip' + (activePaths.has(p) ? ' active' : ''); + /* Truncate long paths: show last 40 chars with ellipsis */ + const label = p.length > 40 ? '\u2026' + p.slice(-39) : p; + chip.innerHTML = `${esc(label)}${count}`; + chip.title = p; + chip.onclick = () => { + if (activePaths.has(p)) activePaths.delete(p); else activePaths.add(p); + if (activePaths.size === 0) activePaths.add(p); + chip.classList.toggle('active'); + applyFilter(); + }; + return chip; +} + +function turnSortSegments(value) { + if (value === undefined || value === null || value === '') return [0]; + return String(value).split('.').map(part => { + const num = Number(part); + return Number.isFinite(num) ? num : 0; + }); +} + +function compareTurns(a, b) { + const aa = turnSortSegments(a); + const bb = turnSortSegments(b); + const len = Math.max(aa.length, bb.length); + for (let i = 0; i < len; i++) { + const diff = (aa[i] || 0) - (bb[i] || 0); + if (diff) return diff; + } + return String(a ?? '').localeCompare(String(b ?? '')); +} + +function applyFilter(preserveDetail) { + filtered = entries.filter(e => isNavigableTraceEntry(e) && activePaths.has(getPath(e))); + if (searchQuery) filtered = filtered.filter(e => matchSearch(e, searchQuery)); + if (activeTools) { + filtered = filtered.filter(e => { + const ro = getResponseOutput(e); + const rc = ro?.content; + if (!Array.isArray(rc)) return false; + return rc.some(b => b.type === 'tool_use' && activeTools.has(b.name)); + }); + } + filtered.sort((a, b) => compareTurns(captureTurnValue(a), captureTurnValue(b))); + let totalTokens = 0, totalDuration = 0; + let sumInput = 0, sumOutput = 0, sumCacheRead = 0, sumCacheCreate = 0; + let sumCacheDenominator = 0; + filtered.forEach(e => { + totalDuration += e.duration_ms || 0; + const u = getUsage(e); + if (u) { + const inputTokens = u.input_tokens || 0; + const cacheRead = u.cache_read_input_tokens || 0; + const cacheCreate = u.cache_creation_input_tokens || 0; + totalTokens += inputTokens + (u.output_tokens || 0); + sumInput += inputTokens; + sumOutput += u.output_tokens || 0; + sumCacheRead += cacheRead; + sumCacheCreate += cacheCreate; + if (inputTokens || cacheRead || cacheCreate) { + if (u._cache_read_in_input) { + sumCacheDenominator += inputTokens; + } else { + sumCacheDenominator += inputTokens + cacheRead + cacheCreate; + } + } + } + }); + $('#stat-turns').textContent = filtered.length; + $('#stat-tokens').textContent = totalTokens.toLocaleString(); + $('#stat-duration').textContent = fmtDuration(totalDuration); + // Token breakdown in header + if (totalTokens > 0) { + $('#stat-input').textContent = sumInput.toLocaleString(); + $('#stat-input-group').style.display = 'flex'; + $('#stat-output').textContent = sumOutput.toLocaleString(); + $('#stat-output-group').style.display = 'flex'; + if (sumCacheRead) { $('#stat-cache-read').textContent = sumCacheRead.toLocaleString(); $('#stat-cache-read-group').style.display = 'flex'; } + else { $('#stat-cache-read-group').style.display = 'none'; } + if (sumCacheCreate) { $('#stat-cache-write').textContent = sumCacheCreate.toLocaleString(); $('#stat-cache-write-group').style.display = 'flex'; } + else { $('#stat-cache-write-group').style.display = 'none'; } + if (sumCacheRead && sumCacheDenominator > 0) { + const hitRate = Math.round(sumCacheRead / sumCacheDenominator * 100); + $('#stat-cache-hit-rate').textContent = hitRate + '%'; + $('#stat-cache-hit-rate-group').style.display = 'flex'; + } else { + $('#stat-cache-hit-rate-group').style.display = 'none'; + } + } else { + ['stat-input-group','stat-output-group','stat-cache-read-group','stat-cache-write-group','stat-cache-hit-rate-group'].forEach(id => $('#'+id).style.display = 'none'); + } + renderToolFilter(); + renderSidebar(preserveDetail); + updatePositionIndicator(); +} + +function renderToolFilter() { + const tf = $('#tool-filter'); + // Collect all tool names from all entries (not just filtered) + const toolSet = new Set(); + entries.forEach(e => { + if (e._isStub) { + // Use stub's response content (already has tool_use blocks from metadata) + const rc = e.response?.body?.content; + if (Array.isArray(rc)) rc.forEach(b => { if (b.type === 'tool_use' && b.name) toolSet.add(b.name); }); + } else { + const ro = getResponseOutput(e); + const rc = ro?.content; + if (Array.isArray(rc)) rc.forEach(b => { if (b.type === 'tool_use' && b.name) toolSet.add(b.name); }); + } + }); + if (toolSet.size === 0) { tf.style.display = 'none'; return; } + tf.style.display = ''; + const tools = [...toolSet].sort(); + tf.innerHTML = '
Tools
' + + tools.map(name => { + const active = activeTools ? activeTools.has(name) : false; + return ``; + }).join('') + + (activeTools ? `` : ''); +} +function toggleToolFilter(name) { + if (!activeTools) activeTools = new Set(); + if (activeTools.has(name)) { activeTools.delete(name); if (activeTools.size === 0) activeTools = null; } + else activeTools.add(name); + applyFilter(); +} +function clearToolFilter() { activeTools = null; applyFilter(); } + +function setSidebarOrderMode(mode) { + if (!SIDEBAR_ORDER_MODES.includes(mode)) return; + if (sidebarOrderMode === mode) return; + sidebarOrderMode = mode; + safeLocalStorageSet('claude-tap-sidebar-order', mode); + updateSidebarSortControls(); + renderSidebar(true); + updatePositionIndicator(); + if (virtualMode && activeIdx >= 0) { + vsScrollToIdx(activeIdx); + } else { + const activeItem = document.querySelector('.sidebar-item.active'); + if (activeItem) activeItem.scrollIntoView({ block: 'nearest' }); + } +} + +function updateSidebarSortControls() { + document.querySelectorAll('.sidebar-sort-btn').forEach(btn => { + const mode = btn.dataset.sortMode; + btn.textContent = mode === 'turn' ? t('sort_turn') : mode === 'session' ? t('sort_session') : t('sort_model'); + const active = mode === sidebarOrderMode; + btn.classList.toggle('active', active); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); +} + +/* ─── Search ─── */ +function onSearch(value) { + searchQuery = value.toLowerCase().trim(); + $('#search-clear').style.display = searchQuery ? '' : 'none'; + applyFilter(); +} +function clearSearch() { + searchQuery = ''; + $('#search-input').value = ''; + $('#search-clear').style.display = 'none'; + applyFilter(); +} +function matchSearch(e, q) { + if (e._isStub) { + // In lazy mode: search metadata fields only (fast, no parsing) + const model = e.request?.body?.model || ''; + if (model.toLowerCase().includes(q)) return true; + const path = e.request?.path || ''; + if (path.toLowerCase().includes(q)) return true; + const sys = e.request?.body?.system || ''; + if (sys.toLowerCase().includes(q)) return true; + const turn = String(displayTurnLabel(e)); + if (turn.includes(q)) return true; + const stubRequestTools = getRequestTools(e.request?.body); + const tools = stubRequestTools.length ? stubRequestTools : getRequestTools(getResponsePayload(e)); + for (const td of tools) { + if (toolDisplayName(td).toLowerCase().includes(q)) return true; + } + const rc = e.response?.body?.content; + if (Array.isArray(rc)) { + for (const block of rc) { + if ((block.name || '').toLowerCase().includes(q)) return true; + } + } + return false; + } + const body = e.request?.body; + if ((body?.model || '').toLowerCase().includes(q)) return true; + const sys = extractSystem(body) || ''; + if (sys.toLowerCase().includes(q)) return true; + const msgs = getMessages(body); + for (const m of msgs) { + const mc = typeof m.content === 'string' ? m.content : JSON.stringify(m.content); + if (mc.toLowerCase().includes(q)) return true; + } + const requestTools = getRequestTools(body); + const tools = requestTools.length ? requestTools : getRequestTools(getResponsePayload(e)); + for (const td of tools) { + if (toolDisplayName(td).toLowerCase().includes(q)) return true; + if (toolDescription(td).toLowerCase().includes(q)) return true; + } + const ro = getResponseOutput(e); + const rc = ro?.content; + if (Array.isArray(rc)) { + for (const block of rc) { + if ((block.text || block.name || '').toLowerCase().includes(q)) return true; + } + } + return false; +} + +/* ─── Global search (Cmd/Ctrl+F) ─── */ +function initGlobalSearch() { + const input = $('#global-search-input'); + $('#global-search-prev').onclick = () => navigateGlobalSearch(-1); + $('#global-search-next').onclick = () => navigateGlobalSearch(1); + $('#global-search-close').onclick = () => closeGlobalSearch(); + input.addEventListener('input', () => { + globalSearchState.query = input.value.trim(); + scheduleGlobalSearchRecalc(); + }); +} + +function openGlobalSearch() { + normalizeFiltersForGlobalSearch(); + globalSearchState.open = true; + $('#global-search-overlay').classList.add('open'); + const input = $('#global-search-input'); + input.value = globalSearchState.query; + input.focus(); + input.select(); + recalcGlobalSearchMatches(); +} + +function closeGlobalSearch() { + globalSearchState.open = false; + globalSearchState.query = ''; + globalSearchState.queries = []; + globalSearchState.matchCounts = []; + globalSearchState.totalMatches = 0; + globalSearchState.currentMatch = -1; + if (globalSearchState.recalcTimer) { + cancelAnimationFrame(globalSearchState.recalcTimer); + globalSearchState.recalcTimer = 0; + } + $('#global-search-input').value = ''; + $('#global-search-overlay').classList.remove('open'); + clearGlobalSearchHighlights($('#detail')); + updateGlobalSearchCount(); +} + +function normalizeFiltersForGlobalSearch() { + // Global search must be able to move across all entries. + let changed = false; + const allPaths = new Set(entries.filter(isNavigableTraceEntry).map(getPath)); + if (activePaths.size !== allPaths.size || [...allPaths].some(p => !activePaths.has(p))) { + activePaths = allPaths; + changed = true; + } + if (activeTools) { activeTools = null; changed = true; } + if (searchQuery) { + searchQuery = ''; + if ($('#search-input')) $('#search-input').value = ''; + if ($('#search-clear')) $('#search-clear').style.display = 'none'; + changed = true; + } + if (changed) applyFilter(true); +} + +function uniqueSearchQueries(values) { + const out = []; + const seen = new Set(); + values.forEach(value => { + const q = String(value || '').toLowerCase(); + if (!q || seen.has(q)) return; + seen.add(q); + out.push(q); + }); + return out; +} + +function buildGlobalSearchQueries(query) { + const base = String(query || '').toLowerCase().trim(); + if (!base) return []; + const variants = [base]; + const keyMatch = base.match(/^([a-z0-9_$.-]+):(\s*.*)$/i); + if (keyMatch) { + const key = keyMatch[1]; + const tail = keyMatch[2] || ''; + const compactTail = tail.trimStart(); + variants.push(`"${key}":${compactTail}`); + variants.push(`"${key}":${tail}`); + variants.push(`"${key}": ${compactTail}`); + variants.push(`${key}":${compactTail}`); + variants.push(`${key}":${tail}`); + variants.push(`${key}": ${compactTail}`); + } + variants.slice().forEach(value => { + if (value.includes('"')) variants.push(value.replaceAll('"', '\\"')); + }); + return uniqueSearchQueries(variants); +} + +function buildGlobalHighlightQueries(query) { + const base = String(query || '').toLowerCase().trim(); + if (!base) return []; + const variants = buildGlobalSearchQueries(base); + const keyMatch = base.match(/^([a-z0-9_$.-]+):(\s*.*)$/i); + if (keyMatch) { + const key = keyMatch[1]; + variants.push(`"${key}"`, key); + } + return uniqueSearchQueries(variants); +} + +function getEntrySearchText(entry) { + const cacheKey = entryStableKey(entry); + if (!cacheKey) return ''; + if (globalSearchState.textCache.has(cacheKey)) return globalSearchState.textCache.get(cacheKey); + if (entry._isStub) { + const lines = getRawLines(); + const raw = lines[entry._rawIdx] || ''; + const text = raw.toLowerCase(); + globalSearchState.textCache.set(cacheKey, text); + return text; + } + const resolved = entry; + const body = resolved.request?.body; + const parts = []; + parts.push(body?.model || ''); + parts.push(extractSystem(body) || ''); + getMessages(body).forEach(m => parts.push(msgToText(m))); + const requestTools = getRequestTools(body); + const tools = requestTools.length ? requestTools : getRequestTools(getResponsePayload(resolved)); + tools.forEach(td => parts.push(JSON.stringify(td))); + const output = getResponseOutput(resolved); + if (output?.content) parts.push(msgToText({ role: 'assistant', content: output.content })); + getResponseEvents(resolved).forEach(ev => parts.push(JSON.stringify(ev))); + parts.push(JSON.stringify(resolved, null, 2)); + const text = parts.join('\n').toLowerCase(); + globalSearchState.textCache.set(cacheKey, text); + return text; +} + +function countOneQueryInText(text, query) { + let count = 0; + let from = 0; + while (query) { + const idx = text.indexOf(query, from); + if (idx === -1) return count; + count += 1; + from = idx + query.length; + } + return count; +} + +function countMatchesInText(text, queries) { + if (!text || !queries.length) return 0; + return Math.max(...queries.map(query => countOneQueryInText(text, query))); +} + +function scheduleGlobalSearchRecalc() { + if (globalSearchState.recalcTimer) cancelAnimationFrame(globalSearchState.recalcTimer); + globalSearchState.recalcTimer = requestAnimationFrame(() => { + globalSearchState.recalcTimer = 0; + recalcGlobalSearchMatches(); + }); +} + +function flushGlobalSearchRecalc() { + if (!globalSearchState.recalcTimer) return; + cancelAnimationFrame(globalSearchState.recalcTimer); + globalSearchState.recalcTimer = 0; + recalcGlobalSearchMatches(); +} + +function recalcGlobalSearchMatches() { + clearGlobalSearchHighlights($('#detail')); + const queries = buildGlobalSearchQueries(globalSearchState.query); + globalSearchState.queries = queries; + if (!queries.length) { + globalSearchState.matchCounts = []; + globalSearchState.totalMatches = 0; + globalSearchState.currentMatch = -1; + updateGlobalSearchCount(); + return; + } + const counts = []; + let total = 0; + entries.forEach(entry => { + if (!isNavigableTraceEntry(entry)) return; + const c = countMatchesInText(getEntrySearchText(entry), queries); + if (c > 0) counts.push({ entryKey: entryStableKey(entry), requestId: entry.request_id, count: c }); + total += c; + }); + globalSearchState.matchCounts = counts; + globalSearchState.totalMatches = total; + globalSearchState.currentMatch = total > 0 ? 0 : -1; + updateGlobalSearchCount(); + revealCurrentSearchMatch(); +} + +function updateGlobalSearchCount() { + const total = globalSearchState.totalMatches; + const current = total > 0 ? (globalSearchState.currentMatch + 1) : 0; + const label = total > 0 ? `${current} of ${total} matches` : '0 of 0'; + $('#global-search-count').textContent = label; +} + +function getTargetForGlobalMatch(globalIndex) { + let seen = 0; + for (const row of globalSearchState.matchCounts) { + if (globalIndex < seen + row.count) { + return { entryKey: row.entryKey, requestId: row.requestId, localIndex: globalIndex - seen }; + } + seen += row.count; + } + return null; +} + +function findFilteredIdxByEntryKey(entryKey, requestId) { + let idx = filtered.findIndex(entry => entryStableKey(entry) === entryKey); + if (idx >= 0) return idx; + return filtered.findIndex(entry => entry.request_id === requestId); +} + +function ensureEntryVisibleForSearch(target) { + let idx = findFilteredIdxByEntryKey(target.entryKey, target.requestId); + if (idx < 0) { + normalizeFiltersForGlobalSearch(); + idx = findFilteredIdxByEntryKey(target.entryKey, target.requestId); + } + if (idx < 0) return -1; + const model = filtered[idx]?.request?.body?.model || 'unknown'; + if (collapsedGroups.has(model)) { + collapsedGroups.delete(model); + renderSidebar(true); + idx = findFilteredIdxByEntryKey(target.entryKey, target.requestId); + } + return idx; +} + +function navigateGlobalSearch(delta) { + flushGlobalSearchRecalc(); + if (!globalSearchState.totalMatches) return; + const total = globalSearchState.totalMatches; + globalSearchState.currentMatch = (globalSearchState.currentMatch + delta + total) % total; + updateGlobalSearchCount(); + revealCurrentSearchMatch(); +} + +function revealCurrentSearchMatch() { + const target = getTargetForGlobalMatch(globalSearchState.currentMatch); + if (!target) return; + const filteredIdx = ensureEntryVisibleForSearch(target); + if (filteredIdx < 0) return; + const sameEntry = currentDetailEntryKey === target.entryKey; + selectEntry(filteredIdx, { force: !sameEntry }); + applyGlobalSearchHighlights(target.localIndex); +} + +function applyGlobalSearchHighlights(targetLocalIndex) { + const detail = $('#detail'); + if (!detail) return; + clearGlobalSearchHighlights(detail); + const queries = buildGlobalHighlightQueries(globalSearchState.query); + if (!queries.length) return; + const marks = highlightSearchInContainer(detail, queries); + autoExpandSearchMatches(marks); + if (!marks.length) return; + let idx = targetLocalIndex; + if (idx < 0 || idx >= marks.length) idx = 0; + marks.forEach((mark, i) => mark.classList.toggle('current', i === idx)); + marks[idx].scrollIntoView({ block: 'center' }); +} + +function clearGlobalSearchHighlights(container) { + if (!container) return; + container.querySelectorAll('mark.global-search-hit').forEach(mark => { + mark.replaceWith(document.createTextNode(mark.textContent || '')); + }); + container.normalize(); +} + +function findNextSearchMatch(text, queries, from) { + let best = null; + queries.forEach(query => { + const idx = text.indexOf(query, from); + if (idx === -1) return; + if (!best || idx < best.index || (idx === best.index && query.length > best.query.length)) { + best = { index: idx, query }; + } + }); + return best; +} + +function highlightSearchInContainer(container, queries) { + if (!queries.length) return []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + if (parent.closest('#global-search-overlay')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + }, + }); + const textNodes = []; + while (walker.nextNode()) textNodes.push(walker.currentNode); + const marks = []; + textNodes.forEach(node => { + const value = node.nodeValue; + const lower = value.toLowerCase(); + let from = 0; + let found = findNextSearchMatch(lower, queries, from); + if (!found) return; + const frag = document.createDocumentFragment(); + while (found) { + if (found.index > from) frag.appendChild(document.createTextNode(value.slice(from, found.index))); + const mark = document.createElement('mark'); + mark.className = 'global-search-hit'; + mark.textContent = value.slice(found.index, found.index + found.query.length); + frag.appendChild(mark); + marks.push(mark); + from = found.index + found.query.length; + found = findNextSearchMatch(lower, queries, from); + } + if (from < value.length) frag.appendChild(document.createTextNode(value.slice(from))); + node.parentNode.replaceChild(frag, node); + }); + return marks; +} + +function autoExpandSearchMatches(marks) { + marks.forEach(mark => { + const sectionBody = mark.closest('.section-body'); + if (sectionBody && !sectionBody.classList.contains('open')) { + sectionBody.classList.add('open'); + sectionBody.previousElementSibling?.querySelector('.chevron')?.classList.add('open'); + } + const toolBody = mark.closest('.tool-block-body'); + if (toolBody && !toolBody.classList.contains('open')) { + toolBody.classList.add('open'); + toolBody.previousElementSibling?.querySelector('.tb-arrow')?.classList.add('open'); + } + }); +} diff --git a/claude_tap/viewer_assets/i18n_ui.js b/claude_tap/viewer_assets/i18n_ui.js new file mode 100644 index 0000000..2401ccd --- /dev/null +++ b/claude_tap/viewer_assets/i18n_ui.js @@ -0,0 +1,185 @@ +/* ─── i18n ─── */ +const I18N = typeof __CLAUDE_TAP_I18N__ !== 'undefined' ? __CLAUDE_TAP_I18N__ : { en: {} }; +function detectLang() { + const supported = ['en','zh-CN','ja','ko','fr','ar','de','ru']; + const nav = navigator.language || navigator.userLanguage || 'en'; + if (supported.includes(nav)) return nav; + const prefix = nav.split('-')[0]; + if (prefix === 'zh') return 'zh-CN'; + const match = supported.find(s => s.startsWith(prefix)); + return match || 'en'; +} +let currentLang = safeLocalStorageGet('claude-tap-lang') || detectLang(); +function t(key) { + const en = I18N.en || {}; + return (I18N[currentLang] || en)[key] || en[key] || key; +} +function formatText(key, values = {}) { + return Object.entries(values).reduce((text, [name, value]) => { + return text.replaceAll(`{${name}}`, String(value)); + }, t(key)); +} +function setLang(lang) { + currentLang = lang; + safeLocalStorageSet('claude-tap-lang', lang); + document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr'; + document.documentElement.lang = lang; + $('#lang-select').value = lang; + updateStaticTexts(); + if (filtered.length) { applyFilter(); selectEntry(activeIdx >= 0 ? activeIdx : 0); } +} +function updateStaticTexts() { + const e = id => document.getElementById(id); + if (e('logo-text')) e('logo-text').textContent = t('title'); + if (e('logo-version')) { + const rawVersion = String(CLAUDE_TAP_VERSION || '').trim(); + if (rawVersion) { + e('logo-version').textContent = rawVersion.startsWith('v') ? rawVersion : `v${rawVersion}`; + e('logo-version').style.display = 'inline-flex'; + } else { + e('logo-version').style.display = 'none'; + } + } + document.title = t('title') + ' Viewer'; + if (e('label-turns')) e('label-turns').textContent = t('stats_turns'); + const tokenLabel = e('label-tokens'); + if (tokenLabel) { + tokenLabel.textContent = t('stats_tokens'); + if (tokenLabel.parentElement) { + tokenLabel.parentElement.title = t('stats_tokens_hint'); + tokenLabel.parentElement.setAttribute('aria-label', t('stats_tokens_hint')); + } + } + if (e('label-time')) e('label-time').textContent = t('stats_time'); + if (e('drop-title')) e('drop-title').textContent = t('drop_title'); + if (e('drop-desc')) e('drop-desc').textContent = t('drop_desc'); + if (e('drop-btn-label')) e('drop-btn-label').textContent = t('drop_btn'); + if (e('empty-trace-title')) e('empty-trace-title').textContent = t('empty_trace_title'); + if (e('empty-trace-desc')) e('empty-trace-desc').textContent = t('empty_trace_desc'); + if (e('empty-trace-count')) e('empty-trace-count').textContent = t('empty_trace_count'); + if (e('empty-trace-hint')) e('empty-trace-hint').textContent = t('empty_trace_hint'); + if (e('search-input')) e('search-input').placeholder = t('search_placeholder'); + if (e('date-label')) e('date-label').textContent = t('history_date'); + if (e('sidebar-sort-label')) e('sidebar-sort-label').textContent = t('sort_label'); + updateHistoryDeleteButton(); + updateSidebarSortControls(); + renderViewerActions(); +} + +function renderViewerActions() { + const wrap = $('#viewer-actions'); + if (!wrap) return; + const exports = TRACE_SESSION_EXPORTS && typeof TRACE_SESSION_EXPORTS === 'object' ? TRACE_SESSION_EXPORTS : {}; + const downloadIcon = ``; + const links = []; + if (exports.showJsonl === true && typeof exports.jsonl === 'string' && exports.jsonl) { + links.push(`${esc(t('export_jsonl'))}`); + } + if (typeof exports.compact === 'string' && exports.compact) { + links.push(`${esc(t('export_compact'))}`); + } + if (typeof exports.html === 'string' && exports.html) { + links.push(`${esc(t('export_html'))}`); + } + if (!links.length) { + wrap.innerHTML = ''; + wrap.style.display = 'none'; + return; + } + wrap.innerHTML = `
+ ${downloadIcon}${esc(t('export_menu'))} +
${links.join('')}
+
`; + wrap.style.display = 'inline-flex'; +} + +/* ─── Theme ─── */ +function initTheme() { + const saved = safeLocalStorageGet('claude-tap-theme'); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const theme = EMBED_QUERY_OPTIONS.theme || saved || (prefersDark ? 'dark' : 'light'); + applyTheme(theme); +} +function toggleTheme() { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + applyTheme(next); + safeLocalStorageSet('claude-tap-theme', next); +} +function applyTheme(theme) { + if (theme === 'dark') { + document.documentElement.dataset.theme = 'dark'; + $('#theme-toggle').textContent = '\u2600'; // sun + } else { + delete document.documentElement.dataset.theme; + $('#theme-toggle').textContent = '\u263E'; // moon + } +} + +/* ─── Init ─── */ +function initEmbedMode() { + if (!EMBED_QUERY_OPTIONS.enabled) return; + document.documentElement.dataset.embedMode = 'true'; + document.body.classList.add('embed-mode'); + if (EMBED_QUERY_OPTIONS.hideHeader) document.body.classList.add('embed-hide-header'); + if (EMBED_QUERY_OPTIONS.hidePath) document.body.classList.add('embed-hide-path'); + if (EMBED_QUERY_OPTIONS.hideHistory) document.body.classList.add('embed-hide-history'); + if (EMBED_QUERY_OPTIONS.hideControls) document.body.classList.add('embed-hide-controls'); + if (EMBED_QUERY_OPTIONS.compact) document.body.classList.add('embed-compact'); +} + +function initLang() { + document.documentElement.dir = currentLang === 'ar' ? 'rtl' : 'ltr'; + document.documentElement.lang = currentLang; + $('#lang-select').value = currentLang; + updateStaticTexts(); +} + +function initCommonUi() { + initEmbedMode(); + initTheme(); + initLang(); + initGlobalSearch(); +} + +function initFileDropZone() { + const dropInner = $('#drop-inner'), fileInput = $('#file-input'); + if (!dropInner || !fileInput || dropInner.dataset.fileDropInitialized === 'true') return; + dropInner.dataset.fileDropInitialized = 'true'; + ['dragover','dragenter'].forEach(e => dropInner.addEventListener(e, ev => { + ev.preventDefault(); + dropInner.classList.add('dragover'); + })); + ['dragleave','drop'].forEach(e => dropInner.addEventListener(e, () => dropInner.classList.remove('dragover'))); + dropInner.addEventListener('drop', ev => { + ev.preventDefault(); + if (ev.dataTransfer.files.length) loadFile(ev.dataTransfer.files[0]); + }); + fileInput.addEventListener('change', () => { + if (fileInput.files.length) loadFile(fileInput.files[0]); + }); +} + +function renderEmptyTraceState() { + entries = []; + filtered = []; + activeIdx = -1; + $('#sidebar-wrap').style.display = 'none'; + $('#detail').style.display = 'none'; + $('#stats').style.display = 'none'; + $('#path-filter').style.display = 'none'; + renderTracePathBar(); + const dropZone = $('#drop-zone'); + dropZone.style.display = 'flex'; + dropZone.innerHTML = ` +
+

${esc(t('empty_trace_title'))}

+

${esc(t('empty_trace_desc'))}

+
+ ${esc(t('empty_trace_count'))} + ${esc(t('empty_trace_hint'))} +
+ + +
`; + initFileDropZone(); +} diff --git a/claude_tap/viewer_assets/lazy_loading.js b/claude_tap/viewer_assets/lazy_loading.js new file mode 100644 index 0000000..0327279 --- /dev/null +++ b/claude_tap/viewer_assets/lazy_loading.js @@ -0,0 +1,204 @@ +/* ─── Lazy loading infrastructure ─── */ +const LAZY_THRESHOLD = 50; +let lazyMode = false; +let rawLines = null; // array of raw JSON strings, populated on first access +const entryCache = new Map(); // index -> parsed full entry +const remoteEntryPromises = new Map(); // index -> pending record fetch + +function getRawLines() { + if (rawLines) return rawLines; + const el = document.getElementById('trace-raw'); + if (!el) return []; + const text = el.textContent; + // Free DOM node memory — we no longer need the script element + el.remove(); + rawLines = text.split('\n').filter(l => l.trim()); + return rawLines; +} + +function hasEmbeddedRawLines() { + return !!rawLines || !!document.getElementById('trace-raw'); +} + +function buildStubEntry(meta, rawIdx) { + // Build an entry object with the same shape as real entries so existing + // sidebar rendering code works unchanged. Nested paths are constructed + // to satisfy property access patterns (e.g. entry.request.body.model). + const usage = {}; + if (meta.input_tokens) usage.input_tokens = meta.input_tokens; + if (meta.output_tokens) usage.output_tokens = meta.output_tokens; + const hasCacheCreate = meta.cache_creation_input_tokens !== undefined && meta.cache_creation_input_tokens !== null; + if (meta.cache_read_input_tokens) { + usage.cache_read_input_tokens = meta.cache_read_input_tokens; + /* Infer cache embedding style from model name so the cache hit rate + denominator is correct in lazy/dashboard mode. Claude/Anthropic and + Bedrock keep cache_read as a separate bucket; OpenAI/Gemini embed it. */ + const m = (meta.model || '').toLowerCase(); + usage._cache_read_in_input = !(hasCacheCreate || m.includes('claude') || m.includes('anthropic') || m.includes('bedrock')); + } + if (meta.cache_creation_input_tokens) usage.cache_creation_input_tokens = meta.cache_creation_input_tokens; + + // Build a minimal system field to support task fingerprinting + const body = { model: meta.model || '' }; + if (meta.codex_app_session_id) { + body.metadata = { codex_app_session_id: meta.codex_app_session_id }; + } + if (typeof meta.request_generate === 'boolean') body.generate = meta.request_generate; + if (meta.has_system && meta.sys_hint) { + body.system = meta.sys_hint; + } + if (meta.tool_names && meta.tool_names.length) { + body.tools = meta.tool_names.map(n => ({ name: n })); + } + + // Build minimal response content for tool filter + const respContent = []; + if (meta.response_tool_names && meta.response_tool_names.length) { + meta.response_tool_names.forEach(n => respContent.push({ type: 'tool_use', name: n })); + } + + const responseBody = { + usage: usage, + content: respContent.length ? respContent : undefined, + error: meta.error_message ? { message: meta.error_message } : undefined, + }; + if (typeof meta.response_generate === 'boolean') responseBody.generate = meta.response_generate; + if (meta.response_output_count) responseBody.output = Array.from({ length: meta.response_output_count }, () => ({})); + + return { + _isStub: true, + _rawIdx: rawIdx, + _entry_index: rawIdx, + turn: meta.turn, + request_id: meta.request_id || '', + timestamp: meta.timestamp || '', + duration_ms: meta.duration_ms || 0, + transport: meta.transport || '', + _session_user_text: meta.session_user_text || '', + request: { + method: meta.method || '', + path: meta.path || '', + headers: meta.codex_app_session_id ? { 'x-codex-app-session-id': meta.codex_app_session_id } : {}, + body: body, + }, + response: { + status: meta.status || 0, + body: responseBody, + }, + }; +} + +function toolDisplayName(td) { + if (!td || typeof td !== 'object') return ''; + const candidates = [ + td.name, + td.function && typeof td.function === 'object' ? td.function.name : null, + td.id, + td.type + ]; + for (const value of candidates) { + if (typeof value === 'string' && value) return value; + } + return ''; +} + +function toolDescription(td) { + if (!td || typeof td !== 'object') return ''; + const desc = td.description || (td.function && typeof td.function === 'object' ? td.function.description : ''); + return typeof desc === 'string' ? desc : ''; +} + +function toolSchema(td) { + if (!td || typeof td !== 'object') return {}; + return td.input_schema || td.parameters || (td.function && typeof td.function === 'object' ? td.function.parameters : null) || {}; +} + +function getFullEntry(entry) { + if (!entry._isStub) return entry; + const idx = entry._rawIdx; + if (entryCache.has(idx)) return entryCache.get(idx); + const lines = getRawLines(); + if (idx < 0 || idx >= lines.length) return entry; + try { + const full = JSON.parse(lines[idx]); + entryCache.set(idx, full); + return full; + } catch (e) { + console.error('Failed to parse entry at index', idx, e); + return entry; + } +} + +function shouldFetchRemoteEntry(entry) { + return !!(entry && entry._isStub && TRACE_RECORDS_API && !hasEmbeddedRawLines()); +} + +function remoteRecordUrl(idx) { + const sep = TRACE_RECORDS_API.includes('?') ? '&' : '?'; + return `${TRACE_RECORDS_API}${sep}offset=${encodeURIComponent(idx)}&limit=1`; +} + +async function fetchRemoteEntry(entry) { + if (!shouldFetchRemoteEntry(entry)) return getFullEntry(entry); + const idx = entry._rawIdx; + if (entryCache.has(idx)) return entryCache.get(idx); + if (!remoteEntryPromises.has(idx)) { + remoteEntryPromises.set(idx, fetch(remoteRecordUrl(idx)) + .then(async resp => { + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const payload = await resp.json(); + const record = Array.isArray(payload.records) ? payload.records[0] : null; + if (!record || typeof record !== 'object') return entry; + entryCache.set(idx, record); + return record; + }) + .catch(err => { + remoteEntryPromises.delete(idx); + throw err; + })); + } + return remoteEntryPromises.get(idx); +} + +function withDisplayFields(full, entry) { + return { + ...full, + _entry_index: entry._entry_index, + display_turn: entry.display_turn, + capture_turn: entry.capture_turn, + record_index: entry.record_index, + websocket_response_index: entry.websocket_response_index, + }; +} + +function resolveEntryForDetail(entry) { + if (!entry || !entry._isStub) return entry; + return withDisplayFields(getFullEntry(entry), entry); +} + +async function resolveEntryForDetailAsync(entry) { + if (!entry || !entry._isStub) return entry; + return withDisplayFields(await fetchRemoteEntry(entry), entry); +} + +/* ─── Virtual scroll state ─── */ +let virtualMode = false; +const VS_ITEM_HEIGHT = 68; +const VS_BUFFER = 10; +let vsFilteredItems = []; // {entry, idx} pairs for virtual scroll + +const globalSearchState = { + open: false, + query: '', + queries: [], + matchCounts: [], + totalMatches: 0, + currentMatch: -1, + textCache: new Map(), + recalcTimer: 0, +}; +const TRACE_JSONL_PATH = typeof __TRACE_JSONL_PATH__ !== 'undefined' ? __TRACE_JSONL_PATH__ : ''; +const TRACE_HTML_PATH = typeof __TRACE_HTML_PATH__ !== 'undefined' ? __TRACE_HTML_PATH__ : ''; +const TRACE_RECORDS_API = typeof __TRACE_RECORDS_API__ !== 'undefined' ? __TRACE_RECORDS_API__ : ''; +const CLAUDE_TAP_VERSION = typeof __CLAUDE_TAP_VERSION__ !== 'undefined' ? __CLAUDE_TAP_VERSION__ : ''; +const TRACE_SESSION_EXPORTS = typeof __TRACE_SESSION_EXPORTS__ !== 'undefined' ? __TRACE_SESSION_EXPORTS__ : null; diff --git a/claude_tap/viewer_assets/live_bootstrap.js b/claude_tap/viewer_assets/live_bootstrap.js new file mode 100644 index 0000000..7f01738 --- /dev/null +++ b/claude_tap/viewer_assets/live_bootstrap.js @@ -0,0 +1,361 @@ +/* ─── Live Mode SSE Support ─── */ +let liveEventSource = null; +let liveConnected = false; +let currentDetailRequestId = null; +let currentDetailEntryKey = null; +let liveRecords = []; +let viewingDate = null; // null = live, string = historical date +let liveRenderTimer = null; +const liveSeenIds = new Set(); + +function initLiveMode() { + const statusEl = $('#live-status'); + statusEl.style.display = 'flex'; + updateLiveStatus('connecting'); + + liveEventSource = new EventSource('/events'); + + liveEventSource.onopen = () => { + liveConnected = true; + updateLiveStatus('connected', liveRecords.length); + }; + + liveEventSource.onmessage = (event) => { + try { + const record = JSON.parse(event.data); + // Deduplicate: SSE replays full history on each reconnect + const id = record.request_id || record.req_id; + if (id && liveSeenIds.has(id)) return; + if (id) liveSeenIds.add(id); + + liveRecords.push(record); + if (!viewingDate) { + entries.push(...normalizeDisplayTurns(expandLiveWebSocketResponseEntries([record]), false)); + updateLiveStatus('connected', entries.length); + clearTimeout(liveRenderTimer); + liveRenderTimer = setTimeout(() => renderApp(true), 50); + } + } catch (e) { + console.error('Failed to parse SSE data:', e); + } + }; + + // EventSource auto-reconnects on transient errors; + // only update status indicator here. + liveEventSource.onerror = () => { + liveConnected = false; + updateLiveStatus('disconnected'); + }; +} + +function updateLiveStatus(status, count) { + const el = $('#live-status'); + if (!el) return; + const colors = { + connecting: { bg: 'var(--amber-bg)', color: 'var(--amber)', dot: 'var(--amber)' }, + connected: { bg: 'var(--green-bg)', color: 'var(--green)', dot: 'var(--green)' }, + disconnected: { bg: 'var(--red-bg)', color: 'var(--red)', dot: 'var(--red)' } + }; + const c = colors[status] || colors.connecting; + const labels = { connecting: 'Connecting...', connected: 'Live', disconnected: 'Disconnected' }; + const countText = count !== undefined ? ` (${count})` : ''; + el.style.background = c.bg; + el.style.color = c.color; + el.innerHTML = `${labels[status]}${countText}`; +} + +function renderLiveWaitingState() { + $('#drop-zone').style.display = 'none'; + $('#sidebar-wrap').style.display = 'flex'; + $('#date-picker').style.display = 'flex'; + ['search-bar','sidebar-sort','tool-filter','position-indicator','sidebar'].forEach(id => { + const el = $('#' + id); + if (el) el.style.display = 'none'; + }); + $('#detail').style.display = ''; + $('#detail').innerHTML = '
📡

Waiting for API calls...

Start using Claude Code to see traces here in real-time

'; + $('#stats').style.display = 'none'; + $('#path-filter').style.display = 'none'; + updateHistoryDeleteButton(); +} + +function updateHistoryDeleteButton() { + const btn = $('#history-delete-btn'); + const text = $('#history-delete-text'); + const sel = $('#date-select'); + if (!btn || !sel) return; + const canDelete = sel.value && sel.value !== 'live'; + btn.disabled = !canDelete; + btn.title = canDelete ? t('history_delete_title') : t('history_delete_live_title'); + btn.setAttribute('aria-label', btn.title); + if (text) text.textContent = t('history_delete_btn'); +} + +function setHistoryDeleteStatus(message, tone = '') { + const el = $('#history-delete-status'); + if (!el) return; + el.className = `history-delete-status ${tone}`.trim(); + el.textContent = message || ''; + el.style.display = message ? 'block' : 'none'; +} + +async function fetchDates(preferredValue = null) { + try { + const resp = await fetch('/api/dates'); + const data = await resp.json(); + const sel = $('#date-select'); + if (!sel || !data.dates) return; + const previous = preferredValue || sel.value || 'live'; + sel.innerHTML = ''; + for (const d of data.dates) { + sel.insertAdjacentHTML('beforeend', ``); + } + if (data.has_legacy) { + sel.insertAdjacentHTML('beforeend', ''); + } + if ([...sel.options].some(option => option.value === previous)) sel.value = previous; + const picker = $('#date-picker'); + if (picker) picker.style.display = 'flex'; + updateHistoryDeleteButton(); + } catch (e) { + console.error('Failed to fetch dates:', e); + } +} + +async function onDateChange(value) { + setHistoryDeleteStatus(''); + updateHistoryDeleteButton(); + if (value === 'live') { + viewingDate = null; + activePaths.clear(); + entries = normalizeDisplayTurns(expandLiveWebSocketResponseEntries(liveRecords.slice(), true), true); + if (entries.length) renderApp(true); + else renderLiveWaitingState(); + return; + } + viewingDate = value; + try { + const resp = await fetch('/api/traces/' + encodeURIComponent(value)); + activePaths.clear(); + entries = normalizeDisplayTurns(expandWebSocketResponseEntries(await resp.json()), true); + renderApp(); + } catch (e) { + console.error('Failed to load traces for date:', value, e); + } +} + +async function deleteSelectedTraceDate() { + const sel = $('#date-select'); + if (!sel || !sel.value || sel.value === 'live') return; + const value = sel.value; + const label = sel.options[sel.selectedIndex]?.textContent || value; + if (!window.confirm(formatText('history_delete_confirm', { date: label }))) return; + + const btn = $('#history-delete-btn'); + if (btn) btn.disabled = true; + setHistoryDeleteStatus(t('history_delete_working'), 'warn'); + try { + const resp = await fetch('/api/traces/' + encodeURIComponent(value), { method: 'DELETE' }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || resp.statusText || String(resp.status)); + + const deleted = Number(data.deleted_files || 0); + setHistoryDeleteStatus( + deleted > 0 + ? formatText('history_delete_done', { count: deleted }) + : formatText('history_delete_empty', { date: label }), + deleted > 0 ? 'ok' : 'warn' + ); + await fetchDates('live'); + viewingDate = null; + activePaths.clear(); + entries = normalizeDisplayTurns(expandLiveWebSocketResponseEntries(liveRecords.slice(), true), true); + if (entries.length) renderApp(true); + else renderLiveWaitingState(); + } catch (e) { + console.error('Failed to delete trace history:', value, e); + setHistoryDeleteStatus(formatText('history_delete_failed', { error: e.message || e }), 'error'); + } finally { + updateHistoryDeleteButton(); + } +} + +function isCompactBlobRef(value) { + return value && + typeof value === 'object' && + !Array.isArray(value) && + Object.keys(value).length === 1 && + value.__claude_tap_blob_ref__ && + value.__claude_tap_blob_ref__.version === 1 && + value.__claude_tap_blob_ref__.kind === 'json' && + typeof value.__claude_tap_blob_ref__.hash === 'string'; +} + +function loadCompactBlobRef(value, blobs, cache) { + const ref = value.__claude_tap_blob_ref__; + if (!cache.has(ref.hash)) { + const blob = blobs[ref.hash]; + if (!blob || blob.kind !== (ref.kind || 'json')) throw new Error(`Missing compact trace blob: ${ref.hash}`); + cache.set(ref.hash, blob.payload); + } + return cache.get(ref.hash); +} + +function parseCompactRefPath(path) { + if (typeof path !== 'string' || !path.startsWith('/')) return null; + return path.slice(1).split('/').map(part => part.replaceAll('~1', '/').replaceAll('~0', '~')); +} + +function materializeCompactRefPath(value, path, blobs, cache) { + if (!path.length) return isCompactBlobRef(value) ? loadCompactBlobRef(value, blobs, cache) : value; + const [key, ...rest] = path; + if (Array.isArray(value)) { + const index = Number(key); + if (!Number.isInteger(index) || index < 0 || index >= value.length) return value; + const replacement = materializeCompactRefPath(value[index], rest, blobs, cache); + if (replacement === value[index]) return value; + const out = value.slice(); + out[index] = replacement; + return out; + } + if (value && typeof value === 'object') { + if (!Object.prototype.hasOwnProperty.call(value, key)) return value; + const replacement = materializeCompactRefPath(value[key], rest, blobs, cache); + if (replacement === value[key]) return value; + return { ...value, [key]: replacement }; + } + return value; +} + +const LEGACY_COMPACT_BLOB_PATHS = [ + ['request', 'body', 'instructions'], + ['request', 'body', 'tools'], + ['response', 'body', 'instructions'], + ['response', 'body', 'tools'], +]; + +const LEGACY_COMPACT_ITEM_BLOB_PATHS = [ + ['request', 'body', 'input'], + ['request', 'body', 'messages'], +]; + +function getCompactPath(value, path) { + let node = value; + for (const key of path) { + if (!node || typeof node !== 'object' || Array.isArray(node) || !Object.prototype.hasOwnProperty.call(node, key)) { + return undefined; + } + node = node[key]; + } + return node; +} + +function legacyCompactRefPaths(record) { + const paths = []; + for (const path of LEGACY_COMPACT_BLOB_PATHS) { + if (isCompactBlobRef(getCompactPath(record, path))) paths.push(path); + } + for (const path of LEGACY_COMPACT_ITEM_BLOB_PATHS) { + const value = getCompactPath(record, path); + if (!Array.isArray(value)) continue; + value.forEach((item, index) => { + if (isCompactBlobRef(item)) paths.push([...path, String(index)]); + }); + } + return paths; +} + +function materializeCompactRecord(payload, blobs, cache) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + const marker = payload.__claude_tap_compact_record__; + if (!marker) return payload; + if (marker.version !== 1) throw new Error(`Unsupported compact trace record version: ${marker.version}`); + let record = payload.record; + let refPaths = Array.isArray(marker.refs) + ? marker.refs.map(ref => parseCompactRefPath(ref && ref.path)).filter(Boolean) + : []; + if (!refPaths.length && record && typeof record === 'object' && !Array.isArray(record)) { + refPaths = legacyCompactRefPaths(record); + } + for (const path of refPaths) { + record = materializeCompactRefPath(record, path, blobs, cache); + } + return record && typeof record === 'object' && !Array.isArray(record) ? record : null; +} + +function materializeCompactTraceBundle(bundle) { + const marker = bundle && typeof bundle === 'object' ? bundle.__claude_tap_compact_trace__ : null; + if (!marker) return null; + if (marker.version !== 1) throw new Error(`Unsupported compact trace bundle version: ${marker.version}`); + const records = Array.isArray(bundle.records) ? bundle.records : []; + const blobs = bundle.blobs && typeof bundle.blobs === 'object' ? bundle.blobs : {}; + const cache = new Map(); + return records.map(record => materializeCompactRecord(record, blobs, cache)).filter(Boolean); +} + +function parseTraceText(text) { + const trimmed = text.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed); + const compactRecords = materializeCompactTraceBundle(parsed); + if (compactRecords) return compactRecords; + if (Array.isArray(parsed)) return parsed.filter(item => item && typeof item === 'object' && !Array.isArray(item)); + } catch { + // Fall through to JSONL parsing. + } + return trimmed.split('\n').map(line => { + try { return JSON.parse(line); } catch { return null; } + }).filter(Boolean); +} + +if (typeof LIVE_MODE !== 'undefined' && LIVE_MODE) { + entries = typeof EMBEDDED_TRACE_DATA !== 'undefined' ? normalizeDisplayTurns(expandLiveWebSocketResponseEntries(EMBEDDED_TRACE_DATA, true), true) : []; + document.addEventListener('DOMContentLoaded', () => { + initCommonUi(); + initLiveMode(); + fetchDates(); + if (entries.length) renderApp(); + else { + renderLiveWaitingState(); + } + }); +} else if (typeof EMBEDDED_TRACE_COMPACT_DATA !== 'undefined') { + entries = normalizeDisplayTurns(expandWebSocketResponseEntries(materializeCompactTraceBundle(EMBEDDED_TRACE_COMPACT_DATA) || []), true); + document.addEventListener('DOMContentLoaded', () => { + initCommonUi(); + if (entries.length) renderApp(); + else renderEmptyTraceState(); + }); +} else if (typeof EMBEDDED_TRACE_META !== 'undefined') { + // Lazy mode: build stub entries from metadata + lazyMode = true; + entries = normalizeDisplayTurns(EMBEDDED_TRACE_META.map((meta, i) => buildStubEntry(meta, i)), true); + document.addEventListener('DOMContentLoaded', () => { + initCommonUi(); + if (entries.length) renderApp(); + else renderEmptyTraceState(); + }); +} else if (typeof EMBEDDED_TRACE_DATA !== 'undefined') { + entries = normalizeDisplayTurns(expandWebSocketResponseEntries(EMBEDDED_TRACE_DATA), true); + document.addEventListener('DOMContentLoaded', () => { + initCommonUi(); + if (entries.length) renderApp(); + else renderEmptyTraceState(); + }); +} else { + document.addEventListener('DOMContentLoaded', () => { + initCommonUi(); + initFileDropZone(); + }); +} + +function loadFile(file) { + const reader = new FileReader(); + reader.onload = () => { + entries = normalizeDisplayTurns(expandWebSocketResponseEntries(parseTraceText(reader.result)), true); + if (!entries.length) { alert('No valid entries found / 未找到有效条目'); return; } + renderApp(); + }; + reader.readAsText(file); +} diff --git a/claude_tap/viewer_assets/renderers.js b/claude_tap/viewer_assets/renderers.js new file mode 100644 index 0000000..8cc1f65 --- /dev/null +++ b/claude_tap/viewer_assets/renderers.js @@ -0,0 +1,987 @@ + +/* ─── Renderers ─── */ +function chatMessageContentToText(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map(part => { + if (typeof part === 'string') return part; + if (!part || typeof part !== 'object') return ''; + if (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text') return part.text || ''; + if (typeof part.text === 'string') return part.text; + return JSON.stringify(part); + }).filter(Boolean).join('\n'); + } + if (content === undefined || content === null) return ''; + return JSON.stringify(content); +} + +function normalizeDisplayContentBlocks(content) { + if (typeof content === 'string') return content.trim() ? [{ type: 'text', text: content }] : []; + if (content === undefined || content === null) return []; + if (!Array.isArray(content)) return [{ type: 'raw', value: content }]; + return content.map(block => { + if (typeof block === 'string') return { type: 'text', text: block }; + if (!block || typeof block !== 'object') return { type: 'raw', value: block }; + return block; + }).filter(block => hasDisplayContent([block])); +} + +function isChatInstructionRole(role) { + return role === 'system' || role === 'developer'; +} + +function chatInstructionMessages(body) { + if (!Array.isArray(body?.messages)) return []; + return body.messages.filter(m => m && isChatInstructionRole(m.role)); +} + +function parseToolCallArguments(args) { + if (args === undefined || args === null || args === '') return {}; + if (typeof args !== 'string') return args; + try { return JSON.parse(args); } catch(e) { return args; } +} + +function chatToolCallToContentBlock(call) { + const fn = call?.function || {}; + return { + type: 'tool_use', + id: call?.id || '', + name: fn.name || call?.name || 'tool_use', + input: parseToolCallArguments(fn.arguments), + }; +} + +function normalizeChatMessageForDisplay(msg) { + if (!msg || typeof msg !== 'object') return { role: 'unknown', content: '' }; + const role = msg.role || 'unknown'; + if (role === 'tool') { + return { + ...msg, + content: [{ type: 'tool_result', tool_use_id: msg.tool_call_id || '', content: msg.content || '' }], + }; + } + const content = []; + if (Array.isArray(msg.content)) content.push(...msg.content); + else if (typeof msg.content === 'string') { + if (msg.content.trim()) content.push({ type: 'text', text: msg.content }); + } else if (msg.content !== undefined && msg.content !== null) { + content.push(msg.content); + } + if (Array.isArray(msg.tool_calls)) { + content.push(...msg.tool_calls.map(chatToolCallToContentBlock)); + } + return { ...msg, content }; +} + +function looksLikeGeminiRequest(value) { + return !!(value && typeof value === 'object' && ( + Array.isArray(value.contents) + || !!value.systemInstruction + )); +} + +function geminiRequest(body) { + if (!body || typeof body !== 'object') return {}; + if (looksLikeGeminiRequest(body.request)) return body.request; + return looksLikeGeminiRequest(body) ? body : {}; +} + +function isGeminiRequestBody(body) { + return looksLikeGeminiRequest(geminiRequest(body)); +} + +function geminiTextFromParts(parts) { + if (!Array.isArray(parts)) return ''; + return parts + .filter(part => part && typeof part === 'object' && typeof part.text === 'string') + .map(part => part.text) + .join('\n'); +} + +function geminiSystemInstruction(body) { + const instruction = geminiRequest(body).systemInstruction; + if (!instruction || typeof instruction !== 'object') return ''; + return geminiTextFromParts(instruction.parts).trim(); +} + +function geminiRole(role) { + return role === 'model' ? 'assistant' : (typeof role === 'string' && role ? role : 'user'); +} + +function geminiFunctionResponseContent(response) { + const payload = response?.response; + const output = payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'output') + ? payload.output + : payload; + if (typeof output === 'string') return output; + if (output === undefined || output === null) return ''; + return JSON.stringify(output, null, 2); +} + +function geminiPartContentBlocks(part) { + const blocks = []; + if (!part || typeof part !== 'object') return blocks; + if (typeof part.text === 'string' && part.text.trim()) { + if (part.thought === true) blocks.push({ type: 'thinking', thinking: part.text }); + else blocks.push({ type: 'text', text: part.text }); + } + if (part.functionCall && typeof part.functionCall === 'object') { + const call = part.functionCall; + blocks.push({ + type: 'tool_use', + id: call.id || '', + name: call.name || 'tool_use', + input: call.args && typeof call.args === 'object' ? call.args : {}, + }); + } + if (part.functionResponse && typeof part.functionResponse === 'object') { + const response = part.functionResponse; + blocks.push({ + type: 'tool_result', + tool_use_id: response.id || response.name || '', + content: geminiFunctionResponseContent(response), + }); + } + return blocks; +} + +function geminiMessages(body) { + const contents = geminiRequest(body).contents; + if (!Array.isArray(contents)) return []; + return contents.map(item => { + if (!item || typeof item !== 'object') return null; + const blocks = (item.parts || []).flatMap(geminiPartContentBlocks); + if (!blocks.length) return null; + let role = geminiRole(item.role); + if (blocks.every(block => block.type === 'tool_result')) role = 'tool'; + return { role, content: blocks }; + }).filter(Boolean); +} + +function flattenGeminiTools(tools) { + if (!Array.isArray(tools)) return []; + const flattened = []; + for (const group of tools) { + if (!group || typeof group !== 'object') continue; + const declarations = group.functionDeclarations; + if (!Array.isArray(declarations)) continue; + for (const decl of declarations) { + if (!decl || typeof decl !== 'object') continue; + flattened.push({ + name: decl.name || '', + description: decl.description || '', + input_schema: decl.parametersJsonSchema || decl.parameters || {}, + }); + } + } + return flattened; +} + +function responsesInputAdditionalTools(input) { + if (!Array.isArray(input)) return []; + const tools = []; + for (const item of input) { + if (!item || typeof item !== 'object') continue; + if (item.type !== 'additional_tools') continue; + if (!Array.isArray(item.tools)) continue; + for (const tool of item.tools) { + if (!tool || typeof tool !== 'object') continue; + tools.push(tool); + } + } + return tools; +} + +function uniqueToolsByDisplayName(tools) { + const seen = new Set(); + const unique = []; + for (const tool of tools) { + const name = toolDisplayName(tool); + const key = name || JSON.stringify(tool); + if (seen.has(key)) continue; + seen.add(key); + unique.push(tool); + } + return unique; +} + +function getRequestTools(body) { + const direct = Array.isArray(body?.tools) ? body.tools : []; + const responsesAdditional = responsesInputAdditionalTools(body?.input); + const geminiTools = flattenGeminiTools(geminiRequest(body).tools); + if (geminiTools.length) return geminiTools; + return uniqueToolsByDisplayName([...direct, ...responsesAdditional]); +} + +function hasDisplayContent(content) { + if (typeof content === 'string') return content.trim().length > 0; + if (!Array.isArray(content)) return content !== undefined && content !== null; + return content.some(block => { + if (!block || typeof block !== 'object') return false; + if (block.type === 'text' || block.type === 'input_text' || block.type === 'output_text') return !!(block.text || '').trim(); + if (block.type === 'image' || block.type === 'input_image') { + const source = block.source || {}; + return !!(block.image_url || block.file_id || source.data || source.url || source.media_type || source.file_id); + } + if (block.type === 'thinking') return !!(block.thinking || '').trim(); + if (block.type === 'tool_use') return true; + if (block.type === 'tool_result') { + const rc = block.content; + if (typeof rc === 'string') return rc.trim().length > 0 || !!block.tool_use_id; + if (Array.isArray(rc)) return rc.length > 0; + return rc !== undefined && rc !== null; + } + return JSON.stringify(block) !== '{}'; + }); +} + +function extractSystem(body) { + if (!body) return null; + const parts = []; + if (typeof body.system === 'string' && body.system.trim()) parts.push(body.system); + if (Array.isArray(body.system)) { + const systemText = body.system.map(b => typeof b === 'string' ? b : b.type === 'text' ? (b.text || '') : JSON.stringify(b)).filter(Boolean).join('\n\n'); + if (systemText.trim()) parts.push(systemText); + } + // Responses API: instructions field holds the system prompt + if (typeof body.instructions === 'string' && body.instructions.trim()) parts.push(body.instructions); + const chatInstructions = chatInstructionMessages(body) + .map(m => chatMessageContentToText(m.content)) + .filter(text => text.trim()); + parts.push(...chatInstructions); + const geminiSystem = geminiSystemInstruction(body); + if (geminiSystem) parts.push(geminiSystem); + return parts.length ? parts.join('\n\n') : null; +} + +function extractSystemBlocks(body) { + if (!body) return []; + const blocks = []; + if (typeof body.system === 'string' && body.system.trim()) { + blocks.push(...normalizeDisplayContentBlocks(body.system)); + } + if (Array.isArray(body.system)) { + blocks.push(...normalizeDisplayContentBlocks(body.system)); + } + if (typeof body.instructions === 'string' && body.instructions.trim()) { + blocks.push(...normalizeDisplayContentBlocks(body.instructions)); + } + for (const message of chatInstructionMessages(body)) { + blocks.push(...normalizeDisplayContentBlocks(message.content)); + } + const geminiSystem = geminiSystemInstruction(body); + if (geminiSystem) { + blocks.push(...normalizeDisplayContentBlocks(geminiSystem)); + } + return blocks; +} + +function parseSseDataFrames(text) { + if (typeof text !== 'string' || !text.includes('data:')) return []; + const events = []; + let dataLines = []; + const flush = () => { + if (!dataLines.length) return; + const raw = dataLines.join('\n'); + dataLines = []; + if (raw === '[DONE]') return; + let data = raw; + try { data = JSON.parse(raw); } catch(e) {} + events.push({ event: 'message', data }); + }; + for (const line of text.split(/\r?\n/)) { + if (line.startsWith('data:')) { + dataLines.push(line.slice(5).trim()); + continue; + } + if (!line.trim()) flush(); + } + flush(); + return events; +} + +function getResponseEvents(entry) { + const sse = entry?.response?.sse_events; + if (Array.isArray(sse) && sse.length > 0) return sse; + const ws = entry?.response?.ws_events; + if (Array.isArray(ws) && ws.length > 0) return ws; + return parseSseDataFrames(entry?.response?.body); +} + +function getEventType(event) { + if (!event || typeof event !== 'object') return ''; + return typeof event.event === 'string' ? event.event : (typeof event.type === 'string' ? event.type : ''); +} + +function getEventData(event) { + if (!event || typeof event !== 'object') return null; + let data = Object.prototype.hasOwnProperty.call(event, 'data') ? event.data : event; + if (typeof data === 'string') { + try { data = JSON.parse(data); } catch(e) { return null; } + } + return data && typeof data === 'object' ? data : null; +} + +function getResponsePayload(entry) { + const body = entry?.response?.body; + if (body && typeof body === 'object') return body; + const response = entry?.response; + if (!response || typeof response !== 'object') return null; + if (response.output || response.content || response.usage || response.previous_response_id || response.id || response.error) return response; + return null; +} + +function getResponsesContinuationInfo(entry) { + if (entry?.derived_from_websocket) return null; + const body = entry?.request?.body; + if (!body || typeof body !== 'object') return null; + const path = entry?.request?.path || ''; + const looksLikeResponses = path.endsWith('/v1/responses') || path === '/responses' || body.type === 'response.create' || Array.isArray(body.input); + if (!looksLikeResponses) return null; + const payload = getResponsePayload(entry) || {}; + const previousId = payload.previous_response_id || body.previous_response_id; + if (!previousId) return null; + const userMessages = getMessages(body).filter(m => m.role === 'user'); + if (userMessages.length > 0) return null; + const headers = entry?.request?.headers || {}; + return { + response_id: payload.id || '', + previous_response_id: previousId, + prompt_cache_key: body.prompt_cache_key || '', + session_id: headers.session_id || headers['session-id'] || '', + codex_version: headers.version || headers['x-codex-version'] || '', + }; +} + +// Normalize messages from Chat Completions (body.messages) or Responses API (body.input) +function getMessages(body) { + if (!body) return []; + if (Array.isArray(body.messages) && body.messages.length > 0) { + return body.messages + .filter(m => !isChatInstructionRole(m?.role)) + .map(normalizeChatMessageForDisplay) + .filter(m => hasDisplayContent(m.content)); + } + if (isGeminiRequestBody(body)) { + return geminiMessages(body).filter(m => hasDisplayContent(m.content)); + } + if (Array.isArray(body.input)) { + const normalizedInput = normalizeWebSocketDerivedInput(body.input); + const messages = normalizedInput.filter(item => { + if (!item || typeof item !== 'object') return false; + if (typeof item.role !== 'string' || !item.role) return false; + return item.type === undefined || item.type === 'message'; + }).map(item => ({ + role: item.role || 'user', + content: Array.isArray(item.content) + ? item.content.map(c => { + if (!c || typeof c !== 'object') return c; + if (c.type === 'input_text' || c.type === 'output_text' || c.type === 'text') return { type: c.type, text: c.text }; + if (c.type === 'tool_use') return c; + if (c.type === 'tool_result') return c; + return c; + }) + : item.content + })); + if (shouldPrependResponsesInstructions(body, messages)) { + return [{ role: 'developer', content: [{ type: 'text', text: body.instructions }] }, ...messages]; + } + return messages; + } + return []; +} + +function shouldPrependResponsesInstructions(body, messages) { + if (!Array.isArray(body?.input) || body.input.length === 0) return false; + if (!Array.isArray(messages) || messages.length === 0) return false; + if (messages.some(m => m.role === 'developer' || m.role === 'system')) return false; + if (!messages.some(m => m.role === 'user')) return false; + return typeof body.instructions === 'string' && body.instructions.trim(); +} + +function shouldRenderRequestContext(entry, body, msgs, respOutput) { + if (!entry || !body || !Array.isArray(msgs) || msgs.length === 0) return false; + const method = entry?.request?.method || ''; + if (!entry.derived_from_websocket && entry.transport !== 'websocket' && method !== 'WEBSOCKET') return false; + const path = getPath(entry); + if (!path.includes('/codex/responses') && !path.includes('/v1/responses')) return false; + if (!Array.isArray(body.input) || body.input.length === 0) return false; + return true; +} + +function normalizeUsage(usage) { + if (!usage || typeof usage !== 'object') return null; + const normalized = { ...usage }; + if ((normalized.input_tokens === undefined || normalized.input_tokens === null || normalized.input_tokens === 0) && usage.prompt_tokens) { + normalized.input_tokens = usage.prompt_tokens; + } + if ((normalized.input_tokens === undefined || normalized.input_tokens === null || normalized.input_tokens === 0) && usage.promptTokenCount) { + normalized.input_tokens = usage.promptTokenCount; + } + if ((normalized.input_tokens === undefined || normalized.input_tokens === null || normalized.input_tokens === 0) && usage.inputTokens) { + normalized.input_tokens = usage.inputTokens; + } + if ((normalized.output_tokens === undefined || normalized.output_tokens === null || normalized.output_tokens === 0) && usage.completion_tokens) { + normalized.output_tokens = usage.completion_tokens; + } + if ((normalized.output_tokens === undefined || normalized.output_tokens === null || normalized.output_tokens === 0) && usage.candidatesTokenCount) { + normalized.output_tokens = usage.candidatesTokenCount; + } + if ((normalized.output_tokens === undefined || normalized.output_tokens === null || normalized.output_tokens === 0) && usage.outputTokens) { + normalized.output_tokens = usage.outputTokens; + } + if ((normalized.total_tokens === undefined || normalized.total_tokens === null || normalized.total_tokens === 0) && usage.totalTokens) { + normalized.total_tokens = usage.totalTokens; + } + if (normalized.cache_read_input_tokens === undefined) { + /* Cache tokens derived from OpenAI/Gemini-style details fields are already + counted inside input_tokens/prompt_tokens. Mark them so the cache hit + rate denominator can avoid double-counting. */ + let embeddedCached = usage.cached_tokens; + if (embeddedCached === undefined) embeddedCached = usage.cachedContentTokenCount; + if (embeddedCached === undefined && usage.input_tokens_details && typeof usage.input_tokens_details === 'object') { + embeddedCached = usage.input_tokens_details.cached_tokens; + } + if (embeddedCached === undefined && usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') { + embeddedCached = usage.prompt_tokens_details.cached_tokens; + } + if (embeddedCached !== undefined && embeddedCached !== null) { + normalized.cache_read_input_tokens = embeddedCached; + normalized._cache_read_in_input = true; + } else if (usage.cacheReadInputTokens !== undefined && usage.cacheReadInputTokens !== null) { + normalized.cache_read_input_tokens = usage.cacheReadInputTokens; + normalized._cache_read_in_input = false; + } + } else { + /* Native cache_read_input_tokens (Claude/Anthropic/Bedrock) is a separate + bucket not included in input_tokens. But if the caller already set + _cache_read_in_input (e.g. lazy-loading stub with model-based inference), + respect the pre-set value. */ + if (normalized._cache_read_in_input === undefined) { + normalized._cache_read_in_input = false; + } + } + if (normalized.cache_creation_input_tokens === undefined && usage.cacheWriteInputTokens !== undefined && usage.cacheWriteInputTokens !== null) { + normalized.cache_creation_input_tokens = usage.cacheWriteInputTokens; + } + return normalized; +} + +// Extract token usage from response.body.usage or SSE response.completed event +function getUsage(entry) { + const u = getResponsePayload(entry)?.usage; + if (u) return normalizeUsage(u); + const geminiUsage = geminiUsageFromPayloads(entry); + if (geminiUsage) return geminiUsage; + const events = getResponseEvents(entry); + for (let i = events.length - 1; i >= 0; i--) { + if (getEventType(events[i]) !== 'response.completed') continue; + const data = getEventData(events[i]); + if (data?.response?.usage) return normalizeUsage(data.response.usage); + } + return null; +} + +function normalizeResponseOutput(output) { + if (!Array.isArray(output) || output.length === 0) return null; + const content = []; + for (const item of output) { + if (!item || typeof item !== 'object') continue; + if (item.type === 'message' && Array.isArray(item.content)) { + for (const c of item.content) { + if (c?.type === 'output_text') content.push({ type: 'text', text: c.text }); + else content.push(c); + } + } else if (isResponseCallItem(item)) { + content.push({ type: 'tool_use', id: item.call_id || item.id || '', name: responseCallToolName(item), input: responseCallInput(item) }); + } else if (item.type === 'reasoning') { + if (!item.summary) continue; + const summaryText = Array.isArray(item.summary) ? item.summary.map(s => s?.text || '').join('\n') : (item.summary.text || JSON.stringify(item.summary)); + if (summaryText.trim()) content.push({ type: 'thinking', thinking: summaryText }); + } + } + return content.length > 0 ? { content } : null; +} + +function normalizeBedrockConverseContent(blocks) { + if (!Array.isArray(blocks) || blocks.length === 0) return []; + const content = []; + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + if (typeof block.text === 'string') { + appendMergeableResponseBlock(content, { type: 'text', text: block.text }); + continue; + } + const reasoning = block.reasoningContent; + if (reasoning && typeof reasoning === 'object') { + const reasoningText = reasoning.text || reasoning.reasoningText?.text || ''; + const signature = reasoning.signature || reasoning.reasoningText?.signature || ''; + const thinking = { type: 'thinking', thinking: reasoningText }; + if (signature) thinking.signature = signature; + if (reasoningText.trim() || signature) content.push(thinking); + continue; + } + const toolUse = block.toolUse; + if (toolUse && typeof toolUse === 'object') { + content.push({ + type: 'tool_use', + id: toolUse.toolUseId || '', + name: toolUse.name || 'tool_use', + input: toolUse.input && typeof toolUse.input === 'object' ? toolUse.input : {}, + }); + continue; + } + if (block.type) content.push(block); + } + return content; +} + +function normalizeBedrockConverseOutput(body) { + const message = body?.output?.message; + if (!message || typeof message !== 'object') return null; + const content = normalizeBedrockConverseContent(message.content); + return content.length > 0 ? { content } : null; +} + +function normalizeChatCompletionsChoiceOutput(body) { + if (!Array.isArray(body?.choices) || body.choices.length === 0) return null; + const content = []; + for (const choice of body.choices) { + const message = choice?.message || choice?.delta; + if (!message || typeof message !== 'object') continue; + if (Array.isArray(message.reasoning_details)) { + for (const detail of message.reasoning_details) { + const text = detail?.text; + if (typeof text === 'string' && text.trim()) { + appendMergeableResponseBlock(content, { type: 'thinking', thinking: text }); + } + } + } + if (!content.length && typeof message.reasoning_content === 'string' && message.reasoning_content.trim()) { + appendMergeableResponseBlock(content, { type: 'thinking', thinking: message.reasoning_content }); + } + if (!content.length && typeof message.reasoning === 'string' && message.reasoning.trim()) { + appendMergeableResponseBlock(content, { type: 'thinking', thinking: message.reasoning }); + } + if (typeof message.thinking === 'string' && message.thinking.trim()) { + appendMergeableResponseBlock(content, { type: 'thinking', thinking: message.thinking }); + } + const normalized = normalizeChatMessageForDisplay({ role: 'assistant', ...message }); + for (const block of normalizeDisplayContentBlocks(normalized.content)) { + appendMergeableResponseBlock(content, block); + } + } + return content.length > 0 ? { content } : null; +} + +function appendMergeableResponseBlock(content, block) { + if (!block || typeof block !== 'object') return; + const prev = content.length ? content[content.length - 1] : null; + if (prev && prev.type === block.type) { + if (block.type === 'thinking') { + prev.thinking = (prev.thinking || '') + (block.thinking || ''); + return; + } + if (block.type === 'text' || block.type === 'input_text' || block.type === 'output_text') { + prev.text = (prev.text || '') + (block.text || ''); + return; + } + } + content.push(block); +} + +function reconstructOutputFromEvents(events) { + if (!Array.isArray(events) || events.length === 0) return null; + const outputItems = []; + for (const ev of events) { + if (getEventType(ev) !== 'response.output_item.done') continue; + const data = getEventData(ev); + const item = data?.item; + const outputIndex = data?.output_index; + if (!item || typeof item !== 'object' || !Number.isInteger(outputIndex)) continue; + outputItems.push({ outputIndex, item }); + } + if (outputItems.length === 0) return null; + return normalizeResponseOutput( + outputItems + .sort((a, b) => a.outputIndex - b.outputIndex) + .map(({ item }) => item) + ); +} + +function geminiResponsePayloads(entry) { + const body = entry?.response?.body; + if (typeof body === 'string') { + return parseSseDataFrames(body) + .map(event => event.data) + .filter(data => data && typeof data === 'object'); + } + if (body && typeof body === 'object') return [body]; + return []; +} + +function geminiUsageFromPayloads(entry) { + if (!isGeminiRequestBody(entry?.request?.body)) return null; + let usage = null; + for (const payload of geminiResponsePayloads(entry)) { + const response = payload?.response && typeof payload.response === 'object' ? payload.response : payload; + if (response?.usageMetadata) usage = response.usageMetadata; + } + return usage ? normalizeUsage(usage) : null; +} + +function geminiResponseOutput(entry) { + if (!isGeminiRequestBody(entry?.request?.body)) return null; + const content = []; + for (const payload of geminiResponsePayloads(entry)) { + const response = payload?.response && typeof payload.response === 'object' ? payload.response : payload; + const candidates = response?.candidates; + if (!Array.isArray(candidates)) continue; + for (const candidate of candidates) { + const parts = candidate?.content?.parts; + if (!Array.isArray(parts)) continue; + for (const part of parts) { + if (!part || typeof part !== 'object') continue; + if (typeof part.text === 'string') { + if (part.thought === true && part.text.trim()) { + appendMergeableResponseBlock(content, { type: 'thinking', thinking: part.text }); + } else if (part.text.trim()) { + appendMergeableResponseBlock(content, { type: 'text', text: part.text }); + } + } + if (part.functionCall && typeof part.functionCall === 'object') { + const call = part.functionCall; + content.push({ + type: 'tool_use', + id: call.id || '', + name: call.name || 'tool_use', + input: call.args && typeof call.args === 'object' ? call.args : {}, + }); + } + } + } + } + return content.length ? { content } : null; +} + +// Extract response output from response.body.content or SSE response.completed event +function getResponseOutput(entry) { + const geminiOutput = geminiResponseOutput(entry); + if (geminiOutput) return geminiOutput; + const body = getResponsePayload(entry); + if (body?.content) return body; + const fromBedrockConverse = normalizeBedrockConverseOutput(body); + if (fromBedrockConverse) return fromBedrockConverse; + const fromChoices = normalizeChatCompletionsChoiceOutput(body); + if (fromChoices) return fromChoices; + const fromBody = normalizeResponseOutput(body?.output); + if (fromBody) return fromBody; + const events = getResponseEvents(entry); + const fromItems = reconstructOutputFromEvents(events); + if (fromItems) return fromItems; + for (let i = events.length - 1; i >= 0; i--) { + if (getEventType(events[i]) !== 'response.completed') continue; + const data = getEventData(events[i]); + const normalized = normalizeResponseOutput(data?.response?.output); + if (normalized) return normalized; + } + return null; +} +function renderSystemPrompt(blocks, copyText = '') { + const normalized = normalizeDisplayContentBlocks(blocks); + if (normalized.length <= 1 && copyText) return `
${esc(copyText)}
`; + return `
${renderContent(normalized, 'system', { frameBlocks: normalized.length > 1 })}
`; +} + +function renderResponsesContinuationNotice(info) { + const rows = [ + ['previous_response_id', info.previous_response_id], + ['response_id', info.response_id], + ['prompt_cache_key', info.prompt_cache_key], + ['session_id', info.session_id], + ['codex_version', info.codex_version], + ].filter(([, value]) => value); + const meta = rows.length + ? `
${rows.map(([key, value]) => `
${esc(key)}
${esc(value)}
`).join('')}
` + : ''; + return `
${esc(t('continuation_title'))}
${esc(t('continuation_message'))}
${meta}
`; +} + +function renderMessages(msgs) { + return msgs.map(m => { + const role = m.role || 'unknown'; + const cls = role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role === 'tool' ? 'tool_result' : (role === 'developer' || role === 'system') ? 'system' : 'system'; + const blockCount = normalizeDisplayContentBlocks(m.content).length; + const rendered = renderContent(m.content, role, { frameBlocks: blockCount > 1 }); + if (!rendered.trim()) return ''; + return `
${esc(role)}
${rendered}
`; + }).filter(Boolean).join(''); +} + +function contentHasImagePlaceholder(content) { + if (typeof content === 'string') return /\[Image #\d+\]/i.test(content); + if (!Array.isArray(content)) return content && typeof content === 'object' && contentHasImagePlaceholder(content.content || content.text || content.output || ''); + return content.some(block => { + if (typeof block === 'string') return contentHasImagePlaceholder(block); + if (!block || typeof block !== 'object') return false; + return contentHasImagePlaceholder(block.text || block.output || block.content || ''); + }); +} + +function wrapContentBlock(inner, block, index, total, options = {}) { + const frameBlock = !!options.frameBlocks && total > 1; + const classes = ['content-block']; + if (frameBlock) classes.push('block-framed'); + if (options.extraClass) classes.push(...String(options.extraClass).split(/\s+/).filter(Boolean)); + return `
${inner}
`; +} + +function isInlineImageUrl(url) { + return typeof url === 'string' && /^data:image\//i.test(url); +} + +function imageSourceFromBlock(block) { + if (!block || typeof block !== 'object') return null; + const source = block.source || {}; + if ( + (block.type === 'image' || block.type === 'input_image' || source.type === 'base64') && + source.type === 'base64' && + source.data + ) { + return { src: `data:${source.media_type || 'image/png'};base64,${source.data}`, alt: source.media_type || 'image' }; + } + if ( + (block.type === 'image' || block.type === 'input_image' || source.type === 'url') && + source.type === 'url' && + isInlineImageUrl(source.url) + ) { + return { src: source.url, alt: source.media_type || 'image' }; + } + const imageUrl = block.image_url; + if (typeof imageUrl === 'string' && isInlineImageUrl(imageUrl)) return { src: imageUrl, alt: 'image' }; + if (imageUrl && typeof imageUrl === 'object' && isInlineImageUrl(imageUrl.url)) return { src: imageUrl.url, alt: 'image' }; + if (block.data && (block.media_type || block.mime_type)) { + return { src: `data:${block.media_type || block.mime_type};base64,${block.data}`, alt: block.media_type || block.mime_type || 'image' }; + } + return null; +} + +function imageBlocksForContent(content) { + const images = []; + const visit = value => { + if (!value) return; + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (typeof value !== 'object') return; + if (imageSourceFromBlock(value)) images.push(value); + if (value.type === 'message' || value.type === 'tool_result' || value.type === 'function_call_output') visit(value.content); + }; + visit(content); + return images; +} + +function imageSourceKey(block) { + const source = imageSourceFromBlock(block); + return source ? source.src.slice(0, 128) + ':' + source.src.length : ''; +} + +function buildSessionImageRegistry() { + if (sessionImageRegistryCache && sessionImageRegistrySize === entries.length) return sessionImageRegistryCache; + const registry = new Map(); + const sourceEntries = lazyMode + ? expandWebSocketResponseEntries(getRawLines().map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean)) + : entries.map(getFullEntry); + for (const entry of sourceEntries) { + const messages = getMessages(entry?.request?.body); + for (const message of messages) { + if (message?.role !== 'user') continue; + const images = imageBlocksForContent(message.content); + if (!images.length) continue; + const key = imageLookupKey(naturalTextForSessionContent(message.content)); + if (!key) continue; + const bucket = registry.get(key) || []; + const seen = new Set(bucket.map(imageSourceKey)); + for (const image of images) { + const imageKey = imageSourceKey(image); + if (!imageKey || seen.has(imageKey)) continue; + seen.add(imageKey); + bucket.push(image); + } + registry.set(key, bucket); + } + } + sessionImageRegistryCache = registry; + sessionImageRegistrySize = entries.length; + return registry; +} + +function recoveredImagesForContent(content) { + if (!contentHasImagePlaceholder(content) || imageBlocksForContent(content).length) return []; + const key = imageLookupKey(naturalTextForSessionContent(content)); + if (!key) return []; + return buildSessionImageRegistry().get(key) || []; +} + +function renderImageElement(src, alt = 'image') { + return `${esc(alt)}`; +} + +function renderImageElementForBlock(block) { + const source = imageSourceFromBlock(block); + if (!source) return ''; + return renderImageElement(source.src, source.alt); +} + +function renderImageBlock(block, index = 0, total = 1, options = {}) { + const inner = renderImageElementForBlock(block); + return inner ? wrapContentBlock(inner, block, index, total, { ...options, extraClass: 'image-block' }) : ''; +} + +function renderContent(content, role, options = {}) { + const blocks = normalizeDisplayContentBlocks(content); + const renderedBlocks = blocks.map((block, index) => { + if (block.type === 'text' || block.type === 'input_text' || block.type === 'output_text') { + const txt = block.text || ''; + if (!txt.trim()) return ''; + return wrapContentBlock(`
${esc(txt)}
`, block, index, blocks.length, options); + } + if (block.type === 'thinking') { + const thinking = block.thinking || ''; + if (!thinking.trim()) return ''; + return wrapContentBlock(`thinking
${esc(thinking)}
`, block, index, blocks.length, options); + } + if (block.type === 'tool_use') { + const label = block.id ? `${block.name || 'tool_use'} (${block.id})` : (block.name || 'tool_use'); + return wrapContentBlock(`${esc(label)}${renderToolInput(block.input)}`, block, index, blocks.length, options); + } + if (block.type === 'tool_result') { + const rc = block.content; + if (typeof rc === 'string') { + return wrapContentBlock(`result (${esc(block.tool_use_id || '')})
${esc(rc)}
`, block, index, blocks.length, options); + } + if (Array.isArray(rc)) { + const parts = rc.map(c => { + if (c.type === 'text') return `
${esc(c.text)}
`; + if (c.type === 'image' || c.type === 'input_image') { + const renderedImage = renderImageElementForBlock(c); + if (renderedImage) return renderedImage; + } + return `
${esc(JSON.stringify(c))}
`; + }).join(''); + return wrapContentBlock(`result${parts}`, block, index, blocks.length, options); + } + return wrapContentBlock(`
${esc(JSON.stringify(block, null, 2))}
`, block, index, blocks.length, options); + } + if (block.type === 'image' || block.type === 'input_image') { + const renderedImage = renderImageBlock(block, index, blocks.length, options); + if (renderedImage) return renderedImage; + const source = block.source || {}; + const fileId = block.file_id || source.file_id; + const hasUrl = block.image_url || source.url; + const label = fileId ? `image: file_id ${fileId}` : hasUrl ? 'image: url' : `image: ${source.media_type || 'unknown'}`; + return wrapContentBlock(`${esc(label)}`, block, index, blocks.length, options); + } + if (block.type === 'raw') return wrapContentBlock(`
${esc(JSON.stringify(block.value, null, 2))}
`, block, index, blocks.length, options); + return wrapContentBlock(`
${esc(JSON.stringify(block, null, 2))}
`, block, index, blocks.length, options); + }).join(''); + const recovered = role === 'user' + ? recoveredImagesForContent(content).map((block, index, images) => renderImageBlock(block, index, images.length)).join('') + : ''; + return renderedBlocks + recovered; +} + +function valueHasReadableEscapes(value) { + if (typeof value === 'string') { + return value.includes('\n') + || value.includes('\r') + || value.includes('\t') + || /\\(?:r\\n|n|r|t|"|u[0-9a-fA-F]{4})/.test(value); + } + if (Array.isArray(value)) return value.some(valueHasReadableEscapes); + if (value && typeof value === 'object') return Object.values(value).some(valueHasReadableEscapes); + return false; +} + +function decodeEscapedTextForView(value) { + if (typeof value !== 'string') return ''; + return value + .replace(/\\r\\n/g, '\n') + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => { + try { return String.fromCharCode(parseInt(hex, 16)); } + catch { return `\\u${hex}`; } + }) + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); +} + +function renderToolInput(input) { + const raw = JSON.stringify(input, null, 2) || ''; + if (!valueHasReadableEscapes(input)) return `
${esc(raw)}
`; + const decoded = decodeEscapedTextForView(raw); + const copyIcon = ``; + return `
` + + `` + + `` + + `` + + `` + + `
${esc(raw)}
` + + `
`; +} + +function renderTools(tools) { + return tools.map(td => { + const name = toolDisplayName(td) || 'unknown', desc = toolDescription(td); + const shortDesc = desc.split('\n')[0].substring(0, 120); + const schema = toolSchema(td); + const props = schema.properties || {}; + const required = new Set(schema.required || []); + let paramsHtml = ''; + const keys = Object.keys(props); + if (keys.length) { + paramsHtml = `
${t('params')}
` + keys.map(k => { + const p = props[k], type = p.type || (p.enum ? 'enum' : ''), pdesc = p.description || ''; + const req = required.has(k) ? `${t('required')}` : ''; + const typeTag = type ? `${esc(type)}` : ''; + const descLine = pdesc ? `
${esc(pdesc)}
` : ''; + return `
${esc(k)}${typeTag}${req}
${descLine}
`; + }).join(''); + } + return `
${esc(name)}${esc(shortDesc)}
${desc ? `
${esc(desc)}
` : ''}${paramsHtml}
`; + }).join(''); +} + +function renderResponseContent(body, contextOnly = false) { + if (!body?.content) { + const msg = contextOnly ? t('response_context_only') : t('no_content'); + return `${msg}`; + } + return renderContent(body.content, 'assistant'); +} + +function renderTokenUsage(u) { + const items = [ + { label: t('tok_input'), val: u.input_tokens || 0, color: 'var(--blue)' }, + { label: t('tok_output'), val: u.output_tokens || 0, color: 'var(--green)' }, + { label: t('tok_cache_read'), val: u.cache_read_input_tokens || 0, color: 'var(--cyan)' }, + { label: t('tok_cache_create'), val: u.cache_creation_input_tokens || 0, color: 'var(--amber)' }, + ]; + return `
${items.map(i => + `
${i.label}${i.val.toLocaleString()}
` + ).join('')}
`; +} + +function renderSSEEvents(events) { + return events.map(e => { + const eventType = getEventType(e) || 'event'; + const payload = Object.prototype.hasOwnProperty.call(e, 'data') ? e.data : e; + const data = typeof payload === 'string' ? payload : JSON.stringify(payload); + const short = data.length > 200 ? data.substring(0, 200) + '...' : data; + return `
${esc(eventType)}${esc(short)}
`; + }).join(''); +} diff --git a/claude_tap/viewer_assets/responses.js b/claude_tap/viewer_assets/responses.js new file mode 100644 index 0000000..10a0a43 --- /dev/null +++ b/claude_tap/viewer_assets/responses.js @@ -0,0 +1,602 @@ +function isCodexResponsesWebSocketEntry(entry) { + if (entry?.transport !== 'websocket') return false; + const path = entry?.request?.path || ''; + return path.endsWith('/backend-api/codex/responses') || path.endsWith('/v1/responses') || path === '/responses'; +} + +function isResponsesPath(path) { + return path === '/responses' || path === '/v1/responses' || path.endsWith('/backend-api/codex/responses'); +} + +function getResponseIdFromEvent(event) { + const data = getEventData(event); + return data?.response?.id || data?.item?.id || data?.item_id || ''; +} + +function responseEventMatchesId(event, responseId) { + if (!responseId) return false; + const data = getEventData(event); + if (data?.response?.id === responseId) return true; + const itemId = data?.item?.id || data?.item_id || ''; + return itemId.includes(responseId.slice(5, 25)); +} + +function splitWebSocketResponseEvents(events) { + const groups = []; + let current = null; + for (const event of events || []) { + const type = getEventType(event); + if (type === 'response.created') { + if (current && current.events.length) groups.push(current); + current = { responseId: getResponseIdFromEvent(event), events: [event] }; + continue; + } + if (!current) continue; + if (responseEventMatchesId(event, current.responseId) || type.startsWith('response.')) { + current.events.push(event); + } + if (type === 'response.completed') { + groups.push(current); + current = null; + } + } + if (current && current.events.length) groups.push(current); + return groups.filter(group => group.events.some(event => getEventType(event) === 'response.completed')); +} + +function completedResponseFromEvents(events) { + for (let i = events.length - 1; i >= 0; i--) { + if (getEventType(events[i]) !== 'response.completed') continue; + const data = getEventData(events[i]); + if (data?.response && typeof data.response === 'object') return data.response; + } + return null; +} + +function hasOutputItemEvent(events) { + return events.some(event => getEventType(event) === 'response.output_item.done'); +} + +function isDisplayableWebSocketResponseGroup(group) { + const completed = completedResponseFromEvents(group.events); + if (!completed) return false; + const created = responseCreatedFromEvents(group.events); + const isGenerateFalse = completed.generate === false || created?.generate === false; + return !(isGenerateFalse && !hasOutputItemEvent(group.events) && (completed.usage?.output_tokens || 0) === 0); +} + +function responseCreatedFromEvents(events) { + for (const event of events) { + if (getEventType(event) !== 'response.created') continue; + const data = getEventData(event); + if (data?.response && typeof data.response === 'object') return data.response; + } + return null; +} + +function mergeWebSocketResponseGroups(groups) { + const events = []; + for (const group of groups) events.push(...group.events); + return { responseId: groups.map(group => group.responseId).filter(Boolean).join('+'), events }; +} + +function parseResponseToolArguments(args) { + if (args === undefined || args === null || args === '') return {}; + if (typeof args !== 'string') return args; + try { return JSON.parse(args); } catch(e) { return args; } +} + +function toolSearchOutputContent(item) { + const names = []; + if (Array.isArray(item?.tools)) { + for (const namespace of item.tools) { + if (!namespace || typeof namespace !== 'object') continue; + const namespaceName = typeof namespace.name === 'string' ? namespace.name : ''; + if (namespaceName) names.push(namespaceName); + if (!Array.isArray(namespace.tools)) continue; + for (const tool of namespace.tools) { + if (!tool || typeof tool !== 'object' || typeof tool.name !== 'string' || !tool.name) continue; + names.push(namespaceName ? `${namespaceName}.${tool.name}` : tool.name); + } + } + } + if (names.length) return ['tool_search_output', ...names].join('\n'); + if (Array.isArray(item?.tools)) return JSON.stringify(item.tools, null, 2); + if (item?.output !== undefined) return typeof item.output === 'string' ? item.output : JSON.stringify(item.output, null, 2); + return JSON.stringify(item || {}, null, 2); +} + +function responseCallToolName(item) { + if (item?.type === 'tool_search_call') return 'tool_search'; + if (typeof item?.name === 'string' && item.name) return item.name; + if (typeof item?.type === 'string' && item.type.endsWith('_call')) return item.type.slice(0, -'_call'.length); + return ''; +} + +function isResponseCallItem(item) { + return !!(item && typeof item === 'object' && typeof item.type === 'string' && item.type.endsWith('_call')); +} + +function responseCallInput(item) { + if (Object.prototype.hasOwnProperty.call(item, 'arguments')) return parseResponseToolArguments(item.arguments); + const input = {}; + for (const [key, value] of Object.entries(item || {})) { + if (['id', 'type', 'status', 'call_id', 'name', 'execution'].includes(key)) continue; + input[key] = value; + } + return input; +} + +function isResponseToolResultItem(item) { + if (!item || typeof item !== 'object' || typeof item.type !== 'string') return false; + return item.type === 'tool_search_output' || item.type.endsWith('_call_output'); +} + +function responseToolResultContent(item) { + if (item?.type === 'tool_search_output') return toolSearchOutputContent(item); + if (Object.prototype.hasOwnProperty.call(item || {}, 'output')) { + return typeof item.output === 'string' ? item.output : JSON.stringify(item.output, null, 2); + } + const content = {}; + for (const [key, value] of Object.entries(item || {})) { + if (['id', 'type', 'status', 'call_id', 'execution'].includes(key)) continue; + content[key] = value; + } + return JSON.stringify(content, null, 2); +} + +function responseInputItemToMessage(item) { + if (!item || typeof item !== 'object') return item; + if (isResponseCallItem(item)) { + return { + type: 'message', + role: 'assistant', + content: [{ + type: 'tool_use', + id: item.call_id || item.id || '', + name: responseCallToolName(item), + input: responseCallInput(item) + }] + }; + } + if (isResponseToolResultItem(item)) { + return { + type: 'message', + role: 'tool', + content: [{ type: 'tool_result', tool_use_id: item.call_id || '', content: responseToolResultContent(item) }] + }; + } + return item; +} + +function normalizeWebSocketDerivedInput(input) { + if (!Array.isArray(input)) return input; + return input.map(responseInputItemToMessage); +} + +function webSocketOutputMessages(events) { + const messages = []; + const items = []; + for (const event of events || []) { + if (getEventType(event) !== 'response.output_item.done') continue; + const data = getEventData(event); + if (data?.item && Number.isInteger(data.output_index)) items.push({ outputIndex: data.output_index, item: data.item }); + } + items.sort((a, b) => a.outputIndex - b.outputIndex); + for (const { item } of items) { + if (isResponseCallItem(item)) { + messages.push({ + type: 'message', + role: 'assistant', + content: [{ + type: 'tool_use', + id: item.call_id || item.id || '', + name: responseCallToolName(item), + input: responseCallInput(item) + }] + }); + } else if (item.type === 'message') { + messages.push({ + type: 'message', + role: item.role || 'assistant', + content: Array.isArray(item.content) ? item.content : [] + }); + } + } + return messages; +} + +function responseBodyOutputMessages(output) { + const messages = []; + if (!Array.isArray(output)) return messages; + for (const item of output) { + if (!item || typeof item !== 'object') continue; + if (isResponseCallItem(item)) { + messages.push({ + type: 'message', + role: 'assistant', + content: [{ + type: 'tool_use', + id: item.call_id || item.id || '', + name: responseCallToolName(item), + input: responseCallInput(item) + }] + }); + continue; + } + if (item.type === 'message') { + messages.push({ + type: 'message', + role: item.role || 'assistant', + content: Array.isArray(item.content) ? item.content : [] + }); + } + } + return messages; +} + +function isToolResultInputItem(item) { + if (!item || typeof item !== 'object') return false; + if (isResponseToolResultItem(item)) return true; + if (item.role !== 'tool') return false; + const content = Array.isArray(item.content) ? item.content : []; + return content.some(block => block?.type === 'tool_result'); +} + +function messageKey(message) { + try { return JSON.stringify(message); } catch(e) { return String(message); } +} + +function uniqueMessages(messages) { + const seen = new Set(); + const unique = []; + for (const message of messages) { + const key = messageKey(message); + if (seen.has(key)) continue; + seen.add(key); + unique.push(message); + } + return unique; +} + +function regularMessagesFromBody(body) { + const normalized = normalizeWebSocketDerivedInput(Array.isArray(body?.input) ? body.input : []); + return normalized.filter(item => !isToolResultInputItem(item)); +} + +function toolResultMessagesFromInput(input) { + const normalized = normalizeWebSocketDerivedInput(Array.isArray(input) ? input : []); + return normalized.filter(isToolResultInputItem); +} + +function toolUseIdsFromMessage(message) { + const content = Array.isArray(message?.content) ? message.content : []; + return content + .filter(block => block?.type === 'tool_use') + .map(block => block.id || block.tool_use_id || '') + .filter(Boolean); +} + +function toolResultIdsFromMessage(message) { + const content = Array.isArray(message?.content) ? message.content : []; + return content + .filter(block => block?.type === 'tool_result') + .map(block => block.tool_use_id || block.id || '') + .filter(Boolean); +} + +function interleaveWebSocketOutputGroups(outputGroups, toolResults) { + const resultPool = (toolResults || []).map(message => ({ + message, + ids: toolResultIdsFromMessage(message), + used: false + })); + const messages = []; + for (const outputMessages of outputGroups || []) { + messages.push(...outputMessages); + const callIds = outputMessages.flatMap(toolUseIdsFromMessage); + for (const callId of callIds) { + const matched = resultPool.find(result => !result.used && result.ids.includes(callId)); + if (!matched) continue; + matched.used = true; + messages.push(matched.message); + } + } + messages.push(...resultPool.filter(result => !result.used).map(result => result.message)); + return messages; +} + +function buildWebSocketHistoryInput(input, priorGroups, priorRequestBodies) { + const normalized = normalizeWebSocketDerivedInput(Array.isArray(input) ? input : []); + const regularMessages = uniqueMessages([ + ...(priorRequestBodies || []).flatMap(regularMessagesFromBody), + ...normalized.filter(item => !isToolResultInputItem(item)) + ]); + const toolResults = uniqueMessages([ + ...(priorRequestBodies || []).flatMap(body => toolResultMessagesFromInput(body?.input)), + ...normalized.filter(isToolResultInputItem) + ]); + const outputGroups = (priorGroups || []).map(group => webSocketOutputMessages(group.events)); + const priorOutputKeys = new Set(outputGroups.flat().map(messageKey)); + const contextMessages = regularMessages.filter(message => !priorOutputKeys.has(messageKey(message))); + const interleavedOutputs = interleaveWebSocketOutputGroups(outputGroups, toolResults); + return uniqueMessages([...contextMessages, ...interleavedOutputs]); +} + +function requestBodiesForWebSocketEntry(entry) { + const events = entry?.request?.ws_events; + if (Array.isArray(events) && events.length) { + return events + .map(event => getEventData(event)) + .filter(event => event && typeof event === 'object'); + } + return []; +} + +function previousResponseIdForGroup(group) { + const created = responseCreatedFromEvents(group.events); + const completed = completedResponseFromEvents(group.events); + return created?.previous_response_id || completed?.previous_response_id || ''; +} + +function requestBodyForWebSocketGroup(entry, groups, idx) { + const requestBodies = requestBodiesForWebSocketEntry(entry); + const previousId = previousResponseIdForGroup(groups[idx]); + if (previousId) { + const matched = requestBodies.find(body => body?.previous_response_id === previousId); + if (matched) return matched; + } + const nonPrefetchBodies = requestBodies.filter(body => body?.generate !== false); + if (nonPrefetchBodies[idx]) return nonPrefetchBodies[idx]; + if (requestBodies[idx]) return requestBodies[idx]; + return idx === 0 ? bodyWithoutToolResultOnlyInput(entry.request?.body) : entry.request?.body; +} + +function requestSourceForWebSocketResponseEntry(entry, groups, idx, priorHistoryInput = []) { + const hasRequestEvents = Array.isArray(entry.request?.ws_events) && entry.request.ws_events.length > 0; + if (Array.isArray(priorHistoryInput) && priorHistoryInput.length && !hasRequestEvents && entry.request?.body) { + return entry.request.body; + } + return requestBodyForWebSocketGroup(entry, groups, idx); +} + +function bodyWithoutToolResultOnlyInput(body) { + const next = cloneJson(body || {}) || {}; + if (Array.isArray(next.input) && next.input.every(isToolResultInputItem)) { + next.input = []; + } + return next; +} + +function buildWebSocketResponseEntry(entry, groups, idx, priorHistoryInput = []) { + const group = groups[idx]; + const completed = completedResponseFromEvents(group.events) || {}; + const created = responseCreatedFromEvents(group.events) || {}; + const priorGroups = groups.slice(0, idx); + const priorRequestBodies = priorGroups.map((_, priorIdx) => requestBodyForWebSocketGroup(entry, groups, priorIdx)); + const requestSource = requestSourceForWebSocketResponseEntry(entry, groups, idx, priorHistoryInput); + const requestBody = cloneJson(requestSource || {}) || {}; + const source = Object.keys(completed).length ? completed : created; + for (const key of ['model', 'instructions', 'tools', 'previous_response_id', 'prompt_cache_key', 'tool_choice', 'parallel_tool_calls', 'text', 'reasoning']) { + if (source[key] !== undefined && source[key] !== null) requestBody[key] = source[key]; + } + if (Array.isArray(priorHistoryInput) && priorHistoryInput.length) { + const currentInput = normalizeWebSocketDerivedInput(Array.isArray(requestBody.input) ? requestBody.input : []); + requestBody.input = uniqueMessages([...priorHistoryInput, ...currentInput]); + } else { + requestBody.input = buildWebSocketHistoryInput(requestBody.input, priorGroups, priorRequestBodies); + } + const createdAt = completed.created_at || created.created_at; + const completedAt = completed.completed_at; + const durationMs = createdAt && completedAt ? Math.max(0, (completedAt - createdAt) * 1000) : entry.duration_ms; + const splitFromSingleRecord = groups.length > 1; + const captureTurn = splitFromSingleRecord ? `${entry.turn || '?'}.${idx + 1}` : entry.turn; + return { + ...cloneJson(entry), + request_id: splitFromSingleRecord ? `${entry.request_id || 'ws'}:${idx + 1}` : entry.request_id, + turn: captureTurn, + capture_turn: captureTurn, + duration_ms: durationMs, + timestamp: createdAt ? new Date(createdAt * 1000).toISOString() : entry.timestamp, + derived_from_websocket: splitFromSingleRecord || entry.derived_from_websocket === true, + websocket_response_index: idx + 1, + request: { + ...(cloneJson(entry.request) || {}), + body: requestBody, + }, + response: { + ...(cloneJson(entry.response) || {}), + body: cloneJson(completed), + ws_events: group.events, + }, + }; +} + +function createWebSocketResponseHistoryStore() { + return new Map(); +} + +function webSocketHistoryInputForResponse(historyByResponseId, responseId) { + const chain = []; + const seen = new Set(); + let cursor = responseId; + while (cursor && historyByResponseId.has(cursor) && !seen.has(cursor)) { + seen.add(cursor); + const node = historyByResponseId.get(cursor); + chain.push(node); + cursor = node.previousResponseId || ''; + } + chain.reverse(); + return uniqueMessages(chain.flatMap(node => [ + ...(node.requestInput || []), + ...(node.outputMessages || []) + ])); +} + +function storeWebSocketResponseHistory(historyByResponseId, responseId, previousResponseId, requestBody, outputMessages) { + if (!responseId) return; + const requestInput = normalizeWebSocketDerivedInput(Array.isArray(requestBody?.input) ? requestBody.input : []); + historyByResponseId.set(responseId, { + previousResponseId: previousResponseId || '', + requestInput, + outputMessages: outputMessages || [] + }); +} + +function isDisplayableResponsesEntry(entry) { + const body = entry?.request?.body || {}; + const payload = getResponsePayload(entry) || {}; + const output = Array.isArray(payload.output) ? payload.output : []; + const isGenerateFalse = body.generate === false || payload.generate === false; + return !(isGenerateFalse && output.length === 0 && (payload.usage?.output_tokens || 0) === 0); +} + +function stitchDirectResponsesEntry(entry, historyByResponseId) { + const body = entry?.request?.body; + const payload = getResponsePayload(entry) || {}; + if (!body || typeof body !== 'object') return entry; + const previousId = body.previous_response_id || payload.previous_response_id || ''; + if (!isDisplayableResponsesEntry(entry)) { + const responseId = payload.id || ''; + if (responseId) { + storeWebSocketResponseHistory( + historyByResponseId, + responseId, + previousId, + body, + responseBodyOutputMessages(payload.output), + ); + } + return null; + } + let nextEntry = entry; + let requestBody = body; + if (previousId) { + const priorHistoryInput = webSocketHistoryInputForResponse(historyByResponseId, previousId); + if (Array.isArray(priorHistoryInput) && priorHistoryInput.length) { + requestBody = cloneJson(body) || {}; + const currentInput = normalizeWebSocketDerivedInput(Array.isArray(requestBody.input) ? requestBody.input : []); + requestBody.input = uniqueMessages([...priorHistoryInput, ...currentInput]); + nextEntry = { + ...cloneJson(entry), + request: { + ...(cloneJson(entry.request) || {}), + body: requestBody, + }, + }; + } + } + const responseId = payload.id || ''; + if (responseId) { + storeWebSocketResponseHistory( + historyByResponseId, + responseId, + previousId, + requestBody, + responseBodyOutputMessages(payload.output), + ); + } + return nextEntry; +} + +function expandWebSocketResponseEntries(rawEntries, historyByResponseId = createWebSocketResponseHistoryStore()) { + const expanded = []; + for (const entry of rawEntries || []) { + if (!isCodexResponsesWebSocketEntry(entry)) { + expanded.push(entry); + continue; + } + const groups = splitWebSocketResponseEvents(entry.response?.ws_events || []); + if (!groups.length) { + const stitchedEntry = stitchDirectResponsesEntry(entry, historyByResponseId); + if (stitchedEntry) expanded.push(stitchedEntry); + continue; + } + const displayableGroups = groups + .map((group, idx) => ({ group, idx })) + .filter(({ group }) => isDisplayableWebSocketResponseGroup(group)); + if (!displayableGroups.length) continue; + for (const { group, idx } of displayableGroups) { + const previousId = previousResponseIdForGroup(group); + const priorHistoryInput = previousId ? webSocketHistoryInputForResponse(historyByResponseId, previousId) : []; + const responseEntry = buildWebSocketResponseEntry(entry, groups, idx, priorHistoryInput); + expanded.push(responseEntry); + + const completed = completedResponseFromEvents(group.events) || {}; + const created = responseCreatedFromEvents(group.events) || {}; + const responseId = completed.id || created.id || ''; + if (responseId) { + const outputMessages = webSocketOutputMessages(group.events); + const requestBody = requestSourceForWebSocketResponseEntry(entry, groups, idx, priorHistoryInput); + storeWebSocketResponseHistory(historyByResponseId, responseId, previousId, requestBody, outputMessages); + } + } + } + return expanded; +} + +let liveWebSocketResponseHistoryById = createWebSocketResponseHistoryStore(); + +function expandLiveWebSocketResponseEntries(rawEntries, reset = false) { + if (reset) liveWebSocketResponseHistoryById = createWebSocketResponseHistoryStore(); + return expandWebSocketResponseEntries(rawEntries, liveWebSocketResponseHistoryById); +} + +let nextDisplayTurn = 1; +const DISPLAY_TURN_PRIMARY_PATH_PREFIXES = ['/v1/messages', '/v1/responses', '/backend-api/codex/responses', '/v1/chat/completions', '/v1/completions', '/v1internal:generateContent', '/v1internal:streamGenerateContent']; + +function displayTurnPath(entry) { + return (entry?.request?.path || '/unknown').replace(/\?.*$/, ''); +} + +function isDisplayTurnCandidate(entry) { + if (!entry || typeof entry !== 'object') return false; + const path = displayTurnPath(entry); + if (path.includes('/count_tokens') || path.endsWith('/models') || path.includes('/models?')) return false; + if (isResponsesPath(path) && !isDisplayableResponsesEntry(entry)) return false; + if (entry.derived_from_websocket) return true; + if (isResponsesPath(path)) { + return true; + } + if (path.startsWith('/model/') && (path.endsWith('/invoke') || path.endsWith('/invoke-with-response-stream'))) { + return true; + } + return DISPLAY_TURN_PRIMARY_PATH_PREFIXES.some(prefix => path.startsWith(prefix)); +} + +function normalizeDisplayTurns(rawEntries, reset = true) { + if (reset) nextDisplayTurn = 1; + return (rawEntries || []).map((entry, idx) => { + if (!entry || typeof entry !== 'object') return entry; + if (entry._entry_index === undefined) entry._entry_index = idx; + if (entry.capture_turn === undefined && entry.turn !== undefined) entry.capture_turn = entry.turn; + if (!isDisplayTurnCandidate(entry)) { + if (reset) delete entry.display_turn; + return entry; + } + if (reset || entry.display_turn === undefined) { + entry.display_turn = nextDisplayTurn; + } + const assignedTurn = Number(entry.display_turn); + nextDisplayTurn = Number.isFinite(assignedTurn) ? Math.max(nextDisplayTurn, assignedTurn + 1) : nextDisplayTurn + 1; + return entry; + }); +} + +function displayTurnValue(entry) { + return entry?.display_turn ?? entry?.turn; +} + +function displayTurnLabel(entry) { + const value = displayTurnValue(entry); + return value === undefined || value === null || value === '' ? '?' : value; +} + +function isNavigableTraceEntry(entry) { + if (isResponsesPath(displayTurnPath(entry)) && !isDisplayableResponsesEntry(entry)) return false; + return true; +} + +function captureTurnValue(entry) { + return entry?.capture_turn ?? entry?.turn; +} diff --git a/claude_tap/viewer_assets/sections_json.js b/claude_tap/viewer_assets/sections_json.js new file mode 100644 index 0000000..bd816d6 --- /dev/null +++ b/claude_tap/viewer_assets/sections_json.js @@ -0,0 +1,146 @@ + +/* ─── Section ─── */ +function encodeCopyText(text) { + return btoa(unescape(encodeURIComponent(text))); +} + +function section(title, body, defaultOpen = true, copyText = null, badge = null) { + const oc = defaultOpen ? 'open' : '', ac = defaultOpen ? 'chevron open' : 'chevron'; + let extra = ''; + if (badge) extra += `${esc(badge)}`; + if (copyText !== null) extra += ``; + return `
${title}${extra}
${body}
`; +} + +function bindSections(container) { + container.querySelectorAll('.section-header').forEach(h => { + h.addEventListener('click', ev => { + if (ev.target.classList.contains('copy-btn')) { + const text = decodeURIComponent(escape(atob(ev.target.dataset.copy))); + copyToClipboard(text, ev.target); + return; + } + h.nextElementSibling.classList.toggle('open'); + h.querySelector('.chevron').classList.toggle('open'); + }); + }); + container.querySelectorAll('.trace-copy-btn').forEach(btn => { + btn.addEventListener('click', ev => { + ev.stopPropagation(); + const text = decodeURIComponent(escape(atob(btn.dataset.copy))); + copyToClipboard(text, btn); + }); + }); + container.querySelectorAll('.tool-block-header').forEach(h => { + h.addEventListener('click', () => { + h.nextElementSibling.classList.toggle('open'); + h.querySelector('.tb-arrow').classList.toggle('open'); + }); + }); + container.querySelectorAll('.tool-input-toggle').forEach(btn => { + btn.addEventListener('click', ev => { + ev.stopPropagation(); + const wrapper = btn.closest('.tool-input-readable'); + const view = wrapper?.querySelector('.tool-input-view'); + if (!view) return; + const expanded = view.classList.toggle('expanded'); + const dataKey = expanded ? 'decoded' : 'raw'; + view.textContent = decodeURIComponent(escape(atob(view.dataset[dataKey] || ''))); + btn.textContent = expanded ? t('string_show_raw') : '↵'; + btn.title = expanded ? t('string_show_raw') : t('string_expand_escapes'); + btn.setAttribute('aria-label', expanded ? t('string_show_raw') : t('string_expand_escapes')); + btn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + }); + }); + container.querySelectorAll('.tool-input-copy').forEach(btn => { + btn.addEventListener('click', ev => { + ev.stopPropagation(); + const wrapper = btn.closest('.tool-input-readable'); + const view = wrapper?.querySelector('.tool-input-view'); + if (!view) return; + const dataKey = view.classList.contains('expanded') ? 'decoded' : 'raw'; + const text = decodeURIComponent(escape(atob(view.dataset[dataKey] || ''))); + copyToClipboard(text, btn, '✓'); + }); + }); +} + +/* ─── JSON tree view ─── */ +let _jtId = 0; + +function renderJSONTree(obj, depth = 0) { + if (depth > 50) return ''; + + if (obj === null) return 'null'; + if (typeof obj === 'boolean') return `${obj}`; + if (typeof obj === 'number') return `${obj}`; + if (typeof obj === 'string') return `${esc(JSON.stringify(obj))}`; + if (typeof obj !== 'object') return esc(String(obj)); + + const isArray = Array.isArray(obj); + const keys = isArray ? obj.map((_, i) => i) : Object.keys(obj); + const len = keys.length; + + if (len === 0) return `${isArray ? '[]' : '{}'}`; + + const id = 'jt' + (_jtId++); + const bracket = isArray ? '[' : '{'; + const closeBracket = isArray ? ']' : '}'; + const summary = `${len} ${isArray ? 'item' : 'key'}${len !== 1 ? 's' : ''}`; + + let items = ''; + keys.forEach((key, i) => { + const comma = i < len - 1 ? ',' : ''; + const k = isArray ? '' : `${esc(JSON.stringify(key))}: `; + items += `
${k}${renderJSONTree(obj[key], depth + 1)}${comma}
`; + }); + + return `` + + `${bracket}` + + `… ${summary}` + + `
${items}
` + + `
${closeBracket}
`; +} + +function toggleJT(event, id) { + event.stopPropagation(); + const node = document.getElementById(id); + const summary = document.getElementById(id + 's'); + const closeLine = document.getElementById(id + 'c'); + const toggle = event.currentTarget; + const open = node.classList.contains('jt-open'); + if (open) { + node.classList.remove('jt-open'); + toggle.classList.remove('jt-open'); + toggle.textContent = '▶'; + if (summary) summary.classList.add('jt-show'); + if (closeLine) closeLine.classList.add('jt-hidden'); + } else { + node.classList.add('jt-open'); + toggle.classList.add('jt-open'); + toggle.textContent = '▼'; + if (summary) summary.classList.remove('jt-show'); + if (closeLine) closeLine.classList.remove('jt-hidden'); + } +} + +function copyRequestBody(btn) { + let e = filtered[activeIdx]; if (!e) return; + e = resolveEntryForDetail(e); + copyToClipboard(JSON.stringify(e.request?.body, null, 2), btn); +} +function copyCurl(btn) { + let e = filtered[activeIdx]; if (!e) return; + e = resolveEntryForDetail(e); + const method = e.request?.method || 'POST', path = e.request?.path || '/v1/messages'; + const headers = e.request?.headers || {}, body = e.request?.body; + const base = e.upstream_base_url || 'https://api.anthropic.com'; + let cmd = `curl -X ${method} '${base}${path}'`; + for (const [k, v] of Object.entries(headers)) { + const kl = k.toLowerCase(); + if (kl === 'host' || kl === 'content-length' || kl === 'accept-encoding') continue; + cmd += ` \\\n -H '${k}: ${v}'`; + } + if (body) cmd += ` \\\n -d '${JSON.stringify(body).replace(/'/g, "'\\''")}'`; + copyToClipboard(cmd, btn); +} diff --git a/claude_tap/viewer_assets/sidebar.js b/claude_tap/viewer_assets/sidebar.js new file mode 100644 index 0000000..2bd3a96 --- /dev/null +++ b/claude_tap/viewer_assets/sidebar.js @@ -0,0 +1,879 @@ + +/* ─── Model helpers ─── */ +function modelPriority(m) { + const l = m.toLowerCase(); + if (l.includes('opus')) return 0; + if (l.includes('sonnet')) return 1; + if (l.includes('haiku')) return 3; + return 2; +} +function compareSidebarModelOrder(a, b) { + const ma = a.request?.body?.model || 'unknown'; + const mb = b.request?.body?.model || 'unknown'; + const pa = modelPriority(ma), pb = modelPriority(mb); + if (pa !== pb) return pa - pb; + const modelDiff = ma.localeCompare(mb); + if (modelDiff) return modelDiff; + return compareTurns(captureTurnValue(a), captureTurnValue(b)); +} +function modelColor(m) { + const l = m.toLowerCase(); + if (l.includes('opus')) return 'var(--purple)'; + if (l.includes('sonnet')) return 'var(--blue)'; + if (l.includes('haiku')) return 'var(--green)'; + return 'var(--text-tertiary)'; +} +function modelBadge(m) { + const l = m.toLowerCase(); + if (l.includes('opus')) return { bg: 'var(--purple-bg)', fg: 'var(--purple)' }; + if (l.includes('sonnet')) return { bg: 'var(--blue-bg)', fg: 'var(--blue)' }; + if (l.includes('haiku')) return { bg: 'var(--green-bg)', fg: 'var(--green)' }; + return { bg: 'var(--bg)', fg: 'var(--text-tertiary)' }; +} + +function sessionTextSnippet(text, maxLen = 56) { + const normalized = String(text || '').replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLen) return normalized; + return normalized.slice(0, Math.max(0, maxLen - 3)).trimEnd() + '...'; +} + +function naturalTextFromPromptPayload(payload) { + if (typeof payload === 'string') return cleanUserPromptText(payload); + if (Array.isArray(payload)) { + for (const item of payload) { + const text = naturalTextFromPromptPayload(item); + if (text) return text; + } + return ''; + } + if (!payload || typeof payload !== 'object') return ''; + for (const key of ['prompt', 'request', 'instruction', 'message', 'query', 'text', 'title']) { + if (typeof payload[key] !== 'string') continue; + const text = cleanUserPromptText(payload[key]); + if (text) return text; + } + if (payload.content !== undefined) return naturalTextForSessionContent(payload.content); + return ''; +} + +function cleanUserPromptText(text) { + let value = String(text || '').trim(); + if (!value) return ''; + if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') { + try { + const decoded = JSON.parse(value); + if (typeof decoded === 'string' && decoded.trim()) value = decoded.trim(); + } catch (_) { + // Keep the original text when it only looks JSON-quoted. + } + } + if (/^[{[]/.test(value)) { + try { + const decoded = JSON.parse(value); + const prompt = naturalTextFromPromptPayload(decoded); + if (prompt) return prompt; + } catch (_) { + // Keep scanning the original text when it only looks like JSON. + } + } + const userRequest = value.match(/\s*([\s\S]*?)\s*<\/USER_REQUEST>/i); + if (userRequest) return userRequest[1].trim(); + const codexRequest = value.match(/^#+\s*My request for Codex:\s*([\s\S]*?)\s*$/im); + if (codexRequest) return codexRequest[1].trim(); + const session = value.match(/^\s*([\s\S]*?)\s*<\/session>$/i); + if (session) return session[1].trim(); + const firstTag = value.match(/^<([A-Za-z_-]+)(?:\s|>)/); + const injectedTags = new Set([ + 'artifacts', + 'codex_internal_context', + 'environment_context', + 'local-command-caveat', + 'session_context', + 'skills', + 'slash_commands', + 'subagents', + 'system-reminder', + 'user_information', + ]); + if (firstTag && injectedTags.has(firstTag[1].toLowerCase())) return ''; + if (value.startsWith('# AGENTS.md instructions') || value.startsWith('')) return ''; + if (value.startsWith('# Files mentioned by the user:')) return ''; + if (/^<\/?image(_input)?(\s+[^>]*)?>$/i.test(value)) return ''; + if (/^\[SUGGESTION MODE:/i.test(value)) return ''; + if (/^(web page content|page content|网页内容)\s*[::]/i.test(value)) return ''; + if (/^\[Image:\s*source:/i.test(value)) return ''; + return value.replace(/^\[Image #\d+\]\s*/i, '').trim(); +} + +function imageLookupKey(text) { + let value = String(text || '').trim(); + const session = value.match(/^\s*([\s\S]*?)\s*<\/session>$/i); + if (session) value = session[1].trim(); + return value.replace(/^\[Image #\d+\]\s*/i, '').trim(); +} + +let sessionTooltipEl = null; +function sessionTooltip() { + if (sessionTooltipEl) return sessionTooltipEl; + sessionTooltipEl = document.createElement('div'); + sessionTooltipEl.id = 'session-full-input-tooltip'; + sessionTooltipEl.className = 'session-hover-tooltip'; + document.body.appendChild(sessionTooltipEl); + return sessionTooltipEl; +} + +function positionSessionTooltip(trigger, tooltip) { + const rect = trigger.getBoundingClientRect(); + let left = rect.right + 10; + let maxWidth = Math.min(520, window.innerWidth - left - 12); + if (maxWidth < 240) { + left = 12; + maxWidth = Math.max(180, window.innerWidth - 24); + } + tooltip.style.width = maxWidth + 'px'; + tooltip.style.left = left + 'px'; + tooltip.style.top = '0px'; + const desiredTop = rect.top; + const height = tooltip.offsetHeight || 0; + const top = Math.max(12, Math.min(desiredTop, window.innerHeight - height - 12)); + tooltip.style.top = top + 'px'; +} + +function showSessionTooltip(trigger) { + const fullText = trigger?.dataset?.fullUserInput || ''; + if (!fullText) return; + const tooltip = sessionTooltip(); + tooltip.textContent = fullText; + tooltip.classList.add('visible'); + trigger.setAttribute('aria-describedby', tooltip.id); + positionSessionTooltip(trigger, tooltip); +} + +function hideSessionTooltip(trigger) { + if (!sessionTooltipEl) return; + sessionTooltipEl.classList.remove('visible'); + if (trigger) trigger.removeAttribute('aria-describedby'); +} + +function bindSessionInputTooltip(header, fullText, snippet) { + const original = String(fullText || '').trim(); + if (!original || !snippet.endsWith('...')) return; + header.dataset.fullUserInput = original; + header.tabIndex = 0; + header.addEventListener('mouseenter', () => showSessionTooltip(header)); + header.addEventListener('focus', () => showSessionTooltip(header)); + header.addEventListener('mouseleave', () => hideSessionTooltip(header)); + header.addEventListener('blur', () => hideSessionTooltip(header)); +} + +function naturalTextForSessionContent(content) { + if (content === undefined || content === null) return ''; + if (typeof content === 'string') return cleanUserPromptText(content); + if (!Array.isArray(content)) { + if (typeof content === 'object') { + if (content.type === 'tool_result' || content.type === 'function_call_output') return ''; + if (typeof content.text === 'string') return cleanUserPromptText(content.text); + if (typeof content.output === 'string') return cleanUserPromptText(content.output); + if (content.content !== undefined) return naturalTextForSessionContent(content.content); + } + return ''; + } + for (const block of content) { + let text = ''; + if (typeof block === 'string') { + text = block; + } else if (block && typeof block === 'object') { + if (block.type === 'tool_result' || block.type === 'function_call_output') continue; + if (block.type === 'text' || block.type === 'input_text' || block.type === 'output_text') text = block.text || ''; + else if (block.type === 'message') text = naturalTextForSessionContent(block.content); + else if (typeof block.text === 'string') text = block.text; + else if (typeof block.output === 'string') text = block.output; + } + const prompt = cleanUserPromptText(text); + if (prompt) return prompt; + } + return ''; +} + +function contentTextForSession(content) { + if (content === undefined || content === null) return ''; + if (typeof content === 'string') return content.trim(); + if (!Array.isArray(content)) { + if (typeof content === 'object') { + if (typeof content.text === 'string') return content.text.trim(); + if (typeof content.output === 'string') return content.output.trim(); + if (content.content !== undefined) return contentTextForSession(content.content); + } + return ''; + } + return content.map(block => { + if (!block || typeof block !== 'object') return String(block || '').trim(); + if (block.type === 'text' || block.type === 'input_text' || block.type === 'output_text') return block.text || ''; + if (block.type === 'message') return contentTextForSession(block.content); + if (block.type === 'thinking') return block.thinking || ''; + if (block.type === 'tool_use' || block.type === 'function_call') return `[${block.name || block.type}]`; + if (block.type === 'tool_result') return contentTextForSession(block.content); + if (block.type === 'function_call_output') return block.output || ''; + if (typeof block.text === 'string') return block.text; + if (typeof block.output === 'string') return block.output; + return ''; + }).filter(Boolean).join('\n').trim(); +} + +function isToolResultOnlyMessage(message) { + const content = message?.content; + if (!Array.isArray(content) || content.length === 0) return false; + return content.every(block => block && typeof block === 'object' + && (block.type === 'tool_result' || block.type === 'function_call_output')); +} + +function latestUserInputText(entry) { + return latestUserInputInfo(entry).userText; +} + +function firstUserInputInfo(entry) { + const msgs = getMessages(entry?.request?.body); + for (let i = 0; i < msgs.length; i++) { + const message = msgs[i]; + if (message?.role !== 'user' || isToolResultOnlyMessage(message)) continue; + const text = naturalTextForSessionContent(message.content); + if (text) return { userText: text, userIndex: i, messageCount: msgs.length }; + } + return { userText: '', userIndex: -1, messageCount: msgs.length }; +} + +function latestUserInputInfo(entry) { + const msgs = getMessages(entry?.request?.body); + for (let i = msgs.length - 1; i >= 0; i--) { + const message = msgs[i]; + if (message?.role !== 'user' || isToolResultOnlyMessage(message)) continue; + const text = naturalTextForSessionContent(message.content); + if (text) return { userText: text, userIndex: i, messageCount: msgs.length }; + } + return { userText: '', userIndex: -1, messageCount: msgs.length }; +} + +function codexAppSessionInfo(entry) { + const metadata = entry?.request?.body?.metadata || {}; + const headers = entry?.request?.headers || {}; + const sessionId = metadata.codex_app_session_id || headers['x-codex-app-session-id'] || ''; + if (!sessionId) return null; + const first = firstUserInputInfo(entry); + return { + sessionId, + userText: first.userText || entry._session_user_text || '', + userIndex: first.userIndex, + messageCount: first.messageCount, + }; +} + +function finalResponseText(entry) { + const output = getResponseOutput(entry); + const outputText = contentTextForSession(output?.content); + if (outputText) return outputText; + const payload = getResponsePayload(entry); + return contentTextForSession(payload?.output || payload?.content || payload?.message || payload); +} + +function isContinuationWithoutUserInput(entry) { + if (previousResponseIdForDiff(entry)) return true; + const input = entry?.request?.body?.input; + return Array.isArray(input) && input.length > 0 && input.every(isResponseToolResultItem); +} + +function isTitleGenerationEntry(entry) { + const sys = extractSystem(entry?.request?.body) || ''; + return /generate a concise/i.test(sys) && /single\s+"title"\s+field/i.test(sys); +} + +function sessionRootTurn(entry) { + const rootTurn = parseInt(String(captureTurnValue(entry) ?? '').split('.')[0], 10); + return isNaN(rootTurn) ? null : rootTurn; +} + +function shouldContinueSessionGroup(entry, info, currentGroup) { + if (!currentGroup || !info.userText || currentGroup.userText !== info.userText) return false; + if (isTitleGenerationEntry(entry)) return false; + if (currentGroup.metadataOnly) return true; + if (info.messageCount > 1 && info.userIndex === currentGroup.userIndex) return true; + return false; +} + +function sessionKeyForEntry(entry, currentGroup) { + const codexApp = codexAppSessionInfo(entry); + const info = latestUserInputInfo(entry); + const metadataOnly = isTitleGenerationEntry(entry); + const rootTurn = sessionRootTurn(entry); + if (codexApp) { + const userText = info.userText || codexApp.userText; + const userIndex = info.userText ? info.userIndex : codexApp.userIndex; + if (userText) { + if (shouldContinueSessionGroup(entry, { ...info, userText, userIndex }, currentGroup)) { + return { key: currentGroup.key, userText, userIndex, metadataOnly, rootTurn }; + } + return { + key: 'codexapp-user:' + codexApp.sessionId + ':' + userIndex + ':' + userText, + userText, + userIndex, + metadataOnly, + rootTurn, + }; + } + return { + key: 'codexapp:' + codexApp.sessionId, + userText: codexApp.userText, + userIndex: codexApp.userIndex, + metadataOnly, + rootTurn, + }; + } + if (info.userText) { + if (shouldContinueSessionGroup(entry, info, currentGroup)) { + return { key: currentGroup.key, userText: info.userText, userIndex: info.userIndex, metadataOnly, rootTurn }; + } + return { + key: 'user:' + sessionTurnDiscriminator(entry) + ':' + info.userIndex + ':' + info.userText, + userText: info.userText, + userIndex: info.userIndex, + metadataOnly, + rootTurn, + }; + } + if (currentGroup) return { key: currentGroup.key, userText: '', userIndex: currentGroup.userIndex, metadataOnly, rootTurn }; + const rootTurnText = String(captureTurnValue(entry) ?? '').split('.')[0]; + if (rootTurnText) return { key: 'turn:' + rootTurnText, userText: '', userIndex: -1, metadataOnly, rootTurn }; + return { key: 'request:' + (entry?.request_id || ''), userText: '', userIndex: -1, metadataOnly, rootTurn: null }; +} + +function sessionTurnDiscriminator(entry) { + const rootTurn = String(captureTurnValue(entry) ?? '').split('.')[0]; + if (rootTurn) return 'turn:' + rootTurn; + return 'request:' + (entry?.request_id || ''); +} + +function buildSessionGroups(items) { + const groups = []; + const groupsByKey = new Map(); + const titleGenerationItems = []; + + function canMergeNonContiguousSession(info) { + return typeof info.key === 'string' && info.key.startsWith('codexapp:'); + } + + function createGroup(info, item) { + const group = { + key: info.key, + userText: info.userText, + userIndex: info.userIndex, + metadataOnly: !!info.metadataOnly, + rootTurn: info.rootTurn, + firstOrder: item.order, + responseText: '', + items: [], + }; + groups.push(group); + if (canMergeNonContiguousSession(info)) groupsByKey.set(info.key, group); + return group; + } + + function addItemToGroup(group, item, info) { + if (!group.userText && info.userText) { + group.userText = info.userText; + group.userIndex = info.userIndex; + } + if (group.rootTurn == null && info.rootTurn != null) group.rootTurn = info.rootTurn; + if (!info.metadataOnly) group.metadataOnly = false; + const responseText = finalResponseText(item.entry); + if (responseText) group.responseText = responseText; + group.items.push(item); + } + + function bestTitleGenerationGroup(item, info) { + let best = null; + groups.forEach(group => { + if (info.userText && group.userText && info.userText !== group.userText) return; + let distance; + if (info.rootTurn != null && group.rootTurn != null) { + distance = Math.abs(info.rootTurn - group.rootTurn); + if (distance > 2) return; + } else { + distance = Math.abs(item.order - group.firstOrder); + } + const futurePenalty = item.order < group.firstOrder ? 0.25 : 0; + const score = distance + futurePenalty; + if (!best || score < best.score) best = { group, score }; + }); + return best ? best.group : null; + } + + items.forEach((rawItem, order) => { + const item = { ...rawItem, order }; + if (isTitleGenerationEntry(item.entry)) { + titleGenerationItems.push(item); + return; + } + const current = groups[groups.length - 1] || null; + const info = sessionKeyForEntry(item.entry, current); + let group = current && current.key === info.key ? current : null; + if (!group && canMergeNonContiguousSession(info)) group = groupsByKey.get(info.key) || null; + if (!group) group = createGroup(info, item); + addItemToGroup(group, item, info); + }); + + titleGenerationItems.forEach(item => { + const info = sessionKeyForEntry(item.entry, null); + const group = bestTitleGenerationGroup(item, info) || createGroup(info, item); + addItemToGroup(group, item, info); + }); + + groups.forEach(group => { + group.items.sort((a, b) => a.order - b.order); + }); + return groups; +} + +function sessionGroupKey(group, groupIdx) { + return 'session:' + groupIdx + ':' + group.key; +} + +function sessionRowsForItems(items) { + const rows = []; + buildSessionGroups(items).forEach((group, groupIdx) => { + const groupKey = sessionGroupKey(group, groupIdx); + rows.push({ type: 'group', group, groupIdx, groupKey }); + if (!collapsedGroups.has(groupKey)) { + group.items.forEach(item => rows.push({ type: 'entry', ...item })); + } + }); + return rows; +} + +function sidebarItemsForMode() { + const items = filtered.map((entry, idx) => ({ entry, idx })); + if (sidebarOrderMode === 'model') return items.sort((a, b) => compareSidebarModelOrder(a.entry, b.entry)).map(item => ({ type: 'entry', ...item })); + if (sidebarOrderMode === 'session') return sessionRowsForItems(items); + return items.map(item => ({ type: 'entry', ...item })); +} + +/* ─── Visual order helpers ─── */ +function buildVisualOrder() { + if (virtualMode) { + visualOrder = vsFilteredItems.filter(item => item.type !== 'group').map(item => item.idx); + return; + } + visualOrder = []; + document.querySelectorAll('.sidebar-item').forEach(el => { + // Skip items whose parent container is collapsed (display: none) + if (el.parentElement && el.parentElement.style.display === 'none') return; + visualOrder.push(parseInt(el.dataset.idx)); + }); +} + +function visualNavigate(delta) { + if (!visualOrder.length) return; + const pos = visualOrder.indexOf(activeIdx); + if (pos === -1) { selectEntry(visualOrder[0]); return; } + const next = Math.max(0, Math.min(pos + delta, visualOrder.length - 1)); + selectEntry(visualOrder[next]); +} + +/* ─── Sidebar ─── */ +const collapsedGroups = new Set(); // Track collapsed model groups + +/* ─── Task type fingerprinting ─── */ +const TASK_COLORS = [ + { color: 'var(--blue)', bg: 'var(--blue-bg)' }, + { color: 'var(--green)', bg: 'var(--green-bg)' }, + { color: 'var(--purple)', bg: 'var(--purple-bg)' }, + { color: 'var(--amber)', bg: 'var(--amber-bg)' }, + { color: 'var(--cyan)', bg: 'var(--cyan-bg)' }, + { color: 'var(--orange)', bg: 'var(--orange-bg)' }, + { color: 'var(--red)', bg: 'var(--red-bg)' }, + { color: 'var(--indigo)', bg: 'var(--purple-bg)' }, +]; +const taskFingerprintCache = new Map(); + +function getTaskFingerprint(e) { + const rid = e.request_id; + if (taskFingerprintCache.has(rid)) return taskFingerprintCache.get(rid); + const body = e.request?.body; + if (!body) { taskFingerprintCache.set(rid, null); return null; } + // Extract full system prompt text + let sysText = ''; + if (typeof body.system === 'string') sysText = body.system; + else if (Array.isArray(body.system)) { + sysText = body.system.map(s => typeof s === 'string' ? s : (s.text || '')).join('\n'); + } else if (typeof body.instructions === 'string') { + sysText = body.instructions; + } else if (Array.isArray(body.messages)) { + // OpenAI chat-completions schema: system prompt lives in messages[0..n] with role="system" + sysText = body.messages + .filter(m => m && m.role === 'system') + .map(m => typeof m.content === 'string' ? m.content : (Array.isArray(m.content) ? m.content.map(c => c?.text || '').join('\n') : '')) + .join('\n'); + } + // Tool names sorted for stable fingerprint + const requestTools = getRequestTools(body); + const tools = requestTools.length ? requestTools : getRequestTools(getResponsePayload(e)); + const toolKey = tools.map(toolDisplayName).sort().join(','); + // Build fingerprint from full system prompt + tool set + const fp = sysText + '|' + toolKey; + // Derive a short label from system prompt content + const lower = sysText.toLowerCase(); + let label = ''; + if (!sysText && !tools.length) label = 'simple'; + // Self-identification phrases ("You are X") win over generic substring matches, + // since other agents may mention "Claude Code" inside their own prompt body. + else if (lower.includes('you are opencode')) label = 'OpenCode'; + else if (lower.includes('you are hermes')) label = 'Hermes'; + else if (lower.includes('you are codex')) label = 'Codex'; + else if (lower.includes('operating inside pi') || lower.includes('pi, a coding agent harness')) label = 'Pi'; + else if (lower.includes('claude code')) label = 'Claude Code'; + else if (lower.includes('claude agent')) label = 'Claude Agent'; + else if (lower.includes('openclaw')) label = 'OpenClaw'; + else if (lower.includes('subagent') || lower.includes('sub-agent')) label = 'Subagent'; + else if (lower.includes('bash')) label = 'Bash'; + else if (lower.includes('explore')) label = 'Explore'; + else if (lower.includes('plan')) label = 'Plan'; + else if (sysText) { + const firstLine = sysText.split('\n').find(l => { + const line = l.trim().toLowerCase(); + return line && !line.startsWith('x-anthropic-billing-header:'); + }) || ''; + label = firstLine.slice(0, 20).trim() || (tools.length + ' tools'); + } else { + label = tools.length + ' tools'; + } + const result = { fp, label }; + taskFingerprintCache.set(rid, result); + return result; +} + +function getTaskColor(fp) { + if (!fp) return TASK_COLORS[0]; + // Hash with sampling for long strings (system prompts can be very large) + let hash = 0; + const step = fp.length > 1000 ? Math.floor(fp.length / 500) : 1; + for (let i = 0; i < fp.length; i += step) hash = ((hash << 5) - hash + fp.charCodeAt(i)) | 0; + hash = ((hash << 5) - hash + fp.length) | 0; // include length for extra differentiation + return TASK_COLORS[Math.abs(hash) % TASK_COLORS.length]; +} + +function createSidebarItem(e, i) { + const item = document.createElement('div'); + const statusCode = getResponseStatus(e); + const failed = statusCode >= 400; + item.className = 'sidebar-item' + (failed ? ' is-error' : ''); + item.dataset.idx = i; + const u = getUsage(e); + const inTok = u?.input_tokens || 0, outTok = u?.output_tokens || 0; + const model = e.request?.body?.model || ''; + const shortModel = model.replace(/^claude-/, '').replace(/-\d{8}$/, ''); + const badge = modelBadge(model); + const timeStr = e.timestamp ? new Date(e.timestamp).toLocaleTimeString() : ''; + // Task type coloring + const taskInfo = getTaskFingerprint(e); + const taskColor = taskInfo ? getTaskColor(taskInfo.fp) : TASK_COLORS[0]; + item.style.borderLeftColor = failed ? 'var(--red)' : taskColor.color; + const taskBadgeHtml = taskInfo && taskInfo.label ? `${esc(taskInfo.label)}` : ''; + const errorDot = failed ? `` : ''; + item.innerHTML = ` +
+ ${t('turn')} ${displayTurnLabel(e)}${errorDot} + ${taskBadgeHtml} + ${esc(shortModel)} +
+
+ ${(inTok + outTok).toLocaleString()} ${t('tok')} + ${fmtDuration(e.duration_ms || 0)} + ${esc(timeStr)} +
+
${esc((e.request?.method || '') + ' ' + (e.request?.path || ''))}
`; + item.onclick = () => selectEntry(i); + return item; +} + +function createSessionGroupHeader(group, groupIdx, groupKey, onToggle) { + const firstEntry = group.items[0]?.entry; + const taskInfo = firstEntry ? getTaskFingerprint(firstEntry) : null; + const taskColor = taskInfo ? getTaskColor(taskInfo.fp) : TASK_COLORS[groupIdx % TASK_COLORS.length]; + const isCollapsed = collapsedGroups.has(groupKey); + const label = sessionTextSnippet(group.userText, 48); + const header = document.createElement('div'); + header.className = 'sidebar-group-header'; + header.innerHTML = `${esc(t('sort_session'))} ${groupIdx + 1}${label ? ' - ' + esc(label) : ''}${group.items.length}`; + bindSessionInputTooltip(header, group.userText, label); + header.onclick = () => { + hideSessionTooltip(header); + onToggle(header); + }; + return header; +} + +function renderSidebar(preserveDetail) { + const sb = $('#sidebar'); + const prevScrollTop = sb.scrollTop; + updateSidebarSortControls(); + + // Use virtual scroll for large filtered sets + if (filtered.length > LAZY_THRESHOLD) { + virtualMode = true; + sb.classList.add('virtual-scroll'); + vsFilteredItems = sidebarItemsForMode(); + buildVisualOrder(); + vsInitSidebar(sb, prevScrollTop, preserveDetail); + return; + } + + // Standard DOM-based sidebar for small traces + virtualMode = false; + sb.classList.remove('virtual-scroll'); + sb.innerHTML = ''; + if (sidebarOrderMode === 'turn') { + let lastTs = null; + filtered.forEach((e, i) => { + const gap = _timeGapHtml(e, lastTs); + if (gap) sb.insertAdjacentHTML('beforeend', gap); + lastTs = e.timestamp ? new Date(e.timestamp).getTime() : null; + sb.appendChild(createSidebarItem(e, i)); + }); + buildVisualOrder(); + _restoreSelection(preserveDetail); + if (preserveDetail) sb.scrollTop = prevScrollTop; + return; + } + + if (sidebarOrderMode === 'session') { + const groups = buildSessionGroups(filtered.map((entry, idx) => ({ entry, idx }))); + groups.forEach((group, groupIdx) => { + const groupKey = sessionGroupKey(group, groupIdx); + const isCollapsed = collapsedGroups.has(groupKey); + const content = document.createElement('div'); + if (isCollapsed) content.style.display = 'none'; + group.items.forEach(({ entry: e, idx: i }) => content.appendChild(createSidebarItem(e, i))); + const header = createSessionGroupHeader(group, groupIdx, groupKey, headerEl => { + const nowCollapsed = content.style.display !== 'none'; + content.style.display = nowCollapsed ? 'none' : ''; + headerEl.querySelector('.group-chevron').classList.toggle('open'); + if (nowCollapsed) collapsedGroups.add(groupKey); else collapsedGroups.delete(groupKey); + buildVisualOrder(); + updateMobileNav(); + }); + sb.appendChild(header); + sb.appendChild(content); + }); + buildVisualOrder(); + _restoreSelection(preserveDetail); + if (preserveDetail) sb.scrollTop = prevScrollTop; + return; + } + + const groups = new Map(); + filtered.forEach((e, i) => { + const model = e.request?.body?.model || 'unknown'; + if (!groups.has(model)) groups.set(model, []); + groups.get(model).push({ entry: e, idx: i }); + }); + const sorted = [...groups.keys()].sort((a, b) => { + const pa = modelPriority(a), pb = modelPriority(b); + return pa !== pb ? pa - pb : a.localeCompare(b); + }); + if (sorted.length <= 1) { + // Insert time gap indicators between entries + let lastTs = null; + filtered.forEach((e, i) => { + const gap = _timeGapHtml(e, lastTs); + if (gap) sb.insertAdjacentHTML('beforeend', gap); + lastTs = e.timestamp ? new Date(e.timestamp).getTime() : null; + sb.appendChild(createSidebarItem(e, i)); + }); + } else { + sorted.forEach(model => { + const items = groups.get(model); + const shortModel = model.replace(/^claude-/, '').replace(/-\d{8}$/, ''); + const color = modelColor(model); + const isCollapsed = collapsedGroups.has(model); + const header = document.createElement('div'); + header.className = 'sidebar-group-header'; + header.innerHTML = `${esc(shortModel)}${items.length}`; + const content = document.createElement('div'); + if (isCollapsed) content.style.display = 'none'; + items.forEach(({ entry: e, idx: i }) => content.appendChild(createSidebarItem(e, i))); + header.onclick = () => { + const nowCollapsed = content.style.display !== 'none'; + content.style.display = nowCollapsed ? 'none' : ''; + header.querySelector('.group-chevron').classList.toggle('open'); + if (nowCollapsed) collapsedGroups.add(model); else collapsedGroups.delete(model); + buildVisualOrder(); + updateMobileNav(); + }; + sb.appendChild(header); + sb.appendChild(content); + }); + } + buildVisualOrder(); + _restoreSelection(preserveDetail); + if (preserveDetail) sb.scrollTop = prevScrollTop; +} + +function _restoreSelection(preserveDetail) { + let restoredIdx = -1; + if (preserveDetail && currentDetailEntryKey) { + restoredIdx = filtered.findIndex(e => entryStableKey(e) === currentDetailEntryKey); + } + if (restoredIdx < 0 && preserveDetail && currentDetailRequestId) { + restoredIdx = filtered.findIndex(e => e.request_id === currentDetailRequestId); + } + if (restoredIdx >= 0) { + selectEntry(restoredIdx, { force: false }); + } else if (activeIdx >= 0 && activeIdx < filtered.length) { + selectEntry(activeIdx, { force: !preserveDetail }); + } else if (filtered.length) { + let defaultIdx = 0; + for (let i = 0; i < filtered.length; i++) { + const m = (filtered[i].request?.body?.model || '').toLowerCase(); + if (m.includes('opus') || m.includes('sonnet')) { defaultIdx = i; break; } + } + selectEntry(defaultIdx); + } else { + activeIdx = -1; $('#detail').innerHTML = `
${t('empty_state')}
`; + } +} + +/* ─── Time gap indicator ─── */ +function _timeGapHtml(entry, lastTs) { + if (!lastTs || !entry.timestamp) return ''; + const ts = new Date(entry.timestamp).getTime(); + const gapMs = ts - lastTs; + if (gapMs < 60000) return ''; // less than 1 min + let label; + if (gapMs < 3600000) label = Math.round(gapMs / 60000) + ' min gap'; + else label = (gapMs / 3600000).toFixed(1) + ' hr gap'; + return `
${label}
`; +} + +/* ─── Position indicator ─── */ +function updatePositionIndicator() { + const pi = $('#position-indicator'); + if (!pi) return; + if (!filtered.length || activeIdx < 0) { + pi.style.display = 'none'; + return; + } + pi.style.display = 'flex'; + const entry = filtered[activeIdx]; + const turnNum = displayTurnLabel(entry); + const pos = activeIdx + 1; + const total = filtered.length; + const pct = total > 1 ? ((pos - 1) / (total - 1)) * 100 : 0; + $('#pi-current').textContent = turnNum; + $('#pi-total').textContent = `of ${total}`; + $('#pi-fill').style.width = pct + '%'; +} + +/* ─── Virtual scroll ─── */ +let _vsRafPending = false; + +function vsInitSidebar(sb, prevScrollTop, preserveDetail) { + sb.innerHTML = ''; + const spacer = document.createElement('div'); + spacer.className = 'vs-spacer'; + spacer.style.height = (vsFilteredItems.length * VS_ITEM_HEIGHT) + 'px'; + sb.appendChild(spacer); + + // Throttled scroll handler + sb.onscroll = () => { + if (!_vsRafPending) { + _vsRafPending = true; + requestAnimationFrame(() => { + _vsRafPending = false; + vsRenderVisible(); + }); + } + }; + + if (preserveDetail) sb.scrollTop = prevScrollTop; + vsRenderVisible(); + _restoreSelection(preserveDetail); +} + +function vsRenderVisible() { + const sb = $('#sidebar'); + if (!sb) return; + const spacer = sb.querySelector('.vs-spacer'); + if (!spacer) return; + + const scrollTop = sb.scrollTop; + const viewHeight = sb.clientHeight; + const startIdx = Math.max(0, Math.floor(scrollTop / VS_ITEM_HEIGHT) - VS_BUFFER); + const endIdx = Math.min(vsFilteredItems.length, Math.ceil((scrollTop + viewHeight) / VS_ITEM_HEIGHT) + VS_BUFFER); + + spacer.querySelectorAll('.sidebar-item, .sidebar-group-header').forEach(el => el.remove()); + + for (let i = startIdx; i < endIdx; i++) { + const row = vsFilteredItems[i]; + let item; + if (row.type === 'group') { + item = createSessionGroupHeader(row.group, row.groupIdx, row.groupKey, () => { + if (collapsedGroups.has(row.groupKey)) collapsedGroups.delete(row.groupKey); + else collapsedGroups.add(row.groupKey); + renderSidebar(true); + updateMobileNav(); + }); + } else { + const { entry, idx } = row; + item = createSidebarItem(entry, idx); + item.classList.toggle('active', idx === activeIdx); + } + item.style.position = 'absolute'; + item.style.left = '0'; + item.style.right = '0'; + item.style.top = (i * VS_ITEM_HEIGHT) + 'px'; + item.style.height = VS_ITEM_HEIGHT + 'px'; + spacer.appendChild(item); + } +} + +function vsScrollToIdx(idx) { + const sb = $('#sidebar'); + if (!sb || !virtualMode) return; + const pos = vsFilteredItems.findIndex(item => item.idx === idx); + if (pos < 0) return; + const itemTop = pos * VS_ITEM_HEIGHT; + const itemBottom = itemTop + VS_ITEM_HEIGHT; + if (itemTop < sb.scrollTop) { + sb.scrollTop = itemTop; + } else if (itemBottom > sb.scrollTop + sb.clientHeight) { + sb.scrollTop = itemBottom - sb.clientHeight; + } +} + +function selectEntry(idx, opts) { + if (idx < 0 || idx >= filtered.length) return; + const force = !opts || opts.force !== false; + const entry = filtered[idx]; + const entryKey = entryStableKey(entry); + activeIdx = idx; + if (virtualMode) { + vsRenderVisible(); + } else { + document.querySelectorAll('.sidebar-item').forEach(el => { + el.classList.toggle('active', parseInt(el.dataset.idx) === idx); + }); + } + // Skip re-render if same entry and force=false (preserves scroll position in live mode) + if (!force && currentDetailEntryKey === entryKey) { + // Just update sidebar highlight, keep detail as-is + } else { + renderDetailForEntry(entry); + } + if (virtualMode) { + vsScrollToIdx(idx); + } else { + const active = document.querySelector('.sidebar-item.active'); + if (active) active.scrollIntoView({ block: 'nearest' }); + } + updatePositionIndicator(); + mobileShowDetail(); + updateMobileNav(); +} diff --git a/claude_tap/viewer_assets/state.js b/claude_tap/viewer_assets/state.js new file mode 100644 index 0000000..bf2284a --- /dev/null +++ b/claude_tap/viewer_assets/state.js @@ -0,0 +1,59 @@ +const $ = s => document.querySelector(s); +const EMBED_QUERY_OPTIONS = parseEmbedQueryOptions(); +let entries = [], filtered = [], activeIdx = -1, activePaths = new Set(), searchQuery = '', activeTools = null; +let sessionImageRegistryCache = null, sessionImageRegistrySize = -1; +let visualOrder = []; // filtered indices in sidebar visual (DOM) order, excludes collapsed items +const SIDEBAR_ORDER_MODES = ['model', 'turn', 'session']; +function safeLocalStorageGet(key) { + try { return window.localStorage.getItem(key); } catch(e) { return null; } +} +function safeLocalStorageSet(key, value) { + try { window.localStorage.setItem(key, value); } catch(e) {} +} +const savedSidebarOrderMode = safeLocalStorageGet('claude-tap-sidebar-order'); +let sidebarOrderMode = SIDEBAR_ORDER_MODES.includes(savedSidebarOrderMode) ? savedSidebarOrderMode : 'model'; + +function readBooleanQuery(params, key) { + const value = params.get(key); + return value === '1' || value === 'true' || value === ''; +} + +function parseEmbedQueryOptions() { + const params = new URLSearchParams(window.location.search || ''); + const enabled = readBooleanQuery(params, 'embed') || readBooleanQuery(params, 'iframe'); + const theme = params.get('theme') === 'dark' ? 'dark' : params.get('theme') === 'light' ? 'light' : null; + return { + enabled, + hideHeader: enabled && readBooleanQuery(params, 'hideHeader'), + hidePath: enabled && readBooleanQuery(params, 'hidePath'), + hideHistory: enabled && readBooleanQuery(params, 'hideHistory'), + hideControls: enabled && readBooleanQuery(params, 'hideControls'), + compact: enabled && params.get('density') === 'compact', + theme, + }; +} + +function cloneJson(value) { + if (value === undefined || value === null) return value; + try { return JSON.parse(JSON.stringify(value)); } catch(e) { return value; } +} + +function entryStableKey(entry) { + if (!entry) return ''; + const requestId = entry.request_id || entry.req_id || ''; + const parts = [requestId || 'entry']; + const entryIndex = entry._entry_index ?? entry._rawIdx; + const websocketIndex = entry.websocket_response_index; + const recordIndex = entry.record_index; + const captureTurn = entry.capture_turn ?? entry.turn; + if (entryIndex !== undefined && entryIndex !== null && entryIndex !== '') { + parts.push(`idx:${entryIndex}`); + } else if (websocketIndex !== undefined && websocketIndex !== null && websocketIndex !== '') { + parts.push(`ws:${websocketIndex}`); + } else if (recordIndex !== undefined && recordIndex !== null && recordIndex !== '') { + parts.push(`record:${recordIndex}`); + } else if (captureTurn !== undefined && captureTurn !== null && captureTurn !== '') { + parts.push(`turn:${captureTurn}`); + } + return parts.join('|'); +} diff --git a/claude_tap/viewer_assets/utilities_mobile.js b/claude_tap/viewer_assets/utilities_mobile.js new file mode 100644 index 0000000..12e66d5 --- /dev/null +++ b/claude_tap/viewer_assets/utilities_mobile.js @@ -0,0 +1,115 @@ + +/* ─── Utilities ─── */ +function esc(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } +function fmtDuration(ms) { return ms < 1000 ? ms + 'ms' : (ms / 1000).toFixed(1) + 's'; } +function fmtChars(n) { return n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); } + +/* ─── Keyboard nav ─── */ +document.addEventListener('keydown', ev => { + if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'f') { + ev.preventDefault(); + openGlobalSearch(); + return; + } + if (globalSearchState.open) { + if (ev.key === 'Escape') { + ev.preventDefault(); + closeGlobalSearch(); + return; + } + if (ev.key === 'Enter') { + ev.preventDefault(); + navigateGlobalSearch(ev.shiftKey ? -1 : 1); + return; + } + } + // Ctrl/Cmd+G: jump to turn number + if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 'g') { + ev.preventDefault(); + promptJumpToTurn(); + return; + } + const tag = ev.target?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || ev.target?.isContentEditable) return; + if (!visualOrder.length) return; + if (ev.key === 'ArrowDown' || ev.key === 'j') { ev.preventDefault(); visualNavigate(1); } + if (ev.key === 'ArrowUp' || ev.key === 'k') { ev.preventDefault(); visualNavigate(-1); } + if (ev.key === 'Home') { ev.preventDefault(); selectEntry(visualOrder[0]); } + if (ev.key === 'End') { ev.preventDefault(); selectEntry(visualOrder[visualOrder.length - 1]); } + if (ev.key === 'PageDown') { ev.preventDefault(); visualNavigate(10); } + if (ev.key === 'PageUp') { ev.preventDefault(); visualNavigate(-10); } +}); + +function promptJumpToTurn() { + if (!filtered.length) return; + const input = prompt('Jump to turn number:'); + if (!input) return; + const turnNum = parseInt(input, 10); + if (isNaN(turnNum)) return; + // Find the filtered entry with matching turn number + const idx = filtered.findIndex(e => Number(displayTurnValue(e)) === turnNum); + if (idx >= 0) { + selectEntry(idx); + } else { + // Find closest turn + let closestIdx = 0, closestDist = Infinity; + filtered.forEach((e, i) => { + const dist = Math.abs((Number(displayTurnValue(e)) || 0) - turnNum); + if (dist < closestDist) { closestDist = dist; closestIdx = i; } + }); + selectEntry(closestIdx); + } +} + +/* ─── Mobile sidebar toggle (R1) ─── */ +function isMobile() { return window.matchMedia('(max-width: 768px)').matches; } + +function mobileShowDetail() { + if (!isMobile()) return; + const sidebarWrap = document.getElementById('sidebar-wrap'); + const detail = document.getElementById('detail'); + const backBtn = document.getElementById('mobile-back-btn'); + const navBar = document.getElementById('mobile-nav-bar'); + if (sidebarWrap) sidebarWrap.classList.add('mobile-hidden'); + if (detail) detail.classList.add('mobile-fullwidth'); + if (backBtn) backBtn.style.display = ''; + if (navBar) navBar.style.display = ''; + updateMobileNav(); +} + +function mobileShowSidebar() { + const sidebarWrap = document.getElementById('sidebar-wrap'); + const detail = document.getElementById('detail'); + const backBtn = document.getElementById('mobile-back-btn'); + const navBar = document.getElementById('mobile-nav-bar'); + if (sidebarWrap) sidebarWrap.classList.remove('mobile-hidden'); + if (detail) detail.classList.remove('mobile-fullwidth'); + if (backBtn) backBtn.style.display = 'none'; + if (navBar) navBar.style.display = 'none'; +} + +function updateMobileNav() { + const prevBtn = document.getElementById('mobile-prev-btn'); + const nextBtn = document.getElementById('mobile-next-btn'); + const pos = document.getElementById('mobile-nav-pos'); + if (!prevBtn || !nextBtn || !pos) return; + const total = visualOrder.length; + if (total === 0) { + prevBtn.disabled = true; + nextBtn.disabled = true; + pos.textContent = ''; + return; + } + const vPos = visualOrder.indexOf(activeIdx); + prevBtn.disabled = vPos <= 0; + nextBtn.disabled = vPos >= total - 1; + pos.textContent = (vPos + 1) + ' / ' + total; +} + +function mobilePrev() { + visualNavigate(-1); +} + +function mobileNext() { + visualNavigate(1); +} diff --git a/claude_tap/viewer_assets/viewer.css b/claude_tap/viewer_assets/viewer.css new file mode 100644 index 0000000..10b6274 --- /dev/null +++ b/claude_tap/viewer_assets/viewer.css @@ -0,0 +1,1554 @@ +:root { + --bg: #f4f5f7; + --bg-card: #ffffff; + --bg-hover: #f0f1f4; + --bg-active: #e8ecf4; + --bg-code: #f6f8fa; + --border: #e2e4e9; + --scrollbar-bg1: #bbbec7; + --border-light: #edeef1; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.06); + --shadow-md: 0 2px 8px rgba(0,0,0,0.08); + --radius: 10px; + --radius-sm: 6px; + + --text: #1c2024; + --text-secondary: #60646c; + --text-tertiary: #a0a4ab; + + --blue: #3b82f6; + --blue-bg: #eff6ff; + --green: #10b981; + --green-bg: #ecfdf5; + --amber: #f59e0b; + --amber-bg: #fffbeb; + --red: #ef4444; + --red-bg: #fef2f2; + --red-bg-hi: #fecaca; + --green-bg-hi: #bbf7d0; + --purple: #8b5cf6; + --purple-bg: #f5f3ff; + --cyan: #06b6d4; + --cyan-bg: #ecfeff; + --orange: #f97316; + --orange-bg: #fff7ed; + --indigo: #6366f1; + + --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace; + --sans: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, sans-serif; +} +[data-theme="dark"] { + --bg: #0d1117; + --bg-card: #161b22; + --bg-hover: #1c2128; + --bg-active: #252c35; + --bg-code: #1c2128; + --border: #30363d; + --border-light: #21262d; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.3); + --shadow-md: 0 2px 8px rgba(0,0,0,0.4); + --text: #e6edf3; + --text-secondary: #8b949e; + --text-tertiary: #6e7681; + --blue: #58a6ff; + --blue-bg: #0d2240; + --green: #3fb950; + --green-bg: #0d2d1a; + --amber: #d29922; + --amber-bg: #2d2200; + --red: #f85149; + --red-bg: #3d1117; + --red-bg-hi: #6b2020; + --green-bg-hi: #1a5c33; + --purple: #bc8cff; + --purple-bg: #1f1338; + --cyan: #39d2c0; + --cyan-bg: #0a2e2c; + --orange: #f0883e; + --orange-bg: #2d1b00; + --indigo: #8b8fff; +} +[data-theme="dark"] .msg.user { border-color: #1f3a5f; } +[data-theme="dark"] .msg.assistant { border-color: #1a3a26; } +[data-theme="dark"] .msg.system { border-color: #3d3000; } +[data-theme="dark"] .msg.tool_result { border-color: #2a1f48; } +[data-theme="dark"] .msg pre, [data-theme="dark"] .msg .pre-text { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,0.06); } +[data-theme="dark"] .content-block.block-framed { background: rgba(255,255,255,0.03); border-color: rgba(255,255,255,0.08); } +[data-theme="dark"] .json-view .jk { color: #bc8cff; } +[data-theme="dark"] .json-view .js { color: #3fb950; } +[data-theme="dark"] .json-view .jn { color: #d29922; } +[data-theme="dark"] .json-view .jb { color: #f85149; } +[data-theme="dark"] .json-view .json-punct { color: #6e7681; } +[data-theme="dark"] .jt-toggle { color: #6e7681; } +[data-theme="dark"] .jt-toggle:hover { color: #c9d1d9; } +[data-theme="dark"] .jt-summary { color: #6e7681; } +*,*::before,*::after { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: var(--sans); background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; overflow: hidden; -webkit-font-smoothing: antialiased; } + +/* ─── Header ─── */ +.header { + display: flex; align-items: center; gap: 16px; + padding: 0 20px; min-height: 52px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + flex-shrink: 0; z-index: 10; +} +.header .logo { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.header .logo svg { width: 22px; height: 22px; } +.header .logo span { font-size: 14px; font-weight: 600; color: var(--text); letter-spacing: -0.3px; } +.header .logo .logo-version { + display: none; align-items: center; + font-size: 10px; font-weight: 600; letter-spacing: 0.2px; + color: var(--text-tertiary); background: var(--bg); + border: 1px solid var(--border); border-radius: 999px; + padding: 1px 6px; line-height: 1.4; +} +.header .divider { width: 1px; height: 24px; background: var(--border); flex-shrink: 0; } +.path-filter { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; min-width: 0; flex: 1 1 0; } +.filter-chip-toggle { + font-size: 11px; font-family: var(--sans); padding: 4px 10px; border-radius: 20px; + border: 1px dashed var(--border); background: var(--bg); color: var(--text-tertiary); + cursor: pointer; transition: all .15s; white-space: nowrap; line-height: 1; +} +.filter-chip-toggle:hover { border-color: var(--blue); color: var(--blue); } +.filter-chip { + font-size: 12px; font-family: var(--mono); padding: 4px 10px; border-radius: 20px; + border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); + cursor: pointer; transition: all .15s; white-space: nowrap; line-height: 1; +} +.filter-chip:hover { border-color: var(--blue); color: var(--blue); } +.filter-chip.active { background: var(--blue); border-color: var(--blue); color: #fff; } +.filter-chip .chip-count { font-size: 10px; margin-left: 4px; opacity: .6; } +.stats { + margin-left: auto; display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); flex-shrink: 0; +} +.stats .stat-group { display: flex; align-items: center; gap: 4px; } +.stats .stat-val { font-weight: 600; color: var(--text-secondary); font-family: var(--mono); } +.viewer-actions { + display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; +} +.viewer-action { + display: inline-flex; align-items: center; justify-content: center; gap: 5px; + min-height: 28px; padding: 4px 9px; border-radius: var(--radius-sm); + border: 1px solid var(--border); background: var(--bg-card); + color: var(--text-secondary); text-decoration: none; font-size: 12px; font-weight: 500; + transition: border-color .15s, color .15s, background .15s, transform .15s; + white-space: nowrap; +} +.viewer-action:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-bg); } +.viewer-action:active { transform: translateY(1px); } +.viewer-action svg { width: 13px; height: 13px; flex-shrink: 0; } +.export-menu { + position: relative; + display: inline-flex; +} +.export-menu summary { + list-style: none; + cursor: pointer; +} +.export-menu summary::-webkit-details-marker { display: none; } +.export-menu[open] .viewer-action { + border-color: var(--blue); + color: var(--blue); + background: var(--blue-bg); +} +.export-menu-list { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 40; + display: grid; + gap: 2px; + min-width: 150px; + padding: 5px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14); +} +.export-menu-item { + display: flex; + align-items: center; + min-height: 30px; + padding: 6px 9px; + border-radius: 6px; + color: var(--text-secondary); + text-decoration: none; + font-family: var(--sans); + font-size: 12px; + white-space: nowrap; +} +.export-menu-item:hover { + color: var(--blue); + background: var(--blue-bg); +} +.trace-path-bar { + display: none; align-items: center; gap: 6px; padding: 4px 12px; + background: var(--bg); border-bottom: 1px solid var(--border); + font-size: 11px; color: var(--text-tertiary); font-family: var(--mono); + flex-shrink: 0; +} +.trace-path-bar .tp-label { font-family: var(--sans); font-weight: 500; color: var(--text-secondary); flex-shrink: 0; } +.trace-path-bar .tp-val { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } +.trace-path-bar .tp-copy { + flex-shrink: 0; cursor: pointer; border: none; background: none; + color: var(--text-tertiary); padding: 2px 4px; border-radius: 3px; line-height: 1; +} +.trace-path-bar .tp-copy:hover { color: var(--blue); background: var(--blue-bg); } +.trace-path-bar .tp-sep { width: 1px; height: 14px; background: var(--border); flex-shrink: 0; } + +/* ─── Layout ─── */ +.main { display: flex; flex: 1; overflow: hidden; } + +/* ─── Sidebar ─── */ +#sidebar-wrap { + width: 300px; min-width: 260px; flex-shrink: 0; + border-right: 1px solid var(--border); + background: var(--bg-card); +} +.sidebar { + overflow-y: auto; +} +.sidebar::-webkit-scrollbar { width: 4px; } +.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +.history-bar { + display: none; align-items: center; gap: 8px; + padding: 6px 12px; border-bottom: 1px solid var(--border); + font-size: 13px; +} +.history-label { color: var(--text-secondary); white-space: nowrap; } +.history-select { + min-width: 0; flex: 1; padding: 3px 6px; + border-radius: 4px; border: 1px solid var(--border); + background: var(--bg-card); color: var(--text); + font-size: 13px; cursor: pointer; +} +.history-delete-btn { + display: inline-flex; align-items: center; justify-content: center; gap: 5px; + min-height: 26px; padding: 3px 7px; + border: 1px solid var(--border); border-radius: 6px; + background: var(--bg-card); color: var(--red); + font-family: var(--sans); font-size: 12px; line-height: 1; + cursor: pointer; transition: color .15s, border-color .15s, background .15s, opacity .15s; +} +.history-delete-btn svg { width: 13px; height: 13px; flex-shrink: 0; } +.history-delete-btn:hover:not(:disabled) { border-color: var(--red); background: var(--red-bg); } +.history-delete-btn:disabled { + cursor: not-allowed; opacity: .45; color: var(--text-tertiary); +} +.history-delete-status { + display: none; padding: 4px 12px; border-bottom: 1px solid var(--border); + font-size: 11px; color: var(--text-secondary); background: var(--bg); +} +.history-delete-status.ok { color: var(--green); } +.history-delete-status.warn { color: var(--amber); } +.history-delete-status.error { color: var(--red); } + +.sidebar-group-header { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; cursor: pointer; user-select: none; + font-size: 12px; font-weight: 600; color: var(--text-secondary); + background: var(--bg); border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 1; + text-transform: uppercase; letter-spacing: .5px; +} +.sidebar-group-header:hover { background: var(--bg-hover); } +.sidebar-group-header .group-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; +} +.sidebar-group-header .group-name { flex: 1; min-width: 0; } +.sidebar-group-header .group-count { + font-size: 10px; font-weight: 500; color: var(--text-tertiary); + background: var(--bg-hover); padding: 1px 6px; border-radius: 8px; +} +.sidebar-group-header .group-chevron { + font-size: 10px; color: var(--text-tertiary); transition: transform .15s; +} +.sidebar-group-header .group-chevron.open { transform: rotate(90deg); } +.session-hover-tooltip { + position: fixed; + z-index: 80; + display: none; + max-height: min(360px, calc(100vh - 24px)); + overflow: auto; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); + color: var(--text); + box-shadow: var(--shadow-md); + font-size: 12px; + font-weight: 500; + line-height: 1.55; + letter-spacing: 0; + text-transform: none; + white-space: pre-wrap; + overflow-wrap: anywhere; + pointer-events: none; +} +.session-hover-tooltip.visible { display: block; } + +.sidebar-item { + padding: 10px 16px; cursor: pointer; transition: background .1s; + border-bottom: 1px solid var(--border-light); position: relative; + border-left: 3px solid transparent; +} +.sidebar-item:hover { background: var(--bg-hover); } +.sidebar-item.active { background: var(--blue-bg); } +.sidebar-item .si-row1 { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } +.sidebar-item .si-turn-wrap { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } +.sidebar-item .si-turn { font-size: 13px; font-weight: 600; color: var(--text); } +.sidebar-item .si-error-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--red); box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); + flex-shrink: 0; +} +.sidebar-item.is-error { border-left-color: var(--red) !important; } +.sidebar-item.is-error .si-path { color: var(--red); } +.sidebar-item .si-model { + font-size: 11px; padding: 1px 6px; border-radius: 4px; + font-weight: 500; margin-left: auto; +} +.sidebar-item .si-row2 { display: flex; gap: 10px; font-size: 11px; } +.sidebar-item .si-tok { color: var(--green); font-family: var(--mono); font-weight: 500; } +.sidebar-item .si-dur { color: var(--amber); font-family: var(--mono); font-weight: 500; } +.sidebar-item .si-time { color: var(--text-tertiary); font-family: var(--mono); font-weight: 500; margin-left: auto; } +.sidebar-item .si-task { + font-size: 10px; padding: 1px 5px; border-radius: 3px; + font-weight: 500; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; +} +.sidebar-item .si-path { + font-size: 10px; color: var(--text-tertiary); font-family: var(--mono); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; +} + +/* ─── Detail ─── */ +.detail { flex: 1; overflow-y: auto; padding: 12px 20px; background: var(--bg); } +.detail::-webkit-scrollbar { width: 8px; } +.detail::-webkit-scrollbar-thumb { background: var(--scrollbar-bg1); border-radius: 4px; } +.error-banner { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px 14px; margin: 0 0 12px; + border: 1px solid var(--red); border-left: 4px solid var(--red); + border-radius: var(--radius-sm); background: var(--red-bg); +} +.error-banner .eb-icon { color: var(--red); font-size: 15px; line-height: 1.2; flex-shrink: 0; } +.error-banner .eb-content { min-width: 0; } +.error-banner .eb-title { font-size: 13px; font-weight: 700; color: var(--red); line-height: 1.4; } +.error-banner .eb-message { + margin-top: 2px; font-size: 12px; color: var(--text-secondary); + white-space: pre-wrap; word-break: break-word; +} +.continuation-banner { + display: flex; align-items: flex-start; gap: 10px; + padding: 12px 14px; margin: 0 0 12px; + border: 1px solid var(--amber); border-left: 4px solid var(--amber); + border-radius: var(--radius-sm); background: var(--amber-bg); +} +.continuation-banner .cb-icon { color: var(--amber); font-size: 15px; line-height: 1.2; flex-shrink: 0; } +.continuation-banner .cb-content { min-width: 0; } +.continuation-banner .cb-title { font-size: 13px; font-weight: 700; color: var(--amber); line-height: 1.4; } +.continuation-banner .cb-message { + margin-top: 2px; font-size: 12px; color: var(--text-secondary); + white-space: pre-wrap; word-break: break-word; +} +.continuation-banner .cb-meta { + display: grid; grid-template-columns: max-content minmax(0, 1fr); + gap: 4px 10px; margin-top: 10px; font-size: 11px; +} +.continuation-banner .cb-key { color: var(--text-tertiary); font-family: var(--mono); } +.continuation-banner .cb-val { + color: var(--text); font-family: var(--mono); + overflow-wrap: anywhere; +} + +/* ─── Action bar ─── */ +.action-bar { + display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; + position: sticky; top: 6px; z-index: 6; + width: fit-content; max-width: 100%; + margin: 0 0 8px; + padding: 4px 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.85); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); +} +.act-btn { + display: inline-flex; align-items: center; gap: 4px; + font-size: 11px; padding: 4px 8px; border-radius: 6px; + border: none; background: transparent; + color: var(--text-secondary); cursor: pointer; font-family: var(--sans); + transition: all .15s; +} +.act-btn:hover { background: var(--bg-hover); color: var(--blue); } +.act-btn:active { transform: translateY(1px); } +.act-btn svg { width: 12px; height: 12px; } +[data-theme="dark"] .action-bar { + background: rgba(22, 27, 34, 0.82); + border-color: #3a4048; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} +.detail-inspector-bar { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + margin: 0 0 10px; + padding: 6px; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: 0 4px 16px rgba(15, 23, 42, 0.07); +} +[data-theme="dark"] .detail-inspector-bar { + background: rgba(22, 27, 34, 0.9); + border-color: #3a4048; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28); +} +.detail-tabs { + display: inline-flex; align-items: center; gap: 3px; flex-wrap: wrap; + min-width: 0; +} +.detail-tab { + display: inline-flex; align-items: center; gap: 6px; + min-height: 30px; padding: 5px 10px; + border: 1px solid transparent; border-radius: 6px; + background: transparent; color: var(--text-secondary); + cursor: pointer; font-family: var(--mono); font-size: 11px; line-height: 1; + transition: color .15s, border-color .15s, background .15s; +} +.detail-tab:hover { color: var(--blue); border-color: var(--border); background: var(--bg-hover); } +.detail-tab.active { + color: var(--blue); + border-color: color-mix(in srgb, var(--blue) 45%, transparent); + background: var(--blue-bg); +} +.trace-format-bar { + display: flex; align-items: center; justify-content: flex-end; gap: 4px; + margin: -2px 0 8px; +} +.trace-format-btn { + min-height: 26px; padding: 4px 9px; + border: 1px solid var(--border); border-radius: 6px; + background: var(--bg-card); color: var(--text-secondary); + cursor: pointer; font-family: var(--mono); font-size: 10px; line-height: 1; + transition: color .15s, border-color .15s, background .15s; +} +.trace-format-btn:hover { color: var(--blue); border-color: var(--blue); } +.trace-format-btn.active { + color: var(--blue); + border-color: color-mix(in srgb, var(--blue) 45%, transparent); + background: var(--blue-bg); +} +.trace-grid { display: grid; gap: 10px; } +.trace-block { + border: 1px solid var(--border); + border-radius: 7px; + background: var(--bg-card); + overflow: hidden; +} +.trace-block-title { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 8px 11px; + border-bottom: 1px solid var(--border-light); + color: var(--text); + font-family: var(--mono); + font-size: 12px; + font-weight: 700; +} +.trace-block-title .trace-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.trace-block-title .trace-actions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: auto; + flex-shrink: 0; +} +.trace-block-title .trace-badge { + color: var(--text-tertiary); + font-size: 10px; + font-weight: 500; +} +.trace-block-title .trace-copy-btn { + font-size: 11px; padding: 3px 10px; border-radius: var(--radius-sm); + border: 1px solid var(--border); background: var(--bg-card); + color: var(--text-tertiary); cursor: pointer; transition: all .15s; +} +.trace-block-title .trace-copy-btn:hover { color: var(--blue); border-color: var(--blue); } +.trace-code { + padding: 12px 13px; + max-height: 360px; + overflow: auto; + background: var(--bg-code); + color: var(--text); + font-family: var(--mono); + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + border: none; +} +.trace-pretty { + padding: 10px 12px; + max-height: 360px; + overflow: auto; + background: var(--bg-code); + font-family: var(--mono); + font-size: 12px; + line-height: 1.55; +} +.trace-pretty-object, .trace-pretty-array { display: grid; gap: 7px; } +.trace-kv, .trace-nested, .trace-array-item { + border-left: 2px solid var(--border-light); + padding-left: 9px; +} +.trace-key, .trace-nested-title, .trace-index { + display: inline-block; + margin-right: 8px; + color: var(--text-tertiary); + font-weight: 700; +} +.trace-index { + min-width: 22px; + color: var(--blue); +} +.trace-value { color: var(--text); } +.trace-string-block { + margin: 5px 0 0; + white-space: pre-wrap; + word-break: break-word; + color: var(--text); +} +.trace-empty { color: var(--text-tertiary); } + +/* ─── Section ─── */ +.section { + margin-bottom: 8px; border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; + background: var(--bg-card); box-shadow: var(--shadow-sm); +} +.section-header { + display: flex; align-items: center; padding: 8px 12px; + cursor: pointer; user-select: none; gap: 8px; + transition: background .1s; +} +.section-header:hover { background: var(--bg-hover); } +.section-header .chevron { + font-size: 10px; color: var(--text-tertiary); transition: transform .2s; width: 12px; +} +.section-header .chevron.open { transform: rotate(90deg); } +.section-header .title { font-size: 13px; font-weight: 600; color: var(--text); } +.section-header .badge { + font-size: 11px; padding: 2px 8px; border-radius: 10px; + background: var(--bg); color: var(--text-tertiary); font-weight: 500; margin-left: auto; +} +.section-header .copy-btn { + font-size: 11px; padding: 3px 10px; border-radius: var(--radius-sm); + border: 1px solid var(--border); background: var(--bg-card); + color: var(--text-tertiary); cursor: pointer; margin-left: 6px; transition: all .15s; +} +.section-header .copy-btn:hover { color: var(--blue); border-color: var(--blue); } +.section-body { padding: 0 16px 16px; display: none; overflow-x: auto; } +.section-body.open { display: block; } +.section-body pre, .section-body .pre-text { + white-space: pre-wrap; word-break: break-word; font-family: var(--mono); + font-size: 12px; background: var(--bg-code); padding: 12px 14px; + border-radius: var(--radius-sm); line-height: 1.6; border: 1px solid var(--border-light); + max-height: 500px; overflow-y: auto; +} + +/* ─── Messages ─── */ +.msg { + margin-bottom: 8px; border-radius: var(--radius-sm); + padding: 12px 16px; font-size: 13px; line-height: 1.6; + border: 1px solid var(--border-light); +} +.msg.user { background: var(--blue-bg); border-color: #c7d2fe; } +.msg.assistant { background: var(--green-bg); border-color: #a7f3d0; } +.msg.system { background: var(--amber-bg); border-color: #fde68a; } +.msg.tool_result { background: var(--purple-bg); border-color: #c4b5fd; } +.msg-role { + display: inline-block; font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: .5px; padding: 2px 8px; border-radius: 4px; margin-bottom: 8px; +} +.msg.user .msg-role { background: var(--blue); color: #fff; } +.msg.assistant .msg-role { background: var(--green); color: #fff; } +.msg.system .msg-role { background: var(--amber); color: #fff; } +.msg.tool_result .msg-role { background: var(--purple); color: #fff; } +.msg pre, .msg .pre-text { + white-space: pre-wrap; word-break: break-word; font-family: var(--mono); + font-size: 12px; background: rgba(0,0,0,0.04); padding: 10px 12px; + border-radius: var(--radius-sm); margin-top: 8px; max-height: 400px; + overflow-y: auto; border: 1px solid rgba(0,0,0,0.06); line-height: 1.5; +} +.content-block { margin-top: 8px; white-space: pre-wrap; word-break: break-word; } +.content-block:first-child { margin-top: 0; } +.content-block.block-framed { + border: 1px solid var(--border-light); + border-radius: var(--radius-sm); padding: 9px 11px; + background: rgba(255,255,255,0.45); +} +.content-block-text { white-space: pre-wrap; word-break: break-word; } +.system-prompt-blocks .content-block-text { + font-family: var(--mono); font-size: 12px; line-height: 1.6; +} +.content-image, +.message-image { + display: block; width: auto; height: auto; + min-width: min(160px, 100%); max-width: min(100%, 720px); max-height: 420px; + border-radius: 6px; border: 1px solid var(--border); background: var(--bg); + object-fit: contain; +} +.image-block { white-space: normal; } +.content-image-placeholder { + display: inline-flex; align-items: center; max-width: 100%; + padding: 3px 8px; border: 1px solid var(--border); border-radius: 6px; + color: var(--muted); background: var(--bg); font-family: var(--mono); + font-size: 12px; overflow-wrap: anywhere; +} +.tool-use-label { + display: inline-flex; align-items: center; gap: 4px; + font-size: 11px; font-weight: 600; color: var(--cyan); + background: var(--cyan-bg); padding: 2px 8px; border-radius: 4px; margin-bottom: 4px; +} +.thinking-label { + display: inline-flex; align-items: center; gap: 4px; + font-size: 11px; font-weight: 600; color: var(--indigo); + background: #eef2ff; padding: 2px 8px; border-radius: 4px; margin-bottom: 4px; +} + +/* ─── Tool blocks ─── */ +.tool-block { + border: 1px solid var(--border-light); border-radius: var(--radius-sm); + margin-bottom: 4px; overflow: hidden; background: var(--bg-card); +} +.tool-block-header { + display: flex; align-items: baseline; gap: 10px; + padding: 8px 12px; cursor: pointer; user-select: none; transition: background .1s; +} +.tool-block-header:hover { background: var(--bg-hover); } +.tb-arrow { font-size: 9px; color: var(--text-tertiary); transition: transform .15s; flex-shrink: 0; } +.tb-arrow.open { transform: rotate(90deg); } +.tb-name { color: var(--cyan); font-family: var(--mono); font-size: 12px; font-weight: 600; flex-shrink: 0; } +.tb-desc { color: var(--text-tertiary); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } +.tool-block-body { + display: none; padding: 10px 12px 12px; border-top: 1px solid var(--border-light); font-size: 12px; +} +.tool-block-body.open { display: block; } +.tb-full-desc { + color: var(--text-secondary); white-space: pre-wrap; word-break: break-word; + margin-bottom: 10px; line-height: 1.55; font-size: 12px; +} +.tb-params-title { font-size: 11px; font-weight: 600; color: var(--text); margin-bottom: 8px; text-transform: uppercase; letter-spacing: .3px; } +.tb-param { + padding: 8px 10px; margin-bottom: 4px; border-radius: var(--radius-sm); + background: var(--bg); font-size: 12px; +} +.tb-param:last-child { margin-bottom: 0; } +.tb-param-row1 { display: flex; align-items: center; gap: 8px; } +.tb-pname { color: var(--blue); font-family: var(--mono); font-weight: 600; font-size: 12px; } +.tb-ptype { + font-size: 10px; font-family: var(--mono); color: var(--amber); + background: var(--amber-bg); padding: 1px 6px; border-radius: 3px; font-weight: 500; +} +.tb-prequired { + font-size: 10px; font-weight: 600; color: var(--red); + background: var(--red-bg); padding: 1px 6px; border-radius: 3px; +} +.tb-pdesc { + color: var(--text-tertiary); margin-top: 4px; line-height: 1.5; + white-space: pre-wrap; word-break: break-word; font-size: 12px; +} + +/* ─── Token usage ─── */ +.token-bar { display: flex; gap: 12px; font-size: 11px; padding: 4px 0 6px; flex-wrap: wrap; margin-bottom: 4px; } +.tok-item { display: flex; align-items: center; gap: 4px; } +.tok-dot { width: 6px; height: 6px; border-radius: 50%; } +.tok-label { color: var(--text-tertiary); font-size: 11px; } +.tok-val { color: var(--text-secondary); font-weight: 600; font-family: var(--mono); font-size: 11px; } + +/* ─── JSON viewer ─── */ +.json-view { + font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; + max-height: 600px; overflow-y: auto; line-height: 1.55; + background: var(--bg-code); padding: 14px; border-radius: var(--radius-sm); + border: 1px solid var(--border-light); +} +.json-view .jk { color: #7c3aed; } +.json-view .js { color: #059669; } +.json-view .jn { color: #d97706; } +.json-view .jb { color: #dc2626; } +.json-view .jnull { color: var(--text-tertiary); } +.json-view .json-punct { color: var(--text-tertiary); } +.tool-input-readable { + display: block; + max-width: 100%; + position: relative; +} +.tool-input-actions { + position: absolute; + top: 6px; + right: 6px; + z-index: 2; + display: inline-flex; + gap: 4px; + margin: 0; +} +.tool-input-btn { + border: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-card) 92%, transparent); + color: var(--text-secondary); + border-radius: 999px; + min-width: 24px; + height: 22px; + padding: 0 7px; + font-family: var(--sans); + font-size: 10px; + line-height: 20px; + cursor: pointer; + box-shadow: var(--shadow-sm); +} +.tool-input-btn:hover { + color: var(--blue); + border-color: var(--blue); + background: var(--blue-bg); +} +.tool-input-view { + max-height: 420px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + padding-right: 84px; +} +.tool-input-view.expanded { + border-color: color-mix(in srgb, var(--blue) 35%, var(--border)); + background: var(--blue-bg); +} + +/* JSON tree */ +.jt-line { min-height: 1.55em; } +.jt-toggle { + cursor: pointer; user-select: none; display: inline-block; width: 16px; color: var(--text-tertiary); + font-size: 10px; line-height: 1.55; vertical-align: top; margin-right: 2px; flex-shrink: 0; +} +.jt-toggle:hover { color: var(--text-primary); } +.jt-children { margin-left: 18px; display: none; } +.jt-children.jt-open { display: block; } +.jt-close { } +.jt-close.jt-hidden { display: none; } +.jt-summary { display: none; color: var(--text-tertiary); font-style: italic; } +.jt-summary.jt-show { display: inline; } + +/* ─── Diff modal ─── */ +.diff-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.35); + display: flex; align-items: center; justify-content: center; + overflow-y: auto; padding: 16px 12px; + z-index: 100; backdrop-filter: blur(2px); +} +.diff-modal { + background: var(--bg); border-radius: 14px; + width: 96vw; max-width: 1400px; height: 92vh; max-height: 92vh; + display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 24px 80px rgba(0,0,0,0.2); +} +.diff-header { + display: flex; align-items: center; padding: 16px 20px; + background: var(--bg-card); border-bottom: 1px solid var(--border); + border-radius: 14px 14px 0 0; +} +.diff-header .diff-title { font-size: 14px; font-weight: 600; color: var(--text); } +.diff-nav-btn { + background: var(--bg-hover); border: 1px solid var(--border); border-radius: var(--radius-sm); + font-size: 13px; color: var(--text-secondary); cursor: pointer; + padding: 3px 10px; line-height: 1.2; +} +.diff-nav-btn:hover:not(:disabled) { background: var(--bg-active); color: var(--text); } +.diff-nav-btn:disabled { opacity: 0.3; cursor: default; } +.diff-nav-prev { margin-right: 8px; } +.diff-nav-next { margin-left: 8px; } +.diff-header .diff-close { + margin-left: auto; background: none; border: none; + font-size: 18px; color: var(--text-tertiary); cursor: pointer; + padding: 4px 10px; border-radius: var(--radius-sm); line-height: 1; +} +.diff-header .diff-close:hover { background: var(--bg-hover); color: var(--text); } +.diff-body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 20px; } +.diff-fallback-banner { + display: flex; align-items: center; gap: 8px; + padding: 8px 14px; margin: 8px 20px 0; + background: var(--amber-bg); border: 1px solid var(--amber); + border-radius: var(--radius-sm); font-size: 12px; color: var(--amber); + font-weight: 500; +} +.diff-fallback-banner .dfb-icon { font-size: 14px; flex-shrink: 0; } +.diff-target-select { + margin-left: auto; display: flex; align-items: center; gap: 6px; + font-size: 12px; color: var(--text-secondary); +} +.diff-target-select select { + font-size: 11px; color: var(--text); background: var(--bg-hover); + border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 2px 6px; cursor: pointer; max-width: 200px; +} +.diff-target-select select:hover { border-color: var(--text-tertiary); } +.diff-target-select select:focus { outline: none; border-color: var(--accent, #6c8ebf); } +.diff-section { + margin-bottom: 14px; background: var(--bg-card); + border: 1px solid var(--border); border-radius: var(--radius); + overflow: hidden; +} +.diff-section-header { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; font-size: 13px; font-weight: 600; color: var(--text); +} +.diff-section-header .ds-badge { + font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 10px; +} +.diff-section-header .ds-badge.add { background: var(--green-bg); color: var(--green); } +.diff-section-header .ds-badge.del { background: var(--red-bg); color: var(--red); } +.diff-section-header .ds-badge.change { background: var(--amber-bg); color: var(--amber); } +.diff-section-header .ds-badge.same { background: var(--bg); color: var(--text-tertiary); } +.diff-section-body { padding: 0 16px 14px; } +.diff-unchanged-bar { + display: flex; align-items: center; gap: 8px; + padding: 10px 14px; border-radius: var(--radius-sm); + background: var(--bg); color: var(--text-tertiary); + font-size: 12px; margin-bottom: 8px; +} +.diff-unchanged-bar .dub-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--border); } +.diff-new-msg { + position: relative; margin-bottom: 8px; + border: 2px solid var(--green); border-radius: var(--radius-sm); + max-height: 220px; overflow-y: auto; +} +.diff-new-msg::before { + content: attr(data-label); position: absolute; top: -1px; right: 10px; + font-size: 9px; font-weight: 700; letter-spacing: .5px; + color: #fff; background: var(--green); padding: 2px 8px; + border-radius: 0 0 4px 4px; +} +.diff-removed-msg { + position: relative; margin-bottom: 8px; + border: 2px solid var(--red); border-radius: var(--radius-sm); opacity: .7; + max-height: 220px; overflow-y: auto; +} +.diff-removed-msg::before { + content: attr(data-label); position: absolute; top: -1px; right: 10px; + font-size: 9px; font-weight: 700; letter-spacing: .5px; + color: #fff; background: var(--red); padding: 2px 8px; + border-radius: 0 0 4px 4px; +} +.diff-modified-msg { + margin-bottom: 8px; + border: 2px solid var(--amber); border-radius: var(--radius-sm); + max-height: 260px; overflow-y: auto; +} +.diff-modified-msg .sbs-diff { border: none; border-radius: 0; } +.diff-field-row { + display: flex; align-items: baseline; gap: 10px; + padding: 6px 0; border-bottom: 1px solid var(--border-light); + font-size: 12px; +} +.diff-field-row:last-child { border-bottom: none; } +.diff-field-key { font-family: var(--mono); font-weight: 600; color: var(--text); min-width: 120px; flex-shrink: 0; } +.diff-field-old { + font-family: var(--mono); color: var(--red); text-decoration: line-through; + background: var(--red-bg); padding: 1px 6px; border-radius: 3px; max-width: 300px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.diff-field-arrow { color: var(--text-tertiary); flex-shrink: 0; } +.diff-field-new { + font-family: var(--mono); color: var(--green); + background: var(--green-bg); padding: 1px 6px; border-radius: 3px; max-width: 300px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.diff-param-change, .diff-tool-detail { + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + margin-bottom: 10px; + overflow: hidden; +} +.diff-param-change:last-child, .diff-tool-detail:last-child { margin-bottom: 0; } +.diff-param-change summary, .diff-tool-detail summary { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 12px; + cursor: pointer; + font-size: 12px; + color: var(--text); + background: var(--bg-secondary); +} +.diff-param-change summary:hover, .diff-tool-detail summary:hover { background: var(--bg-hover); } +.diff-param-key, .diff-tool-name { + font-family: var(--mono); + font-weight: 600; + color: var(--cyan); +} +.diff-param-body, .diff-tool-body { padding: 10px 12px 12px; } +.diff-param-body .sbs-diff { border: none; } +.diff-tool-desc { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + margin-bottom: 10px; +} +.diff-tool-nested { + color: var(--text-secondary); + font-size: 12px; + margin-bottom: 10px; +} +.diff-tool-nested strong { color: var(--text); } +.diff-tool-nested ul { margin: 6px 0 0 18px; padding: 0; } +.diff-tool-nested li { margin: 3px 0; } +.diff-tool-json { + max-height: 420px; + overflow: auto; + background: var(--code-bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px; + font-family: var(--mono); + font-size: 11px; + line-height: 1.5; + white-space: pre; +} +/* ─── Side-by-side JSON diff ─── */ +.diff-side-by-side { display: flex; gap: 2px; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; } +.diff-side-by-side .diff-panel { flex: 1; min-width: 0; overflow: auto; } +.diff-side-by-side .diff-panel-header { + padding: 6px 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; + border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 1; +} +.diff-side-by-side .diff-panel.old { background: var(--red-bg); } +.diff-side-by-side .diff-panel.old .diff-panel-header { background: var(--red-bg); color: var(--red); } +.diff-side-by-side .diff-panel.new { background: var(--green-bg); } +.diff-side-by-side .diff-panel.new .diff-panel-header { background: var(--green-bg); color: var(--green); } +.diff-side-by-side pre { + margin: 0; padding: 10px 12px; font-family: var(--mono); font-size: 11px; + line-height: 1.5; white-space: pre-wrap; word-break: break-word; color: var(--text); +} + +/* ─── Side-by-side line diff ─── */ +.sbs-diff { + display: grid; grid-template-columns: 1fr 1fr; + font-family: var(--mono); font-size: 12px; line-height: 1.6; + border: 1px solid var(--border); border-radius: var(--radius-sm); + overflow: auto; max-height: 400px; +} +.sbs-diff .sbs-header { + padding: 4px 12px; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: .5px; + border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 1; background: var(--bg-card); +} +.sbs-diff .sbs-header.old { color: var(--red); } +.sbs-diff .sbs-header.new { color: var(--green); } +.sbs-cell { + padding: 1px 10px; white-space: pre-wrap; word-break: break-word; + border-bottom: 1px solid var(--border-light); +} +.sbs-cell.ctx { color: var(--text-tertiary); opacity: 0.5; } +.sbs-cell.del { background: var(--red-bg); color: var(--red); } +.sbs-cell.add { background: var(--green-bg); color: var(--green); } +.sbs-cell.empty { background: var(--bg-code); } +.sbs-fold { + grid-column: 1 / -1; text-align: center; + padding: 2px 12px; color: var(--text-tertiary); font-style: italic; + background: var(--bg); border-top: 1px solid var(--border-light); + border-bottom: 1px solid var(--border-light); font-size: 11px; +} +.sys-diff-del-hi { background: var(--red-bg-hi); border-radius: 3px; padding: 1px 1px; } +.sys-diff-add-hi { background: var(--green-bg-hi); border-radius: 3px; padding: 1px 1px; } + +/* ─── SSE events ─── */ +.sse-event { margin-bottom: 4px; font-family: var(--mono); font-size: 12px; padding: 3px 0; } +.sse-type { color: var(--cyan); font-weight: 600; margin-right: 8px; } +.sse-data { color: var(--text-tertiary); white-space: pre-wrap; word-break: break-word; } + +/* ─── Drop zone ─── */ +.drop-zone { display: flex; align-items: center; justify-content: center; flex: 1; padding: 40px; } +.drop-zone-inner { + border: 2px dashed var(--border); border-radius: 16px; + padding: 60px 40px; text-align: center; max-width: 480px; + transition: all .25s; background: var(--bg-card); +} +.drop-zone-inner.dragover { border-color: var(--blue); background: var(--blue-bg); } +.drop-zone-inner h2 { font-size: 18px; color: var(--text); margin-bottom: 8px; font-weight: 600; } +.drop-zone-inner p { font-size: 14px; color: var(--text-secondary); margin-bottom: 20px; } +.drop-zone-inner input[type="file"] { display: none; } +.drop-zone-inner label { + display: inline-block; padding: 10px 24px; background: var(--blue); + color: #fff; border-radius: var(--radius-sm); cursor: pointer; font-size: 14px; + font-weight: 500; transition: background .15s; +} +.drop-zone-inner label:hover { background: #2563eb; } + +/* ─── Empty state ─── */ +.empty-state { padding: 40px; text-align: center; color: var(--text-tertiary); font-size: 14px; } +.empty-trace-state { + border-style: solid; + max-width: 620px; + text-align: left; +} +.empty-trace-state h2 { margin-bottom: 10px; } +.empty-trace-state p { margin-bottom: 14px; line-height: 1.6; } +.empty-trace-meta { + display: flex; flex-wrap: wrap; gap: 8px; margin: 18px 0 22px; +} +.empty-trace-pill { + display: inline-flex; align-items: center; gap: 6px; + border: 1px solid var(--border); border-radius: 999px; + padding: 5px 10px; background: var(--bg); color: var(--text-secondary); + font-size: 12px; font-family: var(--mono); +} + +/* ─── Scrollbar for detail ─── */ +/* ─── Search bar ─── */ +.search-bar { + position: relative; padding: 8px 12px; border-bottom: 1px solid var(--border); + background: var(--bg-card); +} +.search-bar input { + width: 100%; padding: 6px 10px 6px 30px; font-size: 12px; font-family: var(--sans); + border: 1px solid var(--border); border-radius: var(--radius-sm); + background: var(--bg); color: var(--text); outline: none; transition: border-color .15s; +} +.search-bar input:focus { border-color: var(--blue); } +.search-bar input::placeholder { color: var(--text-tertiary); } +.search-bar .search-icon { + position: absolute; left: 20px; top: 50%; transform: translateY(-50%); + color: var(--text-tertiary); font-size: 13px; pointer-events: none; +} +.search-bar .search-clear { + position: absolute; right: 20px; top: 50%; transform: translateY(-50%); + background: none; border: none; color: var(--text-tertiary); cursor: pointer; + font-size: 14px; padding: 2px 4px; display: none; line-height: 1; +} +.search-bar .search-clear:hover { color: var(--text); } + +.sidebar-sort { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-card); +} +.sidebar-sort-label { + font-size: 10px; + font-weight: 700; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: .4px; + white-space: nowrap; +} +.sidebar-sort-segment { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + flex: 1; + min-width: 0; + padding: 2px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); +} +.sidebar-sort-btn { + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + font-size: 11px; + font-weight: 600; + line-height: 1; + min-width: 0; + padding: 5px 8px; + transition: background .15s, color .15s; +} +.sidebar-sort-btn:hover { color: var(--text); } +.sidebar-sort-btn.active { + background: var(--bg-card); + color: var(--blue); + box-shadow: var(--shadow-sm); +} + +/* ─── Global find (Cmd/Ctrl+F) ─── */ +.global-search-overlay { + position: fixed; + top: 14px; + right: 16px; + z-index: 2000; + display: none; +} +.global-search-overlay.open { display: block; } +.global-search-box { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-card); + box-shadow: var(--shadow-lg); +} +.global-search-box input { + width: 260px; + font-size: 12px; + font-family: var(--sans); + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + outline: none; +} +.global-search-box input:focus { border-color: var(--blue); } +.global-search-count { + min-width: 90px; + text-align: right; + font-size: 11px; + color: var(--text-secondary); + font-family: var(--mono); +} +.global-search-btn { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text-secondary); + font-size: 11px; + padding: 5px 8px; + cursor: pointer; +} +.global-search-btn:hover { color: var(--blue); border-color: var(--blue); } +.global-search-hit { + background: #fde047; + color: inherit; + border-radius: 2px; + padding: 0; +} +.global-search-hit.current { + background: #fb923c; +} + +/* ─── Virtual scroll sidebar ─── */ +.sidebar.virtual-scroll { + position: relative; + overflow-y: auto; +} +.sidebar .vs-spacer { + position: relative; + width: 100%; +} +.sidebar .vs-spacer .sidebar-item { + position: absolute; + left: 0; + right: 0; +} + +/* ─── Position indicator ─── */ +.position-indicator { + display: none; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 12px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + font-size: 11px; + color: var(--text-secondary); + font-family: var(--mono); + font-weight: 500; +} +.position-indicator .pi-current { + color: var(--blue); + font-weight: 700; +} +.position-indicator .pi-total { + color: var(--text-tertiary); +} +.position-indicator .pi-bar { + flex: 1; + max-width: 120px; + height: 3px; + background: var(--border); + border-radius: 2px; + overflow: hidden; +} +.position-indicator .pi-fill { + height: 100%; + background: var(--blue); + border-radius: 2px; + transition: width 0.15s; +} + +/* ─── Time gap indicator ─── */ +.time-gap { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 3px 12px; + font-size: 10px; + color: var(--text-tertiary); + background: var(--bg); + border-bottom: 1px solid var(--border-light); +} +.time-gap .tg-line { + flex: 1; + height: 1px; + background: var(--border); + max-width: 40px; +} + +/* ─── Theme toggle ─── */ +.theme-toggle { + background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 4px 8px; cursor: pointer; color: var(--text-secondary); display: flex; align-items: center; + transition: all .15s; font-size: 14px; line-height: 1; +} +.theme-toggle:hover { border-color: var(--blue); color: var(--blue); } + +/* ─── Language selector ─── */ +.lang-select { + font-size: 12px; padding: 4px 8px; border-radius: var(--radius-sm); + border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); + cursor: pointer; font-family: var(--sans); outline: none; margin-left: 8px; +} +.lang-select:hover { border-color: var(--blue); } + +/* ─── RTL support ─── */ +[dir="rtl"] #sidebar-wrap { border-right: none; border-left: 1px solid var(--border); } +[dir="rtl"] .sidebar-item { border-left: none; border-right: 3px solid transparent; } +[dir="rtl"] .si-model { margin-left: 0; margin-right: auto; } +[dir="rtl"] .stats { margin-left: 0; margin-right: auto; } +[dir="rtl"] .diff-field-arrow { transform: scaleX(-1); } + +@media (max-width: 900px) { + .sidebar { width: 240px; min-width: 200px; } + .detail { padding: 16px; } +} + +/* Mobile back button hidden by default on desktop */ +#mobile-back-btn { display: none; } + +/* Mobile prev/next nav bar — hidden on desktop */ +#mobile-nav-bar { display: none; } + +/* ─── Mobile responsive (≤ 768px) ─── */ +@media (max-width: 768px) { + + /* R1: Stacked layout — sidebar + detail stack vertically */ + body { overflow: auto; } + .main { flex-direction: column; overflow: visible; } + + #sidebar-wrap { + width: 100% !important; + min-width: 0 !important; + border-right: none; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + + .detail { + flex: none; + padding: 12px; + overflow: visible; + min-height: 0; + } + + /* Mobile sidebar toggle: hide/show sidebar */ + #sidebar-wrap.mobile-hidden { display: none !important; } + #detail.mobile-fullwidth { display: block !important; width: 100%; } + + /* Back button shown only on mobile */ + #mobile-back-btn { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + padding: 10px 14px; + background: var(--bg-card); + border: none; + border-bottom: 1px solid var(--border); + color: var(--blue); + cursor: pointer; + width: 100%; + min-height: 44px; + } + #mobile-back-btn:active { background: var(--bg-hover); } + + /* Mobile prev/next nav bar */ + #mobile-nav-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + min-height: 44px; + gap: 8px; + } + #mobile-nav-bar button { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--blue); + font-size: 18px; + line-height: 1; + padding: 6px 14px; + min-width: 44px; + min-height: 36px; + cursor: pointer; + } + #mobile-nav-bar button:disabled { + color: var(--text-muted); + border-color: var(--border); + cursor: default; + } + #mobile-nav-bar button:not(:disabled):active { background: var(--bg-hover); } + #mobile-nav-pos { + font-size: 13px; + color: var(--text-secondary); + flex: 1; + text-align: center; + } + + /* R2: Header — stack logo + stats vertically; path-filter scrollable */ + .header { + height: auto; + flex-wrap: wrap; + padding: 8px 12px; + gap: 8px; + } + .header .logo span { font-size: 13px; } + .header .divider { display: none; } + .path-filter { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; + scrollbar-width: none; + } + .path-filter::-webkit-scrollbar { display: none; } + .stats { + margin-left: 0; + flex-wrap: wrap; + gap: 8px; + width: 100%; + } + .stats .stat-group { font-size: 11px; } + .viewer-actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + overflow: visible; + } + .viewer-actions::-webkit-scrollbar { display: none; } + .viewer-action { min-height: 36px; padding: 7px 10px; } + .viewer-actions .export-menu { + flex-direction: column; + align-items: flex-start; + } + .viewer-actions .export-menu-list { + position: static; + left: 0; + right: auto; + margin-top: 6px; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + } + .trace-path-bar { + flex-wrap: wrap; + gap: 4px; + padding: 4px 10px; + } + .trace-path-bar .tp-val { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + #token-summary-bar { + flex-wrap: wrap; + gap: 6px; + font-size: 11px; + padding: 6px 10px; + } + + /* R3: Action buttons — stack vertically, min 44px touch target */ + .action-bar { + display: flex; + width: 100%; + top: 0; + padding: 8px; + gap: 6px; + border-radius: 10px; + flex-direction: column; + } + .act-btn { + width: 100%; + min-height: 44px; + justify-content: center; + font-size: 13px; + padding: 10px 16px; + } + /* R4: Detail panel — prevent horizontal overflow */ + .section-body { overflow-x: auto; } + .section-body pre, .section-body .pre-text { + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-width: 100%; + } + .msg pre, .msg .pre-text { + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-width: 100%; + } + .content-block { overflow-wrap: break-word; word-break: break-word; max-width: 100%; } + .json-view { overflow-x: auto; max-width: 100%; } + .token-bar { gap: 10px; } + + /* R5: Diff modal — full-screen */ + .diff-overlay { + align-items: flex-start; + padding: 0; + } + .diff-modal { + width: 100vw; + max-width: 100vw; + max-height: 100vh; + height: 100vh; + border-radius: 0; + display: flex; + flex-direction: column; + } + .diff-header { + flex-wrap: wrap; + gap: 6px; + padding: 10px 12px; + border-radius: 0; + min-height: 52px; + } + .diff-header .diff-title { font-size: 13px; order: 1; flex: 1; } + .diff-nav-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; order: 0; } + .diff-nav-prev { order: 0; } + .diff-nav-next { order: 2; } + .diff-header .diff-close { min-height: 44px; min-width: 44px; padding: 8px 12px; order: 3; font-size: 20px; } + .diff-target-select { + width: 100%; + order: 10; + margin-left: 0; + margin-top: 4px; + } + .diff-target-select select { max-width: 100%; width: 100%; } + .diff-body { flex: 1; overflow-y: auto; padding: 12px; } + + /* R5: Side-by-side diff → each pair stacked on mobile */ + .sbs-diff { + display: block; + overflow-x: auto; + max-height: none; + } + /* Hide the sticky headers; we show per-cell context via border colors */ + .sbs-diff .sbs-header { display: none; } + /* Each pair of cells becomes a mini block */ + .sbs-cell { + display: block; + border-left: 3px solid transparent; + border-bottom: 1px solid var(--border-light); + } + .sbs-cell.del { border-left-color: var(--red); } + .sbs-cell.add { border-left-color: var(--green); } + .sbs-cell.ctx { opacity: 0.5; } + .sbs-cell.empty { display: none; } + .sbs-fold { display: block; } + + /* R5: diff-side-by-side (parameters panel) → stacked */ + .diff-side-by-side { flex-direction: column; } + .diff-side-by-side .diff-panel { min-width: 0; } + + /* R6: Search bar full-width */ + .search-bar input { font-size: 14px; } + .global-search-overlay { + left: 8px; + right: 8px; + top: 8px; + } + .global-search-box { + width: 100%; + gap: 6px; + } + .global-search-box input { + width: auto; + flex: 1; + } + + /* Sidebar items: larger touch targets */ + .sidebar-item { min-height: 44px; padding: 12px 16px; } + .sidebar-group-header { min-height: 44px; } +} +body.embed-mode { + overflow: hidden !important; + background: var(--bg-card); +} +body.embed-mode .main { + height: 100vh; + min-height: 0; + background: var(--bg-card); +} +body.embed-mode.embed-hide-header .header { + display: none !important; +} +body.embed-mode.embed-hide-path .trace-path-bar { + display: none !important; +} +body.embed-mode.embed-hide-history #date-picker, +body.embed-mode.embed-hide-history #history-delete-status { + display: none !important; +} +body.embed-mode.embed-hide-controls .viewer-actions, +body.embed-mode.embed-hide-controls .theme-toggle, +body.embed-mode.embed-hide-controls .lang-select, +body.embed-mode.embed-hide-controls .sidebar-sort, +body.embed-mode.embed-hide-controls #tool-filter, +body.embed-mode.embed-hide-controls #search-bar, +body.embed-mode.embed-hide-controls #mobile-back-btn, +body.embed-mode.embed-hide-controls #mobile-nav-bar, +body.embed-mode.embed-hide-controls .detail-inspector-bar, +body.embed-mode.embed-hide-controls .action-bar, +body.embed-mode.embed-hide-controls .trace-format-bar { + display: none !important; +} +body.embed-mode.embed-compact #sidebar-wrap { + width: 280px; + min-width: 240px; + max-width: 34vw; +} +body.embed-mode.embed-compact .search-bar { + padding: 6px 8px; +} +body.embed-mode.embed-compact .sidebar-item { + padding: 8px 12px; +} +body.embed-mode.embed-compact .detail { + padding: 12px 14px; +} +body.embed-mode.embed-compact .msg, +body.embed-mode.embed-compact .section, +body.embed-mode.embed-compact .content-block.block-framed { + border-radius: 8px; +} +@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } } diff --git a/claude_tap/viewer_i18n.json b/claude_tap/viewer_i18n.json new file mode 100644 index 0000000..e70a474 --- /dev/null +++ b/claude_tap/viewer_i18n.json @@ -0,0 +1,738 @@ +{ + "en": { + "title": "claude-tap", + "stats_turns": "Turns", + "stats_tokens": "Total API tokens", + "stats_time": "Time", + "stats_tokens_hint": "Sum of input and output tokens across captured requests; this is not the current Claude Code /context usage.", + "drop_title": "Load Trace File", + "drop_desc": "Drag & drop a .jsonl trace file, or click to browse", + "drop_btn": "Choose File", + "empty_trace_title": "No API calls captured", + "empty_trace_desc": "This trace file was generated, but it contains no supported API requests. Check the wrapped CLI output, command-line flags, authentication, or network setup.", + "empty_trace_count": "Captured API calls: 0", + "empty_trace_hint": "This is a real empty run, not a broken viewer or unloaded file.", + "turn": "Turn", + "tok": "tok", + "btn_request_json": "Request JSON", + "btn_curl": "cURL", + "btn_diff": "Diff with Prev", + "export_jsonl": "Export JSONL", + "export_compact": "Export JSON", + "export_html": "Export HTML", + "export_menu": "Export", + "section_system": "System Prompt", + "section_messages": "Messages", + "section_tools": "Tools", + "section_context": "Request Context", + "section_response": "Response", + "section_usage": "Token Usage", + "section_sse": "SSE Events", + "section_json": "Full JSON", + "section_metadata": "Metadata", + "tab_default": "Default", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "Pretty", + "copy": "Copy", + "copied": "Copied!", + "string_expand_escapes": "Expand escapes", + "string_show_raw": "Raw", + "params": "Parameters", + "required": "required", + "tok_input": "Input", + "tok_output": "Output", + "tok_cache_read": "Cache Read", + "tok_cache_create": "Cache Create", + "empty_state": "No entries for selected paths", + "no_prev": "No previous request", + "no_content": "No content", + "no_messages": "No messages in either request", + "response_context_only": "No response output captured; showing request context only.", + "continuation_title": "Stateful Responses continuation", + "continuation_message": "This request has previous_response_id but no captured user message history. The Responses input may be empty or contain only continuation items such as tool results, so the missing prompt/history may live in server-side state or an earlier uncaptured frame.", + "diff_unchanged": "previous messages unchanged", + "diff_msg_range": "message #1 ~ #", + "diff_new": "new", + "diff_removed": "removed", + "diff_added": "added", + "diff_changed": "changed", + "diff_no_change": "no change", + "diff_unchanged_lbl": "unchanged", + "diff_system": "System Prompt", + "diff_tools": "Tools", + "diff_params": "Parameters", + "diff_chars": "chars", + "diff_tools_unchanged": "tools, unchanged", + "diff_trailing": "trailing messages unchanged", + "badge_messages": "messages", + "badge_tools": "tools", + "badge_events": "events", + "search_placeholder": "Search messages, tools, prompts...", + "history_date": "Date:", + "history_delete_btn": "Delete", + "history_delete_title": "Delete stored files for the selected history date", + "history_delete_live_title": "Live session files cannot be deleted from this control", + "history_delete_confirm": "Delete stored trace files for {date}? This cannot be undone.", + "history_delete_working": "Deleting...", + "history_delete_done": "Deleted {count} files.", + "history_delete_empty": "No stored files found for {date}.", + "history_delete_failed": "Delete failed: {error}", + "sort_label": "Order", + "sort_model": "Model", + "sort_turn": "Turn", + "sort_session": "Query", + "filter_more": "more", + "filter_less": "Show less", + "diff_sys_old": "Previous", + "diff_sys_new": "Current", + "diff_fallback_warning": "No exact thread match found — showing closest same-model request", + "diff_select_target": "Compare against:", + "diff_select_auto": "Auto" + }, + "zh-CN": { + "title": "claude-tap", + "stats_turns": "轮次", + "stats_tokens": "累计 API Token", + "stats_time": "耗时", + "stats_tokens_hint": "跨所有已捕获请求累计 input+output token;不等同于 Claude Code /context 当前上下文占用。", + "drop_title": "加载 Trace 文件", + "drop_desc": "拖放 .jsonl 文件到此处,或点击选择文件", + "drop_btn": "选择文件", + "empty_trace_title": "未捕获到 API 请求", + "empty_trace_desc": "这个 trace 文件已经生成,但其中没有受支持的 API 请求。请检查被包装 CLI 的输出、命令参数、认证状态或网络配置。", + "empty_trace_count": "已捕获 API 请求:0", + "empty_trace_hint": "这是一次真实的空运行,不是 viewer 损坏或文件未加载。", + "turn": "轮次", + "tok": "tok", + "btn_request_json": "请求 JSON", + "btn_curl": "cURL", + "btn_diff": "对比上次", + "export_jsonl": "导出 JSONL", + "export_compact": "导出 JSON", + "export_html": "导出 HTML", + "export_menu": "导出", + "section_system": "系统提示词", + "section_messages": "消息", + "section_tools": "工具", + "section_context": "请求上下文", + "section_response": "响应", + "section_usage": "Token 用量", + "section_sse": "SSE 事件", + "section_json": "完整 JSON", + "section_metadata": "元数据", + "tab_default": "默认", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "美化", + "copy": "复制", + "copied": "已复制!", + "string_expand_escapes": "展开转义", + "string_show_raw": "原文", + "params": "参数", + "required": "必填", + "tok_input": "输入", + "tok_output": "输出", + "tok_cache_read": "缓存读取", + "tok_cache_create": "缓存创建", + "empty_state": "当前路径下无条目", + "no_prev": "无前序请求", + "no_content": "无内容", + "no_messages": "两次请求均无消息", + "response_context_only": "未捕获到响应输出;当前仅展示请求上下文。", + "continuation_title": "有状态 Responses 续接", + "continuation_message": "此请求包含 previous_response_id,但没有捕获到用户消息历史。Responses input 可能为空,或仅包含工具结果等续接项,因此缺失的提示词/历史可能位于服务端状态中,或位于更早但未捕获的帧中。", + "diff_unchanged": "条前序消息未变化", + "diff_msg_range": "消息 #1 ~ #", + "diff_new": "新增", + "diff_removed": "移除", + "diff_added": "新增", + "diff_changed": "变化", + "diff_no_change": "无变化", + "diff_unchanged_lbl": "无变化", + "diff_system": "系统提示词", + "diff_tools": "工具", + "diff_params": "参数", + "diff_chars": "字符", + "diff_tools_unchanged": "个工具,无变化", + "diff_trailing": "末尾消息无变化", + "badge_messages": "条消息", + "badge_tools": "个工具", + "badge_events": "个事件", + "search_placeholder": "搜索消息、工具、提示词...", + "history_date": "日期:", + "history_delete_btn": "删除", + "history_delete_title": "删除所选历史日期的已存储文件", + "history_delete_live_title": "当前 Live 会话不能在这里删除", + "history_delete_confirm": "确定删除 {date} 的已存储 trace 文件吗?此操作不可撤销。", + "history_delete_working": "正在删除...", + "history_delete_done": "已删除 {count} 个文件。", + "history_delete_empty": "未找到 {date} 的已存储文件。", + "history_delete_failed": "删除失败:{error}", + "sort_label": "排序", + "sort_model": "模型", + "sort_turn": "轮次", + "sort_session": "用户输入", + "filter_more": "更多", + "filter_less": "收起", + "diff_sys_old": "上次", + "diff_sys_new": "本次", + "diff_fallback_warning": "未找到精确线程匹配 — 显示最近的同模型请求", + "diff_select_target": "对比对象:", + "diff_select_auto": "自动" + }, + "ja": { + "title": "claude-tap", + "stats_turns": "ターン", + "stats_tokens": "累計APIトークン", + "stats_time": "時間", + "stats_tokens_hint": "キャプチャ済みリクエスト全体の入力+出力トークン合計です。現在の Claude Code /context 使用量ではありません。", + "drop_title": "トレースファイルを読み込む", + "drop_desc": ".jsonl ファイルをドラッグ&ドロップ、またはクリックして選択", + "drop_btn": "ファイル選択", + "empty_trace_title": "APIリクエストはキャプチャされませんでした", + "empty_trace_desc": "このトレースファイルは生成されましたが、対応する API リクエストは含まれていません。ラップされた CLI の出力、コマンドライン引数、認証状態、またはネットワーク設定を確認してください。", + "empty_trace_count": "キャプチャ済み API リクエスト:0", + "empty_trace_hint": "これは実際の空実行であり、viewer の破損や未ロードのファイルではありません。", + "turn": "ターン", + "tok": "tok", + "btn_request_json": "リクエスト JSON", + "btn_curl": "cURL", + "btn_diff": "前回と比較", + "export_jsonl": "JSONL をエクスポート", + "export_compact": "JSON をエクスポート", + "export_html": "HTML をエクスポート", + "export_menu": "エクスポート", + "section_system": "システムプロンプト", + "section_messages": "メッセージ", + "section_tools": "ツール", + "section_context": "リクエストコンテキスト", + "section_response": "レスポンス", + "section_usage": "トークン使用量", + "section_sse": "SSE イベント", + "section_json": "完全 JSON", + "section_metadata": "メタデータ", + "tab_default": "デフォルト", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "整形", + "copy": "コピー", + "copied": "コピー済み!", + "string_expand_escapes": "エスケープを展開", + "string_show_raw": "元", + "params": "パラメータ", + "required": "必須", + "tok_input": "入力", + "tok_output": "出力", + "tok_cache_read": "キャッシュ読取", + "tok_cache_create": "キャッシュ作成", + "empty_state": "選択パスにエントリがありません", + "no_prev": "前回のリクエストなし", + "no_content": "コンテンツなし", + "no_messages": "両方のリクエストにメッセージなし", + "response_context_only": "レスポンス出力はキャプチャされていません。リクエストコンテキストのみを表示しています。", + "continuation_title": "ステートフルな Responses 継続", + "continuation_message": "このリクエストには previous_response_id がありますが、ユーザーメッセージ履歴はキャプチャされていません。Responses input は空、またはツール結果などの継続項目のみである可能性があり、欠落したプロンプト/履歴はサーバー側状態または以前の未キャプチャフレームにある可能性があります。", + "diff_unchanged": "件の前のメッセージは変更なし", + "diff_msg_range": "メッセージ #1 ~ #", + "diff_new": "新規", + "diff_removed": "削除", + "diff_added": "追加", + "diff_changed": "変更", + "diff_no_change": "変更なし", + "diff_unchanged_lbl": "変更なし", + "diff_system": "システムプロンプト", + "diff_tools": "ツール", + "diff_params": "パラメータ", + "diff_chars": "文字", + "diff_tools_unchanged": "個のツール、変更なし", + "diff_trailing": "末尾メッセージ変更なし", + "badge_messages": "件のメッセージ", + "badge_tools": "個のツール", + "badge_events": "件のイベント", + "search_placeholder": "メッセージ、ツール、プロンプトを検索...", + "history_date": "日付:", + "history_delete_btn": "削除", + "history_delete_title": "選択した履歴日の保存ファイルを削除", + "history_delete_live_title": "Live セッションのファイルはここでは削除できません", + "history_delete_confirm": "{date} の保存済みトレースファイルを削除しますか?この操作は元に戻せません。", + "history_delete_working": "削除中...", + "history_delete_done": "{count} 個のファイルを削除しました。", + "history_delete_empty": "{date} の保存ファイルは見つかりません。", + "history_delete_failed": "削除に失敗しました:{error}", + "sort_label": "順序", + "sort_model": "モデル", + "sort_turn": "ターン", + "sort_session": "クエリ", + "filter_more": "他", + "filter_less": "折りたたむ", + "diff_sys_old": "前回", + "diff_sys_new": "今回", + "diff_fallback_warning": "正確なスレッド一致が見つかりません — 同モデルの直近のリクエストを表示", + "diff_select_target": "比較対象:", + "diff_select_auto": "自動" + }, + "ko": { + "title": "claude-tap", + "stats_turns": "턴", + "stats_tokens": "누적 API 토큰", + "stats_time": "시간", + "stats_tokens_hint": "캡처된 모든 요청의 입력+출력 토큰 합계입니다. 현재 Claude Code /context 사용량이 아닙니다.", + "drop_title": "트레이스 파일 로드", + "drop_desc": ".jsonl 파일을 드래그 앤 드롭하거나 클릭하여 선택", + "drop_btn": "파일 선택", + "empty_trace_title": "API 요청이 캡처되지 않았습니다", + "empty_trace_desc": "이 트레이스 파일은 생성되었지만 지원되는 API 요청이 없습니다. 래핑된 CLI 출력, 명령줄 인수, 인증 상태 또는 네트워크 설정을 확인하세요.", + "empty_trace_count": "캡처된 API 요청: 0", + "empty_trace_hint": "이는 실제 빈 실행이며 viewer 손상이나 파일 미로드가 아닙니다.", + "turn": "턴", + "tok": "tok", + "btn_request_json": "요청 JSON", + "btn_curl": "cURL", + "btn_diff": "이전과 비교", + "export_jsonl": "JSONL 내보내기", + "export_compact": "JSON 내보내기", + "export_html": "HTML 내보내기", + "export_menu": "내보내기", + "section_system": "시스템 프롬프트", + "section_messages": "메시지", + "section_tools": "도구", + "section_context": "요청 컨텍스트", + "section_response": "응답", + "section_usage": "토큰 사용량", + "section_sse": "SSE 이벤트", + "section_json": "전체 JSON", + "section_metadata": "메타데이터", + "tab_default": "기본", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "보기 좋게", + "copy": "복사", + "copied": "복사됨!", + "string_expand_escapes": "이스케이프 펼치기", + "string_show_raw": "원본", + "params": "매개변수", + "required": "필수", + "tok_input": "입력", + "tok_output": "출력", + "tok_cache_read": "캐시 읽기", + "tok_cache_create": "캐시 생성", + "empty_state": "선택한 경로에 항목이 없습니다", + "no_prev": "이전 요청 없음", + "no_content": "내용 없음", + "no_messages": "두 요청 모두 메시지 없음", + "response_context_only": "응답 출력이 캡처되지 않았습니다. 현재 요청 컨텍스트만 표시합니다.", + "continuation_title": "상태 저장 Responses 이어가기", + "continuation_message": "이 요청에는 previous_response_id가 있지만 사용자 메시지 기록이 캡처되지 않았습니다. Responses input은 비어 있거나 도구 결과 같은 이어가기 항목만 포함할 수 있으므로 누락된 프롬프트/기록은 서버 측 상태나 이전에 캡처되지 않은 프레임에 있을 수 있습니다.", + "diff_unchanged": "개의 이전 메시지 변경 없음", + "diff_msg_range": "메시지 #1 ~ #", + "diff_new": "신규", + "diff_removed": "삭제", + "diff_added": "추가", + "diff_changed": "변경", + "diff_no_change": "변경 없음", + "diff_unchanged_lbl": "변경 없음", + "diff_system": "시스템 프롬프트", + "diff_tools": "도구", + "diff_params": "매개변수", + "diff_chars": "자", + "diff_tools_unchanged": "개 도구, 변경 없음", + "diff_trailing": "마지막 메시지 변경 없음", + "badge_messages": "개 메시지", + "badge_tools": "개 도구", + "badge_events": "개 이벤트", + "search_placeholder": "메시지, 도구, 프롬프트 검색...", + "history_date": "날짜:", + "history_delete_btn": "삭제", + "history_delete_title": "선택한 기록 날짜의 저장 파일 삭제", + "history_delete_live_title": "Live 세션 파일은 여기서 삭제할 수 없습니다", + "history_delete_confirm": "{date}의 저장된 트레이스 파일을 삭제할까요? 이 작업은 되돌릴 수 없습니다.", + "history_delete_working": "삭제 중...", + "history_delete_done": "{count}개 파일을 삭제했습니다.", + "history_delete_empty": "{date}의 저장 파일을 찾을 수 없습니다.", + "history_delete_failed": "삭제 실패: {error}", + "sort_label": "정렬", + "sort_model": "모델", + "sort_turn": "턴", + "sort_session": "쿼리", + "filter_more": "더보기", + "filter_less": "접기", + "diff_sys_old": "이전", + "diff_sys_new": "현재", + "diff_fallback_warning": "정확한 스레드 일치를 찾을 수 없습니다 — 가장 가까운 동일 모델 요청을 표시", + "diff_select_target": "비교 대상:", + "diff_select_auto": "자동" + }, + "fr": { + "title": "claude-tap", + "stats_turns": "Tours", + "stats_tokens": "Tokens API cumulés", + "stats_time": "Durée", + "stats_tokens_hint": "Somme des tokens d'entrée et de sortie sur les requêtes capturées ; ce n'est pas l'utilisation /context actuelle de Claude Code.", + "drop_title": "Charger un fichier trace", + "drop_desc": "Glissez-déposez un fichier .jsonl, ou cliquez pour parcourir", + "drop_btn": "Choisir un fichier", + "empty_trace_title": "Aucun appel API capturé", + "empty_trace_desc": "Ce fichier trace a été généré, mais il ne contient aucune requête API prise en charge. Vérifiez la sortie du CLI enveloppé, les options de commande, l'authentification ou la configuration réseau.", + "empty_trace_count": "Appels API capturés : 0", + "empty_trace_hint": "Il s'agit d'une vraie exécution vide, pas d'un viewer cassé ni d'un fichier non chargé.", + "turn": "Tour", + "tok": "tok", + "btn_request_json": "JSON Requête", + "btn_curl": "cURL", + "btn_diff": "Comparer avec préc.", + "export_jsonl": "Exporter JSONL", + "export_compact": "Exporter JSON", + "export_html": "Exporter HTML", + "export_menu": "Exporter", + "section_system": "Prompt système", + "section_messages": "Messages", + "section_tools": "Outils", + "section_context": "Contexte de la requête", + "section_response": "Réponse", + "section_usage": "Utilisation des tokens", + "section_sse": "Événements SSE", + "section_json": "JSON complet", + "section_metadata": "Métadonnées", + "tab_default": "Défaut", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "Lisible", + "copy": "Copier", + "copied": "Copié !", + "string_expand_escapes": "Développer les échappements", + "string_show_raw": "Brut", + "params": "Paramètres", + "required": "requis", + "tok_input": "Entrée", + "tok_output": "Sortie", + "tok_cache_read": "Lecture cache", + "tok_cache_create": "Création cache", + "empty_state": "Aucune entrée pour les chemins sélectionnés", + "no_prev": "Aucune requête précédente", + "no_content": "Pas de contenu", + "no_messages": "Aucun message dans les deux requêtes", + "response_context_only": "Aucune sortie de réponse capturée ; affichage du contexte de requête uniquement.", + "continuation_title": "Continuation Responses avec état", + "continuation_message": "Cette requête contient previous_response_id mais aucun historique de messages utilisateur capturé. L'input Responses peut être vide ou ne contenir que des éléments de continuation comme des résultats d'outils; le prompt/l'historique manquant peut donc se trouver dans l'état serveur ou dans une trame antérieure non capturée.", + "diff_unchanged": "messages précédents inchangés", + "diff_msg_range": "message #1 ~ #", + "diff_new": "nouveau", + "diff_removed": "supprimé", + "diff_added": "ajouté", + "diff_changed": "modifié", + "diff_no_change": "aucun changement", + "diff_unchanged_lbl": "inchangé", + "diff_system": "Prompt système", + "diff_tools": "Outils", + "diff_params": "Paramètres", + "diff_chars": "car.", + "diff_tools_unchanged": "outils, inchangés", + "diff_trailing": "messages finaux inchangés", + "badge_messages": "messages", + "badge_tools": "outils", + "badge_events": "événements", + "search_placeholder": "Rechercher messages, outils, prompts...", + "history_date": "Date :", + "history_delete_btn": "Supprimer", + "history_delete_title": "Supprimer les fichiers stockés pour la date d'historique sélectionnée", + "history_delete_live_title": "Les fichiers de session Live ne peuvent pas être supprimés ici", + "history_delete_confirm": "Supprimer les fichiers trace stockés pour {date} ? Cette action est irréversible.", + "history_delete_working": "Suppression...", + "history_delete_done": "{count} fichiers supprimés.", + "history_delete_empty": "Aucun fichier stocké trouvé pour {date}.", + "history_delete_failed": "Échec de la suppression : {error}", + "sort_label": "Ordre", + "sort_model": "Modèle", + "sort_turn": "Tour", + "sort_session": "Requête", + "filter_more": "plus", + "filter_less": "Réduire", + "diff_sys_old": "Précédent", + "diff_sys_new": "Actuel", + "diff_fallback_warning": "Aucune correspondance de fil exacte trouvée — affichage de la requête du même modèle la plus proche", + "diff_select_target": "Comparer avec :", + "diff_select_auto": "Auto" + }, + "ar": { + "title": "claude-tap", + "stats_turns": "الأدوار", + "stats_tokens": "إجمالي رموز API", + "stats_time": "المدة", + "stats_tokens_hint": "مجموع رموز الإدخال والإخراج عبر الطلبات الملتقطة؛ وليس استخدام /context الحالي في Claude Code.", + "drop_title": "تحميل ملف التتبع", + "drop_desc": "اسحب وأفلت ملف .jsonl هنا، أو انقر للاستعراض", + "drop_btn": "اختر ملفاً", + "empty_trace_title": "لم يتم التقاط أي طلبات API", + "empty_trace_desc": "تم إنشاء ملف التتبع هذا، لكنه لا يحتوي على أي طلبات API مدعومة. تحقق من مخرجات CLI المغلف أو وسائط الأمر أو حالة المصادقة أو إعدادات الشبكة.", + "empty_trace_count": "طلبات API الملتقطة: 0", + "empty_trace_hint": "هذه عملية تشغيل فارغة حقيقية، وليست عارضاً معطلاً أو ملفاً غير محمل.", + "turn": "دور", + "tok": "رمز", + "btn_request_json": "JSON الطلب", + "btn_curl": "cURL", + "btn_diff": "مقارنة مع السابق", + "export_jsonl": "تصدير JSONL", + "export_compact": "تصدير JSON", + "export_html": "تصدير HTML", + "export_menu": "تصدير", + "section_system": "موجه النظام", + "section_messages": "الرسائل", + "section_tools": "الأدوات", + "section_context": "سياق الطلب", + "section_response": "الاستجابة", + "section_usage": "استخدام الرموز", + "section_sse": "أحداث SSE", + "section_json": "JSON الكامل", + "section_metadata": "البيانات الوصفية", + "tab_default": "افتراضي", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "منسق", + "copy": "نسخ", + "copied": "تم النسخ!", + "string_expand_escapes": "توسيع رموز الهروب", + "string_show_raw": "خام", + "params": "المعاملات", + "required": "مطلوب", + "tok_input": "الإدخال", + "tok_output": "الإخراج", + "tok_cache_read": "قراءة التخزين", + "tok_cache_create": "إنشاء التخزين", + "empty_state": "لا توجد إدخالات للمسارات المحددة", + "no_prev": "لا يوجد طلب سابق", + "no_content": "لا يوجد محتوى", + "no_messages": "لا توجد رسائل في كلا الطلبين", + "response_context_only": "لم يتم التقاط مخرجات استجابة؛ يتم عرض سياق الطلب فقط.", + "continuation_title": "استمرار Responses بحالة محفوظة", + "continuation_message": "يحتوي هذا الطلب على previous_response_id ولكن لا يحتوي على سجل رسائل مستخدم ملتقط. قد يكون Responses input فارغاً أو يحتوي فقط على عناصر استمرار مثل نتائج الأدوات، لذلك قد يكون الموجه/السجل المفقود في حالة الخادم أو في إطار سابق لم يتم التقاطه.", + "diff_unchanged": "رسالة سابقة بدون تغيير", + "diff_msg_range": "الرسالة #1 ~ #", + "diff_new": "جديد", + "diff_removed": "محذوف", + "diff_added": "مضاف", + "diff_changed": "معدّل", + "diff_no_change": "بدون تغيير", + "diff_unchanged_lbl": "بدون تغيير", + "diff_system": "موجه النظام", + "diff_tools": "الأدوات", + "diff_params": "المعاملات", + "diff_chars": "حرف", + "diff_tools_unchanged": "أداة، بدون تغيير", + "diff_trailing": "الرسائل الأخيرة بدون تغيير", + "badge_messages": "رسالة", + "badge_tools": "أداة", + "badge_events": "حدث", + "search_placeholder": "بحث في الرسائل والأدوات والموجهات...", + "history_date": "التاريخ:", + "history_delete_btn": "حذف", + "history_delete_title": "حذف الملفات المخزنة لتاريخ السجل المحدد", + "history_delete_live_title": "لا يمكن حذف ملفات جلسة Live من هذا التحكم", + "history_delete_confirm": "هل تريد حذف ملفات التتبع المخزنة لـ {date}؟ لا يمكن التراجع عن هذا الإجراء.", + "history_delete_working": "جارٍ الحذف...", + "history_delete_done": "تم حذف {count} ملف.", + "history_delete_empty": "لم يتم العثور على ملفات مخزنة لـ {date}.", + "history_delete_failed": "فشل الحذف: {error}", + "sort_label": "الترتيب", + "sort_model": "النموذج", + "sort_turn": "الدور", + "sort_session": "استعلام", + "filter_more": "المزيد", + "filter_less": "تقليص", + "diff_sys_old": "السابق", + "diff_sys_new": "الحالي", + "diff_fallback_warning": "لم يتم العثور على تطابق دقيق للخيط — عرض أقرب طلب لنفس النموذج", + "diff_select_target": "مقارنة مع:", + "diff_select_auto": "تلقائي" + }, + "de": { + "title": "claude-tap", + "stats_turns": "Runden", + "stats_tokens": "API-Token gesamt", + "stats_time": "Dauer", + "stats_tokens_hint": "Summe aus Eingabe- und Ausgabe-Token über erfasste Anfragen; nicht die aktuelle Claude Code-/context-Nutzung.", + "drop_title": "Trace-Datei laden", + "drop_desc": "Ziehen Sie eine .jsonl-Datei hierher, oder klicken Sie zum Auswählen", + "drop_btn": "Datei wählen", + "empty_trace_title": "Keine API-Aufrufe erfasst", + "empty_trace_desc": "Diese Trace-Datei wurde erzeugt, enthält aber keine unterstützten API-Anfragen. Prüfen Sie die Ausgabe des umschlossenen CLI, Befehlsoptionen, Authentifizierung oder Netzwerkeinstellungen.", + "empty_trace_count": "Erfasste API-Aufrufe: 0", + "empty_trace_hint": "Dies ist ein echter leerer Lauf, kein defekter Viewer und keine ungeladene Datei.", + "turn": "Runde", + "tok": "Tok", + "btn_request_json": "Anfrage-JSON", + "btn_curl": "cURL", + "btn_diff": "Mit vorh. vergleichen", + "export_jsonl": "JSONL exportieren", + "export_compact": "JSON exportieren", + "export_html": "HTML exportieren", + "export_menu": "Exportieren", + "section_system": "System-Prompt", + "section_messages": "Nachrichten", + "section_tools": "Werkzeuge", + "section_context": "Anfragekontext", + "section_response": "Antwort", + "section_usage": "Token-Nutzung", + "section_sse": "SSE-Ereignisse", + "section_json": "Vollständiges JSON", + "section_metadata": "Metadaten", + "tab_default": "Standard", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "Lesbar", + "copy": "Kopieren", + "copied": "Kopiert!", + "string_expand_escapes": "Escapes erweitern", + "string_show_raw": "Roh", + "params": "Parameter", + "required": "erforderlich", + "tok_input": "Eingabe", + "tok_output": "Ausgabe", + "tok_cache_read": "Cache-Lesen", + "tok_cache_create": "Cache-Erstellen", + "empty_state": "Keine Einträge für ausgewählte Pfade", + "no_prev": "Keine vorherige Anfrage", + "no_content": "Kein Inhalt", + "no_messages": "Keine Nachrichten in beiden Anfragen", + "response_context_only": "Keine Antwortausgabe erfasst; es wird nur der Anfragekontext angezeigt.", + "continuation_title": "Zustandsbehaftete Responses-Fortsetzung", + "continuation_message": "Diese Anfrage enthält previous_response_id, aber keinen erfassten Benutzer-Nachrichtenverlauf. Das Responses input kann leer sein oder nur Fortsetzungselemente wie Tool-Ergebnisse enthalten; der fehlende Prompt/Verlauf kann daher im serverseitigen Zustand oder in einem früheren nicht erfassten Frame liegen.", + "diff_unchanged": "vorherige Nachrichten unverändert", + "diff_msg_range": "Nachricht #1 ~ #", + "diff_new": "neu", + "diff_removed": "entfernt", + "diff_added": "hinzugefügt", + "diff_changed": "geändert", + "diff_no_change": "keine Änderung", + "diff_unchanged_lbl": "unverändert", + "diff_system": "System-Prompt", + "diff_tools": "Werkzeuge", + "diff_params": "Parameter", + "diff_chars": "Zeichen", + "diff_tools_unchanged": "Werkzeuge, unverändert", + "diff_trailing": "letzte Nachrichten unverändert", + "badge_messages": "Nachrichten", + "badge_tools": "Werkzeuge", + "badge_events": "Ereignisse", + "search_placeholder": "Nachrichten, Werkzeuge, Prompts suchen...", + "history_date": "Datum:", + "history_delete_btn": "Löschen", + "history_delete_title": "Gespeicherte Dateien für das ausgewählte Verlaufsdatum löschen", + "history_delete_live_title": "Live-Sitzungsdateien können hier nicht gelöscht werden", + "history_delete_confirm": "Gespeicherte Trace-Dateien für {date} löschen? Dies kann nicht rückgängig gemacht werden.", + "history_delete_working": "Löschen...", + "history_delete_done": "{count} Dateien gelöscht.", + "history_delete_empty": "Keine gespeicherten Dateien für {date} gefunden.", + "history_delete_failed": "Löschen fehlgeschlagen: {error}", + "sort_label": "Reihenfolge", + "sort_model": "Modell", + "sort_turn": "Runde", + "sort_session": "Abfrage", + "filter_more": "mehr", + "filter_less": "Weniger", + "diff_sys_old": "Vorherige", + "diff_sys_new": "Aktuelle", + "diff_fallback_warning": "Keine exakte Thread-Übereinstimmung gefunden — zeige nächste Anfrage desselben Modells", + "diff_select_target": "Vergleichen mit:", + "diff_select_auto": "Auto" + }, + "ru": { + "title": "claude-tap", + "stats_turns": "Ходы", + "stats_tokens": "Всего API-токенов", + "stats_time": "Время", + "stats_tokens_hint": "Сумма входных и выходных токенов по захваченным запросам; это не текущее использование /context в Claude Code.", + "drop_title": "Загрузить файл трассировки", + "drop_desc": "Перетащите .jsonl файл сюда или нажмите для выбора", + "drop_btn": "Выбрать файл", + "empty_trace_title": "API-вызовы не захвачены", + "empty_trace_desc": "Этот файл трассировки был создан, но не содержит поддерживаемых API-запросов. Проверьте вывод обернутого CLI, параметры команды, состояние аутентификации или сетевые настройки.", + "empty_trace_count": "Захвачено API-вызовов: 0", + "empty_trace_hint": "Это реальный пустой запуск, а не сломанный viewer и не незагруженный файл.", + "turn": "Ход", + "tok": "ток", + "btn_request_json": "JSON запроса", + "btn_curl": "cURL", + "btn_diff": "Сравнить с пред.", + "export_jsonl": "Экспорт JSONL", + "export_compact": "Экспорт JSON", + "export_html": "Экспорт HTML", + "export_menu": "Экспорт", + "section_system": "Системный промпт", + "section_messages": "Сообщения", + "section_tools": "Инструменты", + "section_context": "Контекст запроса", + "section_response": "Ответ", + "section_usage": "Использование токенов", + "section_sse": "SSE-события", + "section_json": "Полный JSON", + "section_metadata": "Метаданные", + "tab_default": "По умолчанию", + "tab_trace": "Trace", + "format_json": "JSON", + "format_yaml": "YAML", + "format_pretty": "Удобно", + "copy": "Копировать", + "copied": "Скопировано!", + "string_expand_escapes": "Развернуть экранирование", + "string_show_raw": "Исходное", + "params": "Параметры", + "required": "обязательно", + "tok_input": "Ввод", + "tok_output": "Вывод", + "tok_cache_read": "Чтение кэша", + "tok_cache_create": "Создание кэша", + "empty_state": "Нет записей для выбранных путей", + "no_prev": "Нет предыдущего запроса", + "no_content": "Нет содержимого", + "no_messages": "Нет сообщений в обоих запросах", + "response_context_only": "Вывод ответа не захвачен; показан только контекст запроса.", + "continuation_title": "Состояние продолжения Responses", + "continuation_message": "Этот запрос содержит previous_response_id, но не содержит захваченной истории пользовательских сообщений. Responses input может быть пустым или содержать только элементы продолжения, например результаты инструментов, поэтому отсутствующий промпт/история могут находиться в серверном состоянии или в более раннем незахваченном фрейме.", + "diff_unchanged": "предыдущих сообщений без изменений", + "diff_msg_range": "сообщение #1 ~ #", + "diff_new": "новое", + "diff_removed": "удалено", + "diff_added": "добавлено", + "diff_changed": "изменено", + "diff_no_change": "без изменений", + "diff_unchanged_lbl": "без изменений", + "diff_system": "Системный промпт", + "diff_tools": "Инструменты", + "diff_params": "Параметры", + "diff_chars": "симв.", + "diff_tools_unchanged": "инструм., без изменений", + "diff_trailing": "последние сообщения без изменений", + "badge_messages": "сообщений", + "badge_tools": "инструм.", + "badge_events": "событий", + "search_placeholder": "Поиск сообщений, инструментов, промптов...", + "history_date": "Дата:", + "history_delete_btn": "Удалить", + "history_delete_title": "Удалить сохраненные файлы для выбранной даты истории", + "history_delete_live_title": "Файлы Live-сеанса нельзя удалить этим элементом", + "history_delete_confirm": "Удалить сохраненные файлы трассировки за {date}? Это действие нельзя отменить.", + "history_delete_working": "Удаление...", + "history_delete_done": "Удалено файлов: {count}.", + "history_delete_empty": "Сохраненные файлы за {date} не найдены.", + "history_delete_failed": "Не удалось удалить: {error}", + "sort_label": "Порядок", + "sort_model": "Модель", + "sort_turn": "Ход", + "sort_session": "Запрос", + "filter_more": "ещё", + "filter_less": "Свернуть", + "diff_sys_old": "Предыдущий", + "diff_sys_new": "Текущий", + "diff_fallback_warning": "Точное совпадение потока не найдено — показан ближайший запрос той же модели", + "diff_select_target": "Сравнить с:", + "diff_select_auto": "Авто" + } +} diff --git a/claude_tap/ws_proxy.py b/claude_tap/ws_proxy.py new file mode 100644 index 0000000..c70bd94 --- /dev/null +++ b/claude_tap/ws_proxy.py @@ -0,0 +1,609 @@ +"""WebSocket proxy – forward WS connections to upstream and record traces.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +import uuid +from collections import deque +from datetime import datetime, timezone + +import aiohttp +from aiohttp import web +from aiohttp.helpers import get_env_proxy_for_url +from yarl import URL + +from claude_tap.proxy import capture_only_response, filter_headers, is_capture_only_request +from claude_tap.trace import TraceWriter +from claude_tap.upstream import build_upstream_url, format_upstream_error + +log = logging.getLogger("claude-tap") + +# --------------------------------------------------------------------------- +# WebSocket proxy +# --------------------------------------------------------------------------- + +# Headers managed by the WebSocket handshake — must not be forwarded to upstream. +_WS_HANDSHAKE_HEADERS = frozenset( + { + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", + "sec-websocket-accept", + } +) +_COMPLETED_RESPONSE_KEY_CACHE_SIZE = 1024 + + +def _get_ws_proxy_settings(ws_url: str) -> tuple[URL, aiohttp.BasicAuth | None] | None: + """Resolve HTTP proxy and auth from env for a WebSocket URL. + + aiohttp's ``ws_connect`` does not check ``trust_env`` to auto-resolve + proxy settings from environment variables (unlike ``_request``). + ``get_env_proxy_for_url`` also doesn't recognise the ``wss://``/``ws://`` + schemes. Work around both by converting the scheme to its HTTP equivalent + (``wss`` → ``https``, ``ws`` → ``http``) for the lookup. + """ + if ws_url.startswith("wss://"): + lookup_url = URL("https://" + ws_url[6:]) + elif ws_url.startswith("ws://"): + lookup_url = URL("http://" + ws_url[5:]) + else: + return None + + try: + return get_env_proxy_for_url(lookup_url) + except LookupError: + return None + + +async def _handle_websocket(request: web.Request) -> web.StreamResponse: + """Proxy a WebSocket connection to the upstream, recording all messages.""" + ctx: dict = request.app["trace_ctx"] + target: str = ctx["target_url"] + writer: TraceWriter = ctx["writer"] + session: aiohttp.ClientSession = ctx["session"] + store_stream_events = bool(ctx.get("store_stream_events", False)) + + strip_prefix: str = ctx.get("strip_path_prefix", "") + fwd_path = request.path_qs + if strip_prefix and fwd_path.startswith(strip_prefix): + fwd_path = fwd_path[len(strip_prefix) :] or "/" + upstream_url = build_upstream_url(target, fwd_path) + + # Convert HTTP scheme to WebSocket scheme for upstream + if upstream_url.startswith("https://"): + upstream_ws_url = "wss://" + upstream_url[8:] + elif upstream_url.startswith("http://"): + upstream_ws_url = "ws://" + upstream_url[7:] + else: + upstream_ws_url = upstream_url + + # Forward auth headers, strip hop-by-hop and WS handshake headers + fwd_headers = filter_headers(request.headers) + fwd_headers.pop("Host", None) + for h in list(fwd_headers.keys()): + if h.lower() in _WS_HANDSHAKE_HEADERS: + del fwd_headers[h] + + # Forward WebSocket subprotocol if present + protocols: tuple[str, ...] = () + ws_protocol = request.headers.get("Sec-WebSocket-Protocol") + if ws_protocol: + protocols = tuple(p.strip() for p in ws_protocol.split(",")) + + req_id = f"req_{uuid.uuid4().hex[:12]}" + t0 = time.monotonic() + ctx["turn_counter"] = ctx.get("turn_counter", 0) + 1 + turn = ctx["turn_counter"] + log_prefix = f"[Turn {turn}]" + + if ctx.get("capture_only"): + return await _handle_capture_only_websocket( + request=request, + writer=writer, + target=target, + protocols=protocols, + req_id=req_id, + turn=turn, + t0=t0, + log_prefix=log_prefix, + store_stream_events=store_stream_events, + ) + + # Resolve proxy from env — aiohttp ws_connect ignores trust_env + proxy_settings = _get_ws_proxy_settings(upstream_ws_url) if session.trust_env else None + ws_connect_kwargs: dict[str, object] = {} + if proxy_settings: + proxy_url, proxy_auth = proxy_settings + ws_connect_kwargs["proxy"] = proxy_url + if proxy_auth is not None: + ws_connect_kwargs["proxy_auth"] = proxy_auth + log.info(f"{log_prefix} → WS UPGRADE {request.path_qs} (upstream={upstream_ws_url}, via proxy {proxy_url})") + else: + log.info(f"{log_prefix} → WS UPGRADE {request.path_qs} (upstream={upstream_ws_url})") + + # Connect to upstream first — if it fails, return HTTP 502 before upgrading + try: + upstream_ws = await session.ws_connect( + upstream_ws_url, + headers=fwd_headers, + protocols=protocols, + **ws_connect_kwargs, + ) + except Exception as exc: + duration_ms = int((time.monotonic() - t0) * 1000) + error_message = format_upstream_error(exc, target_url=target, upstream_url=upstream_ws_url) + log.error(f"{log_prefix} upstream WS connect to {upstream_ws_url} failed: {error_message}") + record = _build_ws_record( + req_id=req_id, + turn=turn, + duration_ms=duration_ms, + path_qs=request.path_qs, + req_headers=request.headers, + client_messages=[], + server_messages=[], + upstream_base_url=target, + error=error_message, + store_stream_events=store_stream_events, + ) + await writer.write(record) + return web.Response(status=502, text=error_message) + + # Upstream connected — accept client WebSocket upgrade + client_ws = web.WebSocketResponse(protocols=protocols) + await client_ws.prepare(request) + + client_messages: list[str] = [] + server_messages: list[str] = [] + client_message_count = 0 + server_message_count = 0 + completed_records_written = 0 + completed_response_keys: set[str] = set() + completed_response_key_order: deque[str] = deque() + pending_write: asyncio.Task[None] | None = None + + def _pop_buffered_snapshot() -> tuple[int, list[str], list[str]]: + nonlocal completed_records_written + completed_records_written += 1 + record_client_messages = client_messages.copy() + record_server_messages = server_messages.copy() + client_messages.clear() + server_messages.clear() + return completed_records_written, record_client_messages, record_server_messages + + def _pop_buffered_server_snapshot() -> tuple[int, list[str], list[str]]: + nonlocal completed_records_written + completed_records_written += 1 + record_server_messages = server_messages.copy() + server_messages.clear() + return completed_records_written, [], record_server_messages + + async def _write_buffered_snapshot(snapshot: tuple[int, list[str], list[str]]) -> None: + record_number, record_client_messages, record_server_messages = snapshot + record = _build_ws_record( + req_id=req_id if record_number == 1 else f"{req_id}_{record_number}", + turn=turn if record_number == 1 else f"{turn}.{record_number}", + duration_ms=int((time.monotonic() - t0) * 1000), + path_qs=request.path_qs, + req_headers=request.headers, + client_messages=record_client_messages, + server_messages=record_server_messages, + upstream_base_url=target, + store_stream_events=store_stream_events, + ) + await writer.write(record) + + async def _write_buffered_record() -> None: + await _write_buffered_snapshot(_pop_buffered_snapshot()) + + def _schedule_buffered_snapshot(snapshot: tuple[int, list[str], list[str]]) -> None: + nonlocal pending_write + previous_write = pending_write + + async def _write_after_previous() -> None: + if previous_write is not None: + await previous_write + await _write_buffered_snapshot(snapshot) + + pending_write = asyncio.create_task(_write_after_previous()) + + async def _drain_pending_write() -> None: + if pending_write is not None: + await asyncio.shield(pending_write) + + def _pop_completed_snapshot(response_key: str, terminal_message: str) -> tuple[int, list[str], list[str]] | None: + if response_key in completed_response_keys: + if server_messages and server_messages[-1] == terminal_message: + server_messages.pop() + if server_messages: + return _pop_buffered_server_snapshot() + return None + completed_response_keys.add(response_key) + completed_response_key_order.append(response_key) + if len(completed_response_key_order) > _COMPLETED_RESPONSE_KEY_CACHE_SIZE: + expired = completed_response_key_order.popleft() + completed_response_keys.discard(expired) + return _pop_buffered_snapshot() + + async def _relay_client_to_upstream(): + nonlocal client_message_count + try: + async for msg in client_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + client_message_count += 1 + client_messages.append(msg.data) + await upstream_ws.send_str(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await upstream_ws.send_bytes(msg.data) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + elif msg.type == aiohttp.WSMsgType.ERROR: + break + except (ConnectionError, asyncio.CancelledError): + pass + + async def _relay_upstream_to_client(): + nonlocal server_message_count + try: + async for msg in upstream_ws: + if msg.type == aiohttp.WSMsgType.TEXT: + server_message_count += 1 + server_messages.append(msg.data) + response_key = _response_completed_message_key(msg.data) + completed_snapshot = ( + _pop_completed_snapshot(response_key, msg.data) if response_key is not None else None + ) + try: + await client_ws.send_str(msg.data) + finally: + if completed_snapshot is not None: + _schedule_buffered_snapshot(completed_snapshot) + elif msg.type == aiohttp.WSMsgType.BINARY: + await client_ws.send_bytes(msg.data) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + elif msg.type == aiohttp.WSMsgType.ERROR: + break + except (ConnectionError, asyncio.CancelledError): + pass + + # Run bidirectional relay — stop when either side closes + tasks = [ + asyncio.create_task(_relay_client_to_upstream()), + asyncio.create_task(_relay_upstream_to_client()), + ] + _done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + + if not upstream_ws.closed: + await upstream_ws.close() + if not client_ws.closed: + await client_ws.close() + + duration_ms = int((time.monotonic() - t0) * 1000) + + await _drain_pending_write() + + if client_messages or server_messages: + await _write_buffered_record() + + log.info( + f"{log_prefix} ← WS closed ({duration_ms}ms, " + f"{client_message_count} client→upstream, " + f"{server_message_count} upstream→client)" + ) + + return client_ws + + +async def _handle_capture_only_websocket( + *, + request: web.Request, + writer: TraceWriter, + target: str, + protocols: tuple[str, ...], + req_id: str, + turn: int, + t0: float, + log_prefix: str, + store_stream_events: bool, +) -> web.WebSocketResponse: + client_ws = web.WebSocketResponse(protocols=protocols) + await client_ws.prepare(request) + + client_messages: list[str] = [] + deadline = time.monotonic() + 30 + while True: + timeout = max(0.1, deadline - time.monotonic()) + try: + msg = await asyncio.wait_for(client_ws.receive(), timeout=timeout) + except asyncio.TimeoutError: + break + + if msg.type == aiohttp.WSMsgType.TEXT: + client_messages.append(msg.data) + req_body = _reconstruct_ws_request_body(client_messages) or {} + if is_prompt_bearing_ws_request_body(req_body): + break + if time.monotonic() >= deadline: + break + continue + if msg.type in (aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.CLOSED): + break + if msg.type == aiohttp.WSMsgType.ERROR: + break + + req_body = _reconstruct_ws_request_body(client_messages) or {} + response_body = capture_only_response(request.path_qs, req_body) + response_messages = [ + json.dumps({"type": "response.created", "response": {**response_body, "status": "in_progress"}}), + json.dumps({"type": "response.completed", "response": {**response_body, "status": "completed"}}), + ] + for message in response_messages: + await client_ws.send_str(message) + await client_ws.close() + + duration_ms = int((time.monotonic() - t0) * 1000) + record = _build_ws_record( + req_id=req_id, + turn=turn, + duration_ms=duration_ms, + path_qs=request.path_qs, + req_headers=request.headers, + client_messages=client_messages, + server_messages=response_messages, + upstream_base_url=target, + store_stream_events=store_stream_events, + ) + await writer.write(record) + log.info(f"{log_prefix} ← WS capture-only ({duration_ms}ms, upstream skipped)") + return client_ws + + +def _build_ws_record( + req_id: str, + turn: int | str, + duration_ms: int, + path_qs: str, + req_headers: dict, + client_messages: list[str], + server_messages: list[str], + upstream_base_url: str, + error: str | None = None, + store_stream_events: bool = True, +) -> dict: + """Build a trace record for a WebSocket session.""" + req_body = _reconstruct_ws_request_body(client_messages) + + # Parse server messages into structured events + ws_events: list[dict] = [] + for msg in server_messages: + try: + parsed = json.loads(msg) + ws_events.append(parsed) + except (json.JSONDecodeError, ValueError): + ws_events.append({"raw": msg}) + + resp_body = _reconstruct_ws_response_body(ws_events) + req_events = _parse_ws_messages(client_messages) + + record: dict = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "request_id": req_id, + "turn": turn, + "duration_ms": duration_ms, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": path_qs, + "headers": filter_headers(req_headers, redact_keys=True), + "body": req_body, + }, + "response": { + "status": 101 if error is None else 502, + "headers": {}, + "body": resp_body, + }, + } + if store_stream_events and ws_events: + record["response"]["ws_events"] = ws_events + if store_stream_events and req_events: + record["request"]["ws_events"] = req_events + if error is not None: + record["response"]["error"] = error + if upstream_base_url: + record["upstream_base_url"] = upstream_base_url + return record + + +def _parse_ws_messages(messages: list[str]) -> list[dict]: + parsed_messages: list[dict] = [] + for msg in messages: + try: + parsed = json.loads(msg) + parsed_messages.append(parsed if isinstance(parsed, dict) else {"raw": parsed}) + except (json.JSONDecodeError, ValueError): + parsed_messages.append({"raw": msg}) + return parsed_messages + + +def _response_completed_message_key(message: str) -> str | None: + try: + parsed = json.loads(message) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(parsed, dict) or parsed.get("type") not in ("response.completed", "response.done"): + return None + response = parsed.get("response") + if isinstance(response, dict) and response.get("id"): + return str(response["id"]) + return json.dumps(parsed, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + + +def _reconstruct_ws_request_body(client_messages: list[str]) -> dict | None: + """Merge client WebSocket messages into the most complete request body.""" + merged: dict | None = None + for msg in client_messages: + try: + parsed = json.loads(msg) + except (json.JSONDecodeError, ValueError): + continue + if not isinstance(parsed, dict): + continue + if merged is None: + merged = parsed.copy() + continue + for key, value in parsed.items(): + if key in ("input", "tools"): + if isinstance(merged.get(key), list) and isinstance(value, list): + merged[key] = _merge_json_lists(merged[key], value) + elif value: + merged[key] = value + else: + merged.setdefault(key, value) + continue + if value not in (None, "", [], {}): + merged[key] = value + else: + merged.setdefault(key, value) + return merged + + +def _merge_json_lists(existing: list, incoming: list) -> list: + """Append JSON-like list items while preserving order and removing exact duplicates.""" + merged = list(existing) + seen = {_json_list_item_key(item) for item in merged} + for item in incoming: + key = _json_list_item_key(item) + if key in seen: + continue + merged.append(item) + seen.add(key) + return merged + + +def _json_list_item_key(item: object) -> str: + try: + return json.dumps(item, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + except (TypeError, ValueError): + return repr(item) + + +def _reconstruct_ws_response_body(ws_events: list[dict]) -> dict | None: + """Build a best-effort response body from WS events. + + Recent Codex versions may emit multiple response.completed events and keep + the actual assistant text inside response.output_item.done rather than the + terminal response payload. Reconstruct a richer body for traces/viewer use. + """ + merged: dict | None = None + output_items: dict[int, dict] = {} + + for event in ws_events: + if not isinstance(event, dict): + continue + + event_type = event.get("type") + payload = event.get("response", event) + if isinstance(payload, dict) and event_type in ( + "response.created", + "response.in_progress", + "response.completed", + "response.done", + ): + if merged is None: + merged = payload.copy() + else: + for key, value in payload.items(): + if key == "output": + if value: + merged[key] = value + else: + merged.setdefault(key, value) + continue + if key == "usage": + if value: + merged[key] = value + else: + merged.setdefault(key, value) + continue + if value not in (None, "", [], {}): + merged[key] = value + else: + merged.setdefault(key, value) + + if event_type == "response.output_item.done": + item = event.get("item") + output_index = event.get("output_index") + if isinstance(item, dict) and isinstance(output_index, int): + output_items[output_index] = item + + if output_items: + ordered_output = [output_items[idx] for idx in sorted(output_items)] + if merged is None: + merged = {"output": ordered_output} + elif not merged.get("output"): + merged["output"] = ordered_output + + return merged + + +def reconstruct_ws_response_body(ws_events: list[dict]) -> dict | None: + """Public wrapper for websocket response-body reconstruction. + + Forward and reverse proxy code paths both need identical reconstruction + behavior so viewer output stays consistent across transport modes. + """ + return _reconstruct_ws_response_body(ws_events) + + +def reconstruct_ws_request_body(client_messages: list[str]) -> dict | None: + """Public wrapper for websocket request-body reconstruction.""" + return _reconstruct_ws_request_body(client_messages) + + +def is_prompt_bearing_ws_request_body(body: dict | None) -> bool: + """Return whether a reconstructed WebSocket request contains an actual prompt.""" + if not isinstance(body, dict): + return False + if not is_capture_only_request("", body): + return False + for key in ("system", "instructions", "system_instruction", "systemInstruction", "messages", "contents", "prompt"): + if body.get(key): + return True + input_value = body.get("input") + if isinstance(input_value, str): + return bool(input_value.strip()) + if isinstance(input_value, list): + return any(_ws_input_item_is_prompt(item) for item in input_value) + nested = body.get("request") + return isinstance(nested, dict) and is_prompt_bearing_ws_request_body(nested) + + +def _ws_input_item_is_prompt(item: object) -> bool: + if isinstance(item, str): + return bool(item.strip()) + if not isinstance(item, dict): + return False + if item.get("type") == "function_call_output": + return False + if item.get("role") in {"user", "developer", "system"}: + return True + return any(key in item for key in ("content", "text", "input_text")) diff --git a/docs/architecture.png b/docs/architecture.png new file mode 100644 index 0000000..0d525ce Binary files /dev/null and b/docs/architecture.png differ diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..af7b1ec --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + claude-tap + Transparent API Proxy for Claude Code + + + + + + + + + + Developer + + + + + + + $ tap + + claude-tap + + + + + + + + claude + code + Claude Code + + + + + + + PROXY + aiohttp + Reverse Proxy + intercept + record + + + + + + + ☁️ + Anthropic API + + + + + + + {...} + trace.jsonl + + + + + + + </> + trace.html + + + + + + + + + Live Viewer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + run + spawn + POST + forward + SSE + record + broadcast + + + + + Data Flow: + + + Request + + + SSE Response + + + Output + + + Live Stream + + diff --git a/docs/billing-header-diff.png b/docs/billing-header-diff.png new file mode 100644 index 0000000..166e8a3 Binary files /dev/null and b/docs/billing-header-diff.png differ diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..838d438 Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/demo_zh.gif b/docs/demo_zh.gif new file mode 100644 index 0000000..f7f911a Binary files /dev/null and b/docs/demo_zh.gif differ diff --git a/docs/diff-modal.png b/docs/diff-modal.png new file mode 100644 index 0000000..9f7b8a6 Binary files /dev/null and b/docs/diff-modal.png differ diff --git a/docs/evidence/pr114/README.md b/docs/evidence/pr114/README.md new file mode 100644 index 0000000..0aac0c4 --- /dev/null +++ b/docs/evidence/pr114/README.md @@ -0,0 +1,9 @@ +# PR 114 Evidence + +This evidence captures a real dry run of the new update subcommand from this PR branch: + +```bash +uv run claude-tap update --installer pip --dry-run +``` + +The command prints the foreground pip upgrade command without performing a network upgrade. diff --git a/docs/evidence/pr114/README.zh.md b/docs/evidence/pr114/README.zh.md new file mode 100644 index 0000000..da4594a --- /dev/null +++ b/docs/evidence/pr114/README.zh.md @@ -0,0 +1,10 @@ +# PR 114 证据 + +此证据记录了该 PR 分支中新 `update` 子命令的一次真实 dry run: + +```bash +uv run claude-tap update --installer pip --dry-run +``` + +该命令只打印前台 pip 升级命令,不执行网络升级。 + diff --git a/docs/evidence/pr114/update-command-dry-run.png b/docs/evidence/pr114/update-command-dry-run.png new file mode 100644 index 0000000..ff5fc2e Binary files /dev/null and b/docs/evidence/pr114/update-command-dry-run.png differ diff --git a/docs/evidence/pr157/README.md b/docs/evidence/pr157/README.md new file mode 100644 index 0000000..bf8127b --- /dev/null +++ b/docs/evidence/pr157/README.md @@ -0,0 +1,7 @@ +# PR 157 Evidence + +This screenshot was captured from a viewer generated from a real trace under +`.traces/`. Nested request and response payloads were collapsed before capture +so the evidence shows the JSON tree interaction without exposing headers or +prompt content. + diff --git a/docs/evidence/pr157/README.zh.md b/docs/evidence/pr157/README.zh.md new file mode 100644 index 0000000..bb10dc8 --- /dev/null +++ b/docs/evidence/pr157/README.zh.md @@ -0,0 +1,6 @@ +# PR 157 证据 + +这张截图来自基于 `.traces/` 中真实 trace 生成的 viewer。截图前已折叠 +request 和 response 中的嵌套 payload,因此证据只展示 JSON tree 交互, +不会暴露 headers 或 prompt 内容。 + diff --git a/docs/evidence/pr157/collapsible-json-tree.png b/docs/evidence/pr157/collapsible-json-tree.png new file mode 100644 index 0000000..b0391ae Binary files /dev/null and b/docs/evidence/pr157/collapsible-json-tree.png differ diff --git a/docs/guides/OPENCLAW_README.md b/docs/guides/OPENCLAW_README.md new file mode 100644 index 0000000..5248030 --- /dev/null +++ b/docs/guides/OPENCLAW_README.md @@ -0,0 +1,156 @@ +--- +name: openclaw-claude-tap-setup +description: Configure and launch claude-tap as a local proxy for OpenClaw integration. Use when setting up claude-tap with OpenClaw, troubleshooting API proxy issues, or managing trace collection in an OpenClaw environment. +--- + +# Claude Tap Setup Guide (Required Reading for OpenClaw Integration) + +Simplified Chinese version: [OpenClaw 集成的 Claude Tap 设置指南](OPENCLAW_README.zh.md). + +## Background + +OpenClaw can trace model API requests through a local proxy such as claude-tap. Once configured, all API requests destined for a given model provider are forwarded through the local proxy to the upstream endpoint, while requests and responses are recorded for debugging. + +**Important: After pointing a provider's `baseUrl` in openclaw.json to the local proxy, you MUST ensure the proxy process is running. Otherwise, API requests will fail with connection refused, bringing down the entire OpenClaw service.** + +## Configuration + +### 1. claude-tap Configuration in openclaw.json + +Add a `baseUrl` pointing to the local proxy under `models.providers.` in `~/.openclaw/openclaw.json`. Example for Anthropic: + +```json +{ + "models": { + "providers": { + "anthropic": { + "baseUrl": "http://127.0.0.1:8787", + "api": "anthropic-messages", + "models": [ + { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6 (via claude-tap)", + "api": "anthropic-messages", + "reasoning": false, + "input": ["text", "image"], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 8192 + } + ] + } + } + } +} +``` + +**Key fields:** +- `baseUrl`: Must point to the address and port claude-tap listens on (default `http://127.0.0.1:8787`) +- Other fields: Configure model parameters as needed + +### 2. Starting claude-tap + +After configuring `openclaw.json`, **you MUST start claude-tap before starting OpenClaw**. + +#### API Proxy Only (No Frontend) + +```bash +nohup claude-tap \ + --tap-port 8787 \ + --tap-host 127.0.0.1 \ + --tap-no-launch \ + --tap-no-open \ + > ~/.openclaw/logs/claude-tap.log 2>&1 & +``` + +#### API Proxy + Live Viewer Frontend + +```bash +nohup claude-tap \ + --tap-port 8787 \ + --tap-host 127.0.0.1 \ + --tap-no-launch \ + --tap-no-open \ + --tap-live \ + --tap-live-port 8788 \ + > ~/.openclaw/logs/claude-tap.log 2>&1 & +``` + +**Parameter Reference:** + +| Parameter | Description | +|-----------|-------------| +| `--tap-port 8787` | API proxy listen port; must match the port in openclaw.json `baseUrl` | +| `--tap-host 127.0.0.1` | Bind to loopback address; local access only, not exposed to the network | +| `--tap-no-launch` | Start the proxy only; do not launch the Claude CLI client | +| `--tap-no-open` | Do not auto-open the HTML report on exit | +| `--tap-live` | Enable the live trace viewer frontend | +| `--tap-live-port 8788` | Live Viewer frontend port | + +**Security Notice: You MUST specify `--tap-host 127.0.0.1` to ensure both the proxy and frontend listen only on the loopback address. Without this flag, `--tap-no-launch` mode defaults to binding `0.0.0.0`, exposing the port to the public network.** + +### 3. Verifying claude-tap Is Running + +```bash +# Check whether the ports are listening +ss -tlnp | grep -E "8787|8788" + +# Expected output (both ports should be bound to 127.0.0.1 and in LISTEN state): +# LISTEN 0 128 127.0.0.1:8787 0.0.0.0:* users:(("claude-tap",...)) +# LISTEN 0 128 127.0.0.1:8788 0.0.0.0:* users:(("claude-tap",...)) +# If you see 0.0.0.0:8787, the port is publicly exposed — stop immediately +# and restart with --tap-host 127.0.0.1 + +# Test proxy connectivity +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8787/ +# A 404 response means the proxy is running (no handler on the root path is expected) + +# Check the process +ps aux | grep claude-tap | grep -v grep +``` + +### 4. Accessing the Live Viewer Frontend + +Because it is bound to 127.0.0.1, the Live Viewer is accessible only from the local machine: `http://127.0.0.1:8788` + +For remote access, use SSH port forwarding: +```bash +ssh -L 8788:127.0.0.1:8788 user@remote-host +``` +Then open `http://127.0.0.1:8788` in your local browser. + +## Startup / Restart Order (Critical) + +``` +1. Start claude-tap (port 8787) +2. Verify the port is listening (ss -tlnp | grep 8787 — confirm LISTEN state) +3. Verify proxy connectivity (curl http://127.0.0.1:8787/ — expect 404) +4. Only after both checks pass, restart the OpenClaw gateway +``` + +**NEVER restart the gateway while claude-tap is not ready. You must confirm that the claude-tap port is listening and the proxy is reachable before restarting the gateway.** + +**If the order is reversed, the proxy is not started, or verification is skipped, all model API calls routed through the proxy will fail (connection refused), effectively bringing the service down.** + +## Troubleshooting + +### API Service Down / Cannot Call Models + +1. Check whether the claude-tap process exists: `ps aux | grep claude-tap` +2. Check whether the port is listening: `ss -tlnp | grep 8787` +3. If not running, start claude-tap following the steps above +4. Restart the OpenClaw gateway + +### Removing claude-tap + +Delete the `baseUrl` field (or the entire custom provider block) from `~/.openclaw/openclaw.json` so that OpenClaw connects directly to the provider's official API. Then restart the gateway. + +## Logs + +- claude-tap log: `~/.openclaw/logs/claude-tap.log` +- Trace files are saved by default in: `./.traces/` diff --git a/docs/guides/OPENCLAW_README.zh.md b/docs/guides/OPENCLAW_README.zh.md new file mode 100644 index 0000000..4493cd7 --- /dev/null +++ b/docs/guides/OPENCLAW_README.zh.md @@ -0,0 +1,159 @@ +--- +name: openclaw-claude-tap-setup +description: Configure and launch claude-tap as a local proxy for OpenClaw integration. Use when setting up claude-tap with OpenClaw, troubleshooting API proxy issues, or managing trace collection in an OpenClaw environment. +--- + +# Claude Tap 设置指南(OpenClaw 集成必读) + +English version: [Claude Tap Setup Guide](OPENCLAW_README.md). + +## 背景 + +OpenClaw 可以通过 claude-tap 这样的本地代理 trace 模型 API 请求。配置完成后,发往指定模型 provider 的所有 API 请求都会先转发到本地代理,再由本地代理转发到上游端点,同时记录请求和响应用于调试。 + +**重要:把 openclaw.json 中 provider 的 `baseUrl` 指向本地代理后,必须确保代理进程正在运行。否则 API 请求会 connection refused,导致整个 OpenClaw 服务不可用。** + +## 配置 + +### 1. openclaw.json 中的 claude-tap 配置 + +在 `~/.openclaw/openclaw.json` 的 `models.providers.` 下增加指向本地代理的 `baseUrl`。Anthropic 示例: + +```json +{ + "models": { + "providers": { + "anthropic": { + "baseUrl": "http://127.0.0.1:8787", + "api": "anthropic-messages", + "models": [ + { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6 (via claude-tap)", + "api": "anthropic-messages", + "reasoning": false, + "input": ["text", "image"], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 200000, + "maxTokens": 8192 + } + ] + } + } + } +} +``` + +**关键字段:** + +- `baseUrl`:必须指向 claude-tap 监听的地址和端口,默认是 `http://127.0.0.1:8787` +- 其他字段:按需配置模型参数 + +### 2. 启动 claude-tap + +配置 `openclaw.json` 后,**必须先启动 claude-tap,再启动 OpenClaw**。 + +#### 仅 API 代理(无前端) + +```bash +nohup claude-tap \ + --tap-port 8787 \ + --tap-host 127.0.0.1 \ + --tap-no-launch \ + --tap-no-open \ + > ~/.openclaw/logs/claude-tap.log 2>&1 & +``` + +#### API 代理 + 实时查看器前端 + +```bash +nohup claude-tap \ + --tap-port 8787 \ + --tap-host 127.0.0.1 \ + --tap-no-launch \ + --tap-no-open \ + --tap-live \ + --tap-live-port 8788 \ + > ~/.openclaw/logs/claude-tap.log 2>&1 & +``` + +**参数说明:** + +| 参数 | 说明 | +|------|------| +| `--tap-port 8787` | API 代理监听端口;必须与 openclaw.json `baseUrl` 中的端口一致 | +| `--tap-host 127.0.0.1` | 绑定到 loopback 地址;仅允许本地访问,不暴露到网络 | +| `--tap-no-launch` | 只启动代理;不启动 Claude CLI 客户端 | +| `--tap-no-open` | 退出时不自动打开 HTML 报告 | +| `--tap-live` | 启用实时 trace viewer 前端 | +| `--tap-live-port 8788` | 实时 viewer 前端端口 | + +**安全提示:必须指定 `--tap-host 127.0.0.1`,确保代理和前端都只监听 loopback 地址。若不指定该参数,`--tap-no-launch` 模式默认绑定 `0.0.0.0`,会把端口暴露到公网。** + +### 3. 验证 claude-tap 正在运行 + +```bash +# 检查端口是否监听 +ss -tlnp | grep -E "8787|8788" + +# 期望输出(两个端口都应绑定到 127.0.0.1 且处于 LISTEN 状态): +# LISTEN 0 128 127.0.0.1:8787 0.0.0.0:* users:(("claude-tap",...)) +# LISTEN 0 128 127.0.0.1:8788 0.0.0.0:* users:(("claude-tap",...)) +# 如果看到 0.0.0.0:8787,说明端口已公开暴露;立即停止 +# 并使用 --tap-host 127.0.0.1 重新启动 + +# 测试代理连通性 +curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8787/ +# 404 表示代理正在运行(根路径没有 handler 是预期行为) + +# 检查进程 +ps aux | grep claude-tap | grep -v grep +``` + +### 4. 访问实时查看器前端 + +因为绑定到 `127.0.0.1`,实时 viewer 只能从本机访问:`http://127.0.0.1:8788` + +远程访问请使用 SSH 端口转发: + +```bash +ssh -L 8788:127.0.0.1:8788 user@remote-host +``` + +然后在本地浏览器打开 `http://127.0.0.1:8788`。 + +## 启动 / 重启顺序(关键) + +```text +1. 启动 claude-tap(端口 8787) +2. 验证端口正在监听(ss -tlnp | grep 8787,确认 LISTEN 状态) +3. 验证代理连通性(curl http://127.0.0.1:8787/,期望 404) +4. 以上两项都通过后,再重启 OpenClaw gateway +``` + +**绝不要在 claude-tap 未就绪时重启 gateway。重启 gateway 前必须确认 claude-tap 端口正在监听,并且代理可达。** + +**如果顺序反了、代理没启动,或跳过验证,所有通过代理路由的模型 API 调用都会失败(connection refused),实际效果就是服务不可用。** + +## 故障排查 + +### API 服务不可用 / 无法调用模型 + +1. 检查 claude-tap 进程是否存在:`ps aux | grep claude-tap` +2. 检查端口是否监听:`ss -tlnp | grep 8787` +3. 如果未运行,按上面的步骤启动 claude-tap +4. 重启 OpenClaw gateway + +### 移除 claude-tap + +从 `~/.openclaw/openclaw.json` 删除 `baseUrl` 字段,或删除整个自定义 provider block,让 OpenClaw 直接连接 provider 官方 API。然后重启 gateway。 + +## 日志 + +- claude-tap 日志:`~/.openclaw/logs/claude-tap.log` +- Trace 文件默认保存到:`./.traces/` diff --git a/docs/guides/agent-trace-viewer.html b/docs/guides/agent-trace-viewer.html new file mode 100644 index 0000000..2681bfb --- /dev/null +++ b/docs/guides/agent-trace-viewer.html @@ -0,0 +1,488 @@ + + + + + + Local AI Agent Trace Viewer Guide - claude-tap + + + + + + + + + + + + + + +
+ +
+ +
+
+
+

Guide

+

Local AI Agent Trace Viewer

+

+ A practical guide to viewing Claude Code, Codex, OpenAI Responses API, Gemini, and other AI coding + agent traces locally with claude-tap. +

+ +
+
+ +
+ + +
+

+ claude-tap is a local trace viewer and HTML exporter for AI coding agents. It helps you + inspect prompts, tool calls, token usage, latency, streaming responses, request diffs, and raw API + request shapes without sending private runs to a hosted dashboard. +

+ +
+ claude-tap local AI agent trace viewer showing requests, tool calls, token usage, and latency +
A local viewer for real agent sessions, not reconstructed terminal logs.
+
+ +

What is an AI agent trace?

+

+ An AI agent trace is the recorded request and response flow behind an agent run. For coding agents, a + useful trace usually includes: +

+
    +
  • System prompts and conversation history
  • +
  • Tool schemas, tool calls, tool inputs, and tool results
  • +
  • Streaming response chunks reconstructed into readable output
  • +
  • Token usage, cache usage, and latency
  • +
  • Request diffs between adjacent turns
  • +
+

The terminal shows what the agent says. A trace shows what the agent actually sent.

+ +

Why view traces locally?

+

+ Many observability products are useful for production systems, but local debugging has a different job. + When a coding agent touches private code, private prompts, repository metadata, or internal tools, the + safest default is to inspect the trace on your own machine. +

+

+ claude-tap keeps trace sessions local by default. Common auth headers are redacted before recording, and + exported HTML files are static artifacts that you control. +

+ +

Supported traces and clients

+

claude-tap can trace and inspect sessions from:

+
    +
  • Claude Code
  • +
  • Codex CLI
  • +
  • Codex App
  • +
  • Gemini CLI
  • +
  • Cursor CLI
  • +
  • OpenCode
  • +
  • Kimi CLI
  • +
  • Pi
  • +
  • Hermes Agent
  • +
  • Qoder CLI
  • +
  • Antigravity CLI
  • +
  • CodeBuddy CLI
  • +
+

+ It also supports trace shapes from Anthropic Messages, OpenAI Responses, OpenAI Chat Completions, Gemini, + and Claude-compatible gateways. +

+ +

How to view a trace

+

Install claude-tap:

+
uv tool install claude-tap
+

Run the client through claude-tap:

+
# Claude Code
+claude-tap
+
+# Codex CLI
+claude-tap --tap-client codex
+
+# Codex App local session listener
+claude-tap --tap-client codexapp
+
+# Gemini CLI
+claude-tap --tap-client gemini -- -p "hello"
+

Open the local dashboard or export a standalone HTML file:

+
claude-tap export trace.jsonl --format html
+ +

What to inspect first

+
    +
  • Did the agent receive the prompt and context you expected?
  • +
  • Did tool schemas change between turns?
  • +
  • Did the agent call the right tool with the right parameters?
  • +
  • Did token usage grow because of history, tool results, or repeated context?
  • +
  • Did latency come from the model call, tool call, or a long streaming response?
  • +
+ +

Claude trace viewer

+

+ For Claude Code and Anthropic-compatible traffic, claude-tap shows Anthropic Messages requests, tool calls, + streaming responses, token usage, and Claude-compatible gateway metadata. +

+ +

Codex trace viewer

+

+ For Codex CLI, claude-tap supports OpenAI API key mode and ChatGPT subscription OAuth mode. For Codex App, + it listens to local session JSONL files under CODEX_HOME or ~/.codex. It can inspect OpenAI Responses API + traffic, WebSocket records, local transcript records, tool calls, reasoning/output sections, token usage, + and request diffs. +

+ +

Export traces to HTML

+

The HTML export is useful when you need a portable review artifact:

+
    +
  • Share a debugging run with another maintainer
  • +
  • Attach evidence to a pull request
  • +
  • Archive a model behavior regression
  • +
  • Compare adjacent requests during prompt or tool changes
  • +
+

+ Use a local trace viewer when you want fast inspection of private agent runs. Use hosted observability when + you need production monitoring, team-wide dashboards, alerts, or long-term telemetry. +

+
+
+
+
claude-tap is open source under the MIT License.
+ + diff --git a/docs/guides/agent-trace-viewer.md b/docs/guides/agent-trace-viewer.md new file mode 100644 index 0000000..74c0e41 --- /dev/null +++ b/docs/guides/agent-trace-viewer.md @@ -0,0 +1,115 @@ +# Local AI Agent Trace Viewer + +`claude-tap` is a local trace viewer and HTML exporter for AI coding agents. It helps you inspect Claude Code traces, Codex traces, OpenAI Responses API traces, Anthropic Messages traces, Gemini traces, and other agent runs without uploading private data to a hosted dashboard. + +English guide | [Simplified Chinese guide](agent-trace-viewer.zh.md) + +## What is an AI agent trace? + +An AI agent trace is the recorded request and response flow behind an agent run. For coding agents, a useful trace usually includes: + +- System prompts and conversation history +- Tool schemas, tool calls, tool inputs, and tool results +- Streaming response chunks reconstructed into readable output +- Token usage, cache usage, and latency +- Request diffs between adjacent turns + +These details are hard to recover from terminal output alone. The terminal shows what the agent says; a trace shows what the agent sent. + +## Why view traces locally? + +Many observability products are useful for production systems, but local debugging has a different job. When a coding agent touches private code, private prompts, repository metadata, or internal tools, the safest default is to inspect the trace on your own machine. + +`claude-tap` keeps trace sessions local by default. Common auth headers are redacted before recording, and exported HTML files are static artifacts that you control. + +## Supported traces and clients + +`claude-tap` can trace and inspect sessions from: + +- Claude Code +- Codex CLI +- Codex App +- Gemini CLI +- Cursor CLI +- OpenCode +- Kimi CLI +- Pi +- Hermes Agent +- Qoder CLI +- Antigravity CLI +- CodeBuddy CLI + +It also supports trace shapes from Anthropic Messages, OpenAI Responses, OpenAI Chat Completions, Gemini, and Claude-compatible gateways. + +## How to view a trace + +Install `claude-tap`: + +```bash +uv tool install claude-tap +``` + +Run the client through `claude-tap`: + +```bash +# Claude Code +claude-tap + +# Codex CLI +claude-tap --tap-client codex + +# Codex App local session listener +claude-tap --tap-client codexapp + +# Gemini CLI +claude-tap --tap-client gemini -- -p "hello" +``` + +Open the local dashboard or export a standalone HTML file with embedded compact trace data: + +```bash +claude-tap export trace.jsonl --format html +``` + +## What to inspect first + +Start with these questions: + +- Did the agent receive the prompt and context you expected? +- Did tool schemas change between turns? +- Did the agent call the right tool with the right parameters? +- Did token usage grow because of history, tool results, or repeated context? +- Did latency come from the model call, tool call, or a long streaming response? + +The trace viewer is designed around these debugging questions. + +## Claude trace viewer + +For Claude Code and Anthropic-compatible traffic, `claude-tap` shows Anthropic Messages requests, tool calls, streaming responses, token usage, and Claude-compatible gateway metadata. It can be used as a local Claude trace viewer when you want to inspect Claude Code runs without uploading session data. + +## Codex trace viewer + +For Codex CLI, `claude-tap` supports OpenAI API key mode and ChatGPT subscription OAuth mode. For Codex App, it listens to local session JSONL files under `CODEX_HOME` or `~/.codex`. It can inspect OpenAI Responses API traffic, WebSocket records, local transcript records, tool calls, reasoning/output sections, token usage, and request diffs. + +## Export traces to HTML + +The HTML export is useful when you need a portable review artifact: + +- Share a debugging run with another maintainer +- Attach evidence to a pull request +- Archive a model behavior regression +- Compare adjacent requests during prompt or tool changes + +The exported file is self-contained and can be opened without a hosted dashboard. + +## Local trace viewer vs hosted observability + +Use a local trace viewer when you want fast inspection of private agent runs. Use hosted observability when you need production monitoring, team-wide dashboards, alerts, or long-term telemetry. + +`claude-tap` is focused on the local debugging and review loop: capture a real run, inspect the request flow, and export a static artifact when needed. + +## Next steps + +- [Open the GitHub repository](https://github.com/liaohch3/claude-tap) +- [Check supported clients](../support-matrix.md) +- [Read the Chinese guide](agent-trace-viewer.zh.md) diff --git a/docs/guides/agent-trace-viewer.zh.html b/docs/guides/agent-trace-viewer.zh.html new file mode 100644 index 0000000..68f81bc --- /dev/null +++ b/docs/guides/agent-trace-viewer.zh.html @@ -0,0 +1,460 @@ + + + + + + 本地 AI Agent Trace Viewer 指南 - claude-tap + + + + + + + + +
+ +
+ +
+
+
+

指南

+

本地 AI Agent Trace Viewer

+

+ 使用 claude-tap 在本地查看 Claude Code、Codex、OpenAI Responses API、Gemini 和其他 AI 编程 Agent traces。 +

+ +
+
+ +
+ + +
+

+ claude-tap 是面向 AI 编程 Agent 的本地 trace viewer 和 HTML 导出工具。它可以查看 prompt、工具调用、token 用量、延迟、流式响应、请求 diff 和原始 API 请求结构,不需要把私有运行记录上传到云端 dashboard。 +

+ +
+ claude-tap 本地 AI agent trace viewer,展示请求、工具调用、token 用量和延迟 +
面向真实 agent session 的本地 viewer,而不是从终端日志反推出来的记录。
+
+ +

什么是 AI agent trace?

+

AI agent trace 是一次 agent 运行背后的请求和响应链路记录。对于编程 Agent,一个有用的 trace 通常包括:

+
    +
  • System prompt 和对话历史
  • +
  • 工具 schema、工具调用、工具输入和工具结果
  • +
  • 重建后的流式响应内容
  • +
  • Token 用量、cache 用量和延迟
  • +
  • 相邻轮次之间的请求 diff
  • +
+

终端显示 agent 说了什么;trace 显示 agent 实际发出了什么。

+ +

为什么要本地查看 trace?

+

+ 很多 observability 产品适合生产系统,但本地调试的目标不同。当编程 Agent 接触私有代码、私有 prompt、仓库元数据或内部工具时,最稳妥的默认方式是在自己的机器上检查 trace。 +

+

+ claude-tap 默认把 trace session 留在本地。常见认证 header 会在记录前脱敏,导出的 HTML 文件也是由你自己控制的静态 artifact。 +

+ +

支持的 traces 和客户端

+

claude-tap 可以追踪和查看这些客户端的会话:

+
    +
  • Claude Code
  • +
  • Codex CLI
  • +
  • Codex App
  • +
  • Gemini CLI
  • +
  • Cursor CLI
  • +
  • OpenCode
  • +
  • Kimi CLI
  • +
  • Pi
  • +
  • Hermes Agent
  • +
  • Qoder CLI
  • +
  • Antigravity CLI
  • +
  • CodeBuddy CLI
  • +
+

它也支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、Gemini 和 Claude 兼容网关等 trace 形态。

+ +

如何查看 trace

+

安装 claude-tap:

+
uv tool install claude-tap
+

通过 claude-tap 启动客户端:

+
# Claude Code
+claude-tap
+
+# Codex CLI
+claude-tap --tap-client codex
+
+# Codex App 本地会话监听
+claude-tap --tap-client codexapp
+
+# Gemini CLI
+claude-tap --tap-client gemini -- -p "hello"
+

打开本地 dashboard,或导出独立 HTML 文件:

+
claude-tap export trace.jsonl --format html
+ +

先看哪些内容?

+
    +
  • Agent 是否收到了你预期的 prompt 和上下文?
  • +
  • 多轮之间工具 schema 是否发生了变化?
  • +
  • Agent 是否用正确参数调用了正确工具?
  • +
  • Token 增长来自历史、工具结果,还是重复上下文?
  • +
  • 延迟来自模型调用、工具调用,还是很长的流式响应?
  • +
+ +

Claude trace viewer

+

+ 对于 Claude Code 和 Anthropic 兼容流量,claude-tap 可以展示 Anthropic Messages 请求、工具调用、流式响应、token 用量和 Claude 兼容网关元数据。 +

+ +

Codex trace viewer

+

+ 对于 Codex CLI,claude-tap 支持 OpenAI API key 模式和 ChatGPT 订阅 OAuth 模式。对于 Codex App,它会监听 CODEX_HOME 或 ~/.codex 下的本地 session JSONL 文件。它可以查看 OpenAI Responses API 流量、WebSocket 记录、本地 transcript 记录、工具调用、reasoning/output 区块、token 用量和请求 diff。 +

+ +

导出 trace 到 HTML

+

HTML 导出适合生成可移植的 review artifact:

+
    +
  • 和其他 maintainer 分享一次调试运行
  • +
  • 给 pull request 附上证据
  • +
  • 归档一次模型行为回归
  • +
  • 在 prompt 或工具变更期间对比相邻请求
  • +
+

+ 当你需要快速检查私有 agent runs 时,适合使用本地 trace viewer。当你需要生产监控、团队 dashboard、告警或长期遥测时,适合使用托管 observability。 +

+
+
+
+
claude-tap 是 MIT 协议开源项目。
+ + diff --git a/docs/guides/agent-trace-viewer.zh.md b/docs/guides/agent-trace-viewer.zh.md new file mode 100644 index 0000000..458fa48 --- /dev/null +++ b/docs/guides/agent-trace-viewer.zh.md @@ -0,0 +1,115 @@ +# 本地 AI Agent Trace Viewer + +`claude-tap` 是面向 AI 编程 Agent 的本地 trace viewer 和 HTML 导出工具。它可以查看 Claude Code traces、Codex traces、OpenAI Responses API traces、Anthropic Messages traces、Gemini traces 和其他 agent runs,不需要把私有数据上传到云端 dashboard。 + +[English guide](agent-trace-viewer.md) | 中文指南 + +## 什么是 AI agent trace? + +AI agent trace 是一次 agent 运行背后的请求和响应链路记录。对于编程 Agent,一个有用的 trace 通常包括: + +- System prompt 和对话历史 +- 工具 schema、工具调用、工具输入和工具结果 +- 重建后的流式响应内容 +- Token 用量、cache 用量和延迟 +- 相邻轮次之间的请求 diff + +这些内容很难只从终端输出里恢复。终端显示 agent 说了什么;trace 显示 agent 实际发出了什么。 + +## 为什么要本地查看 trace? + +很多 observability 产品适合生产系统,但本地调试的目标不同。当编程 Agent 接触私有代码、私有 prompt、仓库元数据或内部工具时,最稳妥的默认方式是在自己的机器上检查 trace。 + +`claude-tap` 默认把 trace session 留在本地。常见认证 header 会在记录前脱敏,导出的 HTML 文件也是由你自己控制的静态 artifact。 + +## 支持的 traces 和客户端 + +`claude-tap` 可以追踪和查看这些客户端的会话: + +- Claude Code +- Codex CLI +- Codex App +- Gemini CLI +- Cursor CLI +- OpenCode +- Kimi CLI +- Pi +- Hermes Agent +- Qoder CLI +- Antigravity CLI +- CodeBuddy CLI + +它也支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、Gemini 和 Claude 兼容网关等 trace 形态。 + +## 如何查看 trace + +安装 `claude-tap`: + +```bash +uv tool install claude-tap +``` + +通过 `claude-tap` 启动客户端: + +```bash +# Claude Code +claude-tap + +# Codex CLI +claude-tap --tap-client codex + +# Codex App 本地会话监听 +claude-tap --tap-client codexapp + +# Gemini CLI +claude-tap --tap-client gemini -- -p "hello" +``` + +打开本地 dashboard,或导出内嵌紧凑 trace 数据的独立 HTML 文件: + +```bash +claude-tap export trace.jsonl --format html +``` + +## 先看哪些内容? + +可以从这些问题开始: + +- Agent 是否收到了你预期的 prompt 和上下文? +- 多轮之间工具 schema 是否发生了变化? +- Agent 是否用正确参数调用了正确工具? +- Token 增长来自历史、工具结果,还是重复上下文? +- 延迟来自模型调用、工具调用,还是很长的流式响应? + +trace viewer 的设计就是围绕这些调试问题展开的。 + +## Claude trace viewer + +对于 Claude Code 和 Anthropic 兼容流量,`claude-tap` 可以展示 Anthropic Messages 请求、工具调用、流式响应、token 用量和 Claude 兼容网关元数据。当你想在不上传 session 数据的情况下查看 Claude Code runs,它可以作为本地 Claude trace viewer 使用。 + +## Codex trace viewer + +对于 Codex CLI,`claude-tap` 支持 OpenAI API key 模式和 ChatGPT 订阅 OAuth 模式。对于 Codex App,它会监听 `CODEX_HOME` 或 `~/.codex` 下的本地 session JSONL 文件。它可以查看 OpenAI Responses API 流量、WebSocket 记录、本地 transcript 记录、工具调用、reasoning/output 区块、token 用量和请求 diff。 + +## 导出 trace 到 HTML + +HTML 导出适合生成可移植的 review artifact: + +- 和其他 maintainer 分享一次调试运行 +- 给 pull request 附上证据 +- 归档一次模型行为回归 +- 在 prompt 或工具变更期间对比相邻请求 + +导出的文件是自包含的,不依赖托管 dashboard。 + +## 本地 trace viewer 和托管 observability 的区别 + +当你需要快速检查私有 agent runs 时,适合使用本地 trace viewer。当你需要生产监控、团队 dashboard、告警或长期遥测时,适合使用托管 observability。 + +`claude-tap` 关注的是本地调试和 review 流程:捕获一次真实运行,检查请求链路,并在需要时导出静态 artifact。 + +## 下一步 + +- [打开 GitHub 仓库](https://github.com/liaohch3/claude-tap) +- [查看支持的客户端](../support-matrix.zh.md) +- [Read the English guide](agent-trace-viewer.md) diff --git a/docs/guides/deepseek-claude-code.md b/docs/guides/deepseek-claude-code.md new file mode 100644 index 0000000..ebec1f5 --- /dev/null +++ b/docs/guides/deepseek-claude-code.md @@ -0,0 +1,110 @@ +# Claude Code with DeepSeek API + +This guide shows how to run Claude Code through DeepSeek's Anthropic-compatible API while capturing the traffic with `claude-tap`. + +DeepSeek's official Claude Code guide points Claude Code at `https://api.deepseek.com/anthropic` and uses `deepseek-v4-pro[1m]` for the main Claude Code model. When you capture the session with `claude-tap`, keep that same Claude Code environment. `claude-tap` reads the DeepSeek upstream from `ANTHROPIC_BASE_URL`, then launches Claude Code against the local proxy. + +Simplified Chinese version: [Claude Code 搭配 DeepSeek API](deepseek-claude-code.zh.md). + +## Environment + +Use `ANTHROPIC_AUTH_TOKEN` for Claude Code and leave `ANTHROPIC_API_KEY` unset to avoid Claude Code's API-key conflict prompt. + +```bash +export ANTHROPIC_AUTH_TOKEN="" +unset ANTHROPIC_API_KEY + +export ANTHROPIC_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_OPUS_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_SUBAGENT_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_EFFORT_LEVEL=max +export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic +``` + +`claude-tap` uses the current `ANTHROPIC_BASE_URL` as the real upstream target before it overwrites the launched Claude Code process with the local proxy URL. Use `--tap-target` only when you want to override that detected upstream. + +## Capture With claude-tap + +Run `claude-tap` normally: + +```bash +claude-tap -- --permission-mode bypassPermissions +``` + +For a one-off non-interactive smoke test: + +```bash +claude-tap \ + -- \ + --permission-mode bypassPermissions \ + -p 'Use Bash to run pwd, then reply with DEEPSEEK_CLAUDE_TAP_OK.' +``` + +When the process exits, open the generated viewer: + +```bash +open .traces/*/trace_*.html +``` + +## TLS and Local Proxies + +If the upstream request fails with `SSLCertVerificationError` while direct `curl` calls succeed, the Python process may be using a CA bundle that does not trust your local outbound proxy. Run `claude-tap` with the system bundle or the CA bundle used by your proxy: + +```bash +# macOS/Homebrew examples often use /etc/ssl/cert.pem. +SSL_CERT_FILE=/etc/ssl/cert.pem claude-tap +``` + +On Debian/Ubuntu, the system CA bundle is usually `/etc/ssl/certs/ca-certificates.crt`. + +## Compatibility Notes + +Claude Code 2.1.128 and 2.1.131 can send `metadata.user_id` as a JSON string. DeepSeek's Anthropic-compatible endpoint rejects that value because it only accepts letters, digits, underscores, and hyphens. `claude-tap` normalizes invalid `metadata.user_id` values only when the upstream target is `https://api.deepseek.com/anthropic`; default Anthropic traffic is left unchanged. + +DeepSeek may return `404` for Claude Code's `/v1/models?limit=1000` preflight. Claude Code continues as long as `/v1/messages` succeeds. In the validation run below, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` was set to reduce unrelated startup traffic. + +`claude-tap` redacts common authentication headers such as `Authorization` and `x-api-key`, but it does not globally scrub prompts, request bodies, or tool output. Do not put secrets in prompts, script output, or files that the agent may read into the trace. + +## Verified Run + +Validated on 2026-05-06 with: + +- Direct DeepSeek Anthropic API call returning HTTP `200` +- Claude Code `2.1.131` +- `deepseek-v4-pro[1m]` for main Claude Code turns +- `deepseek-v4-flash` for Claude Code title/auxiliary turns +- `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic` +- `claude-tap` +- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` + +The serial tmux run produced: + +- 3 user rounds +- 11 `/v1/messages` requests +- 6 unique `Bash` `tool_use` blocks, two per requested round +- 6 matching `tool_result` blocks +- A generated HTML viewer from the real trace under `.traces/` +- Viewer screenshots captured after multiple Playwright mouse-wheel scroll events + +Overview: + +![DeepSeek Claude Code serial overview](../../.agents/evidence/images/deepseek-claude-code-serial-overview.png) + +Scrolled detail pane: + +![DeepSeek Claude Code serial detail scroll](../../.agents/evidence/images/deepseek-claude-code-serial-detail-scroll-1.png) + +Scrolled sidebar/navigation: + +![DeepSeek Claude Code serial sidebar scroll](../../.agents/evidence/images/deepseek-claude-code-serial-sidebar-scroll.png) + +Final response after additional mouse-wheel scrolling: + +![DeepSeek Claude Code serial final response](../../.agents/evidence/images/deepseek-claude-code-serial-final-response.png) + +## References + +- [DeepSeek Anthropic API](https://api-docs.deepseek.com/guides/anthropic_api) +- [DeepSeek Claude Code integration](https://api-docs.deepseek.com/quick_start/agent_integrations/claude_code) diff --git a/docs/guides/deepseek-claude-code.zh.md b/docs/guides/deepseek-claude-code.zh.md new file mode 100644 index 0000000..e7abc13 --- /dev/null +++ b/docs/guides/deepseek-claude-code.zh.md @@ -0,0 +1,116 @@ +# Claude Code 搭配 DeepSeek API + +本文说明如何让 Claude Code 通过 DeepSeek 的 Anthropic 兼容 API 运行,并用 `claude-tap` 捕获这条流量。 + +DeepSeek 官方 Claude Code 集成会把 Claude Code 指向 `https://api.deepseek.com/anthropic`,主模型使用 `deepseek-v4-pro[1m]`。如果同时使用 `claude-tap`,继续保留这套 Claude Code 环境即可。`claude-tap` 会先从 `ANTHROPIC_BASE_URL` 读取 DeepSeek 上游,再把 Claude Code 指向本地代理。 + +English version: [Claude Code with DeepSeek API](deepseek-claude-code.md). + +## 环境变量 + +Claude Code 使用 `ANTHROPIC_AUTH_TOKEN` 传入 DeepSeek key,并建议清掉 `ANTHROPIC_API_KEY`,避免 Claude Code 触发 API key 冲突提示。 + +```bash +export ANTHROPIC_AUTH_TOKEN="<你的 DeepSeek API key>" +unset ANTHROPIC_API_KEY + +export ANTHROPIC_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_OPUS_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-pro[1m]" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_SUBAGENT_MODEL="deepseek-v4-flash" +export CLAUDE_CODE_EFFORT_LEVEL=max +export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic +``` + +`claude-tap` 会在改写被启动的 Claude Code 进程之前,把当前 `ANTHROPIC_BASE_URL` 作为真实上游目标。只有想手动覆盖时才需要传 `--tap-target`。 + +## 使用 claude-tap 捕获 + +正常启动 `claude-tap`: + +```bash +claude-tap -- --permission-mode bypassPermissions +``` + +一次性非交互 smoke test: + +```bash +claude-tap \ + -- \ + --permission-mode bypassPermissions \ + -p 'Use Bash to run pwd, then reply with DEEPSEEK_CLAUDE_TAP_OK.' +``` + +进程退出后打开自动生成的 HTML: + +```bash +open .traces/*/trace_*.html +``` + +也可以从 JSONL 重新导出 HTML: + +```bash +claude-tap export .traces/2026-05-06/trace_153111.jsonl -o trace.html +``` + +## TLS 与本地代理 + +如果上游请求报 `SSLCertVerificationError`,但直接 `curl` 能成功,通常是 Python 进程使用的 CA bundle 不信任本地出站代理。可以显式指定系统 CA bundle 或代理使用的 CA bundle: + +```bash +# macOS/Homebrew 常见路径是 /etc/ssl/cert.pem。 +SSL_CERT_FILE=/etc/ssl/cert.pem claude-tap +``` + +Debian/Ubuntu 的系统 CA bundle 通常是 `/etc/ssl/certs/ca-certificates.crt`。 + +## 兼容性说明 + +Claude Code 2.1.128 和 2.1.131 可能把 `metadata.user_id` 作为 JSON 字符串发送。DeepSeek 的 Anthropic 兼容端点会拒绝这个值,因为它只接受字母、数字、下划线和连字符。`claude-tap` 只会在上游目标是 `https://api.deepseek.com/anthropic` 时规范化非法的 `metadata.user_id`;默认 Anthropic 流量保持不变。 + +DeepSeek 对 Claude Code 启动时的 `/v1/models?limit=1000` 预检可能返回 `404`。只要 `/v1/messages` 成功,Claude Code 仍可继续运行。下面的验证运行设置了 `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`,用于减少无关启动流量。 + +`claude-tap` 会脱敏 `Authorization` / `x-api-key` 请求头,但不会全局清洗 prompt、请求 body 或工具输出。不要把 key 写进 prompt、脚本输出或 trace 内容里。 + +## 已验证运行 + +2026-05-06 验证环境: + +- DeepSeek Anthropic API 直连调用返回 HTTP `200` +- Claude Code `2.1.131` +- Claude Code 主对话模型:`deepseek-v4-pro[1m]` +- Claude Code 标题和辅助调用模型:`deepseek-v4-flash` +- `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic` +- `claude-tap` +- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` + +真实 tmux 串行运行结果: + +- 3 轮用户对话 +- 11 个 `/v1/messages` 请求 +- 6 个唯一 `Bash` `tool_use` block,每轮 2 个 +- 6 个匹配的 `tool_result` block +- 从真实 `.traces/` trace 生成 HTML viewer +- 使用 Playwright 多次鼠标滚动后截图 + +概览: + +![DeepSeek Claude Code serial overview](../../.agents/evidence/images/deepseek-claude-code-serial-overview.png) + +滚动后的详情区域: + +![DeepSeek Claude Code serial detail scroll](../../.agents/evidence/images/deepseek-claude-code-serial-detail-scroll-1.png) + +滚动后的侧边栏导航: + +![DeepSeek Claude Code serial sidebar scroll](../../.agents/evidence/images/deepseek-claude-code-serial-sidebar-scroll.png) + +继续滚动后的最终回复: + +![DeepSeek Claude Code serial final response](../../.agents/evidence/images/deepseek-claude-code-serial-final-response.png) + +## 参考文档 + +- [DeepSeek Anthropic API](https://api-docs.deepseek.com/guides/anthropic_api) +- [DeepSeek Claude Code 集成](https://api-docs.deepseek.com/quick_start/agent_integrations/claude_code) diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..63ceb82 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,634 @@ + + + + + + claude-tap - Local AI Agent Trace Viewer + + + + + + + + + + + + + + +
+
+ +
+ +
+
+
+
+

Local AI agent trace viewer

+

See what your coding agent really sent.

+

+ claude-tap captures real API traffic from AI coding agents and turns it into a local trace viewer. + Inspect prompts, tool calls, token usage, latency, streaming responses, and request diffs without + uploading private traces to a hosted dashboard. +

+ +
uv tool install claude-tap
+claude-tap --tap-client codex
+claude-tap export --format html trace.jsonl
+
+
+ claude-tap local AI agent trace viewer showing requests, tool calls, and token usage +
Open a real agent run, inspect each request, and compare context between turns.
+
+
+
+ +
+
+
+ Tool calls + Inputs, outputs, parameters, and adjacent diffs. +
+
+ Token usage + Prompt, completion, cache, and latency metadata. +
+
+ HTML export + Portable trace files for review and archiving. +
+
+ Local-first + No hosted dashboard required for private traces. +
+
+
+ +
+
+
+

Built for coding agent traces, not generic logs.

+

+ Agent runs are shaped by prompts, tool schemas, retries, streaming chunks, and model-specific + request formats. claude-tap keeps those pieces visible so debugging can start from the actual trace. +

+
+
+
+

Inspect full request flow

+

Review system prompts, conversation history, tool schemas, raw request bodies, and reconstructed responses.

+
+
+

Debug behavior with evidence

+

Use structured diffs to see which message, prompt, tool, or parameter changed between adjacent turns.

+
+
+

Share a trace without a service

+

Export a self-contained HTML viewer that teammates can open locally for review, support, or incident notes.

+
+
+
+
+ +
+
+
+

One workflow across agent clients.

+

+ Use one local trace viewer for Claude Code traces, Codex traces, OpenAI Responses API traces, + Gemini traces, and other coding-agent sessions. +

+
+
+ Claude Code + Codex CLI + Codex App + Gemini CLI + Cursor CLI + OpenCode + Kimi CLI + Pi + Hermes Agent + Qoder CLI + Antigravity CLI + CodeBuddy CLI +
+
+
+ +
+
+
+ Structured diff between adjacent AI agent requests +
Structured request diffs show how context changes from one turn to the next.
+
+
+ Dark mode local trace viewer for long AI agent review sessions +
Light and dark viewer modes support longer debugging and review sessions.
+
+
+
+ +
+
+
+

From run to review artifact.

+

+ Keep the trace local while you inspect it, then export a static HTML artifact only when you need to share. +

+
+
+
+ Run + Launch your agent through claude-tap. +
+
+ Capture + Record real API traffic and streaming responses. +
+
+ Inspect + Search turns, tools, usage, latency, and diffs. +
+
+ Export + Save a standalone HTML trace for review. +
+
+
+
+ +
+
+
+

Start with a local trace.

+

+ Use claude-tap when you need to understand what an AI coding agent actually did before you tune prompts, + compare tools, or share a run with another reviewer. +

+
+ Install from GitHub +
+
+
+ +
+
+ claude-tap is open source under the MIT license. +
+
+
+ + diff --git a/docs/index.zh.html b/docs/index.zh.html new file mode 100644 index 0000000..337950a --- /dev/null +++ b/docs/index.zh.html @@ -0,0 +1,199 @@ + + + + + + claude-tap - 本地 AI Agent Trace Viewer + + + + + + + + +
+ +
+
+
+
+
+

本地 AI Agent Trace Viewer

+

看清编程 Agent 真正发出了什么。

+

+ claude-tap 捕获 AI 编程 Agent 的真实 API 流量,并生成本地 trace viewer。你可以查看 prompt、工具调用、token 用量、延迟、流式响应和请求 diff,不需要把私有 trace 上传到云端 dashboard。 +

+ +
uv tool install claude-tap
+claude-tap --tap-client codex
+claude-tap export --format html trace.jsonl
+
+
+ claude-tap 本地 AI agent trace viewer,展示请求、工具调用和 token 用量 +
打开一次真实 agent 运行,检查每个请求,并对比上下文在多轮之间如何变化。
+
+
+
+
+
+
工具调用查看输入、输出、参数和相邻请求 diff。
+
Token 用量记录 prompt、completion、cache 和 latency 信息。
+
HTML 导出生成便于 review 和归档的可移植 trace 文件。
+
本地优先查看私有 trace 不需要托管 dashboard。
+
+
+
+
+
+

为编程 Agent trace 设计,而不是泛用日志。

+

Agent 运行受到 prompt、工具 schema、重试、流式 chunk 和模型请求格式影响。claude-tap 把这些内容保留下来,让调试从真实 trace 开始。

+
+
+

查看完整请求链路

检查 system prompt、对话历史、工具 schema、原始请求体和重建后的响应。

+

用证据调试行为

通过结构化 diff 看清相邻轮次之间是哪条消息、prompt、工具或参数发生了变化。

+

不用服务也能分享 trace

导出自包含 HTML viewer,团队成员可以本地打开用于 review、支持或复盘。

+
+
+
+
+
+
+

一套流程覆盖多种 Agent 客户端。

+

用同一个本地 trace viewer 查看 Claude Code traces、Codex traces、OpenAI Responses API traces、Gemini traces 和其他编程 Agent 会话。

+
+
+ Claude CodeCodex CLICodex AppGemini CLICursor CLIOpenCodeKimi CLIPiHermes AgentQoder CLIAntigravity CLICodeBuddy CLI +
+
+
+
+
+
AI agent 相邻请求之间的结构化 diff
结构化请求 diff 展示上下文如何从一轮变化到下一轮。
+
适合长时间 review 的暗色模式本地 trace viewer
亮色和暗色模式适合更长时间的调试和 review。
+
+
+
+
+
+

从一次运行到一份 review 证据。

+

先把 trace 留在本地检查;只有需要分享时,再导出静态 HTML artifact。

+
+
+
运行通过 claude-tap 启动你的 Agent。
+
捕获记录真实 API 流量和流式响应。
+
检查搜索轮次、工具、用量、延迟和 diff。
+
导出保存独立 HTML trace 供 review。
+
+
+
+
+
+

从本地 trace 开始。

当你需要理解一个 AI 编程 Agent 到底做了什么,再去调 prompt、比较工具或分享运行记录。

+ 从 GitHub 安装 +
+
+
+
claude-tap 是 MIT 协议开源项目。
+ + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..1fa71b4 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://liaohch3.com/claude-tap/sitemap.xml diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..0023d8e --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,19 @@ + + + + https://liaohch3.com/claude-tap/ + 1.0 + + + https://liaohch3.com/claude-tap/index.zh.html + 0.8 + + + https://liaohch3.com/claude-tap/guides/agent-trace-viewer.html + 0.9 + + + https://liaohch3.com/claude-tap/guides/agent-trace-viewer.zh.html + 0.7 + + diff --git a/docs/support-matrix.md b/docs/support-matrix.md new file mode 100644 index 0000000..46af510 --- /dev/null +++ b/docs/support-matrix.md @@ -0,0 +1,239 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-06-24 +source_of_truth: AGENTS.md +--- + +# Support Matrix + +This document tracks all verified (client × auth × target × transport) combinations. +**Any proxy/routing change must verify all applicable rows before merge.** + +Simplified Chinese version: [支持矩阵](support-matrix.zh.md). + +## Client Configurations + +| Client | Auth Mode | Target | strip_path_prefix | Transport | Status | +|--------|-----------|--------|-------------------|-----------|--------| +| Claude Code | API Key | `https://api.anthropic.com` | none | HTTP/SSE | Verified | +| Claude Code | Claude-compatible gateway (`ANTHROPIC_BASE_URL` env or Claude settings) | Custom Anthropic-compatible upstream | none | HTTP/SSE | Unit-tested; DeepSeek real E2E verified | +| Claude Code | Anthropic-compatible Bedrock gateway (`ANTHROPIC_BASE_URL` + `bedrock/...` model) | New API or equivalent gateway routed to AWS Bedrock | none | HTTP/SSE | Unit-tested; New API AWS Bedrock real E2E verified | +| Claude Code | Google Vertex AI pass-through gateway (`CLAUDE_CODE_USE_VERTEX=1` + `ANTHROPIC_VERTEX_BASE_URL`) | Vertex rawPredict-compatible upstream | none | HTTP/SSE | Unit-tested; local E2E verified | +| Codex CLI | API Key (`OPENAI_API_KEY`) | `https://api.openai.com` | none | HTTP/SSE in default reverse mode | Verified | +| Codex CLI | OAuth (`codex login`) | `https://chatgpt.com/backend-api/codex` | `/v1` | HTTP/SSE in default reverse mode | Real E2E verified with Codex 0.144.1 | +| Codex CLI | Explicit `--tap-proxy-mode forward` | Auto-detected upstream | n/a | HTTP/SSE + WebSocket | Unit-tested | +| Codex App | ChatGPT account in Codex App | `codex-app://sessions` | n/a | Local session JSONL transcript import plus automatic best-effort CDP WebSocket enrichment when a Codex App debug endpoint is available | Unit-tested | +| Gemini CLI | Google OAuth / Code Assist | Forward proxy (Google endpoints) | n/a | HTTP/SSE | Real E2E verified | +| Gemini CLI | API key / Vertex-compatible config (`--tap-proxy-mode reverse`) | `https://generativelanguage.googleapis.com` | none | HTTP/SSE | Unit-tested | +| Kimi CLI (legacy kimi-cli) | Kimi CLI auth/config | `https://api.kimi.com/coding/v1` | none | HTTP/SSE Chat Completions | Unit-tested (`KIMI_BASE_URL`) | +| Kimi CLI (legacy kimi-cli) | Kimi CLI auth/config | `https://api.moonshot.ai/v1` | none | HTTP/SSE Chat Completions | Supported by config | +| Kimi Code CLI | `~/.kimi-code/config.toml` + OAuth (`managed:kimi-code`) | `https://api.kimi.com/coding/v1` | none | HTTP/SSE Chat Completions | Unit-tested (`KIMI_CODE_HOME` sandbox) | +| Kimi Code CLI | Custom `type = "kimi"` provider in config | `https://api.moonshot.ai/v1` | none | HTTP/SSE Chat Completions | Supported via `--tap-target` | +| OpenCode | Provider creds via `opencode providers` (OpenAI OAuth and OpenCode free provider verified) | Forward proxy (any HTTPS upstream) | n/a | HTTP/SSE | Real E2E verified | +| OpenCode | Anthropic provider only (`--tap-proxy-mode reverse`) | `https://api.anthropic.com` | none | HTTP/SSE | Unit-tested | +| MiMo Code | Provider creds via `mimo` TUI config or MiMo Platform OAuth | Forward proxy (any HTTPS upstream) | n/a | HTTP/SSE | Unit-tested | +| MiMo Code | Anthropic provider only (`--tap-proxy-mode reverse`; sets `MIMOCODE_MIMO_ONLY=false`) | `https://api.anthropic.com` | none | HTTP/SSE | Unit-tested | +| OpenClaw | Provider creds via `~/.openclaw/openclaw.json` or `OPENCLAW_CONFIG_PATH` | Selected provider `baseUrl` patched through a temporary config file | provider-dependent | HTTP/SSE | Unit-tested | +| OpenClaw | No patchable config (`--tap-proxy-mode reverse`) | Provider env fallback (`OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`, `GOOGLE_GEMINI_BASE_URL`, or `OPENROUTER_BASE_URL`) | provider-dependent | HTTP/SSE | Unit-tested | +| Pi | Provider creds via Pi `/login` or `PI_CODING_AGENT_DIR` auth file (`openai-codex` OAuth verified) | Forward proxy (any HTTPS upstream) | n/a | HTTP/SSE + WebSocket | Real E2E verified | +| Pi | Custom OpenAI-compatible setup (`--tap-proxy-mode reverse`) | `https://api.openai.com` | none | HTTP/SSE | Unit-tested | +| Hermes Agent | Provider creds via `~/.hermes/` | Forward proxy (any HTTPS upstream) | n/a | HTTP/SSE | Unit-tested | +| Hermes Agent | Custom OpenAI-compatible provider (`--tap-proxy-mode reverse`) | `https://api.openai.com` | `/v1` | HTTP/SSE | Unit-tested | +| Cursor CLI | Cursor login (`cursor-agent login`) | Forward proxy to `https://api2.cursor.sh` | n/a | HTTPS/protobuf + local transcript import | Real E2E verified | +| Qoder CLI | Qoder login / `QODER_PERSONAL_ACCESS_TOKEN` / `QODER_JOB_TOKEN` | Forward proxy (Qoder endpoints) | n/a | HTTP/SSE | Real E2E verified | +| Antigravity CLI | Antigravity login | Forward proxy + `CLOUD_CODE_URL` bridge to `https://daily-cloudcode-pa.googleapis.com` | `CLOUD_CODE_URL` | HTTP/SSE | Manual E2E verified; launch env, Code Assist bridge, and automatic macOS user-keychain CA trust are unit-tested | +| CodeBuddy CLI | CodeBuddy login (iOA / WeChat / Google-Github / Enterprise Domain) | Auto-detected from `~/.codebuddy/local_storage/` cache; default `https://copilot.tencent.com/v2` | `CODEBUDDY_BASE_URL` | HTTP/SSE Chat Completions | Real E2E verified on iOA | + +## Default Proxy Mode by Client + +Each client in `CLIENT_CONFIGS` declares a `default_proxy_mode` used when +`--tap-proxy-mode` is omitted: + +| Client | Default mode | Reason | +|--------|--------------|--------| +| `claude` | `reverse` | Single provider, native Claude provider base URL env vars (`ANTHROPIC_BASE_URL`, `ANTHROPIC_BEDROCK_BASE_URL`, `ANTHROPIC_VERTEX_BASE_URL`) | +| `codex` | `reverse` | Launches a temporary sibling provider with the proxy base URL and `supports_websockets=false`, producing one self-contained HTTP/SSE trace record per request without changing `~/.codex/config.toml` | +| `codexapp` | `transcript` | Transcript listener for `CODEX_HOME/sessions` or `~/.codex/sessions`; no proxy is created. CDP WebSocket evidence is added automatically when Codex App exposes a debug endpoint | +| `gemini` | `forward` | Google OAuth / Code Assist uses several Google endpoints; forward proxy captures the flow without assuming a single base URL | +| `kimi` | `reverse` | Legacy kimi-cli; native `KIMI_BASE_URL` env var | +| `kimi-code` | `reverse` | Patches `~/.kimi-code/config.toml` via temporary `KIMI_CODE_HOME` sandbox | +| `mimo` | `forward` | OpenCode fork; multi-provider — forward proxy captures every upstream regardless of which env var the client honors | +| `opencode` | `forward` | Multi-provider; forward proxy captures every upstream regardless of which env var the client honors | +| `openclaw` | `reverse` | Patches the selected OpenClaw provider config when possible, otherwise falls back to provider-specific base URL env vars | +| `pi` | `forward` | Multi-provider; Pi can use OpenAI Codex OAuth and custom model registry providers, so forward proxy captures traffic without relying on a single base URL override | +| `hermes` | `forward` | Multi-provider Python agent; `httpx` and `requests` honor `HTTPS_PROXY` natively, so forward proxy capture is the natural default | +| `cursor` | `forward` | Cursor CLI has no base URL override; forward proxy captures network traffic and local transcripts provide readable turns | +| `qoder` | `forward` | Qoder CLI uses multiple Qoder service endpoints and has no reliable single base URL override | +| `agy` | `forward` | Antigravity uses multiple Google / Antigravity endpoints; claude-tap sets `HTTPS_PROXY` for auxiliary traffic and `CLOUD_CODE_URL` for Code Assist model traffic | +| `codebuddy` | `reverse` | Single provider, native `CODEBUDDY_BASE_URL` env var; supports `--settings` env injection. Endpoint auto-detected from CodeBuddy's login cache | + +Users can override proxy-backed clients with `--tap-proxy-mode {reverse,forward}`. `codexapp` is transcript-only, so `--tap-proxy-mode` does not apply. + +## Subcommand Argv Rewrites + +Some clients delegate to OS service managers (launchd / systemd / schtasks) for +their long-running daemons. The spawned daemon does **not** inherit the +proxy / CA env we inject, so trace capture would silently fail. claude-tap +detects these patterns and rewrites the argv to the foreground equivalent: + +| Client | Detected argv | Rewritten to | Reason | +|--------|---------------|--------------|--------| +| `hermes` | `gateway start [...]` | `gateway run [...]` | Recent hermes versions delegate `gateway start` to systemd / launchd; `gateway run` is the foreground equivalent and is exactly what the systemd unit's `ExecStart=` itself invokes. | + +The rewrite is logged loudly at process start so users can spot it and pass +`--tap-no-launch` + run the original command themselves if they actually want +the daemonised behaviour (and accept that no traffic will be captured). + +> **Note:** Gateway mode only produces traces when a configured messaging platform (Slack, Telegram, etc.) +> delivers a message to the bot. Without an active platform integration, the gateway makes no LLM calls +> and no traces are recorded. Use TUI mode (`claude-tap --tap-client hermes`) for local trace capture. + +## URL Construction Rules + +The proxy constructs upstream URLs as: `target + forwarded_path` + +When `strip_path_prefix` is set, the prefix is removed from the incoming path before forwarding: + +``` +incoming: /v1/responses +strip: /v1 +result: /responses +upstream: {target}/responses +``` + +### Decision Logic + +```python +strip = CLIENT_CONFIGS[client].reverse_strip_path_prefix(target) +``` + +| Target contains `api.openai.com` | strip | Example | +|----------------------------------|-------|---------| +| Yes | none | `/v1/responses` → `api.openai.com/v1/responses` | +| No | `/v1` | `/v1/responses` → `chatgpt.com/.../responses` | + +## Verification Methods + +### Automated (CI) + +- `test_codex_upstream_url_construction` — verifies URL construction for all 5 matrix combinations +- `test_codex_client_reverse_proxy` — e2e with fake upstream (OAuth-like, with strip) +- `test_build_codex_app_transcript_records_preserves_turn_context` — verifies Codex App session JSONL imports as viewer-friendly Responses records with usage, tools, and tool results +- `test_import_codex_app_transcripts_appends_only_new_completed_records` — verifies Codex App transcript polling appends only new completed records +- `test_cdp_recorder_writes_viewer_friendly_websocket_record` — verifies Codex App CDP WebSocket frames are reconstructed into viewer-friendly WebSocket records +- `test_async_main_codexapp_starts_cdp_enrichment_by_default` — verifies `--tap-client codexapp` starts automatic CDP enrichment while honoring the global raw stream event storage setting +- `test_gemini_registered_in_client_configs` — verifies Gemini CLI registration and default forward mode +- `test_run_client_gemini_forward_sets_proxy_ca_and_skips_base_url_envs` — verifies Gemini forward proxy launch env +- `test_run_client_gemini_reverse_sets_both_base_url_envs` — verifies Gemini reverse proxy base URL env injection +- `test_viewer_renders_gemini_semantic_sections` — verifies Gemini systemInstruction, contents, functionDeclarations, functionCall, functionResponse, SSE output, and token usage render as semantic viewer sections +- `test_kimi_registered_in_client_configs` — verifies legacy Kimi CLI registration +- `test_kimi_client_reverse_proxy` — e2e with fake Kimi Chat Completions stream (`KIMI_BASE_URL`) +- `test_kimi_code_*` — verifies Kimi Code CLI registration, sandbox config patch, and e2e capture +- `test_chat_completions_reasoning_content_is_mirrored_as_thinking` — verifies Kimi thinking stream rendering shape +- `test_websocket_proxy_basic` — WS relay and trace recording +- `test_hermes_*` — registration, parse_args default-mode resolution, forward/reverse env, argv rewrite +- `test_openclaw_*` — verifies OpenClaw registration, selected-provider config patching, fallback env routing, and target detection +- `test_pi_*` — registration, parse_args default-mode resolution, forward/reverse env, and argument preservation +- `test_cursor_registered_in_client_configs` — verifies Cursor CLI registration and default forward mode +- `test_run_client_cursor_forward_sets_proxy_ca_and_no_proxy` — verifies Cursor launch env for forward proxy mode +- `test_import_cursor_transcripts_appends_viewer_friendly_records` — verifies readable Cursor transcript import +- `test_import_cursor_transcripts_preserves_tool_uses` — verifies Cursor tool_use blocks render in the viewer trace shape +- `test_qoder_*` — verifies Qoder registration, parse_args default-mode resolution, forward/reverse env, and argument preservation +- `test_parse_args_agy_does_not_require_tap_trust_ca` — verifies Antigravity uses the same launch shape as other clients +- `test_auto_ca_trust_*` — verifies Antigravity automatically requests macOS user-keychain CA trust without sudo +- `test_macos_*_ca_command_*` — verifies CA trust commands use the user login keychain and do not invoke sudo +- `test_codebuddy_*` — verifies CodeBuddy registration, parse_args default reverse mode, settings injection, forward/reverse env, target detection from `CODEBUDDY_BASE_URL` env, and the login-time endpoint cache reader + +### Manual (pre-merge for proxy changes) + +```bash +# API Key mode +uv run python -m claude_tap --tap-client codex --tap-no-launch --tap-port 0 +# Verify log shows correct upstream URL + +# OAuth mode +uv run python -m claude_tap --tap-client codex \ + --tap-target https://chatgpt.com/backend-api/codex --tap-no-launch --tap-port 0 +# Verify log shows correct upstream URL + +# Cursor CLI +uv run python -m claude_tap --tap-client cursor -- -p --trust --model auto "Reply OK" +# Verify the trace contains raw proxy records plus cursor-transcript records + +# Codex App +uv run python -m claude_tap --tap-client codexapp +# Start or continue a Codex App task and verify the dashboard receives transcript records. +# If Codex App exposes a debug endpoint, websocket evidence is added automatically. + +# Qoder CLI +uv run python -m claude_tap --tap-client qoder -- -p "Reply OK" --permission-mode dont_ask +# Verify stdout contains the assistant response and the trace contains Qoder endpoint records + +# Antigravity CLI (macOS) +uv run python -m claude_tap --tap-client agy --tap-live +# On first run, verify macOS prompts only for the user login keychain, not sudo/admin System keychain writes. +# Then verify the trace contains /v1internal:streamGenerateContent model records. + +# Kimi CLI (legacy kimi-cli) +uv run python -m claude_tap --tap-client kimi -- --thinking + +# Kimi Code CLI +uv run python -m claude_tap --tap-client kimi-code -- --thinking +# Verify the trace contains /chat/completions records and thinking/text output + +# Gemini CLI +uv run python -m claude_tap --tap-client gemini -- -p "Reply OK" --yolo --output-format text +# Verify the trace contains Google OAuth / Code Assist API records + +# Pi +uv run python -m claude_tap --tap-client pi -- \ + --model openai-codex/gpt-5.3-codex-spark -p "Reply OK" +# Verify the trace contains chatgpt.com/backend-api records and readable OpenAI Responses sections + +# CodeBuddy (auto-detected endpoint after login) +uv run python -m claude_tap --tap-client codebuddy -- -p "Reply OK" +# Verify the trace contains /v2/chat/completions records and the response body has non-zero token counts +``` + +### Real E2E (optional, when auth is available) + +```bash +# tmux-based real verification +tmux new-session -d -s verify \ + "uv run python -m claude_tap --tap-client codex --tap-target TARGET --tap-no-launch --tap-port 8080" +# In another window: +OPENAI_BASE_URL=http://127.0.0.1:8080/v1 codex exec "Reply: OK" +``` + +```bash +# Cursor CLI real verification +uv run python -m claude_tap --tap-client cursor -- -p --trust --model auto \ + "Use tools to inspect the workspace and reply OK" +# Verify the generated HTML contains cursor-transcript turns and tool_use blocks. +``` + +```bash +# Gemini CLI real verification +uv run python -m claude_tap --tap-client gemini -- -p \ + "Use tools to inspect the workspace and reply OK" --yolo --output-format text +# Verify the trace contains cloudcode-pa.googleapis.com / streamGenerateContent records. +``` + +```bash +# Pi real verification with OpenAI Codex OAuth +uv run python -m claude_tap --tap-client pi -- \ + --model openai-codex/gpt-5.3-codex-spark --tools bash -p \ + "Use bash to inspect the workspace and reply OK" +# Verify the generated viewer shows Tools, System Prompt, Messages, Response, +# SSE/WebSocket events, tool calls, tool outputs, and token usage. +``` + +## Adding New Clients or Backends + +When adding a new client or backend: + +1. Add a row to the matrix above +2. Add a `CLIENT_CONFIGS` entry and a launch/config test +3. Add an e2e test with fake upstream if applicable +4. Verify with real E2E if auth is available +5. Update the public docs in both English and Simplified Chinese (`README.md` plus `README_zh.md`, and matching `docs/guides/*.md` plus `docs/guides/*.zh.md` guide files when applicable) diff --git a/docs/support-matrix.zh.md b/docs/support-matrix.zh.md new file mode 100644 index 0000000..63c4eb1 --- /dev/null +++ b/docs/support-matrix.zh.md @@ -0,0 +1,233 @@ +--- +owner: claude-tap-maintainers +last_reviewed: 2026-06-24 +source_of_truth: AGENTS.md +--- + +# 支持矩阵 + +本文记录所有已验证的客户端、认证方式、上游目标和传输组合。 +**任何代理或路由相关变更在合入前都必须验证适用的矩阵行。** + +English version: [Support Matrix](support-matrix.md). + +## 客户端配置 + +| 客户端 | 认证方式 | 上游目标 | strip_path_prefix | 传输 | 状态 | +|--------|----------|----------|-------------------|------|------| +| Claude Code | API Key | `https://api.anthropic.com` | 无 | HTTP/SSE | 已验证 | +| Claude Code | Claude 兼容网关(`ANTHROPIC_BASE_URL` 环境变量或 Claude settings) | 自定义 Anthropic 兼容上游 | 无 | HTTP/SSE | 单测覆盖;DeepSeek 真实 E2E 已验证 | +| Claude Code | Anthropic 兼容 Bedrock 网关(`ANTHROPIC_BASE_URL` + `bedrock/...` 模型) | New API 或同类网关代理到 AWS Bedrock | 无 | HTTP/SSE | 单测覆盖;New API AWS Bedrock 真实 E2E 已验证 | +| Claude Code | Google Vertex AI 透传网关(`CLAUDE_CODE_USE_VERTEX=1` + `ANTHROPIC_VERTEX_BASE_URL`) | Vertex rawPredict 兼容上游 | 无 | HTTP/SSE | 单测覆盖;本地 E2E 已验证 | +| Codex CLI | API Key (`OPENAI_API_KEY`) | `https://api.openai.com` | 无 | 默认 reverse 模式使用 HTTP/SSE | 已验证 | +| Codex CLI | OAuth (`codex login`) | `https://chatgpt.com/backend-api/codex` | `/v1` | 默认 reverse 模式使用 HTTP/SSE | 已使用 Codex 0.144.1 完成真实 E2E 验证 | +| Codex CLI | 显式指定 `--tap-proxy-mode forward` | 自动识别上游 | n/a | HTTP/SSE + WebSocket | 单测覆盖 | +| Codex App | Codex App 中的 ChatGPT 账号 | `codex-app://sessions` | n/a | 本地 session JSONL transcript import;当 Codex App debug endpoint 可用时自动尽力补充 CDP WebSocket 证据 | 单测覆盖 | +| Gemini CLI | Google OAuth / Code Assist | Forward proxy(Google 端点) | n/a | HTTP/SSE | 真实 E2E 已验证 | +| Gemini CLI | API key / Vertex 兼容配置(`--tap-proxy-mode reverse`) | `https://generativelanguage.googleapis.com` | 无 | HTTP/SSE | 单测覆盖 | +| Kimi CLI(旧版 kimi-cli) | Kimi CLI 认证/配置 | `https://api.kimi.com/coding/v1` | 无 | HTTP/SSE Chat Completions | 单测覆盖(`KIMI_BASE_URL`) | +| Kimi CLI(旧版 kimi-cli) | Kimi CLI 认证/配置 | `https://api.moonshot.ai/v1` | 无 | HTTP/SSE Chat Completions | 配置支持 | +| Kimi Code CLI | `~/.kimi-code/config.toml` + OAuth(`managed:kimi-code`) | `https://api.kimi.com/coding/v1` | 无 | HTTP/SSE Chat Completions | 单测覆盖(`KIMI_CODE_HOME` sandbox) | +| Kimi Code CLI | 配置中自定义 `type = "kimi"` provider | `https://api.moonshot.ai/v1` | 无 | HTTP/SSE Chat Completions | 支持 `--tap-target` | +| OpenCode | 通过 `opencode providers` 配置 provider 凭据(OpenAI OAuth 与 OpenCode free provider 均已验证) | Forward proxy(任意 HTTPS 上游) | n/a | HTTP/SSE | 真实 E2E 已验证 | +| OpenCode | 仅 Anthropic provider(`--tap-proxy-mode reverse`) | `https://api.anthropic.com` | 无 | HTTP/SSE | 单测覆盖 | +| MiMo Code | 通过 `mimo` TUI 配置或 MiMo Platform OAuth 配置 provider 凭据 | Forward proxy(任意 HTTPS 上游) | n/a | HTTP/SSE | 单测覆盖 | +| MiMo Code | 仅 Anthropic provider(`--tap-proxy-mode reverse`;设置 `MIMOCODE_MIMO_ONLY=false`) | `https://api.anthropic.com` | 无 | HTTP/SSE | 单测覆盖 | +| OpenClaw | 通过 `~/.openclaw/openclaw.json` 或 `OPENCLAW_CONFIG_PATH` 配置 provider 凭据 | 通过临时配置文件补丁被选中的 provider `baseUrl` | 取决于 provider | HTTP/SSE | 单测覆盖 | +| OpenClaw | 无可补丁配置(`--tap-proxy-mode reverse`) | provider 环境变量 fallback(`OPENAI_BASE_URL`、`ANTHROPIC_BASE_URL`、`GOOGLE_GEMINI_BASE_URL` 或 `OPENROUTER_BASE_URL`) | 取决于 provider | HTTP/SSE | 单测覆盖 | +| Pi | 通过 Pi `/login` 或 `PI_CODING_AGENT_DIR` auth 文件配置 provider 凭据(`openai-codex` OAuth 已验证) | Forward proxy(任意 HTTPS 上游) | n/a | HTTP/SSE + WebSocket | 真实 E2E 已验证 | +| Pi | 自定义 OpenAI 兼容配置(`--tap-proxy-mode reverse`) | `https://api.openai.com` | 无 | HTTP/SSE | 单测覆盖 | +| Hermes Agent | 通过 `~/.hermes/` 配置 provider 凭据 | Forward proxy(任意 HTTPS 上游) | n/a | HTTP/SSE | 单测覆盖 | +| Hermes Agent | 自定义 OpenAI 兼容 provider(`--tap-proxy-mode reverse`) | `https://api.openai.com` | `/v1` | HTTP/SSE | 单测覆盖 | +| Cursor CLI | Cursor 登录(`cursor-agent login`) | Forward proxy 到 `https://api2.cursor.sh` | n/a | HTTPS/protobuf + 本地 transcript import | 真实 E2E 已验证 | +| Qoder CLI | Qoder 登录 / `QODER_PERSONAL_ACCESS_TOKEN` / `QODER_JOB_TOKEN` | Forward proxy(Qoder 端点) | n/a | HTTP/SSE | 真实 E2E 已验证 | +| Antigravity CLI | Antigravity 登录 | Forward proxy + `CLOUD_CODE_URL` bridge 到 `https://daily-cloudcode-pa.googleapis.com` | `CLOUD_CODE_URL` | HTTP/SSE | 手动 E2E 已验证;启动环境、Code Assist bridge 和 macOS 用户 keychain CA 自动信任已由单测覆盖 | +| CodeBuddy CLI | CodeBuddy 登录(iOA / WeChat / Google-Github / Enterprise Domain) | 自动从 `~/.codebuddy/local_storage/` 缓存识别;默认 `https://copilot.tencent.com/v2` | `CODEBUDDY_BASE_URL` | HTTP/SSE Chat Completions | iOA 真实 E2E 已验证 | + +## 各客户端默认代理模式 + +`CLIENT_CONFIGS` 中的每个客户端都会声明一个 `default_proxy_mode`,在未传入 `--tap-proxy-mode` 时使用: + +| 客户端 | 默认模式 | 原因 | +|--------|----------|------| +| `claude` | `reverse` | 单 provider,原生支持 Claude provider base URL 环境变量(`ANTHROPIC_BASE_URL`、`ANTHROPIC_BEDROCK_BASE_URL`、`ANTHROPIC_VERTEX_BASE_URL`) | +| `codex` | `reverse` | 启动时临时注入使用代理 base URL 且设置 `supports_websockets=false` 的同级 provider,使每个请求生成一条自包含的 HTTP/SSE trace,同时不修改 `~/.codex/config.toml` | +| `codexapp` | `transcript` | 监听 `CODEX_HOME/sessions` 或 `~/.codex/sessions` 的 transcript 客户端;不会创建代理。Codex App 暴露 debug endpoint 时会自动追加 CDP WebSocket 证据 | +| `gemini` | `forward` | Google OAuth / Code Assist 会访问多个 Google 端点;forward proxy 不依赖单一 base URL,更适合作为默认 | +| `kimi` | `reverse` | 旧版 kimi-cli;原生 `KIMI_BASE_URL` 环境变量 | +| `kimi-code` | `reverse` | 通过临时 `KIMI_CODE_HOME` sandbox 补丁 `~/.kimi-code/config.toml` | +| `mimo` | `forward` | OpenCode fork;多 provider — forward proxy 可以捕获所有上游,而不依赖客户端支持哪个环境变量 | +| `opencode` | `forward` | 多 provider;forward proxy 可以捕获所有上游,而不依赖客户端支持哪个环境变量 | +| `openclaw` | `reverse` | 尽量补丁被选中的 OpenClaw provider 配置;否则 fallback 到对应 provider 的 base URL 环境变量 | +| `pi` | `forward` | 多 provider;Pi 可以使用 OpenAI Codex OAuth 和自定义 model registry provider,forward proxy 不依赖单一 base URL 覆盖即可捕获流量 | +| `hermes` | `forward` | 多 provider 的 Python agent;`httpx` 与 `requests` 都原生认 `HTTPS_PROXY`,forward proxy 捕获是最自然的默认 | +| `cursor` | `forward` | Cursor CLI 没有 base URL 覆盖能力;forward proxy 捕获网络流量,本地 transcript 提供可读对话 | +| `qoder` | `forward` | Qoder CLI 会访问多个 Qoder 服务端点,且没有可靠的单一 base URL 覆盖能力 | +| `agy` | `forward` | Antigravity 会访问多个 Google / Antigravity 端点;claude-tap 用 `HTTPS_PROXY` 捕获辅助流量,并用 `CLOUD_CODE_URL` 捕获 Code Assist 模型流量 | +| `codebuddy` | `reverse` | 单 provider,原生支持 `CODEBUDDY_BASE_URL` 环境变量;支持 `--settings` 环境变量注入;上游 endpoint 自动从 CodeBuddy 登录缓存识别 | + +用户可以通过 `--tap-proxy-mode {reverse,forward}` 覆盖有代理的客户端。`codexapp` 是 transcript-only,`--tap-proxy-mode` 不适用。 + +## 子命令 argv 改写 + +部分客户端会把长期运行的守护进程委托给操作系统服务管理器(launchd / systemd / schtasks)。被托管派生出的守护进程**不会**继承我们注入的代理 / CA 环境,trace 抓取会静默失败。claude-tap 会检测这些模式并把 argv 改写为前台等价命令: + +| 客户端 | 命中的 argv | 改写为 | 原因 | +|--------|-------------|--------|------| +| `hermes` | `gateway start [...]` | `gateway run [...]` | 较新版本的 hermes 会把 `gateway start` 委托给 systemd / launchd;`gateway run` 是前台等价命令,也正是 systemd unit 的 `ExecStart=` 自身实际执行的命令 | + +进程启动时会显式打印改写日志,方便用户察觉。如果用户确实需要守护化行为(并接受抓不到流量),可以加 `--tap-no-launch` 自行运行原命令。 + +> **注意:** Gateway 模式只有在配置的消息平台(Slack、Telegram 等)推送消息给 bot 时才会产生 trace。 +> 若没有活跃的平台集成,gateway 不会发起 LLM 请求,也不会生成任何 trace。 +> 本地抓 trace 请使用 TUI 模式(`claude-tap --tap-client hermes`)。 + +## URL 构造规则 + +代理按如下方式构造上游 URL:`target + forwarded_path` + +如果设置了 `strip_path_prefix`,会先从传入 path 中移除该前缀再转发: + +```text +incoming: /v1/responses +strip: /v1 +result: /responses +upstream: {target}/responses +``` + +### 决策逻辑 + +```python +strip = CLIENT_CONFIGS[client].reverse_strip_path_prefix(target) +``` + +| Target 包含 `api.openai.com` | strip | 示例 | +|------------------------------|-------|------| +| 是 | 无 | `/v1/responses` -> `api.openai.com/v1/responses` | +| 否 | `/v1` | `/v1/responses` -> `chatgpt.com/.../responses` | + +## 验证方式 + +### 自动化验证(CI) + +- `test_codex_upstream_url_construction`:验证全部 5 个矩阵组合的 URL 构造 +- `test_codex_client_reverse_proxy`:使用 fake upstream 覆盖 OAuth 类 reverse proxy e2e +- `test_build_codex_app_transcript_records_preserves_turn_context`:验证 Codex App session JSONL 会导入为 viewer 友好的 Responses 记录,并保留 usage、tools 和 tool results +- `test_import_codex_app_transcripts_appends_only_new_completed_records`:验证 Codex App transcript 轮询只追加新的已完成记录 +- `test_cdp_recorder_writes_viewer_friendly_websocket_record`:验证 Codex App CDP WebSocket frame 会重建为 viewer 友好的 WebSocket 记录 +- `test_async_main_codexapp_starts_cdp_enrichment_by_default`:验证 `--tap-client codexapp` 默认启动 CDP 补充采集,并遵循全局原始 stream event 存储设置 +- `test_gemini_registered_in_client_configs`:验证 Gemini CLI 注册和默认 forward 模式 +- `test_run_client_gemini_forward_sets_proxy_ca_and_skips_base_url_envs`:验证 Gemini forward proxy 启动环境变量 +- `test_run_client_gemini_reverse_sets_both_base_url_envs`:验证 Gemini reverse proxy base URL 环境变量注入 +- `test_viewer_renders_gemini_semantic_sections`:验证 Gemini systemInstruction、contents、functionDeclarations、functionCall、functionResponse、SSE output 和 token usage 会渲染为语义化 viewer 区块 +- `test_kimi_registered_in_client_configs`:验证旧版 Kimi CLI 注册 +- `test_kimi_client_reverse_proxy`:使用 fake Kimi Chat Completions stream 覆盖 e2e(`KIMI_BASE_URL`) +- `test_kimi_code_*`:验证 Kimi Code CLI 注册、sandbox 配置补丁与 e2e 捕获 +- `test_chat_completions_reasoning_content_is_mirrored_as_thinking`:验证 Kimi thinking stream 渲染形状 +- `test_websocket_proxy_basic`:验证 WebSocket relay 和 trace 记录 +- `test_hermes_*`:验证 Hermes 注册、parse_args 默认模式解析、forward/reverse 启动环境、argv 改写 +- `test_openclaw_*`:验证 OpenClaw 注册、选中 provider 配置补丁、fallback 环境变量路由和目标探测 +- `test_pi_*`:验证 Pi 注册、parse_args 默认模式解析、forward/reverse 启动环境和参数透传 +- `test_cursor_registered_in_client_configs`:验证 Cursor CLI 注册和默认 forward 模式 +- `test_run_client_cursor_forward_sets_proxy_ca_and_no_proxy`:验证 Cursor forward proxy 启动环境变量 +- `test_import_cursor_transcripts_appends_viewer_friendly_records`:验证 Cursor transcript import 会追加 viewer 友好的记录 +- `test_import_cursor_transcripts_preserves_tool_uses`:验证 Cursor tool_use block 能在 viewer trace shape 中渲染 +- `test_qoder_*`:验证 Qoder 注册、parse_args 默认模式解析、forward/reverse 环境变量和参数透传 +- `test_parse_args_agy_does_not_require_tap_trust_ca`:验证 Antigravity 使用和其他客户端一致的启动形态 +- `test_auto_ca_trust_*`:验证 Antigravity 会自动请求 macOS 用户 keychain CA 信任,且不需要 sudo +- `test_macos_*_ca_command_*`:验证 CA 信任命令使用用户 login keychain 且不会调用 sudo +- `test_codebuddy_*`:验证 CodeBuddy 注册、parse_args 默认 reverse 模式、settings 注入、forward/reverse 环境变量、`CODEBUDDY_BASE_URL` 探测,以及登录缓存读取 + +### 手动验证(代理变更合入前) + +```bash +# API Key 模式 +uv run python -m claude_tap --tap-client codex --tap-no-launch --tap-port 0 +# 验证日志里的上游 URL 正确 + +# OAuth 模式 +uv run python -m claude_tap --tap-client codex \ + --tap-target https://chatgpt.com/backend-api/codex --tap-no-launch --tap-port 0 +# 验证日志里的上游 URL 正确 + +# Cursor CLI +uv run python -m claude_tap --tap-client cursor -- -p --trust --model auto "Reply OK" +# 验证 trace 同时包含 raw proxy records 和 cursor-transcript records + +# Codex App +uv run python -m claude_tap --tap-client codexapp +# 启动或继续一个 Codex App 任务,并验证 dashboard 收到 transcript records。 +# 如果 Codex App 暴露 debug endpoint,websocket 证据会自动追加。 + +# Qoder CLI +uv run python -m claude_tap --tap-client qoder -- -p "Reply OK" --permission-mode dont_ask +# 验证 stdout 包含 assistant 响应,trace 包含 Qoder 端点记录 + +# Antigravity CLI(macOS) +uv run python -m claude_tap --tap-client agy --tap-live +# 首次运行时,验证 macOS 只要求解锁用户 login keychain,不要求 sudo/admin 写 System keychain。 +# 然后验证 trace 包含 /v1internal:streamGenerateContent 模型记录。 + +# Kimi CLI(旧版 kimi-cli) +uv run python -m claude_tap --tap-client kimi -- --thinking + +# Kimi Code CLI +uv run python -m claude_tap --tap-client kimi-code -- --thinking +# 验证 trace 包含 /chat/completions 记录和 thinking/text 输出 + +# Gemini CLI +uv run python -m claude_tap --tap-client gemini -- -p "Reply OK" --yolo --output-format text +# 验证 trace 包含 Google OAuth / Code Assist API 记录 + +# Pi +uv run python -m claude_tap --tap-client pi -- \ + --model openai-codex/gpt-5.3-codex-spark -p "Reply OK" +# 验证 trace 包含 chatgpt.com/backend-api 记录和可读的 OpenAI Responses 区块 + +# CodeBuddy(登录后自动识别端点) +uv run python -m claude_tap --tap-client codebuddy -- -p "Reply OK" +# 验证 trace 包含 /v2/chat/completions 记录,且响应有非零 token 计数 +``` + +### 真实 E2E(有认证时可选) + +```bash +# 基于 tmux 的真实验证 +tmux new-session -d -s verify \ + "uv run python -m claude_tap --tap-client codex --tap-target TARGET --tap-no-launch --tap-port 8080" +# 另一个窗口中: +OPENAI_BASE_URL=http://127.0.0.1:8080/v1 codex exec "Reply: OK" +``` + +```bash +# Cursor CLI 真实验证 +uv run python -m claude_tap --tap-client cursor -- -p --trust --model auto \ + "Use tools to inspect the workspace and reply OK" +# 验证生成的 HTML 包含 cursor-transcript turns 和 tool_use blocks。 +``` + +```bash +# Gemini CLI 真实验证 +uv run python -m claude_tap --tap-client gemini -- -p \ + "Use tools to inspect the workspace and reply OK" --yolo --output-format text +# 验证 trace 包含 cloudcode-pa.googleapis.com / streamGenerateContent 记录。 +``` + +```bash +# Pi + OpenAI Codex OAuth 真实验证 +uv run python -m claude_tap --tap-client pi -- \ + --model openai-codex/gpt-5.3-codex-spark --tools bash -p \ + "Use bash to inspect the workspace and reply OK" +# 验证生成的 viewer 展示 Tools、System Prompt、Messages、Response、 +# SSE/WebSocket events、工具调用、工具输出和 token usage。 +``` + +## 添加新客户端或后端 + +添加新客户端或后端时: + +1. 在上方矩阵中新增一行 +2. 增加 `CLIENT_CONFIGS` 条目和启动/配置测试 +3. 如适用,增加 fake upstream e2e 测试 +4. 如果有认证条件,执行真实 E2E 验证 +5. 同时更新英文和简体中文公开文档:`README.md` 与 `README_zh.md`,适用时还要更新配对的 `docs/guides/*.md` 与 `docs/guides/*.zh.md` diff --git a/docs/viewer-dark.png b/docs/viewer-dark.png new file mode 100644 index 0000000..4903b25 Binary files /dev/null and b/docs/viewer-dark.png differ diff --git a/docs/viewer-light.png b/docs/viewer-light.png new file mode 100644 index 0000000..2b5a22f Binary files /dev/null and b/docs/viewer-light.png differ diff --git a/docs/viewer-zh.png b/docs/viewer-zh.png new file mode 100644 index 0000000..eb346bc Binary files /dev/null and b/docs/viewer-zh.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ab12665 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,110 @@ +[project] +name = "claude-tap" +dynamic = ["version"] +description = "Trace AI CLI API requests via local reverse and forward proxies. Inspect system prompts, messages, tools, and token usage." +requires-python = ">=3.11" +license = "MIT" +authors = [{ name = "liaohch3" }] +readme = "README.md" +keywords = ["claude", "claude-code", "codex", "kimi", "cursor", "qoder", "trace", "proxy", "api", "llm", "context-engineering"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Debuggers", + "Topic :: Software Development :: Testing", +] +dependencies = [ + "aiohttp==3.14.1", + "backports-zstd>=1.0; python_version < '3.14'", + "cryptography>=42.0", +] + +[project.urls] +Homepage = "https://github.com/liaohch3/claude-tap" +Repository = "https://github.com/liaohch3/claude-tap" +Issues = "https://github.com/liaohch3/claude-tap/issues" + +[project.scripts] +claude-tap = "claude_tap.cli:main_entry" + +[build-system] +requires = ["setuptools>=68.0", "setuptools-scm[toml]>=8.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["claude_tap", "claude_tap.viewer_assets"] + +[tool.setuptools_scm] +version_scheme = "guess-next-dev" +local_scheme = "no-local-version" + +[tool.setuptools.package-data] +claude_tap = ["dashboard.html", "viewer.html", "viewer_i18n.json", "py.typed"] +"claude_tap.viewer_assets" = ["*.css", "*.js"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-timeout>=2.3", + "pexpect>=4.9", + "coverage>=7.6", + "ruff>=0.11", +] + +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E501"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", + "--strict-markers", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "asyncio: marks tests as async (run by pytest-asyncio)", + "real_e2e: marks tests as real E2E tests requiring claude CLI (use --run-real-e2e)", +] +timeout = 60 + +[tool.coverage.run] +source = ["claude_tap"] +branch = true +omit = ["tests/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] +show_missing = true + +[tool.claude_tap.coverage] +python_total_min = 65.0 +python_diff_min = 80.0 +viewer_js_function_min = 50.0 +viewer_js_diff_min = 80.0 +viewer_css_selector_min = 65.0 +viewer_css_diff_min = 80.0 + +[dependency-groups] +dev = [ + "playwright>=1.58.0", +] diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..e463adc --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,117 @@ +# Scripts + +## `check_coverage.py` + +Enforce project and incremental coverage targets for backend Python code and the +inline JavaScript and CSS in `claude_tap/viewer.html`. + +Targets are configured in `pyproject.toml` under `[tool.claude_tap.coverage]`: + +- Python project coverage: `python_total_min` +- Python changed executable package lines: `python_diff_min` +- Viewer JavaScript function coverage: `viewer_js_function_min` +- Viewer changed JavaScript functions: `viewer_js_diff_min` +- Viewer CSS selector coverage: `viewer_css_selector_min` +- Viewer changed CSS selectors: `viewer_css_diff_min` + +### Usage + +```bash +python -m coverage run -m pytest tests/ -q +python -m coverage json -o .coverage.json +python scripts/check_coverage.py --python-coverage .coverage.json +``` + +## `check_pr_policy.py` + +Validate PR body policy against the changed files in a pull request. + +The check enforces machine-readable maintainer rules: + +- Summary/Problem/Goal and Validation/Test plan/Results sections are present +- Runtime, viewer, client, proxy, or UI behavior changes include + `raw.githubusercontent.com` screenshot evidence +- PR image links use `raw.githubusercontent.com` +- Raw traces, generated trace viewers, logs, and secret-like files are not part + of the PR +- PR body does not include obvious API keys or bearer tokens + +### Usage + +```bash +python scripts/check_pr_policy.py \ + --body-file /tmp/pr-body.md \ + --changed-files-file /tmp/pr-files.txt +``` + +In GitHub Actions, pass the event payload: + +```bash +python scripts/check_pr_policy.py \ + --event-path "$GITHUB_EVENT_PATH" \ + --changed-files-file /tmp/pr-files.txt +``` + +The CI workflow runs this check both as a standalone `pr-policy` job and inside +the existing required `lint` job. + +## `translate_i18n.py` + +Translate missing i18n strings in `claude_tap/viewer_i18n.json` using OpenRouter. + +It parses the viewer i18n JSON source, finds keys present in both `en` and `zh-CN` but missing in other supported languages (`ja`, `ko`, `fr`, `ar`, `de`, `ru`), and writes new translations back into the same file. + +### Requirements + +- Set `OPENROUTER_API_KEY` in your environment +- Default model: `google/gemini-2.5-flash` + +### Usage + +```bash +# Show missing keys only (no file changes) +python scripts/translate_i18n.py --dry-run + +# Translate missing keys and update viewer_i18n.json in place +python scripts/translate_i18n.py + +# Use a specific model +python scripts/translate_i18n.py --model google/gemini-2.5-flash +``` + +### Advanced usage + +```bash +# Use a custom file/object name (future CLI i18n support) +python scripts/translate_i18n.py --target cli --dry-run +python scripts/translate_i18n.py --file claude_tap/cli.py --object-name I18N --dry-run +``` + +## `check_changelog.py` + +Ensure release tags are documented in `CHANGELOG.md`. + +Publish checks the exact tag being published. + +### Usage + +```bash +# Check latest release tag known to git +python scripts/check_changelog.py + +# Check an explicit release tag +python scripts/check_changelog.py --tag v0.1.40 +``` + +## `update_changelog.py` + +Insert a release section in `CHANGELOG.md` when one is missing. + +Auto-release uses this before tagging so normal feature/fix PRs are not blocked by changelog bookkeeping. If the main branch is protected, auto-release opens or updates a changelog PR with the standard PR body, waits for the PR checks, merges it with the release bot's admin bypass, and publishes after that PR is merged. + +### Usage + +```bash +python scripts/update_changelog.py --version 0.1.40 +python scripts/update_changelog.py --version 0.1.40 --date 2026-05-03 +``` diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100644 index 0000000..80eb0a6 --- /dev/null +++ b/scripts/check_changelog.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Ensure released tags are documented in CHANGELOG.md.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +TAG_RE = re.compile(r"^v(?P\d+\.\d+\.\d+)$") +CHANGELOG_HEADING_RE = re.compile(r"^## \[(?P\d+\.\d+\.\d+)\](?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$", re.MULTILINE) + + +def normalize_tag(tag: str) -> str | None: + match = TAG_RE.match(tag.strip()) + return match.group("version") if match else None + + +def changelog_versions(text: str) -> set[str]: + return {match.group("version") for match in CHANGELOG_HEADING_RE.finditer(text)} + + +def _git(repo_root: Path, args: list[str]) -> str: + return subprocess.check_output(["git", *args], cwd=repo_root, text=True, stderr=subprocess.DEVNULL).strip() + + +def current_exact_tag(repo_root: Path) -> str | None: + try: + tag = _git(repo_root, ["describe", "--exact-match", "--tags", "HEAD"]) + except subprocess.CalledProcessError: + return None + return tag if normalize_tag(tag) else None + + +def latest_version_tag(repo_root: Path) -> str | None: + try: + tags = _git(repo_root, ["tag", "--list", "v[0-9]*", "--sort=-v:refname"]) + except subprocess.CalledProcessError: + return None + for tag in tags.splitlines(): + if normalize_tag(tag): + return tag + return None + + +def required_tag(repo_root: Path, explicit_tag: str | None) -> str | None: + if explicit_tag: + return explicit_tag + return current_exact_tag(repo_root) or latest_version_tag(repo_root) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path.cwd(), help="Repository root path") + parser.add_argument("--tag", help="Release tag to require, e.g. v0.1.40") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + repo_root = args.repo_root.resolve() + tag = required_tag(repo_root, args.tag) + if tag is None: + print("No release tag found; skipping changelog release coverage check.") + return 0 + + version = normalize_tag(tag) + if version is None: + print(f"ERROR: unsupported release tag format: {tag}", file=sys.stderr) + return 1 + + changelog_path = repo_root / "CHANGELOG.md" + try: + documented_versions = changelog_versions(changelog_path.read_text(encoding="utf-8")) + except OSError as exc: + print(f"ERROR: cannot read {changelog_path}: {exc}", file=sys.stderr) + return 1 + + if version not in documented_versions: + print(f"ERROR: CHANGELOG.md is missing release section for [{version}] ({tag})", file=sys.stderr) + return 1 + + print(f"Changelog release coverage passed for {tag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py new file mode 100644 index 0000000..455b0ad --- /dev/null +++ b/scripts/check_coverage.py @@ -0,0 +1,1132 @@ +#!/usr/bin/env python3 +"""Enforce project and incremental coverage targets. + +Python coverage is read from a coverage.py JSON report. Viewer frontend coverage +is measured with Chromium V8 precise coverage against the cross-client viewer +contract traces. The frontend incremental metric is function-oriented: changed +viewer JavaScript functions must be exercised by V8 coverage. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import re +import subprocess +import sys +import tempfile +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONFIG = REPO_ROOT / "pyproject.toml" +DEFAULT_THRESHOLDS = { + "python_total_min": 65.0, + "python_diff_min": 80.0, + "viewer_js_function_min": 50.0, + "viewer_js_diff_min": 80.0, + "viewer_css_selector_min": 65.0, + "viewer_css_diff_min": 80.0, +} +VIEWER_HTML_SOURCE = "claude_tap/viewer.html" +VIEWER_LEGACY_JS_SOURCE = "claude_tap/viewer_assets/viewer.js" +VIEWER_JS_SOURCES = ( + "claude_tap/viewer_assets/state.js", + "claude_tap/viewer_assets/responses.js", + "claude_tap/viewer_assets/lazy_loading.js", + "claude_tap/viewer_assets/i18n_ui.js", + "claude_tap/viewer_assets/live_bootstrap.js", + "claude_tap/viewer_assets/filters_search.js", + "claude_tap/viewer_assets/sidebar.js", + "claude_tap/viewer_assets/detail_trace.js", + "claude_tap/viewer_assets/renderers.js", + "claude_tap/viewer_assets/sections_json.js", + "claude_tap/viewer_assets/diff.js", + "claude_tap/viewer_assets/utilities_mobile.js", +) +VIEWER_CSS_SOURCE = "claude_tap/viewer_assets/viewer.css" +VIEWER_DIFF_PATHS = ["claude_tap/*.py", VIEWER_HTML_SOURCE, "claude_tap/viewer_assets/*"] +VIEWER_STYLE_TEMPLATE_ANCHOR = "" +VIEWER_SCRIPT_TEMPLATE_ANCHOR = "" + + +@dataclass(frozen=True) +class CheckResult: + name: str + percent: float | None + minimum: float + passed: bool + detail: str + + +def _run_git_diff(base: str, paths: list[str]) -> str: + cmd = ["git", "diff", "--unified=0", f"{base}...HEAD", "--", *paths] + return subprocess.check_output(cmd, cwd=REPO_ROOT, text=True) + + +def changed_lines_from_diff(diff_text: str) -> dict[str, set[int]]: + changed: dict[str, set[int]] = {} + current_file: str | None = None + new_line: int | None = None + hunk_re = re.compile(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") + + for line in diff_text.splitlines(): + if line.startswith("diff --git "): + current_file = None + new_line = None + continue + if line.startswith("+++ b/"): + current_file = line[len("+++ b/") :] + changed.setdefault(current_file, set()) + continue + if line.startswith("@@ "): + match = hunk_re.search(line) + if not match: + new_line = None + continue + new_line = int(match.group(1)) + continue + if current_file is None or new_line is None: + continue + if line.startswith("+") and not line.startswith("+++"): + changed[current_file].add(new_line) + new_line += 1 + elif line.startswith("-") and not line.startswith("---"): + continue + else: + new_line += 1 + + return {path: lines for path, lines in changed.items() if lines} + + +def _tag_content(source: str, tag: str) -> str | None: + start_tag = f"<{tag}>" + end_tag = f"" + start = source.find(start_tag) + if start < 0: + return None + content_start = start + len(start_tag) + end = source.find(end_tag, content_start) + if end < 0: + return None + return source[content_start:end].strip("\n") + + +def _replace_tag_block(source: str, tag: str, replacement: str) -> str | None: + start_tag = f"<{tag}>" + end_tag = f"" + start = source.find(start_tag) + if start < 0: + return None + end = source.find(end_tag, start + len(start_tag)) + if end < 0: + return None + return source[:start] + replacement + source[end + len(end_tag) :] + + +def _base_file_text(base: str, path: str) -> str | None: + try: + return subprocess.check_output(["git", "show", f"{base}:{path}"], cwd=REPO_ROOT, text=True) + except subprocess.CalledProcessError: + return None + + +def _filter_pure_viewer_asset_split(changed_lines: dict[str, set[int]], base: str) -> dict[str, set[int]]: + """Ignore asset files that are exact extractions from the base monolithic viewer.""" + js_changed = [source for source in (VIEWER_LEGACY_JS_SOURCE, *VIEWER_JS_SOURCES) if source in changed_lines] + if not js_changed and VIEWER_CSS_SOURCE not in changed_lines and VIEWER_HTML_SOURCE not in changed_lines: + return changed_lines + + base_viewer = _base_file_text(base, VIEWER_HTML_SOURCE) + if base_viewer is None: + return changed_lines + + filtered = dict(changed_lines) + expected_js = _tag_content(base_viewer, "script") + expected_css = _tag_content(base_viewer, "style") + + js_exact = False + if js_changed and expected_js is not None: + try: + if (REPO_ROOT / VIEWER_LEGACY_JS_SOURCE).exists(): + current_js = (REPO_ROOT / VIEWER_LEGACY_JS_SOURCE).read_text(encoding="utf-8").strip("\n") + else: + current_js = "".join((REPO_ROOT / source).read_text(encoding="utf-8") for source in VIEWER_JS_SOURCES) + current_js = current_js.strip("\n") + except OSError: + current_js = None + js_exact = current_js == expected_js + if js_exact: + for source in (VIEWER_LEGACY_JS_SOURCE, *VIEWER_JS_SOURCES): + filtered.pop(source, None) + + css_exact = False + if VIEWER_CSS_SOURCE in filtered and expected_css is not None: + try: + current_css = (REPO_ROOT / VIEWER_CSS_SOURCE).read_text(encoding="utf-8").strip("\n") + except OSError: + current_css = None + css_exact = current_css == expected_css + if css_exact: + filtered.pop(VIEWER_CSS_SOURCE, None) + + if ( + VIEWER_HTML_SOURCE in filtered + and (js_exact or not js_changed) + and (css_exact or VIEWER_CSS_SOURCE not in changed_lines) + ): + expected_template = _replace_tag_block(base_viewer, "style", VIEWER_STYLE_TEMPLATE_ANCHOR) + if expected_template is not None: + expected_template = _replace_tag_block(expected_template, "script", VIEWER_SCRIPT_TEMPLATE_ANCHOR) + try: + current_template = (REPO_ROOT / VIEWER_HTML_SOURCE).read_text(encoding="utf-8") + except OSError: + current_template = None + if ( + expected_template is not None + and current_template is not None + and current_template.strip() == expected_template.strip() + ): + filtered.pop(VIEWER_HTML_SOURCE, None) + return filtered + + +def load_thresholds(config_path: Path = DEFAULT_CONFIG) -> dict[str, float]: + data = tomllib.loads(config_path.read_text(encoding="utf-8")) + configured = data.get("tool", {}).get("claude_tap", {}).get("coverage", {}) + thresholds = dict(DEFAULT_THRESHOLDS) + for key in thresholds: + if key in configured: + thresholds[key] = float(configured[key]) + return thresholds + + +def check_python_coverage( + coverage_json_path: Path, + changed_lines: dict[str, set[int]], + total_min: float, + diff_min: float, +) -> list[CheckResult]: + coverage = json.loads(coverage_json_path.read_text(encoding="utf-8")) + total_percent = float(coverage["totals"]["percent_covered"]) + results = [ + CheckResult( + name="python_total", + percent=total_percent, + minimum=total_min, + passed=total_percent >= total_min, + detail=f"coverage.py total {total_percent:.2f}% >= {total_min:.2f}%", + ) + ] + + executable_changed = 0 + covered_changed = 0 + files = coverage.get("files", {}) + for path, changed in changed_lines.items(): + if not path.startswith("claude_tap/") or not path.endswith(".py"): + continue + file_cov = files.get(path) + if not file_cov: + executable_changed += len(changed) + continue + executed = set(file_cov.get("executed_lines", [])) + missing = set(file_cov.get("missing_lines", [])) + executable = executed | missing + relevant = changed & executable + executable_changed += len(relevant) + covered_changed += len(relevant & executed) + + if executable_changed == 0: + results.append( + CheckResult( + name="python_diff", + percent=None, + minimum=diff_min, + passed=True, + detail="no changed executable Python package lines", + ) + ) + return results + + diff_percent = covered_changed / executable_changed * 100 + results.append( + CheckResult( + name="python_diff", + percent=diff_percent, + minimum=diff_min, + passed=diff_percent >= diff_min, + detail=f"{covered_changed}/{executable_changed} changed executable Python lines covered", + ) + ) + return results + + +def js_function_ranges(source: str) -> dict[str, tuple[int, int]]: + lines = source.splitlines() + ranges: dict[str, tuple[int, int]] = {} + fn_re = re.compile(r"\bfunction\s+([A-Za-z_$][\w$]*)\s*\(") + + line_no = 1 + while line_no <= len(lines): + line = lines[line_no - 1] + match = fn_re.search(line) + if not match: + line_no += 1 + continue + + name = match.group(1) + depth = 0 + seen_open = False + end_line = line_no + scan = line_no + while scan <= len(lines): + for char in lines[scan - 1]: + if char == "{": + depth += 1 + seen_open = True + elif char == "}": + depth -= 1 + if seen_open and depth <= 0: + end_line = scan + break + if seen_open and depth <= 0: + break + scan += 1 + + ranges[name] = (line_no, end_line) + line_no = max(end_line + 1, line_no + 1) + + return ranges + + +def _changed_lines_for_source(source_path: Path, changed_lines: dict[str, set[int]], fallback_key: str) -> set[int]: + keys = [] + if source_path.name == "viewer.js": + keys.append(VIEWER_LEGACY_JS_SOURCE) + elif source_path.name == "viewer.css": + keys.append(VIEWER_CSS_SOURCE) + try: + keys.insert(0, source_path.resolve().relative_to(REPO_ROOT).as_posix()) + except ValueError: + pass + if source_path.name not in { + Path(source).name for source in (*VIEWER_JS_SOURCES, VIEWER_LEGACY_JS_SOURCE, VIEWER_CSS_SOURCE) + }: + keys.append(fallback_key) + for key in keys: + changed = changed_lines.get(key) + if changed: + return changed + return set() + + +def changed_viewer_functions(viewer_js: Path | tuple[Path, ...], changed_lines: dict[str, set[int]]) -> set[str]: + functions: set[str] = set() + for source_path in viewer_js if isinstance(viewer_js, tuple) else (viewer_js,): + changed = _changed_lines_for_source(source_path, changed_lines, VIEWER_HTML_SOURCE) + if not changed: + continue + ranges = js_function_ranges(source_path.read_text(encoding="utf-8")) + for name, (start, end) in ranges.items(): + if any(start <= line <= end for line in changed): + functions.add(name) + return functions + + +def _split_selectors(selector_text: str) -> list[str]: + return [part.strip() for part in selector_text.split(",") if part.strip()] + + +def _is_queryable_selector(selector: str) -> bool: + return not re.search( + r"::|:(hover|active|focus|focus-visible|focus-within|visited|target|checked|disabled|enabled|placeholder-shown)\b", + selector, + ) + + +def _style_block_ranges(source: str) -> list[tuple[int, int]]: + ranges: list[tuple[int, int]] = [] + start: int | None = None + for idx, line in enumerate(source.splitlines(), start=1): + if "" in line and start is not None: + ranges.append((start, idx - 1)) + start = None + if not ranges: + ranges.append((1, len(source.splitlines()))) + return ranges + + +def css_selector_ranges(source: str) -> dict[str, list[tuple[int, int]]]: + lines = source.splitlines() + ranges: dict[str, list[tuple[int, int]]] = {} + + for style_start, style_end in _style_block_ranges(source): + line_no = style_start + while line_no <= style_end: + line = lines[line_no - 1] + if "{" not in line: + line_no += 1 + continue + selector_text = line.split("{", 1)[0].strip() + if not selector_text or selector_text.startswith("@"): + line_no += 1 + continue + + depth = 0 + end_line = line_no + for scan in range(line_no, style_end + 1): + for char in lines[scan - 1]: + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth <= 0: + end_line = scan + break + + for selector in _split_selectors(selector_text): + if _is_queryable_selector(selector): + ranges.setdefault(selector, []).append((line_no, end_line)) + line_no = max(end_line + 1, line_no + 1) + + return ranges + + +def changed_viewer_css_selectors(viewer_css: Path, changed_lines: dict[str, set[int]]) -> set[str]: + changed = _changed_lines_for_source(viewer_css, changed_lines, VIEWER_HTML_SOURCE) + if not changed: + return set() + ranges = css_selector_ranges(viewer_css.read_text(encoding="utf-8")) + selectors: set[str] = set() + for selector, selector_ranges in ranges.items(): + if any(start <= line <= end for start, end in selector_ranges for line in changed): + selectors.add(selector) + return selectors + + +def _main_viewer_script(coverage: dict[str, Any], suffix: str) -> dict[str, Any]: + candidates = [ + script + for script in coverage["result"] + if script.get("url", "").endswith(suffix) and len(script.get("functions", [])) > 50 + ] + if not candidates: + raise RuntimeError("Could not find viewer.html main script in V8 coverage output") + return max(candidates, key=lambda script: len(script.get("functions", []))) + + +def _is_top_level_wrapper(function: dict[str, Any], script_end: int) -> bool: + if function.get("functionName"): + return False + ranges = function.get("ranges", []) + if not ranges: + return False + widest = max((item.get("endOffset", 0) - item.get("startOffset", 0) for item in ranges), default=0) + return script_end > 0 and widest >= script_end * 0.8 + + +def _viewer_script_functions(script: dict[str, Any]) -> list[dict[str, Any]]: + all_ranges = [item for function in script["functions"] for item in function.get("ranges", [])] + script_end = max((item.get("endOffset", 0) for item in all_ranges), default=0) + return [function for function in script["functions"] if not _is_top_level_wrapper(function, script_end)] + + +def _is_function_covered(function: dict[str, Any]) -> bool: + return any(item.get("count", 0) > 0 for item in function.get("ranges", [])) + + +def _load_viewer_contract_helpers() -> tuple[Any, Any, Any]: + contracts_path = REPO_ROOT / "tests" / "test_viewer_contracts.py" + spec = importlib.util.spec_from_file_location("viewer_contracts_for_coverage", contracts_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Could not load viewer contract helpers from {contracts_path}") + contracts = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = contracts + spec.loader.exec_module(contracts) + return contracts._contract_cases, contracts._generate_case_html, contracts._compact_contract_records + + +def collect_viewer_js_coverage() -> tuple[float, set[str], int, int]: + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: # pragma: no cover - exercised in dependency-free environments + raise RuntimeError("Playwright is required for viewer JS coverage") from exc + + _contract_cases, _generate_case_html, _compact_contract_records = _load_viewer_contract_helpers() + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + html_path = _generate_case_html( + tmp_path, + "v8_coverage", + tuple(record for case in _contract_cases() for record in case.records), + ) + empty_html_path = _generate_case_html(tmp_path, "empty_coverage", ()) + compact_html_path = tmp_path / "compact_coverage.html" + compact_bundle_path = tmp_path / "compact_coverage.ctap.json" + remote_html_path = tmp_path / "remote_coverage.html" + from claude_tap.compact_trace import build_compact_trace_bundle + from claude_tap.viewer import ( + _extract_metadata_from_record, + _generate_html_viewer_from_compact_bundle, + _generate_html_viewer_from_metadata, + ) + + compact_bundle = build_compact_trace_bundle(list(_compact_contract_records())) + _generate_html_viewer_from_compact_bundle( + compact_bundle, + compact_html_path, + display_trace_path=compact_bundle_path, + display_html_path=compact_html_path, + ) + compact_bundle_path.write_text( + json.dumps(compact_bundle, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + remote_record = next(record for case in _contract_cases() for record in case.records) + remote_api_url = "https://coverage.local/api/records" + _generate_html_viewer_from_metadata( + [_extract_metadata_from_record(remote_record)], + remote_html_path, + display_trace_path="coverage.ctap.json", + display_html_path=remote_html_path, + records_api_path=remote_api_url, + ) + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.route( + f"{remote_api_url}**", + lambda route: route.fulfill( + status=200, + content_type="application/json", + headers={"Access-Control-Allow-Origin": "*"}, + body=json.dumps( + {"session": {"record_count": 1}, "records": [remote_record]}, + ensure_ascii=False, + separators=(",", ":"), + ), + ), + ) + page.add_init_script("window.__TRACE_SESSION_EXPORTS__ = {jsonl: 'coverage.jsonl', log: 'coverage.log'};") + session = page.context.new_cdp_session(page) + session.send("Profiler.enable") + session.send("Profiler.startPreciseCoverage", {"callCount": True, "detailed": True}) + page.goto(html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.evaluate( + """() => { + activePaths = new Set(entries.map(getPath)); + activeTools = null; + searchQuery = ''; + applyFilter(true); + setSidebarOrderMode('turn'); + setSidebarOrderMode('model'); + setSidebarOrderMode('session'); + const sidebarItems = sidebarItemsForMode(); + const sessionGroups = buildSessionGroups(sidebarItems); + if (sessionGroups.length) { + sessionTextSnippet(sessionGroups[0].userText || 'coverage prompt'); + finalResponseText(sessionGroups[0].items[0].entry); + firstUserInputInfo(sessionGroups[0].items[0].entry); + } + const continuationEntry = { + request_id: 'coverage_continuation', + turn: '999.2', + request: { + body: { + previous_response_id: 'resp_coverage', + input: [{ type: 'function_call_output', output: 'ok' }] + } + }, + response: { + body: { + id: 'resp_coverage_next', + previous_response_id: 'resp_coverage', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'coverage done' }] + } + ] + } + } + }; + isContinuationWithoutUserInput(continuationEntry); + sessionKeyForEntry(continuationEntry, { key: 'coverage', userText: '', responseText: '', items: [] }); + if (entries.length) { + sessionTurnDiscriminator(entries[0]); + sessionKeyForEntry(entries[0], null); + matchSearch(entries[0], '1'); + const originalPrompt = window.prompt; + window.prompt = () => '1'; + promptJumpToTurn(); + window.prompt = originalPrompt; + _buildDiffTargetOptions(Math.min(1, filtered.length - 1)); + if (filtered.length > 1) showDiffForIdx(1, null, 0); + } + const stubEntry = buildStubEntry({ + turn: '2.2', + transport: 'websocket', + method: 'WEBSOCKET', + path: '/v1/responses', + model: 'gpt-5.5', + request_generate: true, + response_output_count: 1, + output_tokens: 1, + }, 0); + normalizeDisplayTurns([stubEntry], true); + const imageBlock = { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + } + }; + imageLookupKey('[Image #1] coverage prompt'); + isInlineImageUrl('data:image/png;base64,abc'); + imageSourceFromBlock(imageBlock); + imageBlocksForContent([{ type: 'text', text: '[Image #1] coverage prompt' }, imageBlock]); + imageSourceKey(imageBlock); + buildSessionImageRegistry(); + naturalTextFromPromptPayload({ prompt: 'coverage prompt' }); + renderImageElement('data:image/png;base64,abc', 'coverage image'); + renderImageElementForBlock(imageBlock); + document.body.insertAdjacentHTML('beforeend', renderImageBlock(imageBlock, 0, 1, { frameBlocks: true })); + renderViewerActions(); + valueHasReadableEscapes({ cmd: 'printf "coverage\\\\n"' }); + decodeEscapedTextForView('line1\\\\nline2\\\\t\\\\u4e00'); + document.body.insertAdjacentHTML( + 'beforeend', + renderToolInput({ cmd: 'printf "coverage\\n"', yield_time_ms: 1000 }) + ); + document.querySelector('.tool-input-toggle')?.click(); + const tooltipTrigger = document.querySelector('.sidebar-group-header') || document.createElement('div'); + if (!tooltipTrigger.isConnected) document.body.appendChild(tooltipTrigger); + tooltipTrigger.dataset.fullUserInput = 'coverage tooltip prompt'; + sessionTooltip(); + showSessionTooltip(tooltipTrigger); + hideSessionTooltip(tooltipTrigger); + formatText('history_delete_done', { count: 1 }); + updateHistoryDeleteButton(); + setHistoryDeleteStatus('coverage', 'ok'); + setHistoryDeleteStatus('', ''); + deleteSelectedTraceDate(); + getTargetForGlobalMatch(0); + findFilteredIdxByEntryKey(entryStableKey(entries[0]), entries[0]?.request_id || ''); + openGlobalSearch(); + globalSearchState.query = 'subagent_type: "'; + uniqueSearchQueries(['subagent_type', 'subagent_type']); + buildGlobalSearchQueries(globalSearchState.query); + buildGlobalHighlightQueries(globalSearchState.query); + getEntrySearchText(entries[0]); + countOneQueryInText('subagent_type: "value"', 'subagent_type'); + countMatchesInText('subagent_type: "value"', buildGlobalSearchQueries(globalSearchState.query)); + scheduleGlobalSearchRecalc(); + flushGlobalSearchRecalc(); + navigateGlobalSearch(1); + revealCurrentSearchMatch(); + applyGlobalSearchHighlights(0); + highlightSearchInContainer(document.querySelector('#detail'), buildGlobalHighlightQueries(globalSearchState.query)); + findNextSearchMatch('subagent_type: "value"', buildGlobalSearchQueries(globalSearchState.query), 0); + closeGlobalSearch(); + visualNavigate(1); + visualNavigate(-1); + vsRenderVisible(); + if (entries.length > 1) compareSidebarModelOrder(entries[0], entries[1]); + }""" + ) + page.evaluate( + """async () => { + const liveStatus = document.querySelector('#live-status') || document.createElement('div'); + liveStatus.id = 'live-status'; + if (!liveStatus.isConnected) document.body.appendChild(liveStatus); + const embeddedRecords = typeof EMBEDDED_TRACE_DATA !== 'undefined' + ? EMBEDDED_TRACE_DATA + : (typeof EMBEDDED_TRACE_COMPACT_DATA !== 'undefined' + ? (materializeCompactTraceBundle(EMBEDDED_TRACE_COMPACT_DATA) || []) + : []); + const wsRecord = embeddedRecords.find(record => record.transport === 'websocket'); + if (wsRecord) { + expandLiveWebSocketResponseEntries([wsRecord], true); + liveRecords = [wsRecord]; + await onDateChange('live'); + const OriginalEventSource = window.EventSource; + window.EventSource = class { + constructor(url) { this.url = url; } + close() {} + }; + try { + initLiveMode(); + liveEventSource.onmessage({ data: JSON.stringify(wsRecord) }); + } finally { + window.EventSource = OriginalEventSource; + } + } + }""" + ) + for index in range(page.evaluate("filtered.length")): + page.evaluate("entryIndex => { detailViewMode = 'default'; selectEntry(entryIndex); }", index) + page.wait_for_selector("#detail .section", timeout=5000) + page.evaluate( + """(entryIndex) => { + const entry = entries[entryIndex]; + const body = entry.request.body; + getMessages(body); + getRequestTools(body); + extractSystem(body); + getUsage(entry); + getResponseEvents(entry); + getResponseOutput(entry); + const jsonSection = Array.from(document.querySelectorAll('#detail .section')) + .find(el => el.querySelector('.title')?.textContent === t('section_json')); + const jsonToggle = jsonSection?.querySelector('.jt-toggle'); + if (jsonToggle) { + jsonToggle.click(); + jsonToggle.click(); + } + setDetailViewMode('trace'); + setTraceFormatMode('json'); + setTraceFormatMode('yaml'); + setTraceFormatMode('pretty'); + renderTracePayload({ + emptyObject: {}, + emptyArray: [], + nested: { key: 'value' }, + array: [{ key: 'value' }], + multiline: 'line one\\nline two', + }); + }""", + index, + ) + page.goto(compact_html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.evaluate( + """(bundleText) => { + const parsed = JSON.parse(bundleText); + const compactRecords = materializeCompactTraceBundle(parsed); + parseTraceText(bundleText); + entries = expandWebSocketResponseEntries(compactRecords); + applyFilter(true); + selectEntry(0); + const compactPayload = parsed.records[0]; + const compactRecord = compactPayload.record; + const refPath = parseCompactRefPath(compactPayload.__claude_tap_compact_record__.refs[0].path); + const blobRef = compactRecord.request.body.instructions; + isCompactBlobRef(blobRef); + loadCompactBlobRef(blobRef, parsed.blobs, new Map()); + materializeCompactRefPath(compactRecord, refPath, parsed.blobs, new Map()); + materializeCompactRecord(compactPayload, parsed.blobs, new Map()); + const legacyPayload = { + __claude_tap_compact_record__: { + version: 1, + encoding: 'json-blob-ref', + }, + record: { + request: { + body: { + instructions: blobRef, + input: [blobRef], + }, + }, + response: { body: {} }, + }, + }; + getCompactPath(legacyPayload.record, ['request', 'body', 'instructions']); + legacyCompactRefPaths(legacyPayload.record); + materializeCompactRecord(legacyPayload, parsed.blobs, new Map()); + }""", + compact_bundle_path.read_text(encoding="utf-8"), + ) + page.goto(remote_html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.evaluate( + """async () => { + const entry = filtered[0]; + hasEmbeddedRawLines(); + shouldFetchRemoteEntry(entry); + remoteRecordUrl(0); + await fetchRemoteEntry(entry); + await resolveEntryForDetailAsync(entry); + withDisplayFields(entry, entry); + selectEntry(0); + }""" + ) + page.wait_for_selector("#detail .section", timeout=5000) + page.goto(empty_html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".empty-trace-state", timeout=5000) + page.set_input_files("#file-input", str(compact_bundle_path)) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.goto(empty_html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".empty-trace-state", timeout=5000) + coverage = session.send("Profiler.takePreciseCoverage") + session.send("Profiler.stopPreciseCoverage") + session.send("Profiler.disable") + browser.close() + + main_functions = _viewer_script_functions(_main_viewer_script(coverage, "v8_coverage.html")) + empty_functions = _viewer_script_functions(_main_viewer_script(coverage, "empty_coverage.html")) + compact_functions = _viewer_script_functions(_main_viewer_script(coverage, "compact_coverage.html")) + remote_functions = _viewer_script_functions(_main_viewer_script(coverage, "remote_coverage.html")) + auxiliary_covered_names = { + function.get("functionName", "") + for function in [*empty_functions, *compact_functions, *remote_functions] + if function.get("functionName") and _is_function_covered(function) + } + covered_functions = [ + function + for function in main_functions + if _is_function_covered(function) or function.get("functionName", "") in auxiliary_covered_names + ] + covered_names = {function.get("functionName", "") for function in covered_functions if function.get("functionName")} + percent = len(covered_functions) / len(main_functions) * 100 if main_functions else 100.0 + return percent, covered_names, len(covered_functions), len(main_functions) + + +def collect_viewer_css_coverage() -> tuple[float, set[str], int, int, int]: + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: # pragma: no cover - exercised in dependency-free environments + raise RuntimeError("Playwright is required for viewer CSS coverage") from exc + + _contract_cases, _generate_case_html, _ = _load_viewer_contract_helpers() + collect_css_script = r"""() => { + const skipped = []; + const used = new Set(); + const all = []; + const skipRe = /::|:(hover|active|focus|focus-visible|focus-within|visited|target|checked|disabled|enabled|placeholder-shown)\b/; + const splitSelectors = text => text.split(',').map(s => s.trim()).filter(Boolean); + const visit = rules => { + for (const rule of Array.from(rules || [])) { + if (rule.type === CSSRule.STYLE_RULE) { + for (const selector of splitSelectors(rule.selectorText)) { + if (skipRe.test(selector)) { + skipped.push(selector); + continue; + } + try { + all.push(selector); + if (document.querySelector(selector)) used.add(selector); + } catch (e) { + skipped.push(selector); + } + } + } else if (rule.cssRules) { + visit(rule.cssRules); + } + } + }; + for (const sheet of Array.from(document.styleSheets)) { + try { visit(sheet.cssRules); } catch (e) {} + } + return { used: [...used], all: [...new Set(all)], skipped: [...new Set(skipped)] }; + }""" + + used_selectors: set[str] = set() + all_selectors: set[str] = set() + skipped_selectors: set[str] = set() + + def merge(snapshot: dict[str, list[str]]) -> None: + used_selectors.update(snapshot["used"]) + all_selectors.update(snapshot["all"]) + skipped_selectors.update(snapshot["skipped"]) + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + html_path = _generate_case_html( + tmp_path, + "css_usage", + tuple(record for case in _contract_cases() for record in case.records), + ) + empty_html_path = _generate_case_html(tmp_path, "empty_css_usage", ()) + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 1000}) + page.add_init_script("window.__TRACE_SESSION_EXPORTS__ = {jsonl: 'coverage.jsonl', log: 'coverage.log'};") + page.goto(html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.evaluate( + """() => { + activePaths = new Set(entries.map(getPath)); + activeTools = null; + searchQuery = ''; + applyFilter(true); + }""" + ) + page.evaluate( + """() => { + renderViewerActions(); + const imageBlock = { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + } + }; + document.body.insertAdjacentHTML('beforeend', renderImageBlock(imageBlock, 0, 1, { frameBlocks: true })); + const tooltipTrigger = document.querySelector('.sidebar-group-header') || document.createElement('div'); + if (!tooltipTrigger.isConnected) document.body.appendChild(tooltipTrigger); + tooltipTrigger.dataset.fullUserInput = 'coverage tooltip prompt'; + showSessionTooltip(tooltipTrigger); + document.body.insertAdjacentHTML( + 'beforeend', + renderToolInput({ cmd: 'printf "coverage\\n"', yield_time_ms: 1000 }) + ); + document.querySelector('.tool-input-view')?.classList.add('expanded'); + }""" + ) + merge(page.evaluate(collect_css_script)) + page.evaluate("setSidebarOrderMode('turn')") + merge(page.evaluate(collect_css_script)) + page.evaluate("setSidebarOrderMode('model')") + merge(page.evaluate(collect_css_script)) + page.evaluate( + """() => { + const picker = document.querySelector('#date-picker'); + if (picker) picker.style.display = 'flex'; + setHistoryDeleteStatus('coverage ok', 'ok'); + }""" + ) + merge(page.evaluate(collect_css_script)) + page.evaluate("setHistoryDeleteStatus('coverage warn', 'warn')") + merge(page.evaluate(collect_css_script)) + page.evaluate("setHistoryDeleteStatus('coverage error', 'error')") + merge(page.evaluate(collect_css_script)) + + for index in range(page.evaluate("filtered.length")): + page.evaluate("entryIndex => { detailViewMode = 'default'; selectEntry(entryIndex); }", index) + merge(page.evaluate(collect_css_script)) + page.evaluate("setDetailViewMode('trace')") + merge(page.evaluate(collect_css_script)) + for mode in ("json", "yaml", "pretty"): + page.evaluate("mode => setTraceFormatMode(mode)", mode) + merge(page.evaluate(collect_css_script)) + page.evaluate( + """() => { + document.querySelector('#detail')?.insertAdjacentHTML( + 'beforeend', + renderTracePayload({ + emptyObject: {}, + emptyArray: [], + nested: { key: 'value' }, + array: [{ key: 'value' }], + multiline: 'line one\\nline two', + }) + ); + }""" + ) + merge(page.evaluate(collect_css_script)) + + page.evaluate( + """() => { + openGlobalSearch(); + const input = document.querySelector('#global-search-input'); + input.value = 'contract'; + input.dispatchEvent(new Event('input', { bubbles: true })); + }""" + ) + merge(page.evaluate(collect_css_script)) + + if page.evaluate("filtered.length") > 1: + page.evaluate("showDiffForIdx(1, null, 0)") + merge(page.evaluate(collect_css_script)) + + page.evaluate( + """() => { + setDetailViewMode('default'); + const contentBlockEntry = entries.find(entry => entry.request_id === 'req_content_block_boundary_contract'); + if (contentBlockEntry) renderDetail(contentBlockEntry); + document.querySelector('#detail')?.insertAdjacentHTML( + 'afterbegin', + '
id
resp
' + ); + }""" + ) + merge(page.evaluate(collect_css_script)) + page.evaluate("document.documentElement.setAttribute('data-theme', 'dark')") + merge(page.evaluate(collect_css_script)) + page.set_viewport_size({"width": 390, "height": 900}) + page.evaluate("mobileShowDetail()") + merge(page.evaluate(collect_css_script)) + page.set_viewport_size({"width": 1440, "height": 900}) + page.goto( + ( + f"{html_path.resolve().as_uri()}?embed=1&hideHeader=1&hidePath=1" + "&hideHistory=1&hideControls=1&density=compact&theme=light" + ), + timeout=10000, + ) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.evaluate( + """() => { + const contentBlockEntry = entries.find(entry => entry.request_id === 'req_content_block_boundary_contract'); + if (contentBlockEntry) renderDetail(contentBlockEntry); + }""" + ) + merge(page.evaluate(collect_css_script)) + page.goto(empty_html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".empty-trace-state", timeout=5000) + merge(page.evaluate(collect_css_script)) + browser.close() + + percent = len(used_selectors) / len(all_selectors) * 100 if all_selectors else 100.0 + return percent, used_selectors, len(used_selectors), len(all_selectors), len(skipped_selectors) + + +def check_viewer_js_coverage( + changed_functions: set[str], + function_min: float, + diff_min: float, +) -> list[CheckResult]: + function_percent, covered_names, covered_count, total_count = collect_viewer_js_coverage() + results = [ + CheckResult( + name="viewer_js_functions", + percent=function_percent, + minimum=function_min, + passed=function_percent >= function_min, + detail=f"{covered_count}/{total_count} V8 functions executed", + ) + ] + + if not changed_functions: + results.append( + CheckResult( + name="viewer_js_diff", + percent=None, + minimum=diff_min, + passed=True, + detail="no changed viewer JavaScript functions", + ) + ) + return results + + covered_changed = changed_functions & covered_names + diff_percent = len(covered_changed) / len(changed_functions) * 100 + missing = ", ".join(sorted(changed_functions - covered_names)) or "none" + results.append( + CheckResult( + name="viewer_js_diff", + percent=diff_percent, + minimum=diff_min, + passed=diff_percent >= diff_min, + detail=f"{len(covered_changed)}/{len(changed_functions)} changed JS functions covered; missing: {missing}", + ) + ) + return results + + +def check_viewer_css_coverage( + changed_selectors: set[str], + selector_min: float, + diff_min: float, + coverage: tuple[float, set[str], int, int, int] | None = None, +) -> list[CheckResult]: + selector_percent, used_selectors, used_count, total_count, skipped_count = coverage or collect_viewer_css_coverage() + results = [ + CheckResult( + name="viewer_css_selectors", + percent=selector_percent, + minimum=selector_min, + passed=selector_percent >= selector_min, + detail=f"{used_count}/{total_count} queryable CSS selectors matched; {skipped_count} state/pseudo selectors skipped", + ) + ] + + if not changed_selectors: + results.append( + CheckResult( + name="viewer_css_diff", + percent=None, + minimum=diff_min, + passed=True, + detail="no changed viewer CSS selectors", + ) + ) + return results + + covered_changed = changed_selectors & used_selectors + diff_percent = len(covered_changed) / len(changed_selectors) * 100 + missing = ", ".join(sorted(changed_selectors - used_selectors)) or "none" + results.append( + CheckResult( + name="viewer_css_diff", + percent=diff_percent, + minimum=diff_min, + passed=diff_percent >= diff_min, + detail=f"{len(covered_changed)}/{len(changed_selectors)} changed CSS selectors matched; missing: {missing}", + ) + ) + return results + + +def print_results(results: list[CheckResult]) -> None: + for result in results: + status = "PASS" if result.passed else "FAIL" + if result.percent is None: + print(f"{status} {result.name}: {result.detail} (target {result.minimum:.2f}%)") + else: + print(f"{status} {result.name}: {result.percent:.2f}% >= {result.minimum:.2f}% ({result.detail})") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default="origin/main", help="Base ref for incremental coverage diff") + parser.add_argument("--python-coverage", type=Path, default=Path(".coverage.json")) + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) + parser.add_argument("--skip-python", action="store_true") + parser.add_argument("--skip-viewer-js", action="store_true") + parser.add_argument("--skip-viewer-css", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + thresholds = load_thresholds(args.config) + changed_lines = _filter_pure_viewer_asset_split( + changed_lines_from_diff(_run_git_diff(args.base, VIEWER_DIFF_PATHS)), + args.base, + ) + + results: list[CheckResult] = [] + if not args.skip_python: + results.extend( + check_python_coverage( + args.python_coverage, + changed_lines, + thresholds["python_total_min"], + thresholds["python_diff_min"], + ) + ) + if not args.skip_viewer_js: + results.extend( + check_viewer_js_coverage( + changed_viewer_functions(tuple(REPO_ROOT / source for source in VIEWER_JS_SOURCES), changed_lines), + thresholds["viewer_js_function_min"], + thresholds["viewer_js_diff_min"], + ) + ) + if not args.skip_viewer_css: + results.extend( + check_viewer_css_coverage( + changed_viewer_css_selectors(REPO_ROOT / VIEWER_CSS_SOURCE, changed_lines), + thresholds["viewer_css_selector_min"], + thresholds["viewer_css_diff_min"], + ) + ) + + print_results(results) + return 0 if all(result.passed for result in results) else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_legibility.py b/scripts/check_legibility.py new file mode 100644 index 0000000..585384a --- /dev/null +++ b/scripts/check_legibility.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Deterministic legibility checks for maintainer standards, plans, and architecture manifest.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +REQUIRED_STANDARDS_KEYS = ("owner", "last_reviewed", "source_of_truth") +ALLOWED_PLAN_STATUS = {"active", "completed", "cancelled"} +AGENT_DOCS_DIR = Path(".agents") / "docs" +STANDARDS_DIR = AGENT_DOCS_DIR / "standards" +PLANS_DIR = AGENT_DOCS_DIR / "plans" +ARCHITECTURE_MANIFEST = AGENT_DOCS_DIR / "architecture" / "manifest.yaml" +PUBLIC_DOCS_DIR = Path("docs") +README_DOC_PAIRS = ((Path("README.md"), Path("README_zh.md")),) + + +@dataclass +class CheckResult: + failures: list[str] + warnings: list[str] + + +def parse_frontmatter(markdown_text: str) -> dict[str, str]: + lines = markdown_text.splitlines() + if len(lines) < 3 or lines[0].strip() != "---": + return {} + frontmatter_lines: list[str] = [] + for line in lines[1:]: + if line.strip() == "---": + break + frontmatter_lines.append(line) + else: + return {} + + fields: dict[str, str] = {} + for line in frontmatter_lines: + if ":" not in line: + continue + key, value = line.split(":", 1) + fields[key.strip()] = value.strip() + return fields + + +def parse_expected_paths(manifest_text: str) -> list[str]: + lines = manifest_text.splitlines() + in_expected_paths = False + expected_paths: list[str] = [] + + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped == "expected_paths:": + in_expected_paths = True + continue + if in_expected_paths: + if not stripped.startswith("- "): + break + value = stripped[2:].strip().strip("'\"") + if value: + expected_paths.append(value) + + return expected_paths + + +def parse_iso_date(date_str: str) -> dt.date | None: + if not ISO_DATE_RE.match(date_str): + return None + try: + return dt.date.fromisoformat(date_str) + except ValueError: + return None + + +def _is_within_repo_root(repo_root: Path, candidate_path: Path) -> bool: + try: + candidate_path.relative_to(repo_root) + return True + except ValueError: + return False + + +def _strip_fenced_code_blocks(markdown_text: str) -> str: + kept_lines: list[str] = [] + in_fence = False + fence_char = "" + fence_len = 0 + + for line in markdown_text.splitlines(): + stripped = line.lstrip() + if not in_fence and (stripped.startswith("```") or stripped.startswith("~~~")): + in_fence = True + fence_char = stripped[0] + fence_len = len(stripped) - len(stripped.lstrip(fence_char)) + continue + + if in_fence: + if stripped.startswith(fence_char * fence_len): + in_fence = False + continue + + kept_lines.append(line) + + return "\n".join(kept_lines) + + +def check_standards_freshness( + repo_root: Path, *, freshness_days: int, strict_freshness: bool, today: dt.date +) -> CheckResult: + failures: list[str] = [] + warnings: list[str] = [] + standards_dir = repo_root / STANDARDS_DIR + + for file_path in sorted(standards_dir.glob("*.md")): + frontmatter = parse_frontmatter(file_path.read_text(encoding="utf-8")) + + for key in REQUIRED_STANDARDS_KEYS: + if not frontmatter.get(key): + failures.append(f"{file_path}: missing frontmatter key '{key}'") + + last_reviewed_raw = frontmatter.get("last_reviewed") + if not last_reviewed_raw: + continue + + reviewed_date = parse_iso_date(last_reviewed_raw) + if reviewed_date is None: + failures.append(f"{file_path}: last_reviewed must be ISO date YYYY-MM-DD") + continue + + age_days = (today - reviewed_date).days + if age_days > freshness_days: + msg = ( + f"{file_path}: last_reviewed {last_reviewed_raw} is stale " + f"({age_days} days old, threshold={freshness_days})" + ) + if strict_freshness: + failures.append(msg) + else: + warnings.append(msg) + + return CheckResult(failures=failures, warnings=warnings) + + +def check_architecture_manifest(repo_root: Path) -> CheckResult: + failures: list[str] = [] + resolved_repo_root = repo_root.resolve() + manifest_path = repo_root / ARCHITECTURE_MANIFEST + if not manifest_path.exists(): + return CheckResult(failures=[f"{manifest_path}: missing manifest file"], warnings=[]) + + expected_paths = parse_expected_paths(manifest_path.read_text(encoding="utf-8")) + if not expected_paths: + failures.append(f"{manifest_path}: expected_paths list is empty or missing") + return CheckResult(failures=failures, warnings=[]) + + for relative_path in expected_paths: + candidate_path = Path(relative_path) + if candidate_path.is_absolute(): + failures.append(f"{manifest_path}: expected path must be relative: {relative_path}") + continue + + absolute_path = (repo_root / candidate_path).resolve() + if not _is_within_repo_root(resolved_repo_root, absolute_path): + failures.append(f"{manifest_path}: expected path resolves outside repo root: {relative_path}") + continue + + if not absolute_path.exists(): + failures.append(f"{manifest_path}: expected path missing: {relative_path}") + + return CheckResult(failures=failures, warnings=[]) + + +def check_plan_state_drift(repo_root: Path) -> CheckResult: + failures: list[str] = [] + plans_dir = repo_root / PLANS_DIR + + for file_path in sorted(plans_dir.glob("**/*.md")): + text = file_path.read_text(encoding="utf-8") + frontmatter = parse_frontmatter(text) + status = frontmatter.get("status") + + if status not in ALLOWED_PLAN_STATUS: + failures.append(f"{file_path}: status must be one of {sorted(ALLOWED_PLAN_STATUS)}") + continue + + if status == "completed" and "- [ ]" in _strip_fenced_code_blocks(text): + failures.append(f"{file_path}: completed plan still contains unchecked TODO checkboxes") + + return CheckResult(failures=failures, warnings=[]) + + +def _public_doc_counterpart(path: Path) -> Path: + if path.name.endswith(".zh.md"): + return path.with_name(f"{path.name.removesuffix('.zh.md')}.md") + return path.with_name(f"{path.stem}.zh.md") + + +def check_public_docs_bilingual(repo_root: Path) -> CheckResult: + failures: list[str] = [] + + for english_path, chinese_path in README_DOC_PAIRS: + absolute_english = repo_root / english_path + absolute_chinese = repo_root / chinese_path + if absolute_english.exists() and not absolute_chinese.exists(): + failures.append(f"{absolute_english}: missing Simplified Chinese counterpart: {chinese_path}") + if absolute_chinese.exists() and not absolute_english.exists(): + failures.append(f"{absolute_chinese}: missing English counterpart: {english_path}") + + public_docs_dir = repo_root / PUBLIC_DOCS_DIR + if not public_docs_dir.exists(): + return CheckResult(failures=failures, warnings=[]) + + for file_path in sorted(public_docs_dir.glob("**/*.md")): + counterpart = _public_doc_counterpart(file_path) + if not counterpart.exists(): + counterpart_relative = counterpart.relative_to(repo_root) + if file_path.name.endswith(".zh.md"): + failures.append(f"{file_path}: missing English counterpart: {counterpart_relative}") + else: + failures.append(f"{file_path}: missing Simplified Chinese counterpart: {counterpart_relative}") + + return CheckResult(failures=failures, warnings=[]) + + +def run_checks(repo_root: Path, *, freshness_days: int, strict_freshness: bool, today: dt.date) -> CheckResult: + failures: list[str] = [] + warnings: list[str] = [] + + for result in ( + check_standards_freshness( + repo_root, + freshness_days=freshness_days, + strict_freshness=strict_freshness, + today=today, + ), + check_architecture_manifest(repo_root), + check_plan_state_drift(repo_root), + check_public_docs_bilingual(repo_root), + ): + failures.extend(result.failures) + warnings.extend(result.warnings) + + return CheckResult(failures=failures, warnings=warnings) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", type=Path, default=Path.cwd(), help="Repository root path (default: current dir)" + ) + parser.add_argument( + "--freshness-days", + type=int, + default=60, + help="Warn when standards last_reviewed is older than this many days (default: 60)", + ) + parser.add_argument( + "--strict-freshness", + action="store_true", + help="Promote stale standards freshness warnings to failures", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + result = run_checks( + args.repo_root, + freshness_days=args.freshness_days, + strict_freshness=args.strict_freshness, + today=dt.date.today(), + ) + + for warning in result.warnings: + print(f"WARNING: {warning}") + for failure in result.failures: + print(f"ERROR: {failure}") + + if result.warnings: + print(f"Legibility check warnings: {len(result.warnings)}") + if result.failures: + print(f"Legibility check failures: {len(result.failures)}") + return 1 + + print("Legibility checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_pr.sh b/scripts/check_pr.sh new file mode 100755 index 0000000..289b599 --- /dev/null +++ b/scripts/check_pr.sh @@ -0,0 +1,244 @@ +#!/bin/sh +set -eu + +usage() { + cat <<'EOF' +Usage: scripts/check_pr.sh [--repo owner/repo] [--no-tests] + +Options: + --repo OWNER/REPO Override repository (default: current gh repo) + --no-tests Skip local test gates + -h, --help Show this help +EOF +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "error: required command not found: $1" >&2 + exit 1 + fi +} + +pr_number="" +repo="" +run_tests=1 + +while [ "$#" -gt 0 ]; do + case "$1" in + --repo) + if [ "$#" -lt 2 ]; then + echo "error: --repo requires a value" >&2 + usage + exit 1 + fi + repo="$2" + shift 2 + ;; + --no-tests) + run_tests=0 + shift + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "error: unknown option: $1" >&2 + usage + exit 1 + ;; + *) + if [ -n "$pr_number" ]; then + echo "error: unexpected extra argument: $1" >&2 + usage + exit 1 + fi + pr_number="$1" + shift + ;; + esac +done + +if [ -z "$pr_number" ]; then + echo "error: missing " >&2 + usage + exit 1 +fi + +require_cmd gh +require_cmd python3 +if [ "$run_tests" -eq 1 ]; then + require_cmd uv +fi + +if [ -z "$repo" ]; then + repo="$(gh repo view --json nameWithOwner --template '{{.nameWithOwner}}')" + if [ -z "$repo" ]; then + echo "error: failed to detect repository; use --repo owner/repo" >&2 + exit 1 + fi +fi + +metadata="$(gh pr view "$pr_number" --repo "$repo" \ + --json title,state,isDraft,mergeStateStatus,headRefName,baseRefName,url \ + --template '{{.title}}{{"\n"}}{{.state}}{{"\n"}}{{.isDraft}}{{"\n"}}{{.mergeStateStatus}}{{"\n"}}{{.headRefName}}{{"\n"}}{{.baseRefName}}{{"\n"}}{{.url}}')" + +pr_title=$(printf '%s\n' "$metadata" | sed -n '1p') +pr_state=$(printf '%s\n' "$metadata" | sed -n '2p') +pr_draft=$(printf '%s\n' "$metadata" | sed -n '3p') +pr_merge_status=$(printf '%s\n' "$metadata" | sed -n '4p') +pr_head=$(printf '%s\n' "$metadata" | sed -n '5p') +pr_base=$(printf '%s\n' "$metadata" | sed -n '6p') +pr_url=$(printf '%s\n' "$metadata" | sed -n '7p') + +pr_body="$(gh pr view "$pr_number" --repo "$repo" --json body --template '{{.body}}')" +pr_files="$(gh pr view "$pr_number" --repo "$repo" --json files --jq '.files[].path')" + +if [ -z "$pr_title" ] || [ -z "$pr_state" ] || [ -z "$pr_merge_status" ]; then + echo "error: failed to parse PR metadata from gh" >&2 + exit 1 +fi + +check_lines="" +if check_lines="$(gh pr checks "$pr_number" --repo "$repo" --json bucket,name,state \ + --template '{{range .}}{{.bucket}}{{"\t"}}{{.name}}{{"\t"}}{{.state}}{{"\n"}}{{end}}')"; then + : +else + checks_exit=$? + if [ "$checks_exit" -ne 8 ]; then + echo "error: failed to fetch PR checks (gh exit $checks_exit)" >&2 + exit 1 + fi +fi + +pass_count=0 +fail_count=0 +pending_count=0 +check_total=0 + +if [ -n "$check_lines" ]; then + tab_char=$(printf '\t') + while IFS=$tab_char read -r bucket check_name check_state; do + [ -n "$bucket" ] || continue + check_total=$((check_total + 1)) + case "$bucket" in + pass|skipping) + pass_count=$((pass_count + 1)) + ;; + fail|cancel) + fail_count=$((fail_count + 1)) + ;; + pending) + pending_count=$((pending_count + 1)) + ;; + *) + pending_count=$((pending_count + 1)) + ;; + esac + done < %s\n' "$pr_head" "$pr_base" +printf 'Checks: pass=%s fail=%s pending=%s total=%s\n' "$pass_count" "$fail_count" "$pending_count" "$check_total" + +gate_failed=0 +if [ "$run_tests" -eq 1 ]; then + echo 'Local gates:' + + if uv run ruff check .; then + echo ' PASS uv run ruff check .' + else + gate_failed=1 + echo ' FAIL uv run ruff check .' + fi + + if uv run ruff format --check .; then + echo ' PASS uv run ruff format --check .' + else + gate_failed=1 + echo ' FAIL uv run ruff format --check .' + fi + + if uv run pytest tests/ -x --timeout=60; then + echo ' PASS uv run pytest tests/ -x --timeout=60' + else + gate_failed=1 + echo ' FAIL uv run pytest tests/ -x --timeout=60' + fi +else + echo 'Local gates: skipped (--no-tests)' +fi + +policy_failed=0 +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM +printf '%s' "$pr_body" >"$tmp_dir/body.md" +printf '%s\n' "$pr_files" >"$tmp_dir/files.txt" +if python3 scripts/check_pr_policy.py --body-file "$tmp_dir/body.md" --changed-files-file "$tmp_dir/files.txt"; then + : +else + policy_failed=1 +fi + +ready=1 +reasons="" + +append_reason() { + if [ -n "$reasons" ]; then + reasons="$reasons; $1" + else + reasons="$1" + fi +} + +if [ "$pr_state" != "OPEN" ]; then + ready=0 + append_reason "PR state is $pr_state" +fi + +if [ "$pr_draft" = "true" ]; then + ready=0 + append_reason 'PR is draft' +fi + +case "$pr_merge_status" in + CLEAN|HAS_HOOKS) + ;; + *) + ready=0 + append_reason "mergeStateStatus is $pr_merge_status" + ;; +esac + +if [ "$fail_count" -gt 0 ]; then + ready=0 + append_reason "$fail_count CI check(s) failing" +fi + +if [ "$pending_count" -gt 0 ]; then + ready=0 + append_reason "$pending_count CI check(s) pending" +fi + +if [ "$gate_failed" -eq 1 ]; then + ready=0 + append_reason 'local gates failed' +fi +if [ "$policy_failed" -eq 1 ]; then + ready=0 + append_reason 'PR policy failed' +fi + +if [ "$ready" -eq 1 ]; then + echo 'VERDICT: READY - all merge-readiness checks passed' + exit 0 +fi + +echo "VERDICT: NOT_READY - $reasons" +exit 2 diff --git a/scripts/check_pr_policy.py b/scripts/check_pr_policy.py new file mode 100644 index 0000000..25363d6 --- /dev/null +++ b/scripts/check_pr_policy.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Validate pull request body and changed-file policy gates.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import unquote, urlparse + +IMAGE_EXT_RE = re.compile(r"\.(?:png|jpe?g|gif|svg|webp)(?:[?#][^\s)]*)?$", re.IGNORECASE) +MARKDOWN_IMAGE_RE = re.compile(r"!\[[^\]]*]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)", re.IGNORECASE) +HTML_IMAGE_RE = re.compile(r"]*\bsrc=[\"']([^\"']+)[\"']", re.IGNORECASE) +PLAIN_IMAGE_URL_RE = re.compile( + r"https?://[^\s<>()\"']+\.(?:png|jpe?g|gif|svg|webp)(?:[?#][^\s<>()\"']*)?", re.IGNORECASE +) + +SUMMARY_HEADINGS = { + "summary", + "problem", + "goal", + "refactor summary", + "fix summary", +} +VALIDATION_HEADINGS = { + "validation", + "test plan", + "results", +} + +EVIDENCE_REQUIRED_PATHS = ( + "claude_tap/", + "docs/support-matrix.md", +) +EVIDENCE_SCREENSHOT_ROOT = ".agents/evidence/pr/" +EVIDENCE_SCREENSHOT_KEYWORDS = ( + "browser", + "dashboard", + "e2e", + "session", + "trace", + "viewer", +) +REAL_EVIDENCE_MARKERS = ( + ".traces/", + "--run-real-e2e", + "claude-tap --tap-client", + "dashboard", + "jsonl", + "real e2e", + "real trace", + "run_real_e2e", + "trace viewer", + "uv run claude-tap", + "viewer html", +) + +SECRET_PATTERNS = ( + re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), + re.compile(r"\bBearer\s+eyJ[A-Za-z0-9_.-]+"), + re.compile(r"\b(?:ANTHROPIC|OPENAI|OPENROUTER|GITHUB)_[A-Z0-9_]*KEY\s*="), +) + + +@dataclass(frozen=True) +class PolicyResult: + ok: bool + failures: tuple[str, ...] + warnings: tuple[str, ...] + + +def _heading_names(body: str) -> set[str]: + headings: set[str] = set() + for line in body.splitlines(): + match = re.match(r"^\s{0,3}#{1,6}\s+(.+?)\s*$", line) + if match: + headings.add(match.group(1).strip().lower()) + return headings + + +def _image_urls(body: str) -> list[str]: + urls = [match.group(1).strip() for match in MARKDOWN_IMAGE_RE.finditer(body)] + urls.extend(match.group(1).strip() for match in HTML_IMAGE_RE.finditer(body)) + urls.extend(match.group(0).strip() for match in PLAIN_IMAGE_URL_RE.finditer(body)) + + unique: list[str] = [] + seen: set[str] = set() + for url in urls: + if url not in seen: + seen.add(url) + unique.append(url) + return unique + + +def _raw_image_urls(urls: list[str]) -> list[str]: + return [url for url in urls if "://raw.githubusercontent.com/" in url and IMAGE_EXT_RE.search(url)] + + +def _raw_image_paths(urls: list[str]) -> list[str]: + paths: list[str] = [] + for url in urls: + parsed = urlparse(url) + if parsed.netloc != "raw.githubusercontent.com": + continue + parts = [unquote(part) for part in parsed.path.split("/") if part] + if ".agents" in parts: + paths.append("/".join(parts[parts.index(".agents") :])) + elif len(parts) >= 4: + paths.append("/".join(parts[3:])) + return paths + + +def _body_without_image_references(body: str) -> str: + stripped = MARKDOWN_IMAGE_RE.sub("", body) + stripped = HTML_IMAGE_RE.sub("", stripped) + return PLAIN_IMAGE_URL_RE.sub("", stripped) + + +def _has_real_evidence_source(body: str) -> bool: + lower = _body_without_image_references(body).lower() + return any(marker in lower for marker in REAL_EVIDENCE_MARKERS) + + +def _evidence_required_paths(changed_files: list[str]) -> list[str]: + required: list[str] = [] + for path in changed_files: + normalized = path.strip() + if not normalized: + continue + if normalized.startswith(EVIDENCE_REQUIRED_PATHS): + required.append(normalized) + return required + + +def _dangerous_files(changed_files: list[str]) -> list[str]: + dangerous: list[str] = [] + for path in changed_files: + normalized = path.strip() + if not normalized: + continue + name = Path(normalized).name.lower() + suffix = Path(normalized).suffix.lower() + if normalized.startswith(".traces/") and suffix in {".jsonl", ".html", ".log"}: + dangerous.append(normalized) + elif name.startswith("trace_") and suffix in {".jsonl", ".html", ".log"}: + dangerous.append(normalized) + elif name in {".env", "id_rsa", "id_ed25519"}: + dangerous.append(normalized) + return dangerous + + +def validate_policy(body: str, changed_files: list[str]) -> PolicyResult: + failures: list[str] = [] + warnings: list[str] = [] + body = body.strip() + + if not body: + failures.append("PR body is empty") + return PolicyResult(ok=False, failures=tuple(failures), warnings=()) + + headings = _heading_names(body) + if not headings.intersection(SUMMARY_HEADINGS): + failures.append("PR body is missing a Summary, Problem, Goal, or equivalent section") + if not headings.intersection(VALIDATION_HEADINGS): + failures.append("PR body is missing a Validation, Test plan, or Results section") + + for pattern in SECRET_PATTERNS: + if pattern.search(body): + failures.append("PR body appears to contain a secret or bearer token") + break + + image_urls = _image_urls(body) + raw_urls = _raw_image_urls(image_urls) + non_raw_urls = [url for url in image_urls if url not in raw_urls] + if non_raw_urls: + failures.append("PR image evidence must use raw.githubusercontent.com URLs") + + evidence_paths = _evidence_required_paths(changed_files) + if evidence_paths and not raw_urls: + failures.append( + "PR changes runtime/viewer/client behavior but has no raw.githubusercontent.com screenshot evidence" + ) + warnings.append("Evidence-triggering files: " + ", ".join(evidence_paths[:8])) + elif evidence_paths: + raw_paths = _raw_image_paths(raw_urls) + evidence_image_paths = [path for path in raw_paths if path.startswith(EVIDENCE_SCREENSHOT_ROOT)] + if not evidence_image_paths: + failures.append("PR runtime screenshot evidence must link to committed .agents/evidence/pr/ images") + elif not any( + any(keyword in Path(path).name.lower() for keyword in EVIDENCE_SCREENSHOT_KEYWORDS) + for path in evidence_image_paths + ): + failures.append( + "PR runtime screenshot evidence must be trace/viewer/dashboard evidence, not test-output art" + ) + if not _has_real_evidence_source(body): + failures.append("PR runtime evidence must document the real trace/viewer source used for screenshots") + + dangerous = _dangerous_files(changed_files) + if dangerous: + failures.append("PR includes raw trace, generated viewer, log, or secret-like files") + warnings.append("Blocked files: " + ", ".join(dangerous[:8])) + + return PolicyResult(ok=not failures, failures=tuple(failures), warnings=tuple(warnings)) + + +def _load_body(args: argparse.Namespace) -> str: + if args.body is not None: + return args.body + if args.body_file: + return Path(args.body_file).read_text(encoding="utf-8") + event_path = args.event_path or os.environ.get("GITHUB_EVENT_PATH") + if event_path: + data = json.loads(Path(event_path).read_text(encoding="utf-8")) + body = data.get("pull_request", {}).get("body") + if isinstance(body, str): + return body + raise SystemExit("error: provide --body, --body-file, or --event-path with pull_request.body") + + +def _load_changed_files(args: argparse.Namespace) -> list[str]: + if args.changed_file: + return args.changed_file + if args.changed_files_file: + return [ + line.strip() + for line in Path(args.changed_files_file).read_text(encoding="utf-8").splitlines() + if line.strip() + ] + return [] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--body", help="PR body text") + parser.add_argument("--body-file", help="File containing the PR body") + parser.add_argument("--event-path", help="GitHub event JSON path") + parser.add_argument("--changed-files-file", help="File containing changed paths, one per line") + parser.add_argument("--changed-file", action="append", default=[], help="Changed path; repeatable") + args = parser.parse_args(argv) + + result = validate_policy(_load_body(args), _load_changed_files(args)) + if result.ok: + print("PR Policy: PASS") + for warning in result.warnings: + print(f" WARN {warning}") + return 0 + + print("PR Policy: FAIL") + for failure in result.failures: + print(f" FAIL {failure}") + for warning in result.warnings: + print(f" INFO {warning}") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_screenshots.py b/scripts/check_screenshots.py new file mode 100644 index 0000000..3c40a1e --- /dev/null +++ b/scripts/check_screenshots.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Screenshot quality checks for PR evidence images.""" + +from __future__ import annotations + +import argparse +import struct +import zlib +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path +from typing import Iterable + +MIN_DESKTOP_WIDTH = 1280 +MIN_DIMENSION = 400 +MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 +BLANK_DOMINANT_RATIO = 0.90 +MAX_BLANKNESS_SAMPLE_PIXELS = 250_000 +SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"} + + +@dataclass +class ImageInfo: + width: int + height: int + format_name: str + + +@dataclass +class FileResult: + path: Path + info: ImageInfo | None = None + failures: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def status(self) -> str: + if self.failures: + return "FAIL" + if self.warnings: + return "WARN" + return "PASS" + + +def iter_image_files(paths: Iterable[Path]) -> list[Path]: + files: set[Path] = set() + for path in paths: + if path.is_file() and path.suffix.lower() in SUPPORTED_EXTENSIONS: + files.add(path) + continue + if path.is_dir(): + for child in path.rglob("*"): + if child.is_file() and child.suffix.lower() in SUPPORTED_EXTENSIONS: + files.add(child) + return sorted(files) + + +def parse_png_info(raw: bytes) -> ImageInfo: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("not a PNG file") + if raw[12:16] != b"IHDR": + raise ValueError("missing PNG IHDR") + width, height = struct.unpack(">II", raw[16:24]) + return ImageInfo(width=width, height=height, format_name="png") + + +def parse_jpeg_info(raw: bytes) -> ImageInfo: + if len(raw) < 4 or raw[0:2] != b"\xff\xd8": + raise ValueError("not a JPEG file") + i = 2 + sof_markers = { + 0xC0, + 0xC1, + 0xC2, + 0xC3, + 0xC5, + 0xC6, + 0xC7, + 0xC9, + 0xCA, + 0xCB, + 0xCD, + 0xCE, + 0xCF, + } + while i + 9 < len(raw): + if raw[i] != 0xFF: + i += 1 + continue + marker = raw[i + 1] + i += 2 + while marker == 0xFF and i < len(raw): + marker = raw[i] + i += 1 + if marker in {0xD8, 0xD9, 0x01} or 0xD0 <= marker <= 0xD7: + continue + if i + 2 > len(raw): + break + segment_length = struct.unpack(">H", raw[i : i + 2])[0] + if segment_length < 2 or i + segment_length > len(raw): + break + if marker in sof_markers: + if i + 7 > len(raw): + break + height, width = struct.unpack(">HH", raw[i + 3 : i + 7]) + return ImageInfo(width=width, height=height, format_name="jpeg") + i += segment_length + raise ValueError("could not parse JPEG dimensions") + + +def parse_gif_info(raw: bytes) -> ImageInfo: + if len(raw) < 10 or raw[0:6] not in {b"GIF87a", b"GIF89a"}: + raise ValueError("not a GIF file") + width, height = struct.unpack(" ImageInfo: + if len(raw) < 30 or raw[0:4] != b"RIFF" or raw[8:12] != b"WEBP": + raise ValueError("not a WEBP file") + chunk = raw[12:16] + if chunk == b"VP8X": + width = 1 + int.from_bytes(raw[24:27], "little") + height = 1 + int.from_bytes(raw[27:30], "little") + return ImageInfo(width=width, height=height, format_name="webp") + if chunk == b"VP8 ": + if len(raw) < 30: + raise ValueError("WEBP VP8 too short") + width, height = struct.unpack("> 14) & 0x3FFF) + 1 + return ImageInfo(width=width, height=height, format_name="webp") + raise ValueError("unsupported WEBP chunk") + + +def parse_image_info(raw: bytes, extension: str) -> ImageInfo: + extension = extension.lower() + if extension == ".png": + return parse_png_info(raw) + if extension in {".jpg", ".jpeg"}: + return parse_jpeg_info(raw) + if extension == ".gif": + return parse_gif_info(raw) + if extension == ".webp": + return parse_webp_info(raw) + raise ValueError(f"unsupported image format: {extension}") + + +def _parse_png_chunks(raw: bytes) -> tuple[dict[str, bytes], bytes]: + i = 8 + chunks: dict[str, bytes] = {} + idat_parts: list[bytes] = [] + while i + 8 <= len(raw): + length = struct.unpack(">I", raw[i : i + 4])[0] + chunk_type = raw[i + 4 : i + 8].decode("ascii", errors="replace") + data_start = i + 8 + data_end = data_start + length + crc_end = data_end + 4 + if crc_end > len(raw): + raise ValueError("corrupt PNG chunk data") + data = raw[data_start:data_end] + if chunk_type == "IDAT": + idat_parts.append(data) + elif chunk_type not in chunks: + chunks[chunk_type] = data + i = crc_end + if chunk_type == "IEND": + break + return chunks, b"".join(idat_parts) + + +def _paeth_predictor(a: int, b: int, c: int) -> int: + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + return a + if pb <= pc: + return b + return c + + +def _unfilter_png_scanlines(filtered: bytes, width: int, height: int, bpp: int) -> list[bytes]: + stride = width * bpp + expected_len = height * (stride + 1) + if len(filtered) != expected_len: + raise ValueError("unexpected PNG IDAT decompressed length") + + rows: list[bytes] = [] + prior = bytearray(stride) + offset = 0 + for _ in range(height): + filter_type = filtered[offset] + offset += 1 + current = bytearray(filtered[offset : offset + stride]) + offset += stride + + if filter_type == 1: + for x in range(stride): + left = current[x - bpp] if x >= bpp else 0 + current[x] = (current[x] + left) & 0xFF + elif filter_type == 2: + for x in range(stride): + current[x] = (current[x] + prior[x]) & 0xFF + elif filter_type == 3: + for x in range(stride): + left = current[x - bpp] if x >= bpp else 0 + current[x] = (current[x] + ((left + prior[x]) // 2)) & 0xFF + elif filter_type == 4: + for x in range(stride): + left = current[x - bpp] if x >= bpp else 0 + up = prior[x] + up_left = prior[x - bpp] if x >= bpp else 0 + current[x] = (current[x] + _paeth_predictor(left, up, up_left)) & 0xFF + elif filter_type != 0: + raise ValueError(f"unsupported PNG filter type: {filter_type}") + + rows.append(bytes(current)) + prior = current + return rows + + +def parse_png_dominant_color_ratio( + raw: bytes, max_sample_pixels: int = MAX_BLANKNESS_SAMPLE_PIXELS +) -> tuple[float, tuple[int, int, int, int]]: + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("not a PNG file") + chunks, idat = _parse_png_chunks(raw) + ihdr = chunks.get("IHDR") + if ihdr is None or len(ihdr) != 13: + raise ValueError("missing PNG IHDR chunk") + + width, height, bit_depth, color_type, compression, filter_method, interlace = struct.unpack(">IIBBBBB", ihdr) + if compression != 0 or filter_method != 0: + raise ValueError("unsupported PNG compression/filter method") + if interlace != 0: + raise ValueError("interlaced PNG is not supported") + if bit_depth != 8: + raise ValueError("only PNG bit depth 8 is supported") + + channels_by_color_type = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4} + channels = channels_by_color_type.get(color_type) + if channels is None: + raise ValueError(f"unsupported PNG color type: {color_type}") + + palette: bytes | None = chunks.get("PLTE") + if color_type == 3 and (palette is None or len(palette) % 3 != 0): + raise ValueError("invalid or missing PNG palette") + + decompressed = zlib.decompress(idat) + rows = _unfilter_png_scanlines(decompressed, width, height, channels) + color_counts: Counter[tuple[int, int, int, int]] = Counter() + total_pixels = width * height + sample_step = 1 + if max_sample_pixels > 0: + sample_step = max(1, (total_pixels + max_sample_pixels - 1) // max_sample_pixels) + sampled_pixels = 0 + pixel_index = 0 + + for row in rows: + first_sample = (-pixel_index) % sample_step + for i in range(first_sample * channels, len(row), sample_step * channels): + px = row[i : i + channels] + if color_type == 0: + color = (px[0], px[0], px[0], 255) + elif color_type == 2: + color = (px[0], px[1], px[2], 255) + elif color_type == 3: + idx = px[0] + p = idx * 3 + if palette is None or p + 2 >= len(palette): + raise ValueError("PNG palette index out of bounds") + color = (palette[p], palette[p + 1], palette[p + 2], 255) + elif color_type == 4: + color = (px[0], px[0], px[0], px[1]) + else: + color = (px[0], px[1], px[2], px[3]) + color_counts[color] += 1 + sampled_pixels += 1 + pixel_index += width + + if not color_counts or sampled_pixels == 0: + raise ValueError("PNG has no pixels") + + dominant_color, dominant_count = color_counts.most_common(1)[0] + return dominant_count / sampled_pixels, dominant_color + + +def _is_blankish_color(color: tuple[int, int, int, int]) -> bool: + red, green, blue, alpha = color + return alpha <= 10 or (red >= 245 and green >= 245 and blue >= 245 and alpha >= 245) + + +def analyze_file(path: Path) -> FileResult: + result = FileResult(path=path) + try: + raw = path.read_bytes() + except OSError as exc: + result.failures.append(f"cannot read file ({exc})") + return result + + file_size = len(raw) + if file_size > MAX_FILE_SIZE_BYTES: + size_mb = file_size / (1024 * 1024) + result.warnings.append(f"large file ({size_mb:.2f}MB > 5.00MB)") + + try: + info = parse_image_info(raw, path.suffix) + result.info = info + except ValueError as exc: + result.failures.append(str(exc)) + return result + + if info.width < MIN_DIMENSION or info.height < MIN_DIMENSION: + result.failures.append(f"very small image ({info.width}x{info.height}; minimum is 400x400)") + + if info.width < MIN_DESKTOP_WIDTH: + result.warnings.append(f"narrow desktop viewport ({info.width}px < 1280px)") + + if info.format_name == "png": + try: + ratio, dominant_color = parse_png_dominant_color_ratio(raw) + if ratio > BLANK_DOMINANT_RATIO and _is_blankish_color(dominant_color): + result.failures.append(f"mostly blank/white image (dominant color ratio {ratio:.1%} > 90%)") + except ValueError as exc: + result.warnings.append(f"blankness check skipped ({exc})") + else: + result.warnings.append("blankness check skipped (only PNG analysis is supported)") + + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("paths", nargs="+", type=Path, help="Image file(s) or directories to scan") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + files = iter_image_files(args.paths) + if not files: + print("[WARN] No image files found for input paths.", flush=True) + print("Summary: PASS=0 WARN=1 FAIL=0", flush=True) + return 0 + + pass_count = 0 + warn_count = 0 + fail_count = 0 + + for file_path in files: + result = analyze_file(file_path) + reasons = result.failures + result.warnings + info_suffix = "" + if result.info is not None: + info_suffix = f" ({result.info.width}x{result.info.height})" + + if reasons: + print(f"[{result.status}] {file_path}{info_suffix}: {'; '.join(reasons)}", flush=True) + else: + print(f"[{result.status}] {file_path}{info_suffix}", flush=True) + + if result.status == "PASS": + pass_count += 1 + elif result.status == "WARN": + warn_count += 1 + else: + fail_count += 1 + + print(f"Summary: PASS={pass_count} WARN={warn_count} FAIL={fail_count}", flush=True) + return 1 if fail_count else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_screenshots.sh b/scripts/check_screenshots.sh new file mode 100755 index 0000000..6e25711 --- /dev/null +++ b/scripts/check_screenshots.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# check_screenshots.sh - Validate screenshot quality before commit +# +# Usage: +# scripts/check_screenshots.sh [path...] +# +# If no paths given, checks all staged PNG/JPG files in git. +# Exit code 0 = all pass, 1 = failures found. + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +PASS=0 +FAIL=0 +WARN=0 + +MIN_WIDTH=1000 +MIN_FILE_SIZE=10240 # 10KB in bytes + +check_file() { + local file="$1" + local name + name=$(basename "$file") + local issues=() + local warnings=() + + # Check file exists + if [[ ! -f "$file" ]]; then + echo -e "${RED}FAIL${NC} $file: file not found" + ((FAIL += 1)) + return + fi + + # Check file size + local size + size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null || echo 0) + if (( size < MIN_FILE_SIZE )); then + issues+=("file size ${size} bytes < ${MIN_FILE_SIZE} (likely blank/error page)") + fi + + # Check dimensions (requires sips on macOS or identify from ImageMagick) + local width=0 + local height=0 + if command -v sips &>/dev/null; then + width=$(sips -g pixelWidth "$file" 2>/dev/null | awk '/pixelWidth/{print $2}') + height=$(sips -g pixelHeight "$file" 2>/dev/null | awk '/pixelHeight/{print $2}') + elif command -v identify &>/dev/null; then + local dims + dims=$(identify -format "%w %h" "$file" 2>/dev/null || echo "0 0") + width=$(echo "$dims" | awk '{print $1}') + height=$(echo "$dims" | awk '{print $2}') + else + warnings+=("cannot check dimensions (install sips or ImageMagick)") + fi + + if (( width > 0 && width < MIN_WIDTH )); then + issues+=("width ${width}px < ${MIN_WIDTH}px (likely mobile viewport)") + fi + + if (( width > 0 && height > 0 )); then + # Check aspect ratio - very tall narrow images are suspicious + local ratio + ratio=$(( height * 100 / width )) + if (( ratio > 300 )); then + warnings+=("aspect ratio ${width}x${height} is very tall/narrow — verify layout") + fi + fi + + # Check for common error page indicators in filename + if [[ "$name" == *"error"* ]] || [[ "$name" == *"404"* ]]; then + warnings+=("filename suggests error page") + fi + + # Report + if (( ${#issues[@]} > 0 )); then + echo -e "${RED}FAIL${NC} $file" + for issue in "${issues[@]}"; do + echo " - $issue" + done + ((FAIL += 1)) + elif (( ${#warnings[@]} > 0 )); then + echo -e "${YELLOW}WARN${NC} $file (${width}x${height}, ${size} bytes)" + for warning in "${warnings[@]}"; do + echo " - $warning" + done + ((WARN += 1)) + else + echo -e "${GREEN}PASS${NC} $file (${width}x${height}, ${size} bytes)" + ((PASS += 1)) + fi +} + +# Collect files to check +files=() +if (( $# > 0 )); then + files=("$@") +else + # Check staged image files + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -iE '\.(png|jpg|jpeg|webp)$' || true) + + if (( ${#files[@]} == 0 )); then + # Fallback: check all images in .agents/evidence/pr/ + while IFS= read -r f; do + [[ -n "$f" ]] && files+=("$f") + done < <(find .agents/evidence/pr -type f \( -name '*.png' -o -name '*.jpg' \) 2>/dev/null || true) + fi +fi + +if (( ${#files[@]} == 0 )); then + echo "No screenshot files to check." + exit 0 +fi + +echo "Checking ${#files[@]} screenshot(s)..." +echo "" + +for f in "${files[@]}"; do + check_file "$f" +done + +echo "" +echo "Results: ${PASS} passed, ${FAIL} failed, ${WARN} warnings" + +if (( FAIL > 0 )); then + echo -e "${RED}Screenshot quality gate FAILED${NC}" + exit 1 +fi + +if (( WARN > 0 )); then + echo -e "${YELLOW}Passed with warnings — review manually${NC}" +fi + +exit 0 diff --git a/scripts/gen_architecture.py b/scripts/gen_architecture.py new file mode 100644 index 0000000..65af37a --- /dev/null +++ b/scripts/gen_architecture.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Generate architecture diagram for claude-tap.""" + +from diagrams import Cluster, Diagram, Edge +from diagrams.generic.storage import Storage +from diagrams.onprem.client import Client, User +from diagrams.onprem.compute import Server +from diagrams.programming.language import Python +from diagrams.saas.cdn import Cloudflare + +# Graph attributes for better styling +graph_attr = { + "fontsize": "16", + "bgcolor": "white", + "pad": "0.3", + "splines": "ortho", + "nodesep": "0.6", + "ranksep": "0.8", +} + +node_attr = { + "fontsize": "11", + "height": "1.2", +} + +edge_attr = { + "fontsize": "9", +} + +with Diagram( + "claude-tap Architecture", + filename="docs/architecture", + outformat="png", + show=False, + direction="LR", # Left to Right for better horizontal layout + graph_attr=graph_attr, + node_attr=node_attr, + edge_attr=edge_attr, +): + with Cluster("User"): + user = User("Developer") + + with Cluster("CLI Layer"): + claude_tap = Python("claude-tap") + claude_code = Client("Claude Code") + + with Cluster("Proxy Layer"): + proxy = Server("Reverse Proxy\n(aiohttp)") + + api = Cloudflare("Anthropic API") + + with Cluster("Output"): + jsonl = Storage("trace.jsonl") + html = Storage("trace.html") + browser = Client("Live Viewer") + + # Main flow + user >> Edge(label="run") >> claude_tap + claude_tap >> Edge(label="spawn") >> claude_code + claude_code >> Edge(label="requests") >> proxy + proxy >> Edge(label="forward") >> api + api >> Edge(label="SSE stream", style="dashed") >> proxy + proxy >> Edge(label="record") >> jsonl + jsonl >> Edge(label="generate") >> html + proxy >> Edge(label="broadcast", style="dotted") >> browser diff --git a/scripts/record_viewer.py b/scripts/record_viewer.py new file mode 100644 index 0000000..49ca57e --- /dev/null +++ b/scripts/record_viewer.py @@ -0,0 +1,171 @@ +"""Record a walkthrough of the claude-tap trace viewer using Playwright. + +Produces: + - Video at 1440x900 in .agents/recordings/video/ + - 10+ high-quality screenshots in .agents/recordings/ +""" + +from __future__ import annotations + +import pathlib +import time + +from playwright.sync_api import sync_playwright + +TRACE_HTML = "/tmp/codex-tap-demo/.traces/trace_20260228_004827.html" +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +SCREENSHOT_DIR = REPO_ROOT / ".agents" / "recordings" +VIDEO_DIR = SCREENSHOT_DIR / "video" +WIDTH, HEIGHT = 1440, 900 + + +def _wait(ms: int = 600) -> None: + time.sleep(ms / 1000) + + +def _screenshot(page, name: str) -> None: + path = SCREENSHOT_DIR / f"{name}.png" + page.screenshot(path=str(path), full_page=False) + print(f" screenshot: {path.name}") + + +def main() -> None: + VIDEO_DIR.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + context = browser.new_context( + viewport={"width": WIDTH, "height": HEIGHT}, + record_video_dir=str(VIDEO_DIR), + record_video_size={"width": WIDTH, "height": HEIGHT}, + ) + page = context.new_page() + page.goto(f"file://{TRACE_HTML}") + page.wait_for_load_state("networkidle") + _wait(800) + + # --- Step 1: Click Turn 1 -> expand Tools & SSE Events --- + print("Step 1: Turn 1 + expand Tools & SSE Events") + page.mouse.click(150, 125) + _wait(500) + _screenshot(page, "viewer-01-turn1-overview") + + # Expand "Tools" section + page.evaluate("""() => { + const headers = document.querySelectorAll('.section-header'); + for (const h of headers) { + if (h.textContent.includes('Tools')) { h.click(); break; } + } + }""") + _wait(400) + + # Expand "SSE Events" section + page.evaluate("""() => { + const headers = document.querySelectorAll('.section-header'); + for (const h of headers) { + if (h.textContent.includes('SSE Events')) { h.click(); break; } + } + }""") + _wait(400) + _screenshot(page, "viewer-02-tools-sse-expanded") + + # --- Step 2: Click "Request JSON" -> expand "Full JSON" -> scroll --- + print("Step 2: Request JSON + Full JSON + scroll") + page.evaluate("""() => { + const btns = document.querySelectorAll('.act-btn'); + for (const b of btns) { + if (b.textContent.includes('Request')) { b.click(); break; } + } + }""") + _wait(400) + + # Expand "Full JSON" + page.evaluate("""() => { + const headers = document.querySelectorAll('.section-header'); + for (const h of headers) { + if (h.textContent.includes('Full JSON')) { h.click(); break; } + } + }""") + _wait(400) + + # Scroll detail panel down + page.evaluate("document.getElementById('detail').scrollTop = 500") + _wait(400) + _screenshot(page, "viewer-03-request-json-scrolled") + + # --- Step 3: Click Turn 5 --- + print("Step 3: Turn 5") + page.mouse.click(150, 401) + _wait(500) + _screenshot(page, "viewer-04-turn5") + + # --- Step 4: Click "Diff with Prev" --- + print("Step 4: Diff with Prev") + page.evaluate("""() => { + const btns = document.querySelectorAll('.act-btn'); + for (const b of btns) { + if (b.textContent.includes('Diff')) { b.click(); break; } + } + }""") + _wait(600) + _screenshot(page, "viewer-05-diff") + + # Close diff overlay + page.evaluate("document.querySelector('.diff-close')?.click()") + _wait(300) + + # --- Step 5: Click "cURL" --- + print("Step 5: cURL") + page.evaluate("""() => { + const btns = document.querySelectorAll('.act-btn'); + for (const b of btns) { + if (b.textContent.includes('cURL')) { b.click(); break; } + } + }""") + _wait(400) + _screenshot(page, "viewer-06-curl") + + # --- Step 6: Click Turn 10 --- + print("Step 6: Turn 10") + page.mouse.click(150, 746) + _wait(500) + _screenshot(page, "viewer-07-turn10") + + # --- Step 7: Toggle dark mode --- + print("Step 7: Dark mode toggle") + # Dismiss any overlay that might be blocking + page.evaluate("document.querySelector('.diff-overlay')?.remove()") + _wait(200) + page.click("#theme-toggle") + _wait(500) + _screenshot(page, "viewer-08-dark-mode") + + # --- Step 8: Scroll sidebar down --- + print("Step 8: Scroll sidebar to show turns 11-18") + page.evaluate("document.getElementById('sidebar').scrollTop = 700") + _wait(400) + _screenshot(page, "viewer-09-sidebar-scrolled") + + # --- Step 9: Click last visible sidebar item --- + print("Step 9: Click last visible turn") + page.evaluate("""() => { + const items = document.querySelectorAll('.sidebar-item'); + if (items.length > 0) items[items.length - 1].click(); + }""") + _wait(500) + _screenshot(page, "viewer-10-last-turn") + + # --- Step 10: Final wide screenshot --- + print("Step 10: Final wide screenshot") + _screenshot(page, "viewer-11-final-wide") + + # Close to finalize video + context.close() + browser.close() + + print(f"\nDone. Video saved to {VIDEO_DIR}/") + print(f"Screenshots saved to {SCREENSHOT_DIR}/") + + +if __name__ == "__main__": + main() diff --git a/scripts/run_real_e2e.sh b/scripts/run_real_e2e.sh new file mode 100755 index 0000000..93e09b9 --- /dev/null +++ b/scripts/run_real_e2e.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run a real Claude E2E flow through claude-tap forward proxy mode without tmux. +# Usage: +# scripts/run_real_e2e.sh +# Env: +# PROMPT_ONE / PROMPT_TWO override default prompts. +# CLAUDE_ARGS extra claude CLI args (e.g. "--model sonnet"). + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +if ! command -v uv >/dev/null 2>&1; then + echo "error: uv not found." >&2 + exit 1 +fi + +if ! command -v claude >/dev/null 2>&1; then + echo "error: claude CLI not found." >&2 + exit 1 +fi + +TS="$(date +%s)" +TRACE_DIR="/tmp/ctap-real-e2e-$TS" +RUN_LOG="/tmp/claude-tap-recordings/real-e2e-$TS.log" +PROMPT_ONE="${PROMPT_ONE:-Reply with exactly: REAL_E2E_TURN_ONE_OK}" +PROMPT_TWO="${PROMPT_TWO:-What was the exact text I asked you to reply with in the previous turn?}" +CLAUDE_ARGS="${CLAUDE_ARGS:-}" + +mkdir -p "$TRACE_DIR" /tmp/claude-tap-recordings + +run_turn() { + local prompt="$1" + shift + local extra_args=("$@") + + local claude_args=() + if [[ -n "$CLAUDE_ARGS" ]]; then + # shellcheck disable=SC2206 + claude_args=($CLAUDE_ARGS) + fi + local cmd=( + uv run python -m claude_tap + --tap-output-dir "$TRACE_DIR" + --tap-proxy-mode forward + -- + ) + if [[ ${#claude_args[@]} -gt 0 ]]; then + cmd+=("${claude_args[@]}") + fi + cmd+=(-p "$prompt") + if [[ ${#extra_args[@]} -gt 0 ]]; then + cmd+=("${extra_args[@]}") + fi + + echo "==> ${cmd[*]}" | tee -a "$RUN_LOG" + "${cmd[@]}" 2>&1 | tee -a "$RUN_LOG" +} + +run_turn "$PROMPT_ONE" +run_turn "$PROMPT_TWO" -c + +uv run python - "$TRACE_DIR" "$PROMPT_ONE" "$PROMPT_TWO" <<'PY' +import json +import sys +from pathlib import Path + +from claude_tap import _generate_html_viewer + +trace_dir = Path(sys.argv[1]) +p1 = sys.argv[2] +p2 = sys.argv[3] + +jsonl_files = sorted(trace_dir.glob("trace_*.jsonl")) +if not jsonl_files: + raise SystemExit(f"assert failed: no trace_*.jsonl found in {trace_dir}") + +all_text_parts: list[str] = [] +message_count = 0 + +for jf in jsonl_files: + text = jf.read_text(encoding="utf-8") + if text: + all_text_parts.append(text) + for line in text.splitlines(): + if not line.strip(): + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + path = rec.get("request", {}).get("path", "") + if isinstance(path, str) and path.startswith("/v1/messages"): + message_count += 1 + + html = jf.with_suffix(".html") + if not html.exists(): + _generate_html_viewer(jf, html) + +all_text = "\n".join(all_text_parts) +if p1 not in all_text: + raise SystemExit("assert failed: prompt one missing in trace jsonl files") +if p2 not in all_text: + raise SystemExit("assert failed: prompt two missing in trace jsonl files") +if message_count < 2: + raise SystemExit(f"assert failed: expected >=2 /v1/messages requests, got {message_count}") + +print(f"assert ok: traces={len(jsonl_files)} message_requests={message_count}") +for jf in jsonl_files: + print(f"JSONL={jf}") + print(f"HTML={jf.with_suffix('.html')}") +PY + +echo "TRACE_DIR=$TRACE_DIR" +echo "RUN_LOG=$RUN_LOG" diff --git a/scripts/run_real_e2e_tmux.sh b/scripts/run_real_e2e_tmux.sh new file mode 100755 index 0000000..8d96311 --- /dev/null +++ b/scripts/run_real_e2e_tmux.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run a real non--print Claude session under tmux, send two prompts, and collect artifacts. +# Usage: +# scripts/run_real_e2e_tmux.sh +# Env: +# PROMPT_ONE / PROMPT_TWO override default prompts +# PERMISSION_MODE override Claude permission mode (default: bypassPermissions) +# SUBMIT_KEY override tmux submit key (default: Enter) + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +if ! command -v tmux >/dev/null 2>&1; then + echo "error: tmux not found. Install it first (e.g. brew install tmux)." >&2 + exit 1 +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "error: uv not found." >&2 + exit 1 +fi + +TS="$(date +%s)" +TRACE_DIR="/tmp/ctap-tmux-simple-$TS" +SESSION="ctap_simple_$TS" +PANE_LOG="/tmp/claude-tap-recordings/tmux-simple-$TS.log" +PERMISSION_MODE="${PERMISSION_MODE:-bypassPermissions}" +PROMPT_ONE="${PROMPT_ONE:-Use the shell tool to run command ls in the current directory, then reply with any 5 filenames only.}" +PROMPT_TWO="${PROMPT_TWO:-Thank you.}" +SUBMIT_KEY="${SUBMIT_KEY:-Enter}" + +mkdir -p "$TRACE_DIR" /tmp/claude-tap-recordings + +wait_for_prompt() { + local timeout="${1:-60}" + local i=0 + while (( i < timeout )); do + if tmux capture-pane -p -S -300 -t "$SESSION" 2>/dev/null | grep -qF "❯"; then + return 0 + fi + sleep 1 + ((i += 1)) + done + return 1 +} + +wait_for_prompt_in_trace() { + local needle="$1" + local timeout="${2:-120}" + local i=0 + while (( i < timeout )); do + local jf + jf="$(ls -1 "$TRACE_DIR"/trace_*.jsonl 2>/dev/null | tail -n1 || true)" + if [[ -n "$jf" ]] && grep -qF "$needle" "$jf"; then + return 0 + fi + sleep 1 + ((i += 1)) + done + return 1 +} + +send_prompt() { + local text="$1" + local key="$2" + tmux send-keys -t "$SESSION" -l "$text" + # Give the TUI a moment to consume the literal input before submit. + sleep 0.2 + tmux send-keys -t "$SESSION" "$key" +} + +submit_prompt_with_detection() { + local text="$1" + local timeout="${2:-120}" + local attempt + local max_attempts=3 + + for ((attempt = 1; attempt <= max_attempts; attempt++)); do + send_prompt "$text" "$SUBMIT_KEY" + if wait_for_prompt_in_trace "$text" "$timeout"; then + echo "submit_key=$SUBMIT_KEY attempt=$attempt" + return 0 + fi + # If submit did not fire, clear line and retry the same key. + tmux send-keys -t "$SESSION" Escape C-u + sleep 1 + done + return 1 +} + +cleanup() { + tmux capture-pane -S -6000 -p -t "$SESSION" > "$PANE_LOG" 2>/dev/null || true + tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +tmux new-session -d -s "$SESSION" -x 160 -y 46 +tmux send-keys -t "$SESSION" -l "cd $REPO_ROOT && uv run python -m claude_tap --tap-output-dir $TRACE_DIR --tap-proxy-mode forward -- --permission-mode $PERMISSION_MODE" +tmux send-keys -t "$SESSION" Enter + +wait_for_prompt 90 || true + +submit_prompt_with_detection "$PROMPT_ONE" 30 || { + echo "error: prompt one not observed in trace jsonl" >&2 + exit 2 +} +wait_for_prompt 120 || true + +submit_prompt_with_detection "$PROMPT_TWO" 30 || { + echo "error: prompt two not observed in trace jsonl" >&2 + exit 3 +} +wait_for_prompt 90 || true + +tmux send-keys -t "$SESSION" -l "/exit" +tmux send-keys -t "$SESSION" Enter +sleep 2 + +JSONL="$(ls -1 "$TRACE_DIR"/trace_*.jsonl | tail -n1)" +HTML="${JSONL%.jsonl}.html" +if [[ ! -f "$HTML" ]]; then + uv run python - "$JSONL" "$HTML" <<'PY' +import sys +from pathlib import Path +from claude_tap import _generate_html_viewer + +_generate_html_viewer(Path(sys.argv[1]), Path(sys.argv[2])) +PY +fi + +uv run python - "$JSONL" "$PROMPT_ONE" "$PROMPT_TWO" <<'PY' +import json +import sys +from pathlib import Path + +jf = Path(sys.argv[1]) +p1 = sys.argv[2] +p2 = sys.argv[3] + +text = jf.read_text(encoding="utf-8") +if p1 not in text: + raise SystemExit("assert failed: prompt one missing in jsonl") +if p2 not in text: + raise SystemExit("assert failed: prompt two missing in jsonl") + +records = [json.loads(x) for x in text.splitlines() if x.strip()] +msg = [r for r in records if r.get("request", {}).get("path", "").startswith("/v1/messages")] +if len(msg) < 2: + raise SystemExit(f"assert failed: expected >=2 /v1/messages requests, got {len(msg)}") +print(f"assert ok: message_requests={len(msg)}") +PY + +echo "TRACE_DIR=$TRACE_DIR" +echo "JSONL=$JSONL" +echo "HTML=$HTML" +echo "PANE_LOG=$PANE_LOG" diff --git a/scripts/translate_i18n.py b/scripts/translate_i18n.py new file mode 100755 index 0000000..1a2df4a --- /dev/null +++ b/scripts/translate_i18n.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Fill missing i18n translations using OpenRouter.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +OPENROUTER_ENDPOINT = "https://openrouter.ai/api/v1/chat/completions" +DEFAULT_MODEL = "google/gemini-2.5-flash" +LANG_ORDER = ["ja", "ko", "fr", "ar", "de", "ru"] + +TARGET_CONFIG = { + "viewer": {"file": "claude_tap/viewer_i18n.json", "object_name": "I18N"}, + # Future scope: if CLI gains i18n object, this target can be used directly. + "cli": {"file": "claude_tap/cli.py", "object_name": "I18N"}, +} + + +@dataclass +class ObjectBlock: + source: str + start: int + end: int + prefix: str + body: str + suffix: str + + +@dataclass +class LangBlock: + lang: str + span_start: int + span_end: int + body_start: int + body_end: int + body: str + + +def extract_object_block(source: str, object_name: str) -> ObjectBlock: + pattern = re.compile( + rf"(?P(?:const|let|var)?\s*{re.escape(object_name)}\s*=\s*\{{)" + rf"(?P[\s\S]*?)" + rf"(?P^\s*\}};?\s*$)", + re.MULTILINE, + ) + match = pattern.search(source) + if not match: + raise ValueError(f"Could not locate object '{object_name}' in source file.") + return ObjectBlock( + source=source, + start=match.start(), + end=match.end(), + prefix=match.group("prefix"), + body=match.group("body"), + suffix=match.group("suffix"), + ) + + +def parse_lang_blocks(object_body: str) -> dict[str, LangBlock]: + pattern = re.compile( + r"^\s*(?P\"[^\"]+\"|[A-Za-z0-9_-]+)\s*:\s*\{" + r"(?P[\s\S]*?)" + r"^\s*\},\s*$", + re.MULTILINE, + ) + blocks: dict[str, LangBlock] = {} + for match in pattern.finditer(object_body): + raw_name = match.group("name") + lang = raw_name[1:-1] if raw_name.startswith('"') and raw_name.endswith('"') else raw_name + blocks[lang] = LangBlock( + lang=lang, + span_start=match.start(), + span_end=match.end(), + body_start=match.start("body"), + body_end=match.end("body"), + body=match.group("body"), + ) + return blocks + + +def parse_lang_entries(lang_body: str) -> dict[str, str]: + entries: dict[str, str] = {} + entry_pattern = re.compile(r"(?P[A-Za-z0-9_]+)\s*:\s*\"(?P(?:\\.|[^\"\\])*)\"") + for match in entry_pattern.finditer(lang_body): + key = match.group("key") + raw_val = match.group("value") + entries[key] = json.loads(f'"{raw_val}"') + return entries + + +def collect_i18n_data( + source: str, object_name: str +) -> tuple[ObjectBlock, dict[str, LangBlock], dict[str, dict[str, str]]]: + object_block = extract_object_block(source, object_name) + lang_blocks = parse_lang_blocks(object_block.body) + lang_entries = {lang: parse_lang_entries(block.body) for lang, block in lang_blocks.items()} + return object_block, lang_blocks, lang_entries + + +def validate_i18n_json(data: object) -> dict[str, dict[str, str]]: + if not isinstance(data, dict): + raise ValueError("I18N JSON must contain an object.") + + entries: dict[str, dict[str, str]] = {} + for lang, values in data.items(): + if not isinstance(lang, str) or not isinstance(values, dict): + raise ValueError("I18N JSON must map language codes to string maps.") + lang_entries: dict[str, str] = {} + for key, value in values.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError("I18N JSON language maps must contain string keys and values.") + lang_entries[key] = value + entries[lang] = lang_entries + return entries + + +def load_i18n_json(path: Path) -> dict[str, dict[str, str]]: + return validate_i18n_json(json.loads(path.read_text(encoding="utf-8"))) + + +def find_missing_keys(lang_entries: dict[str, dict[str, str]], target_languages: list[str]) -> dict[str, list[str]]: + if "en" not in lang_entries or "zh-CN" not in lang_entries: + raise ValueError("Source i18n object must include both 'en' and 'zh-CN'.") + + en_keys = list(lang_entries["en"]) + zh_keys = set(lang_entries["zh-CN"]) + source_keys = [key for key in en_keys if key in zh_keys] + missing: dict[str, list[str]] = {} + for lang in target_languages: + if lang not in lang_entries: + continue + lang_keys = set(lang_entries[lang]) + keys = [key for key in source_keys if key not in lang_keys] + if keys: + missing[lang] = keys + return missing + + +def parse_json_response(text: str) -> dict[str, str]: + cleaned = text.strip() + if cleaned.startswith("```"): + cleaned = re.sub(r"^```(?:json)?\n", "", cleaned) + cleaned = re.sub(r"\n```$", "", cleaned) + data = json.loads(cleaned) + if not isinstance(data, dict): + raise ValueError("Model response must be a JSON object.") + output: dict[str, str] = {} + for key, value in data.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise ValueError("Model response JSON must map string keys to string values.") + output[key] = value + return output + + +def request_openrouter_translation( + api_key: str, + model: str, + lang: str, + keys: list[str], + en_map: dict[str, str], + zh_map: dict[str, str], + existing_lang_map: dict[str, str], +) -> dict[str, str]: + request_items = [ + { + "key": key, + "en": en_map[key], + "zh-CN": zh_map[key], + } + for key in keys + ] + + fullwidth_examples: dict[str, dict[str, str]] = {} + existing_examples = { + key: value for key, value in existing_lang_map.items() if any(symbol in value for symbol in (":", "!", "?")) + } + zh_examples = {key: value for key, value in zh_map.items() if any(symbol in value for symbol in (":", "!", "?"))} + if existing_examples: + fullwidth_examples["target_existing"] = existing_examples + if zh_examples: + fullwidth_examples["zh-CN_reference"] = zh_examples + + prompt = { + "task": "Translate missing UI i18n strings.", + "context": "This is a developer tool (trace viewer) for inspecting LLM API calls.", + "target_language": lang, + "requirements": [ + "Return strict JSON object: key -> translated string.", + "Do not include markdown or explanations.", + "Preserve placeholders and symbols exactly (e.g., #, %s, {name}, ellipsis).", + "Keep short UI label style and terminology consistent.", + ], + "existing_translations_for_consistency": existing_lang_map, + "items_to_translate": request_items, + } + if lang in {"ja", "ko", "zh-CN"}: + prompt["requirements"].append( + "For ja/ko/zh-CN, preserve fullwidth punctuation style (e.g., :!?) to match existing translations." + ) + prompt["requirements"].append( + "If zh-CN reference uses fullwidth punctuation for a key, mirror that punctuation width in translation." + ) + if fullwidth_examples: + prompt["fullwidth_punctuation_examples"] = fullwidth_examples + + payload = { + "model": model, + "temperature": 0, + "messages": [ + { + "role": "system", + "content": "You are a precise software UI localization assistant. Output JSON only.", + }, + { + "role": "user", + "content": json.dumps(prompt, ensure_ascii=False), + }, + ], + } + + request = Request( + OPENROUTER_ENDPOINT, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/liaohch3/claude-tap", + "X-Title": "claude-tap i18n helper", + }, + method="POST", + ) + + try: + with urlopen(request, timeout=90) as response: + response_data = json.loads(response.read().decode("utf-8")) + except HTTPError as exc: + detail = exc.read().decode("utf-8", errors="ignore") + raise RuntimeError(f"OpenRouter request failed ({exc.code}): {detail}") from exc + except URLError as exc: + raise RuntimeError(f"OpenRouter request failed: {exc.reason}") from exc + + try: + content = response_data["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError(f"Unexpected OpenRouter response shape: {response_data}") from exc + + translations = parse_json_response(content) + missing = [key for key in keys if key not in translations] + if missing: + raise RuntimeError(f"Model response missing keys for {lang}: {', '.join(missing)}") + + ordered = {key: translations[key] for key in keys} + return normalize_punctuation_style(lang=lang, translations=ordered, zh_map=zh_map) + + +def normalize_punctuation_style( + lang: str, + translations: dict[str, str], + zh_map: dict[str, str], +) -> dict[str, str]: + if lang not in {"ja", "ko", "zh-CN"}: + return translations + + replacements = {":": ":", "!": "!", "?": "?"} + normalized: dict[str, str] = {} + for key, value in translations.items(): + target = value + zh_value = zh_map.get(key, "") + for ascii_punc, fullwidth_punc in replacements.items(): + if fullwidth_punc in zh_value: + target = target.replace(ascii_punc, fullwidth_punc) + normalized[key] = target + return normalized + + +def apply_translations_to_source( + source: str, + object_name: str, + updates: dict[str, dict[str, str]], +) -> str: + object_block, lang_blocks, _ = collect_i18n_data(source, object_name) + body = object_block.body + + replacements: list[tuple[int, int, str]] = [] + for lang, translations in updates.items(): + if not translations: + continue + lang_block = lang_blocks.get(lang) + if not lang_block: + continue + updated_lang_body = build_updated_lang_body(lang_block.body, translations) + if updated_lang_body != lang_block.body: + replacements.append((lang_block.body_start, lang_block.body_end, updated_lang_body)) + + if not replacements: + return source + + updated_body = body + for start, end, replacement in sorted(replacements, key=lambda item: item[0], reverse=True): + updated_body = updated_body[:start] + replacement + updated_body[end:] + + updated_block = f"{object_block.prefix}{updated_body}{object_block.suffix}" + return source[: object_block.start] + updated_block + source[object_block.end :] + + +def apply_translations_to_json_entries( + lang_entries: dict[str, dict[str, str]], + updates: dict[str, dict[str, str]], +) -> dict[str, dict[str, str]]: + updated = {lang: dict(entries) for lang, entries in lang_entries.items()} + for lang, translations in updates.items(): + if lang not in updated or not translations: + continue + updated[lang].update(translations) + return updated + + +def build_updated_lang_body(lang_body: str, translations: dict[str, str]) -> str: + lines = lang_body.splitlines(keepends=True) + key_line_indices = [i for i, line in enumerate(lines) if re.search(r"[A-Za-z0-9_]+\s*:\s*\"", line)] + if not key_line_indices: + return lang_body + + packed_style = any(len(re.findall(r"[A-Za-z0-9_]+\s*:\s*\"", lines[i])) > 1 for i in key_line_indices) + last_key_idx = key_line_indices[-1] + last_key_line = lines[last_key_idx] + indent_match = re.match(r"(\s*)", last_key_line) + indent = indent_match.group(1) if indent_match else " " + entries = [f"{key}: {json.dumps(value, ensure_ascii=False)}," for key, value in translations.items()] + line_ending = "\r\n" if last_key_line.endswith("\r\n") else "\n" if last_key_line.endswith("\n") else "" + + if packed_style: + inserted_line = f"{indent}{' '.join(entries)}{line_ending}" + insert_at = last_key_idx + 1 + return "".join(lines[:insert_at] + [inserted_line] + lines[insert_at:]) + + inserted_lines = "".join(f"{indent}{entry}{line_ending}" for entry in entries) + insert_at = last_key_idx + 1 + return "".join(lines[:insert_at] + [inserted_lines] + lines[insert_at:]) + + +def resolve_target(args: argparse.Namespace) -> tuple[Path, str]: + if args.file: + target_path = Path(args.file) + else: + target_path = Path(TARGET_CONFIG[args.target]["file"]) + + object_name = args.object_name or TARGET_CONFIG[args.target]["object_name"] + return target_path, object_name + + +def make_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Translate missing i18n keys with OpenRouter") + parser.add_argument("--target", choices=sorted(TARGET_CONFIG), default="viewer", help="Translation target preset") + parser.add_argument("--file", help="Override target file path") + parser.add_argument("--object-name", help="Override JS/Python i18n object name") + parser.add_argument("--model", default=DEFAULT_MODEL, help="OpenRouter model") + parser.add_argument("--dry-run", action="store_true", help="Show missing keys without writing file") + return parser + + +def print_summary(missing: dict[str, list[str]], translated: dict[str, list[str]], dry_run: bool) -> None: + if not missing: + print("No missing translations found.") + return + + if dry_run: + print("Dry run: missing keys that would be translated") + else: + print("Translation summary") + + for lang in LANG_ORDER: + keys = missing.get(lang, []) + if not keys: + continue + done = translated.get(lang, []) + status = "planned" if dry_run else "translated" + print(f"- {lang}: {status} {len(done or keys)} key(s)") + for key in done or keys: + print(f" - {key}") + + +def main(argv: list[str] | None = None) -> int: + parser = make_arg_parser() + args = parser.parse_args(argv) + + target_path, object_name = resolve_target(args) + if not target_path.exists(): + parser.error(f"Target file not found: {target_path}") + + is_json_target = target_path.suffix.lower() == ".json" + if is_json_target: + lang_entries = load_i18n_json(target_path) + source = "" + else: + source = target_path.read_text(encoding="utf-8") + _, _, lang_entries = collect_i18n_data(source, object_name) + missing = find_missing_keys(lang_entries, LANG_ORDER) + + if args.dry_run or not missing: + print_summary(missing, {}, dry_run=True) + return 0 + + api_key = os.getenv("OPENROUTER_API_KEY", "").strip() + if not api_key: + parser.error("OPENROUTER_API_KEY is required unless --dry-run is used") + + updates: dict[str, dict[str, str]] = {} + translated_summary: dict[str, list[str]] = {} + + for lang in LANG_ORDER: + keys = missing.get(lang, []) + if not keys: + continue + print(f"Translating {len(keys)} key(s) for {lang}...") + lang_update = request_openrouter_translation( + api_key=api_key, + model=args.model, + lang=lang, + keys=keys, + en_map=lang_entries["en"], + zh_map=lang_entries["zh-CN"], + existing_lang_map=lang_entries[lang], + ) + updates[lang] = lang_update + translated_summary[lang] = list(lang_update) + + if is_json_target: + updated_entries = apply_translations_to_json_entries(lang_entries, updates) + target_path.write_text(json.dumps(updated_entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + else: + updated_source = apply_translations_to_source(source, object_name, updates) + target_path.write_text(updated_source, encoding="utf-8") + + print_summary(missing, translated_summary, dry_run=False) + print(f"Updated file: {target_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py new file mode 100644 index 0000000..dd6e1e3 --- /dev/null +++ b/scripts/update_changelog.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Insert a release section in CHANGELOG.md when one is missing.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import re +import subprocess +from pathlib import Path + +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +UNRELEASED_RE = re.compile(r"^## \[Unreleased\]\s*$", re.MULTILINE) +RELEASE_HEADING_RE = re.compile(r"^## \[(?P\d+\.\d+\.\d+)\](?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$", re.MULTILINE) +SKIP_SUBJECT_PREFIXES = ( + "chore: bump version", + "chore: update changelog", +) + + +def _git(repo_root: Path, args: list[str]) -> str: + return subprocess.check_output(["git", *args], cwd=repo_root, text=True, stderr=subprocess.DEVNULL).strip() + + +def changelog_has_version(text: str, version: str) -> bool: + return any(match.group("version") == version for match in RELEASE_HEADING_RE.finditer(text)) + + +def previous_release_tag(repo_root: Path) -> str | None: + try: + tags = _git(repo_root, ["tag", "--list", "v[0-9]*", "--sort=-v:refname"]) + except subprocess.CalledProcessError: + return None + return next((tag for tag in tags.splitlines() if tag), None) + + +def release_subjects(repo_root: Path, previous_tag: str | None) -> list[str]: + rev_range = f"{previous_tag}..HEAD" if previous_tag else "HEAD" + try: + raw = _git(repo_root, ["log", "--reverse", "--pretty=format:%s", rev_range]) + except subprocess.CalledProcessError: + return [] + + subjects: list[str] = [] + for line in raw.splitlines(): + subject = line.strip() + if not subject: + continue + if subject.startswith(SKIP_SUBJECT_PREFIXES): + continue + if "[skip release]" in subject: + continue + subjects.append(subject) + return subjects + + +def render_release_section(version: str, release_date: dt.date, subjects: list[str]) -> str: + notes = subjects or ["Maintenance release."] + bullets = "\n".join(f"- {subject}" for subject in notes) + return f"## [{version}] - {release_date.isoformat()}\n\n### Changed\n{bullets}\n\n" + + +def insert_release_section(changelog: str, section: str) -> str: + match = UNRELEASED_RE.search(changelog) + if not match: + raise ValueError("CHANGELOG.md is missing '## [Unreleased]' section") + insert_at = match.end() + return changelog[:insert_at] + "\n\n" + section.rstrip() + changelog[insert_at:] + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path.cwd(), help="Repository root path") + parser.add_argument("--version", required=True, help="Release version without leading 'v', e.g. 0.1.40") + parser.add_argument("--date", default=dt.date.today().isoformat(), help="Release date in YYYY-MM-DD format") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + if not VERSION_RE.match(args.version): + raise SystemExit(f"Invalid version: {args.version}") + release_date = dt.date.fromisoformat(args.date) + repo_root = args.repo_root.resolve() + changelog_path = repo_root / "CHANGELOG.md" + changelog = changelog_path.read_text(encoding="utf-8") + + if changelog_has_version(changelog, args.version): + print(f"CHANGELOG.md already has [{args.version}]") + return 0 + + previous_tag = previous_release_tag(repo_root) + subjects = release_subjects(repo_root, previous_tag) + section = render_release_section(args.version, release_date, subjects) + changelog_path.write_text(insert_release_section(changelog, section), encoding="utf-8") + print(f"Inserted CHANGELOG.md section for [{args.version}]") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_screenshots.py b/scripts/verify_screenshots.py new file mode 100755 index 0000000..6b81965 --- /dev/null +++ b/scripts/verify_screenshots.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Verify viewer HTML renders correctly — catches broken/empty/raw-JSON pages. + +Usage: uv run python scripts/verify_screenshots.py .traces/trace_*.html + +Exit 0 = all OK, exit 1 = problems found. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +# Force UTF-8 stdout/stderr so emoji output works on Windows GBK consoles. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="backslashreplace") + + +def verify_viewer_html(html_path: str) -> list[str]: + issues: list[str] = [] + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 900}) + errors: list[str] = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.goto(Path(html_path).absolute().as_uri(), wait_until="domcontentloaded", timeout=15000) + page.wait_for_timeout(2000) + if errors: + issues.append(f"JS errors: {errors}") + body_text = page.inner_text("body")[:500] + if '"type":"tool_use"' in body_text or "JSONDecodeError" in body_text: + issues.append("Page shows raw JSON dump or Python errors") + empty_trace_state = page.query_selector(".empty-trace-state") + if empty_trace_state: + empty_text = empty_trace_state.inner_text() + if "No API calls captured" not in empty_text or "Captured API calls: 0" not in empty_text: + issues.append("Empty trace state is missing its explicit captured-call summary") + else: + if not page.query_selector(".sidebar"): + issues.append("No sidebar — viewer not rendered") + if len(page.query_selector_all(".sidebar-item")) == 0: + issues.append("No sidebar entries — viewer empty") + if not page.query_selector("#detail"): + issues.append("No detail panel") + browser.close() + return issues + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: verify_screenshots.py ...") + sys.exit(2) + all_ok = True + for f in sys.argv[1:]: + problems = verify_viewer_html(f) + if problems: + print(f"❌ {f}:") + for p in problems: + print(f" - {p}") + all_ok = False + else: + print(f"✅ {f}: OK") + sys.exit(0 if all_ok else 1) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3957357 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,92 @@ +"""Pytest configuration and shared fixtures.""" + +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + +from claude_tap.cli_clients import _extend_no_proxy +from claude_tap.trace_store import get_trace_store, reset_trace_store + + +def trace_db_path(trace_dir: str | Path) -> Path: + return Path(trace_dir) / "claude-tap-test.sqlite3" + + +def e2e_env(env: dict[str, str], trace_dir: str | Path) -> dict[str, str]: + updated = dict(env) + updated["CLOUDTAP_DB"] = str(trace_db_path(trace_dir)) + _extend_no_proxy(updated, ("localhost", "127.0.0.1", "::1")) + return updated + + +def read_trace_records(trace_dir: str | Path, *, session_index: int = -1) -> list[dict]: + db_path = trace_db_path(trace_dir) + reset_trace_store() + os.environ["CLOUDTAP_DB"] = str(db_path) + store = get_trace_store() + rows = store.list_session_rows() + if not rows: + return [] + session_id = rows[session_index]["id"] + return store.load_records(session_id) + + +def read_proxy_log(trace_dir: str | Path, *, session_index: int = -1) -> str: + db_path = trace_db_path(trace_dir) + reset_trace_store() + os.environ["CLOUDTAP_DB"] = str(db_path) + store = get_trace_store() + rows = store.list_session_rows() + if not rows: + return "" + session_id = rows[session_index]["id"] + return store.export_log(session_id) + + +@pytest.fixture(autouse=True) +def isolate_trace_store(): + """Reset the process-wide TraceStore singleton and CLOUDTAP_DB between tests.""" + saved_db = os.environ.get("CLOUDTAP_DB") + os.environ.pop("CLOUDTAP_DB", None) + reset_trace_store() + yield + reset_trace_store() + if saved_db is None: + os.environ.pop("CLOUDTAP_DB", None) + else: + os.environ["CLOUDTAP_DB"] = saved_db + + +@pytest.fixture +def trace_db(tmp_path, monkeypatch): + """Provide an isolated SQLite trace database for each test.""" + db_path = tmp_path / "test-traces.sqlite3" + monkeypatch.setenv("CLOUDTAP_DB", str(db_path)) + reset_trace_store() + yield db_path + reset_trace_store() + + +@pytest.fixture +def temp_trace_dir(): + """Create a temporary directory for trace output.""" + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_") + yield trace_dir + shutil.rmtree(trace_dir, ignore_errors=True) + + +@pytest.fixture +def temp_bin_dir(): + """Create a temporary directory for fake binaries.""" + bin_dir = tempfile.mkdtemp(prefix="claude_tap_bin_") + yield bin_dir + shutil.rmtree(bin_dir, ignore_errors=True) + + +@pytest.fixture +def project_dir(): + """Return the project root directory.""" + return Path(__file__).parent.parent diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..bafad20 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,91 @@ +# Real E2E Tests + +These tests exercise claude-tap with the **real Claude CLI** — no mocks, no fakes. +They start claude-tap from local source code, connect to an actual `claude` binary, +send real prompts, and verify the resulting trace output. + +## Prerequisites + +1. **Claude CLI installed and authenticated:** + ```bash + claude --version + claude -p "hello" # Should work without errors + ``` + +2. **claude-tap installed from local source:** + The test fixtures handle this automatically via `pip install -e .` + +3. **Python dependencies:** + ```bash + uv sync --extra dev + ``` + +4. **Proxy mode selection (recommended):** + - `auto` (default): uses `reverse` when `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` exists, otherwise `forward` + - `reverse` mode (stable): set `ANTHROPIC_API_KEY` + - `forward` mode (experimental): OAuth interception attempts via HTTPS proxy + - For OAuth accounts in automation, configure `ANTHROPIC_AUTH_TOKEN` + (for example via `claude setup-token` once, then save in CI secret). + +## Running + +```bash +# Run all real E2E tests +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 + +# Run a specific test +uv run pytest tests/e2e/test_real_proxy.py::TestRealProxy::test_single_turn --run-real-e2e --timeout=180 + +# Run with verbose output +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 -v -s + +# Recommended: reverse mode with API key +ANTHROPIC_API_KEY=sk-ant-... \ +CLAUDE_TAP_REAL_E2E_PROXY_MODE=reverse \ +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 -v + +# Experimental: forward mode +CLAUDE_TAP_REAL_E2E_PROXY_MODE=forward \ +uv run pytest tests/e2e/ --run-real-e2e --timeout=300 -v +``` + +## Skipping in CI + +These tests are **skipped by default** in CI and local runs. They only execute when +the `--run-real-e2e` flag is explicitly passed. This is controlled by the +`pytest_collection_modifyitems` hook in `conftest.py`. + +## Test Cases + +| Test | What It Verifies | +|------|-----------------| +| `test_single_turn` | Basic prompt-response captured in trace | +| `test_multi_turn` | Conversation memory works with `-c` flag | +| `test_tool_use` | Tool use generates multiple trace records | +| `test_html_viewer_generated` | JSONL trace files are properly created | +| `test_api_key_redaction` | No raw API keys leak into trace files | +| `test_streaming_sse_capture` | SSE events captured in streaming responses | + +## Troubleshooting + +- **Tests time out:** Increase `--timeout` or check network connectivity +- **Claude CLI not found:** Ensure `claude` is in PATH +- **Authentication errors:** Run `claude` interactively first to authenticate +- **Forward mode not intercepting:** Check `trace_*.log`; behavior depends on Claude Code's proxy handling +- **Port conflicts:** Tests use auto-assigned ports (port 0), conflicts are unlikely + +## Architecture + +``` +conftest.py + ├── pytest_addoption # Adds --run-real-e2e flag + ├── pytest_collection_modifyitems # Skips tests when flag not set + ├── installed_claude_tap # pip install -e from local source + ├── proxy_server # Starts claude-tap --tap-no-launch + └── claude_env # Selects reverse/forward proxy mode via env + +test_real_proxy.py + ├── _wait_for_trace_files # Polls trace dir for JSONL records + ├── _run_claude_prompt # Runs `claude -p ` + └── TestRealProxy # All test cases +``` diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..25ddaa6 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""Real E2E tests for claude-tap with actual Claude CLI integration.""" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..87e0af8 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,87 @@ +"""Pytest configuration for real E2E tests. + +These tests require a working `claude` CLI installation and are skipped by default. +Use --run-real-e2e to enable them. +""" + +import os +import shutil +import tempfile +from pathlib import Path + +import pytest + + +def pytest_addoption(parser): + """Add --run-real-e2e command-line flag.""" + parser.addoption( + "--run-real-e2e", + action="store_true", + default=False, + help="Run real E2E tests that require a working claude CLI.", + ) + + +def pytest_collection_modifyitems(config, items): + """Skip real E2E tests unless --run-real-e2e is passed.""" + if config.getoption("--run-real-e2e"): + return + skip_marker = pytest.mark.skip(reason="Need --run-real-e2e flag to run real E2E tests") + e2e_dir = str(Path(__file__).parent) + for item in items: + if str(item.fspath).startswith(e2e_dir): + item.add_marker(skip_marker) + + +@pytest.fixture(scope="session") +def installed_claude_tap(): + """Verify claude-tap is importable from the current environment. + + The project should already be installed via `uv run` or `pip install -e .`. + Returns the project root directory. + """ + project_dir = Path(__file__).parent.parent.parent + # Verify the package is importable (uv run handles installation) + try: + import claude_tap # noqa: F401 + except ImportError: + pytest.fail("claude_tap is not installed. Run with: uv run --extra dev pytest tests/e2e/ --run-real-e2e") + return project_dir + + +@pytest.fixture +def claude_env(installed_claude_tap, monkeypatch): + """Provide env, trace_dir, and selected proxy mode for real E2E runs. + + Mode selection: + - CLAUDE_TAP_REAL_E2E_PROXY_MODE=reverse|forward|auto (default auto) + - auto picks reverse when ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN is set, + otherwise forward. + """ + trace_dir = tempfile.mkdtemp(prefix="claude_tap_real_e2e_") + db_path = str(Path(trace_dir) / "test-traces.sqlite3") + + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + env["CLOUDTAP_DB"] = db_path + + # Also set in current process so the test can load records from the same database + monkeypatch.setenv("CLOUDTAP_DB", db_path) + from claude_tap import trace_store + + trace_store._store = None + # Remove nesting detection vars + env.pop("CLAUDECODE", None) + env.pop("CLAUDE_CODE_SSE_PORT", None) + selected_mode = env.get("CLAUDE_TAP_REAL_E2E_PROXY_MODE", "auto").lower() + if selected_mode not in {"auto", "reverse", "forward"}: + pytest.fail(f"CLAUDE_TAP_REAL_E2E_PROXY_MODE must be one of: auto, reverse, forward (got: {selected_mode})") + + if selected_mode == "auto": + has_static_auth = bool(env.get("ANTHROPIC_API_KEY") or env.get("ANTHROPIC_AUTH_TOKEN")) + selected_mode = "reverse" if has_static_auth else "forward" + + yield env, trace_dir, selected_mode + + # Keep trace dir on failure for debugging; clean on success + shutil.rmtree(trace_dir, ignore_errors=True) diff --git a/tests/e2e/test_real_proxy.py b/tests/e2e/test_real_proxy.py new file mode 100644 index 0000000..b2bb488 --- /dev/null +++ b/tests/e2e/test_real_proxy.py @@ -0,0 +1,261 @@ +"""Real E2E tests using actual Claude CLI. + +These tests run claude-tap as a subprocess and execute the real `claude` CLI. +Proxy mode is selected by fixture config: + - reverse mode uses ANTHROPIC_BASE_URL (recommended when ANTHROPIC_API_KEY is set) + - forward mode uses HTTPS_PROXY + CONNECT/TLS MITM (OAuth-compatible in principle) + +Prerequisites: + - `claude` CLI installed and authenticated + - For reverse mode: set ANTHROPIC_API_KEY + - Run with: uv run --extra dev pytest tests/e2e/ --run-real-e2e -v --timeout=300 +""" + +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _run_claude_tap( + env: dict, + trace_dir: str, + prompt: str, + proxy_mode: str = "forward", + extra_claude_args: list[str] | None = None, + timeout: float = 120, +) -> subprocess.CompletedProcess: + """Run claude-tap wrapping `claude -p ` with the selected mode.""" + cmd = [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-proxy-mode", + proxy_mode, + "--", # separator: everything after goes to claude + "-p", + prompt, + ] + if extra_claude_args: + cmd.extend(extra_claude_args) + + result = subprocess.run( + cmd, + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + return result + + +def _read_trace_records(trace_dir: str) -> list[dict]: + """Read all trace records from the SQLite database.""" + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + records = [] + for row in store.list_session_rows(): + records.extend(store.load_records(row["id"])) + return records + + +class TestRealProxy: + """Tests that run real Claude CLI through the claude-tap proxy.""" + + @pytest.mark.timeout(180) + def test_single_turn(self, claude_env): + """Single prompt-response: verify trace captures the exchange.""" + env, trace_dir, proxy_mode = claude_env + + result = _run_claude_tap(env, trace_dir, "Reply with exactly: HELLO_E2E_TEST", proxy_mode=proxy_mode) + + assert result.returncode == 0, ( + f"claude-tap failed (code {result.returncode}):\n" + f"stdout: {result.stdout[:1000]}\nstderr: {result.stderr[:1000]}" + ) + assert "HELLO_E2E_TEST" in result.stdout, f"Expected HELLO_E2E_TEST in output:\n{result.stdout[:500]}" + + records = _read_trace_records(trace_dir) + assert len(records) >= 1, f"Expected at least 1 trace record, got {len(records)}" + + # OAuth/SDK may issue several preflight calls before /v1/messages. + msg_records = [r for r in records if "/v1/messages" in r.get("request", {}).get("path", "")] + assert msg_records, "Expected at least one /v1/messages trace record" + + # Verify trace structure on a messages call + record = msg_records[0] + assert "request" in record + assert "response" in record + assert record["request"]["method"] == "POST" + + # Verify response content captured from any messages response + found_expected_text = False + for rec in msg_records: + resp_body = rec.get("response", {}).get("body", {}) + if not isinstance(resp_body, dict): + continue + content = resp_body.get("content", []) + texts = [c.get("text", "") for c in content if c.get("type") == "text"] + full_text = " ".join(texts) + if "HELLO_E2E_TEST" in full_text: + found_expected_text = True + break + assert found_expected_text, "Expected HELLO_E2E_TEST in at least one /v1/messages trace response" + + @pytest.mark.timeout(300) + def test_multi_turn(self, claude_env): + """Two calls with -c flag: verify conversation memory works.""" + env, trace_dir, proxy_mode = claude_env + + # Turn 1: ask to remember a code + r1 = _run_claude_tap( + env, trace_dir, "Remember this code: ZEBRA_42. Just confirm you remember it.", proxy_mode=proxy_mode + ) + assert r1.returncode == 0, f"Turn 1 failed:\nstdout: {r1.stdout[:500]}\nstderr: {r1.stderr[:500]}" + + # Turn 2: with -c (continue) ask to recall + r2 = _run_claude_tap( + env, + trace_dir, + "What was the code I asked you to remember?", + proxy_mode=proxy_mode, + extra_claude_args=["-c"], + ) + assert r2.returncode == 0, f"Turn 2 failed:\nstdout: {r2.stdout[:500]}\nstderr: {r2.stderr[:500]}" + assert "ZEBRA_42" in r2.stdout, f"Expected ZEBRA_42 in continued conversation:\n{r2.stdout[:500]}" + + # Verify multiple trace records across both runs + records = _read_trace_records(trace_dir) + assert len(records) >= 2, f"Expected at least 2 trace records for multi-turn, got {len(records)}" + + @pytest.mark.timeout(180) + def test_tool_use(self, claude_env): + """Prompt that triggers tool use: verify trace captures tool_use blocks.""" + env, trace_dir, proxy_mode = claude_env + + result = _run_claude_tap( + env, trace_dir, "What files are in the current directory? Use ls to check.", proxy_mode=proxy_mode + ) + assert result.returncode == 0, f"Tool use test failed:\n{result.stdout[:500]}\n{result.stderr[:500]}" + + records = _read_trace_records(trace_dir) + # Tool use generates multiple API calls (initial + tool result + response) + assert len(records) >= 2, f"Expected at least 2 trace records for tool use, got {len(records)}" + + # Verify at least one record has tool_use content block + has_tool_use = False + for record in records: + resp_body = record.get("response", {}).get("body", {}) + if isinstance(resp_body, dict): + for block in resp_body.get("content", []): + if block.get("type") == "tool_use": + has_tool_use = True + break + if has_tool_use: + break + assert has_tool_use, "Expected at least one trace record with tool_use content block" + + @pytest.mark.timeout(180) + def test_html_viewer_generated(self, claude_env): + """Verify .html viewer file is generated after a session.""" + env, trace_dir, proxy_mode = claude_env + + result = _run_claude_tap(env, trace_dir, "Reply with exactly: HTML_CHECK", proxy_mode=proxy_mode) + assert result.returncode == 0 + + # Query database to get session ID + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_rows = store.list_session_rows() + assert len(session_rows) >= 1 + session_id = session_rows[0]["id"] + + # Explicitly run the export command to generate the HTML viewer file + cmd = [ + sys.executable, + "-m", + "claude_tap", + "export", + "--session-id", + session_id, + "-o", + str(Path(trace_dir) / "viewer.html"), + ] + export_result = subprocess.run(cmd, env=env, capture_output=True, text=True) + assert export_result.returncode == 0 + + html_files = list(Path(trace_dir).glob("*.html")) + assert len(html_files) >= 1, ( + f"Expected HTML viewer file in {trace_dir}, found: {list(Path(trace_dir).iterdir())}" + ) + + # Verify HTML contains embedded compact trace data + html_content = html_files[0].read_text() + assert "EMBEDDED_TRACE_COMPACT_DATA" in html_content, "HTML viewer should contain EMBEDDED_TRACE_COMPACT_DATA" + + # Verify Dashboard: line in stdout + assert "Dashboard:" in result.stdout, "Expected 'Dashboard:' URL in stdout" + + @pytest.mark.timeout(180) + def test_api_key_redaction(self, claude_env): + """Verify no raw API keys appear in trace files.""" + env, trace_dir, proxy_mode = claude_env + + result = _run_claude_tap(env, trace_dir, "Reply with exactly: REDACTION_CHECK", proxy_mode=proxy_mode) + assert result.returncode == 0 + + records = _read_trace_records(trace_dir) + assert len(records) >= 1 + + # Check all records for raw API keys in headers + for record in records: + req_headers = record.get("request", {}).get("headers", {}) + for key_name in ("x-api-key", "authorization"): + val = req_headers.get(key_name, "") + if not val: + # Try case-insensitive + for k, v in req_headers.items(): + if k.lower() == key_name: + val = v + break + if val and len(val) > 10: + assert "..." in val, f"Header {key_name} not redacted: {val[:30]}..." + + @pytest.mark.timeout(180) + def test_streaming_sse_capture(self, claude_env): + """Verify SSE events are captured in streaming responses.""" + env, trace_dir, proxy_mode = claude_env + + result = _run_claude_tap(env, trace_dir, "Reply with exactly: SSE_CAPTURE_TEST", proxy_mode=proxy_mode) + assert result.returncode == 0 + + records = _read_trace_records(trace_dir) + assert len(records) >= 1 + + # Check if any record has sse_events (streaming response) + has_sse = False + for record in records: + sse_events = record.get("response", {}).get("sse_events") + if sse_events and len(sse_events) > 0: + has_sse = True + event_types = {e.get("event") for e in sse_events if isinstance(e, dict)} + assert "message_start" in event_types, f"Expected message_start in SSE events, got: {event_types}" + break + + assert has_sse, "Expected at least one trace record with sse_events (streaming response)" + + @pytest.mark.timeout(180) + def test_trace_summary(self, claude_env): + """Verify claude-tap prints trace summary with API call count.""" + env, trace_dir, proxy_mode = claude_env + + result = _run_claude_tap(env, trace_dir, "Reply with exactly: SUMMARY_CHECK", proxy_mode=proxy_mode) + assert result.returncode == 0 + + assert "Trace summary" in result.stdout, f"Expected 'Trace summary' in stdout:\n{result.stdout[:500]}" + assert "API calls:" in result.stdout, f"Expected 'API calls:' in stdout:\n{result.stdout[:500]}" diff --git a/tests/fixtures/codex_ws_multi_response_trace.jsonl b/tests/fixtures/codex_ws_multi_response_trace.jsonl new file mode 100644 index 0000000..4d7b19a --- /dev/null +++ b/tests/fixtures/codex_ws_multi_response_trace.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-05-02T02:34:55Z","request_id":"req_sanitized_ws","turn":14,"duration_ms":8648,"transport":"websocket","upstream_base_url":"https://chatgpt.com:443","request":{"method":"WEBSOCKET","path":"/backend-api/codex/responses","headers":{"authorization":"Bearer [REDACTED]","user-agent":"codex/0.128.0","host":"chatgpt.com"},"body":{"type":"response.create","model":"gpt-5.5","instructions":"You are Codex.","input":[{"type":"function_call_output","call_id":"call_sanitized","output":"{\"output\":\"/workspace/project\"}"}],"tools":[{"type":"function","name":"exec_command","description":"Runs a command."}],"previous_response_id":"resp_tool","prompt_cache_key":"cache_sanitized","stream":true},"ws_events":[{"type":"response.create","model":"gpt-5.5","instructions":"You are Codex.","input":[],"tools":[{"type":"function","name":"exec_command","description":"Runs a command."}],"generate":false,"prompt_cache_key":"cache_prefetch","stream":true},{"type":"response.create","model":"gpt-5.5","instructions":"You are Codex.","previous_response_id":"resp_prefetch","input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":"sanitized"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"# AGENTS.md instructions\nSanitized project rules."}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"你好,调用一个工具,然后结束"}]}],"tools":[{"type":"function","name":"exec_command","description":"Runs a command."}],"prompt_cache_key":"cache_tool_call","stream":true},{"type":"response.create","model":"gpt-5.5","instructions":"You are Codex.","previous_response_id":"resp_tool","input":[{"type":"function_call_output","call_id":"call_sanitized","output":"{\"output\":\"/workspace/project\"}"}],"tools":[{"type":"function","name":"exec_command","description":"Runs a command."}],"prompt_cache_key":"cache_final","stream":true}]},"response":{"status":101,"headers":{},"body":{"id":"resp_final","status":"completed","model":"gpt-5.5","previous_response_id":"resp_tool","output":[{"id":"msg_final","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"FINAL_OK"}]}],"usage":{"input_tokens":30,"output_tokens":5,"total_tokens":35}},"ws_events":[{"type":"response.created","response":{"id":"resp_prefetch","object":"response","created_at":1777689288,"status":"in_progress","model":"gpt-5.5","instructions":"You are Codex.","tools":[{"type":"function","name":"exec_command"}],"generate":false},"sequence_number":0},{"type":"response.completed","response":{"id":"resp_prefetch","object":"response","created_at":1777689288,"completed_at":1777689288,"status":"completed","model":"gpt-5.5","instructions":"You are Codex.","tools":[{"type":"function","name":"exec_command"}],"generate":false,"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}},"sequence_number":1},{"type":"codex.rate_limits","plan_type":"test"},{"type":"response.created","response":{"id":"resp_tool","object":"response","created_at":1777689290,"status":"in_progress","model":"gpt-5.5","instructions":"You are Codex.","tools":[{"type":"function","name":"exec_command"}],"previous_response_id":"resp_prefetch"},"sequence_number":2},{"type":"response.output_item.done","item":{"id":"fc_tool","type":"function_call","status":"completed","arguments":"{\"cmd\":\"pwd\"}","call_id":"call_sanitized","name":"exec_command"},"output_index":0,"sequence_number":3},{"type":"response.completed","response":{"id":"resp_tool","object":"response","created_at":1777689290,"completed_at":1777689291,"status":"completed","model":"gpt-5.5","instructions":"You are Codex.","tools":[{"type":"function","name":"exec_command"}],"output":[],"usage":{"input_tokens":20,"output_tokens":4,"total_tokens":24},"previous_response_id":"resp_prefetch"},"sequence_number":4},{"type":"response.created","response":{"id":"resp_final","object":"response","created_at":1777689292,"status":"in_progress","model":"gpt-5.5","instructions":"You are Codex.","tools":[{"type":"function","name":"exec_command"}],"previous_response_id":"resp_tool"},"sequence_number":5},{"type":"response.output_text.delta","item_id":"msg_final","output_index":0,"content_index":0,"delta":"FINAL","sequence_number":6},{"type":"response.output_text.delta","item_id":"msg_final","output_index":0,"content_index":0,"delta":"_OK","sequence_number":7},{"type":"response.output_item.done","item":{"id":"msg_final","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"FINAL_OK"}]},"output_index":0,"sequence_number":8},{"type":"response.completed","response":{"id":"resp_final","object":"response","created_at":1777689292,"completed_at":1777689293,"status":"completed","model":"gpt-5.5","instructions":"You are Codex.","tools":[{"type":"function","name":"exec_command"}],"previous_response_id":"resp_tool","output":[],"usage":{"input_tokens":30,"output_tokens":5,"total_tokens":35}},"sequence_number":9}]}} diff --git a/tests/fixtures/openai_responses_trace.jsonl b/tests/fixtures/openai_responses_trace.jsonl new file mode 100644 index 0000000..351888a --- /dev/null +++ b/tests/fixtures/openai_responses_trace.jsonl @@ -0,0 +1 @@ +{"id":"req_fixture_openai_01","turn":1,"transport":"websocket","upstream_base_url":"https://chatgpt.com/backend-api/codex","request":{"method":"WEBSOCKET","path":"/v1/responses","headers":{"Authorization":"Bearer [REDACTED]","User-Agent":"codex/0.113.0"},"body":{"type":"response.create","model":"gpt-5.4","instructions":"You are Codex, a coding agent.","input":[{"role":"user","content":[{"type":"input_text","text":"Hello"}]}],"tools":[{"type":"function","name":"exec_command","description":"Runs a command in a PTY."},{"type":"web_search","external_web_access":false,"search_content_types":["text"]}]}},"response":{"status":101,"headers":{"Server":"cloudflare"},"body":null,"completed":true,"done":true,"ws_events":[{"type":"response.created","response":{"id":"resp_fixture_01","object":"response","status":"in_progress","model":"gpt-5.4"}},{"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant"}},{"type":"response.content_part.added","output_index":0,"content_index":0,"part":{"type":"output_text","text":""}},{"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hello! How can I help?"},{"type":"response.output_text.done","output_index":0,"content_index":0,"text":"Hello! How can I help?"},{"type":"response.content_part.done","output_index":0,"content_index":0,"part":{"type":"output_text","text":"Hello! How can I help?"}},{"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello! How can I help?"}]}},{"type":"response.completed","response":{"id":"resp_fixture_01","object":"response","status":"completed","model":"gpt-5.4","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello! How can I help?"}]}],"usage":{"input_tokens":500,"output_tokens":10}}}]},"duration_ms":1200,"timestamp":"2026-03-10T20:40:00Z"} diff --git a/tests/test_auto_release_workflow.py b/tests/test_auto_release_workflow.py new file mode 100644 index 0000000..32172e0 --- /dev/null +++ b/tests/test_auto_release_workflow.py @@ -0,0 +1,40 @@ +"""Regression tests for the auto-release workflow shell contract.""" + +from __future__ import annotations + +from pathlib import Path + +WORKFLOW_PATH = Path(__file__).resolve().parent.parent / ".github" / "workflows" / "auto-release.yml" + + +def _workflow_text() -> str: + return WORKFLOW_PATH.read_text(encoding="utf-8") + + +def test_release_pr_body_satisfies_policy_sections() -> None: + workflow = _workflow_text() + + assert "## Summary" in workflow + assert "## Validation" in workflow + assert "## Evidence" in workflow + assert '--body-file "$pr_body_file"' in workflow + assert "Auto-generated release changelog" not in workflow + + +def test_existing_release_pr_body_is_fixed_before_ci_triggering_push() -> None: + workflow = _workflow_text() + + assert workflow.index('gh pr edit "$pr_number" --body-file "$pr_body_file"') < workflow.index( + 'git push --force-with-lease origin "$branch"' + ) + + +def test_release_pr_waits_for_checks_before_admin_merge() -> None: + workflow = _workflow_text() + + assert workflow.index( + 'pr_head="$(gh pr view "$pr_number" --json headRefOid --jq \'.headRefOid\')"' + ) < workflow.index('gh pr checks "$pr_number" --watch --fail-fast --interval 10') + assert workflow.index('gh pr checks "$pr_number" --watch --fail-fast --interval 10') < workflow.index( + 'gh pr merge "$pr_number" --admin --squash --delete-branch' + ) diff --git a/tests/test_bedrock_dashboard.py b/tests/test_bedrock_dashboard.py new file mode 100644 index 0000000..697380e --- /dev/null +++ b/tests/test_bedrock_dashboard.py @@ -0,0 +1,191 @@ +"""Tests for Bedrock-specific dashboard helpers (usage, model, events).""" + +from __future__ import annotations + +import base64 +import json + +from claude_tap.dashboard import ( + _bedrock_events, + _record_model, + _record_response_text, + _record_usage, +) + + +def _bedrock_frame(payload: dict) -> str: + encoded = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() + return "\x00\x00binary-prefix" + json.dumps({"bytes": encoded, "p": "abcdefghijk"}) + "�" + + +def _bedrock_body(*payloads: dict) -> str: + return "".join(_bedrock_frame(p) for p in payloads) + + +def _wrapped_bedrock_frame(payload: dict) -> str: + encoded = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() + return "\x00\x00binary-prefix" + json.dumps({"chunk": {"bytes": encoded}}) + "�" + + +class TestBedrockEvents: + def test_decodes_eventstream_body(self): + body = _bedrock_body( + {"type": "message_start", "message": {"model": "claude-opus-4-6"}}, + {"type": "message_delta", "usage": {"output_tokens": 5}}, + ) + record = {"response": {"body": body}} + events = _bedrock_events(record) + assert len(events) == 2 + assert events[0]["event"] == "message_start" + assert events[1]["event"] == "message_delta" + + def test_decodes_chunk_wrapped_eventstream_body(self): + body = _wrapped_bedrock_frame({"type": "message_start", "message": {"model": "claude-sonnet-4-6"}}) + record = {"response": {"body": body}} + events = _bedrock_events(record) + assert events == [ + {"event": "message_start", "data": {"type": "message_start", "message": {"model": "claude-sonnet-4-6"}}} + ] + + def test_decodes_converse_stream_eventstream_body(self): + body = _bedrock_body( + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}}, + {"metadata": {"usage": {"inputTokens": 6, "outputTokens": 2}}}, + ) + record = {"response": {"body": body}} + events = _bedrock_events(record) + assert [event["event"] for event in events] == ["message_start", "content_block_delta", "message_delta"] + assert events[1]["data"]["delta"] == {"type": "text_delta", "text": "OK"} + assert events[2]["data"]["usage"] == {"inputTokens": 6, "outputTokens": 2} + + def test_decodes_raw_converse_stream_payloads(self): + body = "".join( + json.dumps(payload, separators=(",", ":")) + for payload in ( + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}}, + {"metadata": {"usage": {"inputTokens": 6, "outputTokens": 2}}}, + ) + ) + record = {"response": {"body": body}} + events = _bedrock_events(record) + assert [event["event"] for event in events] == ["message_start", "content_block_delta", "message_delta"] + + def test_preserves_bedrock_error_events(self): + record = {"response": {"body": json.dumps({"modelStreamErrorException": {"message": "stream failed"}})}} + events = _bedrock_events(record) + assert events == [{"event": "modelStreamErrorException", "data": {"message": "stream failed"}}] + + def test_returns_empty_for_non_bedrock_response(self): + record = {"response": {"body": {"content": [], "usage": {}}}} + assert _bedrock_events(record) == [] + + def test_returns_empty_for_missing_response(self): + assert _bedrock_events({}) == [] + assert _bedrock_events({"response": None}) == [] + + +class TestBedrockRecordUsage: + def test_extracts_usage_from_bedrock_events(self): + body = _bedrock_body( + { + "type": "message_start", + "message": { + "model": "claude-opus-4-6", + "usage": {"input_tokens": 10, "cache_read_input_tokens": 5, "output_tokens": 0}, + }, + }, + {"type": "message_delta", "usage": {"output_tokens": 7}}, + ) + record = {"response": {"body": body}} + usage = _record_usage(record) + assert usage["input_tokens"] == 10 + assert usage["output_tokens"] == 7 + assert usage["cache_read_input_tokens"] == 5 + + def test_bedrock_usage_not_used_when_standard_usage_exists(self): + record = {"response": {"body": {"usage": {"input_tokens": 3, "output_tokens": 2}}}} + usage = _record_usage(record) + assert usage["input_tokens"] == 3 + assert usage["output_tokens"] == 2 + + def test_extracts_usage_from_bedrock_converse_response_body(self): + record = { + "response": { + "body": { + "usage": { + "inputTokens": 9, + "outputTokens": 4, + "totalTokens": 13, + "cacheReadInputTokens": 3, + "cacheWriteInputTokens": 2, + } + } + } + } + usage = _record_usage(record) + assert usage["input_tokens"] == 9 + assert usage["output_tokens"] == 4 + assert usage["total_tokens"] == 13 + assert usage["cache_read_input_tokens"] == 3 + assert usage["cache_creation_input_tokens"] == 2 + + def test_extracts_usage_from_bedrock_converse_stream_metadata(self): + body = _bedrock_body( + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}}, + {"metadata": {"usage": {"inputTokens": 6, "outputTokens": 2, "totalTokens": 8}}}, + ) + record = {"response": {"body": body}} + usage = _record_usage(record) + assert usage["input_tokens"] == 6 + assert usage["output_tokens"] == 2 + assert usage["total_tokens"] == 8 + + +def test_record_response_text_reads_bedrock_converse_output_message() -> None: + record = { + "response": { + "body": { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "Bedrock says OK"}], + } + } + } + } + } + assert _record_response_text(record) == "Bedrock says OK" + + +class TestBedrockRecordModel: + def test_extracts_model_from_bedrock_message_start(self): + body = _bedrock_body( + {"type": "message_start", "message": {"model": "claude-opus-4-6", "usage": {}}}, + ) + record = {"response": {"body": body}} + assert _record_model(record) == "claude-opus-4-6" + + def test_extracts_model_from_bedrock_path(self): + record = { + "request": {"path": "/model/us.anthropic.claude-opus-4-6-v1/invoke-with-response-stream"}, + "response": {"body": ""}, + } + assert _record_model(record) == "us.anthropic.claude-opus-4-6-v1" + + def test_preserves_bedrock_model_version_suffix_from_path(self): + record = { + "request": {"path": "/model/anthropic.claude-sonnet-4-20250514-v1:0/invoke"}, + "response": {"body": ""}, + } + assert _record_model(record) == "anthropic.claude-sonnet-4-20250514-v1:0" + + def test_extracts_model_from_bedrock_converse_paths(self): + for suffix in ("converse", "converse-stream"): + record = { + "request": {"path": f"/model/anthropic.claude-sonnet-4-20250514-v1:0/{suffix}"}, + "response": {"body": ""}, + } + assert _record_model(record) == "anthropic.claude-sonnet-4-20250514-v1:0" diff --git a/tests/test_bedrock_proxy.py b/tests/test_bedrock_proxy.py new file mode 100644 index 0000000..c2e6990 --- /dev/null +++ b/tests/test_bedrock_proxy.py @@ -0,0 +1,726 @@ +"""Tests for Bedrock EventStream capture in reverse and forward proxy modes.""" + +from __future__ import annotations + +import base64 +import json +import struct +import zlib +from pathlib import Path +from typing import Any + +import aiohttp +import pytest +from aiohttp import web + +from claude_tap.forward_proxy import ForwardProxyServer +from claude_tap.proxy import ( + capture_only_content_type, + capture_only_response, + capture_only_stream_bytes, + is_capture_only_request, + is_capture_only_streaming_request, + proxy_handler, +) +from claude_tap.trace import TraceWriter +from claude_tap.trace_store import get_trace_store, reset_trace_store + + +def _bedrock_frame(payload: dict[str, Any]) -> bytes: + encoded = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() + return ("\x00\x00binary-prefix" + json.dumps({"bytes": encoded, "p": "abcdefghijk"}) + "\ufffd").encode() + + +def _bedrock_body() -> bytes: + return b"".join( + [ + _bedrock_frame( + { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [], + "usage": {"input_tokens": 6, "cache_read_input_tokens": 2, "output_tokens": 0}, + }, + } + ), + _bedrock_frame({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}), + _bedrock_frame({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "OK"}}), + _bedrock_frame({"type": "content_block_stop", "index": 0}), + _bedrock_frame( + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 2}} + ), + _bedrock_frame({"type": "message_stop"}), + ] + ) + + +def _native_bedrock_eventstream_events(body: bytes) -> list[tuple[dict[str, str], dict[str, Any]]]: + events: list[tuple[dict[str, str], dict[str, Any]]] = [] + offset = 0 + while offset < len(body): + total_len, headers_len = struct.unpack("!II", body[offset : offset + 8]) + prelude = body[offset : offset + 8] + prelude_crc = struct.unpack("!I", body[offset + 8 : offset + 12])[0] + assert zlib.crc32(prelude) & 0xFFFFFFFF == prelude_crc + + message = body[offset : offset + total_len - 4] + message_crc = struct.unpack("!I", body[offset + total_len - 4 : offset + total_len])[0] + assert zlib.crc32(message) & 0xFFFFFFFF == message_crc + + payload_start = offset + 12 + headers_len + payload_end = offset + total_len - 4 + headers = _native_bedrock_eventstream_headers(body[offset + 12 : payload_start]) + events.append((headers, json.loads(body[payload_start:payload_end]))) + offset += total_len + return events + + +def _native_bedrock_eventstream_headers(data: bytes) -> dict[str, str]: + headers: dict[str, str] = {} + offset = 0 + while offset < len(data): + name_len = data[offset] + offset += 1 + name = data[offset : offset + name_len].decode() + offset += name_len + value_type = data[offset] + offset += 1 + assert value_type == 7 + value_len = struct.unpack("!H", data[offset : offset + 2])[0] + offset += 2 + headers[name] = data[offset : offset + value_len].decode() + offset += value_len + return headers + + +def _native_bedrock_eventstream_payloads(body: bytes) -> list[dict[str, Any]]: + return [payload for _headers, payload in _native_bedrock_eventstream_events(body)] + + +def _bedrock_converse_body() -> bytes: + return b"".join( + [ + _bedrock_frame({"messageStart": {"role": "assistant"}}), + _bedrock_frame({"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}}), + _bedrock_frame({"contentBlockStop": {"contentBlockIndex": 0}}), + _bedrock_frame({"messageStop": {"stopReason": "end_turn"}}), + _bedrock_frame( + { + "metadata": { + "usage": { + "inputTokens": 6, + "outputTokens": 2, + "totalTokens": 8, + "cacheReadInputTokens": 3, + "cacheWriteInputTokens": 1, + } + } + } + ), + ] + ) + + +def _bedrock_body_with_error() -> bytes: + return b"".join( + [ + _bedrock_frame( + { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [], + "usage": {"input_tokens": 6}, + }, + } + ), + _bedrock_frame( + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "partial"}} + ), + _bedrock_frame({"modelStreamErrorException": {"message": "stream failed", "originalStatusCode": 424}}), + ] + ) + + +def _make_writer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Any, str, TraceWriter]: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "traces.sqlite3")) + reset_trace_store() + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + return store, session_id, TraceWriter(session_id, store=store) + + +async def _start_reverse_proxy( + target_url: str, writer: TraceWriter, *, store_stream_events: bool = True, capture_only: bool = False +) -> tuple[web.AppRunner, int, aiohttp.ClientSession]: + session = aiohttp.ClientSession(auto_decompress=False) + app = web.Application(client_max_size=0) + app["trace_ctx"] = { + "target_url": target_url, + "writer": writer, + "session": session, + "turn_counter": 0, + "store_stream_events": store_stream_events, + "capture_only": capture_only, + } + app.router.add_route("*", "/{path_info:.*}", proxy_handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, port, session + + +@pytest.mark.asyncio +async def test_reverse_proxy_capture_only_records_without_upstream(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + async def upstream_handler(_request: web.Request) -> web.Response: + raise AssertionError("capture-only must not call upstream") + + upstream_app = web.Application() + upstream_app.router.add_route("*", "/{path_info:.*}", upstream_handler) + upstream_runner = web.AppRunner(upstream_app) + await upstream_runner.setup() + upstream_site = web.TCPSite(upstream_runner, "127.0.0.1", 0) + await upstream_site.start() + upstream_port = upstream_site._server.sockets[0].getsockname()[1] + + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + runner, port, session = await _start_reverse_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + capture_only=True, + ) + + try: + async with aiohttp.ClientSession() as client: + response = await client.post( + f"http://127.0.0.1:{port}/v1/messages", + json={ + "model": "claude-opus", + "system": "system prompt", + "messages": [{"role": "user", "content": "hello"}], + }, + ) + assert response.status == 200 + assert (await response.json())["content"][0]["text"] == "captured" + finally: + writer.close() + await runner.cleanup() + await session.close() + await upstream_runner.cleanup() + + records = store.load_records(session_id) + assert len(records) == 1 + assert records[0]["request"]["body"]["system"] == "system prompt" + + +@pytest.mark.asyncio +async def test_reverse_proxy_strips_anthropic_beta_for_bedrock_gateway_models( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + upstream_requests: list[dict[str, Any]] = [] + + async def upstream_handler(request: web.Request) -> web.Response: + body = await request.json() + upstream_requests.append( + { + "path_qs": request.rel_url.raw_path_qs, + "anthropic_beta": request.headers.get("anthropic-beta"), + "body": body, + } + ) + return web.json_response( + { + "id": "msg_bedrock_gateway", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-6", + "content": [{"type": "text", "text": "ok"}], + "usage": {"input_tokens": 1, "output_tokens": 1}, + "stop_reason": "end_turn", + } + ) + + upstream_app = web.Application() + upstream_app.router.add_route("*", "/{path_info:.*}", upstream_handler) + upstream_runner = web.AppRunner(upstream_app) + await upstream_runner.setup() + upstream_site = web.TCPSite(upstream_runner, "127.0.0.1", 0) + await upstream_site.start() + upstream_port = upstream_site._server.sockets[0].getsockname()[1] + + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + runner, port, session = await _start_reverse_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + ) + + try: + async with aiohttp.ClientSession() as client: + response = await client.post( + f"http://127.0.0.1:{port}/v1/messages?beta=true", + headers={ + "anthropic-version": "2023-06-01", + "anthropic-beta": "claude-code-20250219,structured-outputs-2025-12-15", + }, + json={ + "model": "bedrock/claude-opus-4-6", + "context_management": {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}, + "output_config": {"effort": "high"}, + "thinking": {"type": "adaptive"}, + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}], + }, + ) + assert response.status == 200 + finally: + writer.close() + await runner.cleanup() + await session.close() + await upstream_runner.cleanup() + + assert upstream_requests == [ + { + "path_qs": "/v1/messages", + "anthropic_beta": None, + "body": { + "model": "bedrock/claude-opus-4-6", + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}], + }, + } + ] + + records = store.load_records(session_id) + assert len(records) == 1 + assert records[0]["request"]["path"] == "/v1/messages?beta=true" + assert records[0]["request"]["headers"]["anthropic-beta"] == ("claude-code-20250219,structured-outputs-2025-12-15") + assert records[0]["request"]["body"] == { + "model": "bedrock/claude-opus-4-6", + "context_management": {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}, + "output_config": {"effort": "high"}, + "thinking": {"type": "adaptive"}, + "max_tokens": 16, + "messages": [{"role": "user", "content": "hello"}], + } + + +@pytest.mark.asyncio +async def test_reverse_proxy_capture_only_streams_when_requested(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + async def upstream_handler(_request: web.Request) -> web.Response: + raise AssertionError("capture-only must not call upstream") + + upstream_app = web.Application() + upstream_app.router.add_route("*", "/{path_info:.*}", upstream_handler) + upstream_runner = web.AppRunner(upstream_app) + await upstream_runner.setup() + upstream_site = web.TCPSite(upstream_runner, "127.0.0.1", 0) + await upstream_site.start() + upstream_port = upstream_site._server.sockets[0].getsockname()[1] + + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + runner, port, session = await _start_reverse_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + capture_only=True, + ) + + try: + async with aiohttp.ClientSession() as client: + response = await client.post( + f"http://127.0.0.1:{port}/v1/messages", + json={ + "model": "claude-opus", + "stream": True, + "system": "streaming system prompt", + "messages": [{"role": "user", "content": "hello"}], + }, + ) + assert response.status == 200 + assert response.headers["Content-Type"].startswith("text/event-stream") + assert "event: message_stop" in await response.text() + finally: + writer.close() + await runner.cleanup() + await session.close() + await upstream_runner.cleanup() + + records = store.load_records(session_id) + assert len(records) == 1 + assert records[0]["response"]["headers"]["Content-Type"] == "text/event-stream" + assert records[0]["request"]["body"]["system"] == "streaming system prompt" + + +def test_capture_only_response_shapes_model_probes_by_provider() -> None: + assert is_capture_only_request("/v1/models/gpt-5", None) + assert not is_capture_only_request("/oauth/token", {"refresh_token": "redacted"}) + assert not is_capture_only_request("/v1/embeddings", {"input": "embed me", "model": "text-embedding-3-small"}) + assert not is_capture_only_request("/v1internal:listExperiments", {"request": {"client": "agy"}}) + assert is_capture_only_request("/v1internal:streamGenerateContent?alt=sse", {"request": {"contents": []}}) + + openai_model = capture_only_response("/v1/models/gpt-5", None) + assert openai_model == {"id": "gpt-5", "object": "model", "created": 0, "owned_by": "claude-tap"} + + gemini = capture_only_response("/v1beta/models/gemini-pro:generateContent", None) + assert "candidates" in gemini + gemini_models = capture_only_response("/v1beta/models", {"model": "gemini-pro"}) + assert gemini_models["models"][0]["name"] == "models/gemini-pro" + gemini_model = capture_only_response("/v1beta/models/gemini-pro", {"model": "gemini-pro"}) + assert gemini_model["supportedGenerationMethods"] == ["generateContent", "streamGenerateContent"] + converse = capture_only_response("/model/us.anthropic.claude-sonnet-4-6-v1:0/converse", {"messages": []}) + assert converse["output"]["message"]["content"][0]["text"] == "captured" + assert converse["stopReason"] == "end_turn" + anthropic_completion = capture_only_response("/v1/complete", {"model": "claude", "prompt": "hello"}) + assert anthropic_completion["completion"] == "captured" + completion = capture_only_response("/v1/completions", {"model": "gpt", "prompt": "hello"}) + assert completion["choices"][0]["text"] == "captured" + responses = capture_only_response("/v1/responses", {"model": "gpt", "input": "hello"}) + assert responses["status"] == "completed" + + +def test_capture_only_stream_bytes_are_provider_shaped() -> None: + anthropic = capture_only_stream_bytes("/v1/messages", {"model": "claude"}) + assert b"event: message_start" in anthropic + assert b'"type":"message_start","message"' in anthropic + assert b"data: [DONE]" in capture_only_stream_bytes("/v1/chat/completions", {"model": "gpt"}) + assert b"response.completed" in capture_only_stream_bytes("/v1/responses", {"model": "gpt"}) + assert b'"object":"text_completion"' in capture_only_stream_bytes("/v1/completions", {"model": "gpt"}) + gemini_path = "/v1beta/models/gemini-pro:streamGenerateContent?alt=sse" + assert is_capture_only_streaming_request(gemini_path, {"contents": []}) + assert b"candidates" in capture_only_stream_bytes(gemini_path, {"contents": []}) + code_assist_path = "/v1internal:streamGenerateContent?alt=sse" + assert b"candidates" in capture_only_stream_bytes(code_assist_path, {"request": {"contents": []}}) + assert b"response.completed" not in capture_only_stream_bytes(code_assist_path, {"request": {"contents": []}}) + + +def test_capture_only_bedrock_stream_bytes_are_eventstream_shaped() -> None: + path = "/model/global.anthropic.claude-sonnet-4-6-v1/invoke-with-response-stream" + + body = capture_only_stream_bytes(path, {"anthropic_version": "bedrock-2023-05-31"}) + + assert capture_only_content_type(path, True) == "application/vnd.amazon.eventstream" + payloads = _native_bedrock_eventstream_payloads(body) + assert payloads[0]["type"] == "message_start" + assert payloads[-1]["type"] == "message_stop" + + converse_path = "/model/us.anthropic.claude-sonnet-4-6-v1:0/converse-stream" + converse_events = _native_bedrock_eventstream_events(capture_only_stream_bytes(converse_path, {})) + assert [headers[":event-type"] for headers, _payload in converse_events] == [ + "messageStart", + "contentBlockDelta", + "contentBlockStop", + "messageStop", + "metadata", + ] + + +@pytest.mark.asyncio +async def test_reverse_proxy_capture_only_captures_nested_code_assist_request( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + async def upstream_handler(_request: web.Request) -> web.Response: + raise AssertionError("capture-only must not call upstream for Code Assist prompt requests") + + upstream_app = web.Application() + upstream_app.router.add_route("*", "/{path_info:.*}", upstream_handler) + upstream_runner = web.AppRunner(upstream_app) + await upstream_runner.setup() + upstream_site = web.TCPSite(upstream_runner, "127.0.0.1", 0) + await upstream_site.start() + upstream_port = upstream_site._server.sockets[0].getsockname()[1] + + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + runner, port, session = await _start_reverse_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + capture_only=True, + ) + + try: + async with aiohttp.ClientSession() as client: + response = await client.post( + f"http://127.0.0.1:{port}/v1internal:streamGenerateContent?alt=sse", + json={"request": {"contents": [{"role": "user", "parts": [{"text": "hello"}]}]}}, + ) + assert response.status == 200 + assert response.headers["Content-Type"].startswith("text/event-stream") + assert "candidates" in await response.text() + finally: + writer.close() + await runner.cleanup() + await session.close() + await upstream_runner.cleanup() + + records = store.load_records(session_id) + assert len(records) == 1 + assert records[0]["request"]["path"] == "/v1internal:streamGenerateContent?alt=sse" + assert records[0]["request"]["body"]["request"]["contents"][0]["role"] == "user" + + +@pytest.mark.parametrize( + "bedrock_path", + [ + "/model/arn:aws:bedrock:us-east-1:123456789012:provisioned-model%2Fabc/invoke-with-response-stream", + "/model/us.anthropic.claude-sonnet-4-6-v1:0/converse-stream", + ], +) +@pytest.mark.asyncio +async def test_reverse_proxy_records_bedrock_eventstream_without_stream_flag( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + bedrock_path: str, +) -> None: + bedrock_bytes = _bedrock_converse_body() if "converse-stream" in bedrock_path else _bedrock_body() + + async def upstream_handler(request: web.Request) -> web.StreamResponse: + assert request.raw_path == bedrock_path + assert (await request.json())["messages"][0]["role"] == "user" + response = web.StreamResponse(status=200, headers={"Content-Type": "application/vnd.amazon.eventstream"}) + await response.prepare(request) + await response.write(bedrock_bytes[:64]) + await response.write(bedrock_bytes[64:]) + await response.write_eof() + return response + + upstream_app = web.Application() + upstream_app.router.add_post("/{path_info:.*}", upstream_handler) + upstream_runner = web.AppRunner(upstream_app) + await upstream_runner.setup() + upstream_site = web.TCPSite(upstream_runner, "127.0.0.1", 0) + await upstream_site.start() + upstream_port = upstream_site._server.sockets[0].getsockname()[1] + + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + proxy_runner, proxy_port, proxy_session = await _start_reverse_proxy(f"http://127.0.0.1:{upstream_port}", writer) + + try: + async with aiohttp.ClientSession(auto_decompress=False) as client: + async with client.post( + f"http://127.0.0.1:{proxy_port}{bedrock_path}", + headers={"X-Amz-Security-Token": "aws-session-token-secret"}, + json={"messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}]}, + ) as response: + assert response.status == 200 + assert await response.read() == bedrock_bytes + + writer.close() + records = store.load_records(session_id) + assert len(records) == 1 + record = records[0] + assert record["request"]["path"] == bedrock_path + assert record["request"]["headers"]["X-Amz-Security-Token"] == "***" + if "converse-stream" not in bedrock_path: + assert record["response"]["body"]["model"] == "claude-sonnet-4-6" + assert record["response"]["body"]["content"] == [{"type": "text", "text": "OK"}] + assert record["response"]["body"]["usage"]["input_tokens"] == 6 + assert record["response"]["body"]["usage"]["output_tokens"] == 2 + if "converse-stream" in bedrock_path: + assert record["response"]["body"]["usage"]["cache_read_input_tokens"] == 3 + assert record["response"]["body"]["usage"]["cache_creation_input_tokens"] == 1 + assert "content_block_delta" in [event["event"] for event in record["response"]["sse_events"]] + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + reset_trace_store() + + +@pytest.mark.asyncio +async def test_reverse_proxy_preserves_bedrock_stream_error_without_stream_events( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bedrock_path = "/model/global.anthropic.claude-sonnet-4-6-v1/invoke-with-response-stream" + bedrock_bytes = _bedrock_body_with_error() + + async def upstream_handler(request: web.Request) -> web.StreamResponse: + response = web.StreamResponse(status=200, headers={"Content-Type": "application/vnd.amazon.eventstream"}) + await response.prepare(request) + await response.write(bedrock_bytes) + await response.write_eof() + return response + + upstream_app = web.Application() + upstream_app.router.add_post("/{path_info:.*}", upstream_handler) + upstream_runner = web.AppRunner(upstream_app) + await upstream_runner.setup() + upstream_site = web.TCPSite(upstream_runner, "127.0.0.1", 0) + await upstream_site.start() + upstream_port = upstream_site._server.sockets[0].getsockname()[1] + + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + proxy_runner, proxy_port, proxy_session = await _start_reverse_proxy( + f"http://127.0.0.1:{upstream_port}", writer, store_stream_events=False + ) + + try: + async with aiohttp.ClientSession(auto_decompress=False) as client: + async with client.post( + f"http://127.0.0.1:{proxy_port}{bedrock_path}", + json={"messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}]}, + ) as response: + assert response.status == 200 + assert await response.read() == bedrock_bytes + + writer.close() + record = store.load_records(session_id)[0] + body = record["response"]["body"] + assert "sse_events" not in record["response"] + assert body["content"] == [{"type": "text", "text": "partial"}] + assert body["error"]["type"] == "modelStreamErrorException" + assert body["error"]["message"] == "stream failed" + assert body["bedrock_errors"] == [ + {"type": "modelStreamErrorException", "message": "stream failed", "originalStatusCode": 424} + ] + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + reset_trace_store() + + +class _FakeStreamContent: + def __init__(self, body: bytes) -> None: + self._body = body + + async def iter_any(self): + yield self._body[:80] + yield self._body[80:] + + +class _FakeStreamResponse: + status = 200 + reason = "OK" + headers = {"Content-Type": "application/vnd.amazon.eventstream"} + + def __init__(self, body: bytes) -> None: + self.content = _FakeStreamContent(body) + + +class _FakeSession: + def __init__(self, body: bytes) -> None: + self._body = body + self.calls: list[dict[str, Any]] = [] + + async def request(self, **kwargs): + self.calls.append(kwargs) + return _FakeStreamResponse(self._body) + + +class _MemoryWriter: + def __init__(self) -> None: + self.data = bytearray() + + def write(self, data: bytes) -> None: + self.data.extend(data) + + async def drain(self) -> None: + return None + + +@pytest.mark.parametrize( + "bedrock_path", + [ + "/model/global.anthropic.claude-sonnet-4-6-v1/invoke-with-response-stream", + "/model/global.anthropic.claude-sonnet-4-6-v1:0/converse-stream", + ], +) +@pytest.mark.asyncio +async def test_forward_proxy_records_bedrock_eventstream_without_stream_flag( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + bedrock_path: str, +) -> None: + bedrock_bytes = _bedrock_converse_body() if "converse-stream" in bedrock_path else _bedrock_body() + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + fake_session = _FakeSession(bedrock_bytes) + client_writer = _MemoryWriter() + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=object(), + writer=writer, + session=fake_session, + store_stream_events=True, + ) + + await server._forward_and_record( + "POST", + bedrock_path, + { + "Host": "bedrock-runtime.us-east-1.amazonaws.com", + "Authorization": "Bearer test", + "X-Amz-Security-Token": "aws-session-token-secret", + }, + json.dumps({"messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}]}).encode(), + f"https://bedrock-runtime.us-east-1.amazonaws.com{bedrock_path}", + client_writer, + ) + + writer.close() + records = store.load_records(session_id) + assert len(records) == 1 + record = records[0] + assert fake_session.calls[0]["data"] + assert record["request"]["headers"]["X-Amz-Security-Token"] == "***" + assert b"Transfer-Encoding: chunked" in client_writer.data + assert client_writer.data.endswith(b"0\r\n\r\n") + if "converse-stream" not in bedrock_path: + assert record["response"]["body"]["model"] == "claude-sonnet-4-6" + assert record["response"]["body"]["content"] == [{"type": "text", "text": "OK"}] + if "converse-stream" not in bedrock_path: + assert record["response"]["body"]["usage"]["cache_read_input_tokens"] == 2 + else: + assert record["response"]["body"]["usage"]["cache_read_input_tokens"] == 3 + assert record["response"]["body"]["usage"]["cache_creation_input_tokens"] == 1 + assert record["response"]["body"]["usage"]["output_tokens"] == 2 + assert [event["event"] for event in record["response"]["sse_events"]][0] == "message_start" + reset_trace_store() + + +@pytest.mark.asyncio +async def test_forward_proxy_preserves_bedrock_stream_error_without_stream_events( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + bedrock_path = "/model/global.anthropic.claude-sonnet-4-6-v1/invoke-with-response-stream" + store, session_id, writer = _make_writer(tmp_path, monkeypatch) + fake_session = _FakeSession(_bedrock_body_with_error()) + client_writer = _MemoryWriter() + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=object(), + writer=writer, + session=fake_session, + store_stream_events=False, + ) + + await server._forward_and_record( + "POST", + bedrock_path, + {"Host": "bedrock-runtime.us-east-1.amazonaws.com", "Authorization": "Bearer test"}, + json.dumps({"messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}]}).encode(), + f"https://bedrock-runtime.us-east-1.amazonaws.com{bedrock_path}", + client_writer, + ) + + writer.close() + record = store.load_records(session_id)[0] + body = record["response"]["body"] + assert "sse_events" not in record["response"] + assert body["content"] == [{"type": "text", "text": "partial"}] + assert body["error"]["type"] == "modelStreamErrorException" + assert body["error"]["message"] == "stream failed" + assert body["bedrock_errors"] == [ + {"type": "modelStreamErrorException", "message": "stream failed", "originalStatusCode": 424} + ] + reset_trace_store() diff --git a/tests/test_bedrock_support.py b/tests/test_bedrock_support.py new file mode 100644 index 0000000..8b0002d --- /dev/null +++ b/tests/test_bedrock_support.py @@ -0,0 +1,307 @@ +"""Tests for AWS Bedrock support in Claude target detection.""" + +import os +from unittest.mock import patch + +from claude_tap.cli_clients import ( + CLIENT_CONFIGS, + _detect_claude_target, + _is_aws_native_bedrock_url, +) + + +class TestIsAwsNativeBedrockUrl: + def test_aws_native_endpoint(self): + assert _is_aws_native_bedrock_url("https://bedrock-runtime.us-east-1.amazonaws.com") is True + + def test_aws_fips_endpoint(self): + assert _is_aws_native_bedrock_url("https://bedrock-runtime-fips.us-west-2.amazonaws.com") is True + + def test_aws_vpce_endpoint(self): + assert _is_aws_native_bedrock_url("https://vpce-xxx.bedrock-runtime.us-east-1.vpce.amazonaws.com") is True + + def test_aws_china_endpoint(self): + assert _is_aws_native_bedrock_url("https://bedrock-runtime.cn-north-1.amazonaws.com.cn") is True + + def test_aws_api_aws_endpoint(self): + assert _is_aws_native_bedrock_url("https://bedrock-mantle.us-east-1.api.aws") is True + + def test_aws_mantle_amazonaws_endpoint(self): + assert _is_aws_native_bedrock_url("https://bedrock-mantle.us-east-1.amazonaws.com") is True + + def test_api_gateway_not_native(self): + assert _is_aws_native_bedrock_url("https://abc123.execute-api.us-east-1.amazonaws.com/bedrock") is False + + def test_custom_gateway(self): + assert _is_aws_native_bedrock_url("https://ai-gateway.internal.example.com/bedrock") is False + + def test_custom_company_proxy(self): + assert _is_aws_native_bedrock_url("https://ai-gateway.internal.company.com/bedrock") is False + + def test_empty_string(self): + assert _is_aws_native_bedrock_url("") is False + + def test_malformed_url(self): + assert _is_aws_native_bedrock_url("https://[") is False + + +class TestDetectClaudeTargetBedrock: + def test_custom_bedrock_gateway_takes_priority(self): + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://ai-gateway.internal.example.com/bedrock", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + with patch.dict(os.environ, env, clear=False): + assert _detect_claude_target() == "https://ai-gateway.internal.example.com/bedrock" + + def test_bedrock_gateway_ignored_when_bedrock_mode_is_off(self): + env = { + "CLAUDE_CODE_USE_BEDROCK": "0", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://ai-gateway.internal.example.com/bedrock", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + with patch.dict(os.environ, env, clear=False): + assert _detect_claude_target() == "https://custom.example.com" + + def test_aws_native_bedrock_skipped_falls_to_base_url(self): + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + with patch.dict(os.environ, env, clear=False): + assert _detect_claude_target() == "https://custom.example.com" + + def test_aws_native_bedrock_skipped_falls_to_default(self): + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + } + with patch.dict(os.environ, env, clear=False): + os.environ.pop("ANTHROPIC_BASE_URL", None) + with patch("claude_tap.cli_clients._read_settings_env_base_url", return_value=None): + assert _detect_claude_target() == "https://api.anthropic.com" + + def test_api_aws_bedrock_skipped_falls_to_base_url(self): + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-mantle.us-east-1.api.aws", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + with patch.dict(os.environ, env, clear=False): + assert _detect_claude_target() == "https://custom.example.com" + + def test_custom_bedrock_gateway_from_settings_takes_priority(self): + values = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://ai-gateway.internal.example.com/bedrock", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + + def fake_read(_path, env_key): + return values.get(env_key) + + with patch.dict(os.environ, {}, clear=False): + for key in values: + os.environ.pop(key, None) + with patch("claude_tap.cli_clients._read_settings_env_base_url", side_effect=fake_read): + assert _detect_claude_target() == "https://ai-gateway.internal.example.com/bedrock" + + def test_base_url_used_when_no_bedrock(self): + env = {"ANTHROPIC_BASE_URL": "https://custom.example.com"} + with patch.dict(os.environ, env, clear=False): + os.environ.pop("CLAUDE_CODE_USE_BEDROCK", None) + os.environ.pop("ANTHROPIC_BEDROCK_BASE_URL", None) + os.environ.pop("CLAUDE_CODE_USE_VERTEX", None) + os.environ.pop("ANTHROPIC_VERTEX_BASE_URL", None) + assert _detect_claude_target() == "https://custom.example.com" + + @patch("claude_tap.cli_clients._read_settings_env_base_url", return_value=None) + def test_default_when_no_env(self, mock_read): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAUDE_CODE_USE_BEDROCK", None) + os.environ.pop("ANTHROPIC_BEDROCK_BASE_URL", None) + os.environ.pop("CLAUDE_CODE_USE_VERTEX", None) + os.environ.pop("ANTHROPIC_VERTEX_BASE_URL", None) + os.environ.pop("ANTHROPIC_BASE_URL", None) + target = _detect_claude_target() + assert target == "https://api.anthropic.com" + + +class TestDetectClaudeTargetVertex: + def test_vertex_gateway_takes_priority(self): + env = { + "CLAUDE_CODE_USE_VERTEX": "1", + "CLAUDE_CODE_USE_BEDROCK": "0", + "ANTHROPIC_VERTEX_BASE_URL": "https://vertex-gateway.internal.example.com/vertex", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + with patch.dict(os.environ, env, clear=False): + assert _detect_claude_target() == "https://vertex-gateway.internal.example.com/vertex" + + def test_vertex_gateway_ignored_when_vertex_mode_is_off(self): + env = { + "CLAUDE_CODE_USE_VERTEX": "0", + "CLAUDE_CODE_USE_BEDROCK": "0", + "ANTHROPIC_VERTEX_BASE_URL": "https://vertex-gateway.internal.example.com/vertex", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + with patch.dict(os.environ, env, clear=False): + assert _detect_claude_target() == "https://custom.example.com" + + def test_vertex_gateway_from_settings_takes_priority(self): + values = { + "CLAUDE_CODE_USE_VERTEX": "1", + "CLAUDE_CODE_USE_BEDROCK": "0", + "ANTHROPIC_VERTEX_BASE_URL": "https://vertex-gateway.internal.example.com/vertex", + "ANTHROPIC_BASE_URL": "https://custom.example.com", + } + + def fake_read(_path, env_key): + return values.get(env_key) + + with patch.dict(os.environ, {}, clear=False): + for key in values: + os.environ.pop(key, None) + with patch("claude_tap.cli_clients._read_settings_env_base_url", side_effect=fake_read): + assert _detect_claude_target() == "https://vertex-gateway.internal.example.com/vertex" + + +class TestClaudeConfigProviderEnv: + def test_extra_base_url_envs_includes_bedrock_and_vertex(self): + cfg = CLIENT_CONFIGS["claude"] + assert "ANTHROPIC_BEDROCK_BASE_URL" in cfg.extra_base_url_envs + assert "ANTHROPIC_VERTEX_BASE_URL" in cfg.extra_base_url_envs + + def test_reverse_base_url_envs_includes_all_claude_provider_base_urls(self): + cfg = CLIENT_CONFIGS["claude"] + envs = cfg.reverse_base_url_envs + assert envs == ("ANTHROPIC_BASE_URL", "ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_VERTEX_BASE_URL") + + def test_env_map_rewrites_custom_gateway(self): + """Custom gateways (non-AWS) should be rewritten to localhost.""" + cfg = CLIENT_CONFIGS["claude"] + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://ai-gateway.internal.example.com/bedrock", + } + with patch.dict(os.environ, env, clear=False): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert env_map["ANTHROPIC_BEDROCK_BASE_URL"] == "http://127.0.0.1:8080" + + def test_env_map_ignores_bedrock_when_bedrock_mode_is_off(self): + cfg = CLIENT_CONFIGS["claude"] + env = { + "CLAUDE_CODE_USE_BEDROCK": "0", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://ai-gateway.internal.example.com/bedrock", + } + with patch.dict(os.environ, env, clear=False): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_BEDROCK_BASE_URL" not in env_map + + def test_env_map_ignores_bedrock_when_no_bedrock_base_url_is_configured(self): + cfg = CLIENT_CONFIGS["claude"] + with patch.dict(os.environ, {"CLAUDE_CODE_USE_BEDROCK": "1", "CLAUDE_CODE_USE_VERTEX": "0"}, clear=False): + os.environ.pop("ANTHROPIC_BEDROCK_BASE_URL", None) + with patch("claude_tap.cli_clients._read_settings_env_base_url", return_value=None): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_BEDROCK_BASE_URL" not in env_map + + def test_env_map_skips_aws_native(self): + """AWS native endpoints must NOT be rewritten (SigV4 would fail).""" + cfg = CLIENT_CONFIGS["claude"] + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + } + with patch.dict(os.environ, env, clear=False): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_BEDROCK_BASE_URL" not in env_map + + def test_env_map_skips_mantle_amazonaws_native(self): + cfg = CLIENT_CONFIGS["claude"] + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-mantle.us-east-1.amazonaws.com", + } + with patch.dict(os.environ, env, clear=False): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_BEDROCK_BASE_URL" not in env_map + + def test_env_map_skips_api_aws_native(self): + cfg = CLIENT_CONFIGS["claude"] + env = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-mantle.us-east-1.api.aws", + } + with patch.dict(os.environ, env, clear=False): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_BEDROCK_BASE_URL" not in env_map + + def test_env_map_skips_aws_native_from_settings(self): + cfg = CLIENT_CONFIGS["claude"] + values = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + } + + def fake_read(_path, env_key): + return values.get(env_key) + + with patch.dict(os.environ, {}, clear=False): + for key in values: + os.environ.pop(key, None) + with patch("claude_tap.cli_clients._read_settings_env_base_url", side_effect=fake_read): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_BEDROCK_BASE_URL" not in env_map + + def test_env_map_rewrites_vertex_gateway(self): + cfg = CLIENT_CONFIGS["claude"] + env = { + "CLAUDE_CODE_USE_VERTEX": "1", + "ANTHROPIC_VERTEX_BASE_URL": "https://vertex-gateway.internal.example.com/vertex", + } + with patch.dict(os.environ, env, clear=False): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert env_map["ANTHROPIC_VERTEX_BASE_URL"] == "http://127.0.0.1:8080" + + def test_env_map_ignores_vertex_when_vertex_mode_is_off(self): + cfg = CLIENT_CONFIGS["claude"] + env = { + "CLAUDE_CODE_USE_VERTEX": "0", + "ANTHROPIC_VERTEX_BASE_URL": "https://vertex-gateway.internal.example.com/vertex", + } + with patch.dict(os.environ, env, clear=False): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_VERTEX_BASE_URL" not in env_map + + def test_env_map_ignores_vertex_when_no_vertex_base_url_is_configured(self): + cfg = CLIENT_CONFIGS["claude"] + with patch.dict(os.environ, {"CLAUDE_CODE_USE_VERTEX": "1"}, clear=False): + os.environ.pop("ANTHROPIC_VERTEX_BASE_URL", None) + with patch("claude_tap.cli_clients._read_settings_env_base_url", return_value=None): + env_map = cfg.reverse_base_url_env_map(8080) + assert env_map["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8080" + assert "ANTHROPIC_VERTEX_BASE_URL" not in env_map diff --git a/tests/test_bedrock_viewer.py b/tests/test_bedrock_viewer.py new file mode 100644 index 0000000..2272d14 --- /dev/null +++ b/tests/test_bedrock_viewer.py @@ -0,0 +1,369 @@ +"""Tests for Bedrock EventStream trace normalization in the HTML viewer.""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from claude_tap.viewer import _normalize_record_for_viewer + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + + +def _bedrock_frame(payload: dict) -> str: + encoded = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() + return "\x00\x00binary-prefix" + json.dumps({"bytes": encoded, "p": "abcdefghijk"}) + "\ufffd" + + +def _write_trace(trace_path, records: list[dict]) -> None: + with trace_path.open("w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record) + "\n") + + +def test_normalize_record_for_viewer_decodes_bedrock_eventstream() -> None: + body = "".join( + [ + _bedrock_frame( + { + "type": "message_start", + "message": { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-6", + "content": [], + "usage": { + "input_tokens": 3, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 0, + }, + }, + } + ), + _bedrock_frame({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}), + _bedrock_frame({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "OK"}}), + _bedrock_frame({"type": "content_block_stop", "index": 0}), + _bedrock_frame( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None}, + "usage": {"output_tokens": 1}, + } + ), + _bedrock_frame({"type": "message_stop", "amazon-bedrock-invocationMetrics": {"inputTokenCount": 3}}), + ] + ) + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/model/global.anthropic.claude-opus-4-6-v1/invoke-with-response-stream", + "body": {"messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}]}, + }, + "response": {"status": 200, "headers": {}, "body": body}, + } + + normalized = json.loads(_normalize_record_for_viewer(json.dumps(record))) + + assert [event["event"] for event in normalized["response"]["sse_events"]] == [ + "message_start", + "content_block_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_stop", + ] + assert normalized["response"]["body"]["content"] == [{"type": "text", "text": "OK"}] + assert normalized["response"]["body"]["usage"]["input_tokens"] == 3 + assert normalized["response"]["body"]["usage"]["output_tokens"] == 1 + + +def test_normalize_record_for_viewer_decodes_bedrock_converse_stream() -> None: + body = "".join( + [ + _bedrock_frame({"messageStart": {"role": "assistant"}}), + _bedrock_frame({"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}}), + _bedrock_frame({"contentBlockStop": {"contentBlockIndex": 0}}), + _bedrock_frame({"messageStop": {"stopReason": "end_turn"}}), + _bedrock_frame({"metadata": {"usage": {"inputTokens": 4, "outputTokens": 2, "totalTokens": 6}}}), + ] + ) + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/model/anthropic.claude-sonnet-4-20250514-v1:0/converse-stream", + "body": {"messages": [{"role": "user", "content": [{"text": "ping"}]}]}, + }, + "response": {"status": 200, "headers": {}, "body": body}, + } + + normalized = json.loads(_normalize_record_for_viewer(json.dumps(record))) + + assert [event["event"] for event in normalized["response"]["sse_events"]] == [ + "message_start", + "content_block_delta", + "content_block_stop", + "message_delta", + "message_delta", + ] + assert normalized["response"]["body"]["content"] == [{"type": "text", "text": "OK"}] + assert normalized["response"]["body"]["stop_reason"] == "end_turn" + assert normalized["response"]["body"]["usage"]["input_tokens"] == 4 + assert normalized["response"]["body"]["usage"]["output_tokens"] == 2 + assert normalized["response"]["body"]["usage"]["total_tokens"] == 6 + + +def test_normalize_record_for_viewer_decodes_raw_bedrock_converse_stream_payloads() -> None: + body = "".join( + json.dumps(payload, separators=(",", ":")) + for payload in ( + {"messageStart": {"role": "assistant"}}, + {"contentBlockDelta": {"contentBlockIndex": 0, "delta": {"text": "OK"}}}, + {"metadata": {"usage": {"inputTokens": 4, "outputTokens": 2, "totalTokens": 6}}}, + ) + ) + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/model/anthropic.claude-sonnet-4-20250514-v1:0/converse-stream", + "body": {"messages": [{"role": "user", "content": [{"text": "ping"}]}]}, + }, + "response": {"status": 200, "headers": {}, "body": body}, + } + + normalized = json.loads(_normalize_record_for_viewer(json.dumps(record))) + + assert normalized["response"]["body"]["content"] == [{"type": "text", "text": "OK"}] + assert normalized["response"]["body"]["usage"]["input_tokens"] == 4 + assert normalized["response"]["body"]["usage"]["output_tokens"] == 2 + + +def test_normalize_record_for_viewer_preserves_bedrock_reasoning_signature() -> None: + body = "".join( + [ + _bedrock_frame({"messageStart": {"role": "assistant"}}), + _bedrock_frame( + { + "contentBlockDelta": { + "contentBlockIndex": 0, + "delta": {"reasoningContent": {"text": "thinking", "signature": "sig-123"}}, + } + } + ), + ] + ) + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/model/anthropic.claude-sonnet-4-20250514-v1:0/converse-stream", + "body": {"messages": [{"role": "user", "content": [{"text": "ping"}]}]}, + }, + "response": {"status": 200, "headers": {}, "body": body}, + } + + normalized = json.loads(_normalize_record_for_viewer(json.dumps(record))) + + delta = normalized["response"]["sse_events"][1]["data"]["delta"] + assert delta == {"type": "thinking_delta", "thinking": "thinking", "signature": "sig-123"} + assert normalized["response"]["body"]["content"] == [ + {"type": "thinking", "thinking": "thinking", "signature": "sig-123"} + ] + + +@pytest.mark.skipif(pw_missing, reason="playwright not installed") +def test_bedrock_invoke_path_is_primary_filter(tmp_path) -> None: + from playwright.sync_api import sync_playwright + + from claude_tap.viewer import _generate_html_viewer + + bedrock_path = "/model/global.anthropic.claude-opus-4-6-v1/invoke-with-response-stream" + paths = [ + bedrock_path, + "/mcp-registry/v0/servers", + "/inference-profiles", + "/auxiliary/one", + ] + trace_path = tmp_path / "trace.jsonl" + _write_trace( + trace_path, + [ + { + "timestamp": f"2026-04-27T09:15:{turn:02d}+00:00", + "request_id": f"req_{turn}", + "turn": turn, + "duration_ms": 100, + "request": { + "method": "POST" if path == bedrock_path else "GET", + "path": path, + "headers": {}, + "body": { + "messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}], + } + if path == bedrock_path + else None, + }, + "response": {"status": 200, "headers": {}, "body": {"content": [], "usage": {}}}, + } + for turn, path in enumerate(paths, 1) + ], + ) + + html_path = tmp_path / "trace.html" + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(html_path.resolve().as_uri(), wait_until="networkidle") + chip_text = page.locator("#path-filter .filter-chip").first.inner_text() + sidebar_count = page.locator(".sidebar-item").count() + more_text = page.locator("#path-filter .filter-chip-toggle").inner_text() + browser.close() + + assert "invoke-with-response-stream" in chip_text + assert sidebar_count == 1 + assert "+3" in more_text + + +@pytest.mark.skipif(pw_missing, reason="playwright not installed") +def test_bedrock_billing_header_does_not_become_task_label(tmp_path) -> None: + from playwright.sync_api import sync_playwright + + from claude_tap.viewer import _generate_html_viewer + + trace_path = tmp_path / "trace.jsonl" + _write_trace( + trace_path, + [ + { + "timestamp": "2026-04-27T09:15:01+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream", + "headers": {}, + "body": { + "system": [ + { + "type": "text", + "text": ( + "x-anthropic-billing-header: cc_version=2.1.119.6a6; " + "cc_entrypoint=sdk-ts-e2b-runner;\n" + "You are a Claude agent, built on Anthropic's Claude Agent SDK." + ), + } + ], + "messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}], + }, + }, + "response": {"status": 200, "headers": {}, "body": {"content": [], "usage": {}}}, + } + ], + ) + + html_path = tmp_path / "trace.html" + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(html_path.resolve().as_uri(), wait_until="networkidle") + label = page.locator(".sidebar-item .si-task").first.inner_text() + browser.close() + + assert label == "Claude Agent" + + +@pytest.mark.skipif(pw_missing, reason="playwright not installed") +def test_bedrock_converse_response_output_and_usage_render(tmp_path) -> None: + from playwright.sync_api import sync_playwright + + from claude_tap.viewer import _generate_html_viewer + + trace_path = tmp_path / "trace.jsonl" + _write_trace( + trace_path, + [ + { + "timestamp": "2026-04-27T09:15:01+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/model/anthropic.claude-sonnet-4-20250514-v1:0/converse", + "headers": {}, + "body": { + "messages": [{"role": "user", "content": [{"text": "ping"}]}], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "output": { + "message": { + "role": "assistant", + "content": [ + {"text": "Bedrock says OK"}, + { + "toolUse": { + "toolUseId": "tool-1", + "name": "lookup", + "input": {"query": "ping"}, + } + }, + ], + } + }, + "usage": { + "inputTokens": 9, + "outputTokens": 4, + "totalTokens": 13, + "cacheReadInputTokens": 3, + "cacheWriteInputTokens": 2, + }, + }, + }, + } + ], + ) + + html_path = tmp_path / "trace.html" + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(html_path.resolve().as_uri(), wait_until="networkidle") + result = page.evaluate( + """ + () => ({ + output: getResponseOutput(entries[0]).content, + usage: getUsage(entries[0]), + }) + """ + ) + browser.close() + + assert result["output"] == [ + {"type": "text", "text": "Bedrock says OK"}, + {"type": "tool_use", "id": "tool-1", "name": "lookup", "input": {"query": "ping"}}, + ] + assert result["usage"]["input_tokens"] == 9 + assert result["usage"]["output_tokens"] == 4 + assert result["usage"]["cache_read_input_tokens"] == 3 + assert result["usage"]["cache_creation_input_tokens"] == 2 diff --git a/tests/test_chat_completions_sse.py b/tests/test_chat_completions_sse.py new file mode 100644 index 0000000..b723395 --- /dev/null +++ b/tests/test_chat_completions_sse.py @@ -0,0 +1,387 @@ +"""Regression: SSEReassembler must capture OpenAI Chat Completions streams. + +opencode in forward-proxy mode talks to providers that expose the OpenAI +Chat Completions API (e.g. opencode.ai's `/zen/v1/chat/completions`). Those +streams use bare `data: {...}` frames with no `event:` headers, terminated +by `data: [DONE]`. The pre-fix reassembler required an explicit `event:` +line and silently dropped every Chat Completions response — opencode traces +showed `resp.body=None` and `sse_events=[]` for any non-Anthropic provider. +""" + +from __future__ import annotations + +from claude_tap.sse import SSEReassembler + + +def test_chat_completions_stream_events_are_captured() -> None: + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c1","choices":[{"delta":{"role":"assistant"}}]}\n\n' + b'data: {"id":"c1","choices":[{"delta":{"content":"OK"}}]}\n\n' + b'data: {"id":"c1","choices":[{"finish_reason":"stop","delta":{}}]}\n\n' + b"data: [DONE]\n\n" + ) + + # All three real frames captured; [DONE] is filtered as protocol noise. + assert len(r.events) == 3 + for ev in r.events: + assert ev["event"] == "message" + assert r.events[0]["data"]["choices"][0]["delta"] == {"role": "assistant"} + assert r.events[1]["data"]["choices"][0]["delta"] == {"content": "OK"} + assert r.events[2]["data"]["choices"][0]["finish_reason"] == "stop" + + # Snapshot is reconstructed so the viewer's Response section can render. + snap = r.reconstruct() + assert snap is not None + # True OpenAI Chat Completions shape preserved for fidelity. + assert snap["choices"][0]["message"]["role"] == "assistant" + assert snap["choices"][0]["message"]["content"] == "OK" + assert snap["choices"][0]["finish_reason"] == "stop" + # Anthropic-shape mirror so the existing renderer picks it up unchanged. + assert snap["content"] == [{"type": "text", "text": "OK"}] + + +def test_reassembler_can_reconstruct_without_storing_events() -> None: + r = SSEReassembler(store_events=False) + r.feed_bytes( + b'data: {"id":"c1","choices":[{"delta":{"role":"assistant"}}]}\n\n' + b'data: {"id":"c1","choices":[{"delta":{"content":"OK"}}]}\n\n' + b'data: {"id":"c1","choices":[{"finish_reason":"stop","delta":{}}]}\n\n' + ) + + assert r.events == [] + snap = r.reconstruct() + assert snap is not None + assert snap["choices"][0]["message"]["content"] == "OK" + assert snap["content"] == [{"type": "text", "text": "OK"}] + + +def test_gemini_stream_generate_content_reconstructs_bare_data_frames() -> None: + r = SSEReassembler(store_events=False) + r.feed_bytes( + b'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Hello "}]}}],' + b'"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":1,"totalTokenCount":8}}\n\n' + b'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Gemini."}]},' + b'"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7,' + b'"candidatesTokenCount":2,"totalTokenCount":9,"cachedContentTokenCount":3}}\n\n' + ) + + snap = r.reconstruct() + + assert snap is not None + assert snap["candidates"][0]["content"]["parts"] == [{"text": "Hello Gemini."}] + assert snap["candidates"][0]["finishReason"] == "STOP" + assert snap["content"] == [{"type": "text", "text": "Hello Gemini."}] + assert snap["usageMetadata"]["promptTokenCount"] == 7 + assert snap["usage"]["input_tokens"] == 7 + assert snap["usage"]["output_tokens"] == 2 + assert snap["usage"]["total_tokens"] == 9 + assert snap["usage"]["cache_read_input_tokens"] == 3 + + +def test_gemini_stream_generate_content_reconstructs_wrapped_response_frames() -> None: + r = SSEReassembler(store_events=False) + r.feed_bytes( + b'data: {"response":{"candidates":[{"content":{"role":"model",' + b'"parts":[{"text":"Wrapped "}]}}],"usageMetadata":{"promptTokenCount":11,' + b'"candidatesTokenCount":1,"totalTokenCount":12}}}\n\n' + b'data: {"response":{"candidates":[{"content":{"role":"model",' + b'"parts":[{"text":"Gemini."}]},"finishReason":"STOP"}],' + b'"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":2,' + b'"totalTokenCount":13,"cachedContentTokenCount":4}}}\n\n' + ) + + snap = r.reconstruct() + + assert snap is not None + assert snap["candidates"][0]["content"]["parts"] == [{"text": "Wrapped Gemini."}] + assert snap["candidates"][0]["finishReason"] == "STOP" + assert snap["content"] == [{"type": "text", "text": "Wrapped Gemini."}] + assert snap["usageMetadata"]["promptTokenCount"] == 11 + assert snap["usage"]["input_tokens"] == 11 + assert snap["usage"]["output_tokens"] == 2 + assert snap["usage"]["total_tokens"] == 13 + assert snap["usage"]["cache_read_input_tokens"] == 4 + + +def test_gemini_stream_generate_content_preserves_signed_text_parts() -> None: + r = SSEReassembler(store_events=False) + r.feed_bytes( + b'data: {"candidates":[{"content":{"role":"model","parts":[' + b'{"thought":true,"text":"A","thoughtSignature":"sig1"}]}}]}\n\n' + b'data: {"candidates":[{"content":{"role":"model","parts":[' + b'{"thought":true,"text":"B","thoughtSignature":"sig2"}]}}]}\n\n' + ) + + snap = r.reconstruct() + + assert snap is not None + assert snap["candidates"][0]["content"]["parts"] == [ + {"thought": True, "text": "A", "thoughtSignature": "sig1"}, + {"thought": True, "text": "B", "thoughtSignature": "sig2"}, + ] + assert snap["content"] == [{"type": "thinking", "thinking": "AB"}] + + +def test_chat_completions_usage_dual_naming() -> None: + """Final chunk's `usage` must be exposed under both Anthropic and OpenAI + keys so token displays that only know one schema still work.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c1","model":"hy3","choices":[{"delta":{"role":"assistant","content":"hi"}}]}\n\n' + b'data: {"id":"c1","choices":[{"delta":{},"finish_reason":"stop"}],' + b'"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}\n\n' + b"data: [DONE]\n\n" + ) + snap = r.reconstruct() + assert snap is not None + usage = snap["usage"] + # OpenAI naming preserved + assert usage["prompt_tokens"] == 12 + assert usage["completion_tokens"] == 3 + # Anthropic-aliased copies added so existing stat extractors pick them up + assert usage["input_tokens"] == 12 + assert usage["output_tokens"] == 3 + assert snap["model"] == "hy3" + + +def test_chat_completions_choice_usage_and_cached_tokens() -> None: + """Kimi streams usage inside the final choice object and may expose cached + input tokens as `cached_tokens`; both must feed existing token displays.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c_kimi","model":"kimi-k2","choices":[{"delta":{"role":"assistant","content":"hi"}}]}\n\n' + b'data: {"id":"c_kimi","choices":[{"delta":{},"finish_reason":"stop",' + b'"usage":{"prompt_tokens":8,"completion_tokens":5,"total_tokens":13,"cached_tokens":3}}]}\n\n' + b"data: [DONE]\n\n" + ) + + snap = r.reconstruct() + assert snap is not None + usage = snap["usage"] + assert usage["input_tokens"] == 8 + assert usage["output_tokens"] == 5 + assert usage["cache_read_input_tokens"] == 3 + + +def test_chat_completions_reasoning_content_is_mirrored_as_thinking() -> None: + """Kimi thinking mode streams `reasoning_content` deltas on Chat + Completions chunks. The viewer renders the mirrored thinking block.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c_kimi","choices":[{"delta":{"role":"assistant","reasoning_content":"Think "}}]}\n\n' + b'data: {"id":"c_kimi","choices":[{"delta":{"reasoning_content":"carefully."}}]}\n\n' + b'data: {"id":"c_kimi","choices":[{"delta":{"content":"Done."}}]}\n\n' + b"data: [DONE]\n\n" + ) + + snap = r.reconstruct() + assert snap is not None + assert snap["choices"][0]["message"]["reasoning_content"] == "Think carefully." + assert snap["choices"][0]["message"]["content"] == "Done." + assert snap["content"][0] == {"type": "thinking", "thinking": "Think carefully."} + assert snap["content"][1] == {"type": "text", "text": "Done."} + + +def test_chat_completions_reasoning_is_mirrored_as_thinking() -> None: + """Some OpenAI-compatible providers stream thinking as `delta.reasoning`. + + opencode users reported this shape with MiniMax-M2.7; it should render the + same viewer thinking block as `delta.reasoning_content`. + """ + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c_minimax","model":"MiniMax-M2.7",' + b'"choices":[{"delta":{"role":"assistant","reasoning":"Think "}}]}\n\n' + b'data: {"id":"c_minimax","choices":[{"delta":{"reasoning":"carefully."}}]}\n\n' + b'data: {"id":"c_minimax","choices":[{"delta":{"content":"Done."}}]}\n\n' + b"data: [DONE]\n\n" + ) + + snap = r.reconstruct() + assert snap is not None + assert snap["choices"][0]["message"]["reasoning"] == "Think carefully." + assert snap["choices"][0]["message"]["content"] == "Done." + assert snap["content"][0] == {"type": "thinking", "thinking": "Think carefully."} + assert snap["content"][1] == {"type": "text", "text": "Done."} + + +def test_chat_completions_reasoning_details_buffer_is_mirrored_as_thinking() -> None: + """MiniMax can stream thinking in `reasoning_details[].text` buffers.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c_minimax","model":"MiniMax-M2.7",' + b'"choices":[{"delta":{"role":"assistant","reasoning_details":' + b'[{"index":0,"type":"reasoning.text","text":"Think "},' + b'{"index":1,"type":"reasoning.text","text":"Check "}]}}]}\n\n' + b'data: {"id":"c_minimax","choices":[{"delta":{"reasoning_details":' + b'[{"index":0,"type":"reasoning.text","text":"Think carefully."},' + b'{"index":1,"type":"reasoning.text","text":"Check result."}],' + b'"reasoning_content":"Think carefully.\\n\\nCheck result."}}]}\n\n' + b'data: {"id":"c_minimax","choices":[{"delta":{"content":"Done."}}]}\n\n' + b"data: [DONE]\n\n" + ) + + snap = r.reconstruct() + assert snap is not None + msg = snap["choices"][0]["message"] + assert msg["reasoning_details"] == [ + {"index": 0, "type": "reasoning.text", "text": "Think carefully."}, + {"index": 1, "type": "reasoning.text", "text": "Check result."}, + ] + assert "reasoning_content" not in msg + assert msg["content"] == "Done." + assert snap["content"][0] == {"type": "thinking", "thinking": "Think carefully.\n\nCheck result."} + assert snap["content"][1] == {"type": "text", "text": "Done."} + + +def test_chat_completions_tool_call_accumulation() -> None: + """Tool calls stream as indexed deltas with name/arguments concatenated + across multiple chunks. Final snapshot must have the assembled call.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c1","choices":[{"delta":{"role":"assistant","tool_calls":' + b'[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}\n\n' + b'data: {"id":"c1","choices":[{"delta":{"tool_calls":' + b'[{"index":0,"function":{"arguments":"{\\"city\\":"}}]}}]}\n\n' + b'data: {"id":"c1","choices":[{"delta":{"tool_calls":' + b'[{"index":0,"function":{"arguments":"\\"SF\\"}"}}]}}]}\n\n' + b'data: {"id":"c1","choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n' + b"data: [DONE]\n\n" + ) + snap = r.reconstruct() + assert snap is not None + msg = snap["choices"][0]["message"] + assert msg["tool_calls"][0]["id"] == "call_1" + assert msg["tool_calls"][0]["function"]["name"] == "get_weather" + assert msg["tool_calls"][0]["function"]["arguments"] == '{"city":"SF"}' + assert snap["choices"][0]["finish_reason"] == "tool_calls" + + # Tool call must also be mirrored into Anthropic-shape `content` so the + # viewer (which only reads body.content) can render the tool call. + tool_use_blocks = [b for b in snap["content"] if b.get("type") == "tool_use"] + assert len(tool_use_blocks) == 1 + assert tool_use_blocks[0]["id"] == "call_1" + assert tool_use_blocks[0]["name"] == "get_weather" + assert tool_use_blocks[0]["input"] == {"city": "SF"} + + +def test_chat_completions_tool_only_response_renders_via_content() -> None: + """Pure tool-call responses (no text) must still surface the tool call + through `content`, otherwise the viewer's Response section is blank and + the sidebar's response_tool_names extractor sees nothing.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c2","choices":[{"delta":{"role":"assistant","tool_calls":' + b'[{"index":0,"id":"call_a","type":"function","function":' + b'{"name":"search","arguments":"{\\"q\\":\\"go\\"}"}}]}}]}\n\n' + b'data: {"id":"c2","choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n' + b"data: [DONE]\n\n" + ) + snap = r.reconstruct() + assert snap is not None + # Text mirror is empty (no delta.content was sent). + text_blocks = [b for b in snap["content"] if b.get("type") == "text"] + assert text_blocks and text_blocks[0]["text"] == "" + # tool_use block carries the call so viewer/export can render it. + tool_use = [b for b in snap["content"] if b.get("type") == "tool_use"] + assert len(tool_use) == 1 + assert tool_use[0]["name"] == "search" + assert tool_use[0]["input"] == {"q": "go"} + + +def test_chat_completions_parallel_tool_calls_each_mirror() -> None: + """When a single delta carries multiple tool_calls (parallel calls), + every entry must end up as its own tool_use block in `content`.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c3","choices":[{"delta":{"role":"assistant","tool_calls":[' + b'{"index":0,"id":"a","type":"function","function":{"name":"f1","arguments":"{}"}},' + b'{"index":1,"id":"b","type":"function","function":{"name":"f2","arguments":"{}"}}' + b"]}}]}\n\n" + b'data: {"id":"c3","choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\n' + b"data: [DONE]\n\n" + ) + snap = r.reconstruct() + assert snap is not None + tool_use = [b for b in snap["content"] if b.get("type") == "tool_use"] + names = [b["name"] for b in tool_use] + assert names == ["f1", "f2"] + ids = [b["id"] for b in tool_use] + assert ids == ["a", "b"] + + +def test_chat_completions_usage_only_final_chunk_is_captured() -> None: + """Some providers send a final chunk with empty `choices` and only + `usage` populated; that token info must still land in the snapshot.""" + r = SSEReassembler() + r.feed_bytes( + b'data: {"id":"c4","model":"hy3","choices":[{"delta":{"role":"assistant","content":"hi"}}]}\n\n' + b'data: {"id":"c4","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n' + b'data: {"id":"c4","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}\n\n' + b"data: [DONE]\n\n" + ) + snap = r.reconstruct() + assert snap is not None + assert snap["usage"]["prompt_tokens"] == 10 + assert snap["usage"]["completion_tokens"] == 2 + # Dual naming applied so Anthropic-oriented stat code keeps working. + assert snap["usage"]["input_tokens"] == 10 + assert snap["usage"]["output_tokens"] == 2 + + +def test_chat_completions_usage_only_chunk_without_prior_snapshot_is_skipped() -> None: + """Edge case: if a usage-only chunk arrives before any choices have set + up the snapshot, it is dropped (no body context to attach to).""" + r = SSEReassembler() + r.feed_bytes(b'data: {"choices":[],"usage":{"prompt_tokens":1}}\n\n') + assert r.reconstruct() is None + + +def test_chat_completions_done_sentinel_is_filtered() -> None: + r = SSEReassembler() + r.feed_bytes(b"data: [DONE]\n\n") + assert r.events == [] + + +def test_chat_completions_chunked_across_feeds() -> None: + """Bytes split mid-frame must still produce one coherent event.""" + r = SSEReassembler() + r.feed_bytes(b'data: {"id":"c1","choices":[{"de') + r.feed_bytes(b'lta":{"content":"hel') + r.feed_bytes(b'lo"}}]}\n\n') + assert len(r.events) == 1 + assert r.events[0]["data"]["choices"][0]["delta"]["content"] == "hello" + + +def test_anthropic_stream_unchanged() -> None: + """The Chat Completions support must not regress Anthropic snapshot + reconstruction — it's the path claude/codex rely on.""" + r = SSEReassembler() + r.feed_bytes( + b"event: message_start\n" + b'data: {"type":"message_start","message":{"id":"m1","role":"assistant",' + b'"content":[],"model":"claude-x","usage":{"input_tokens":3,"output_tokens":0}}}\n\n' + b"event: content_block_start\n" + b'data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n' + b"event: content_block_delta\n" + b'data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}\n\n' + b"event: content_block_stop\n" + b'data: {"type":"content_block_stop","index":0}\n\n' + b"event: message_delta\n" + b'data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n' + b'event: message_stop\ndata: {"type":"message_stop"}\n\n' + ) + snap = r.reconstruct() + assert snap is not None + assert snap["content"][0]["text"] == "hi" + assert snap["usage"]["output_tokens"] == 1 + + +def test_mixed_event_and_bare_data_in_one_stream() -> None: + """Defensive: a stream that mixes both shapes shouldn't crash. The bare + frames emit as default-type events; the named ones use their declared name.""" + r = SSEReassembler() + r.feed_bytes(b'data: {"bare":1}\n\nevent: ping\ndata: {"named":2}\n\ndata: {"bare":3}\n\n') + assert [e["event"] for e in r.events] == ["message", "ping", "message"] + assert [e["data"] for e in r.events] == [{"bare": 1}, {"named": 2}, {"bare": 3}] diff --git a/tests/test_check_changelog.py b/tests/test_check_changelog.py new file mode 100644 index 0000000..6951e53 --- /dev/null +++ b/tests/test_check_changelog.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/check_changelog.py.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "check_changelog.py" +MODULE_NAME = "check_changelog" + + +def _load_module(): + spec = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +def test_normalize_tag_accepts_semver_release_tags() -> None: + module = _load_module() + + assert module.normalize_tag("v0.1.40") == "0.1.40" + + +def test_normalize_tag_rejects_non_release_tags() -> None: + module = _load_module() + + assert module.normalize_tag("0.1.40") is None + assert module.normalize_tag("v0.1.40rc1") is None + assert module.normalize_tag("release-0.1.40") is None + + +def test_changelog_versions_reads_keep_a_changelog_headings() -> None: + module = _load_module() + text = """# Changelog + +## [Unreleased] + +## [0.1.40] - 2026-05-03 + +## [0.1.39] +""" + + assert module.changelog_versions(text) == {"0.1.40", "0.1.39"} diff --git a/tests/test_check_coverage.py b/tests/test_check_coverage.py new file mode 100644 index 0000000..9d89f96 --- /dev/null +++ b/tests/test_check_coverage.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import json +import subprocess + +import scripts.check_coverage as coverage_module +from scripts.check_coverage import ( + VIEWER_JS_SOURCES, + _filter_pure_viewer_asset_split, + _is_function_covered, + _tag_content, + _viewer_script_functions, + changed_lines_from_diff, + changed_viewer_css_selectors, + changed_viewer_functions, + check_python_coverage, + check_viewer_css_coverage, + css_selector_ranges, + js_function_ranges, +) + + +def test_changed_lines_from_diff_extracts_new_line_numbers() -> None: + diff = """diff --git a/claude_tap/viewer.py b/claude_tap/viewer.py +--- a/claude_tap/viewer.py ++++ b/claude_tap/viewer.py +@@ -10,0 +11,2 @@ ++def added(): ++ return True +@@ -20,2 +22,2 @@ +-old = 1 ++new = 2 + context = True +""" + + assert changed_lines_from_diff(diff) == {"claude_tap/viewer.py": {11, 12, 22}} + + +def test_filter_pure_viewer_asset_split_ignores_exact_extracted_assets(monkeypatch, tmp_path) -> None: + base_html = """ +""" + assets = tmp_path / "claude_tap" / "viewer_assets" + assets.mkdir(parents=True) + (assets / "viewer.css").write_text(".x { color: red; }\n", encoding="utf-8") + for source in VIEWER_JS_SOURCES: + path = tmp_path / source + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("", encoding="utf-8") + (tmp_path / VIEWER_JS_SOURCES[0]).write_text("function moved() { return true; }\n", encoding="utf-8") + (tmp_path / "claude_tap" / "viewer.html").write_text( + """ +""", + encoding="utf-8", + ) + + def fake_check_output(cmd, **kwargs): + assert cmd == ["git", "show", "origin/main:claude_tap/viewer.html"] + return base_html + + monkeypatch.setattr(coverage_module, "REPO_ROOT", tmp_path) + monkeypatch.setattr(subprocess, "check_output", fake_check_output) + + filtered = _filter_pure_viewer_asset_split( + { + "claude_tap/viewer_assets/viewer.css": {1}, + VIEWER_JS_SOURCES[0]: {1}, + "claude_tap/viewer.html": {1}, + "claude_tap/viewer.py": {10}, + }, + "origin/main", + ) + + assert filtered == {"claude_tap/viewer.py": {10}} + assert _tag_content(base_html, "style") == ".x { color: red; }" + + +def test_changed_viewer_functions_does_not_fallback_to_template_lines_for_split_asset(tmp_path) -> None: + viewer = tmp_path / "state.js" + viewer.write_text( + """function realAssetFunction() { + return true; +} +""", + encoding="utf-8", + ) + + assert changed_viewer_functions(viewer, {"claude_tap/viewer.html": {1, 2}}) == set() + + +def test_check_python_coverage_counts_only_changed_executable_package_lines(tmp_path) -> None: + report = { + "totals": {"percent_covered": 75.0}, + "files": { + "claude_tap/viewer.py": { + "executed_lines": [10, 11, 13], + "missing_lines": [12], + "excluded_lines": [], + } + }, + } + report_path = tmp_path / "coverage.json" + report_path.write_text(json.dumps(report), encoding="utf-8") + + results = check_python_coverage( + report_path, + {"claude_tap/viewer.py": {10, 12, 99}, "tests/test_viewer.py": {1}}, + total_min=65.0, + diff_min=80.0, + ) + + assert results[0].name == "python_total" + assert results[0].passed is True + assert results[1].name == "python_diff" + assert results[1].percent == 50.0 + assert results[1].passed is False + assert results[1].detail == "1/2 changed executable Python lines covered" + + +def test_js_function_ranges_and_changed_viewer_functions_find_touched_functions(tmp_path) -> None: + viewer = tmp_path / "viewer.js" + viewer.write_text( + """function untouched() { + return 1; +} +function changedOne() { + const value = 2; + return value; +} +function changedTwo() { return 3; } +""", + encoding="utf-8", + ) + + assert js_function_ranges(viewer.read_text(encoding="utf-8")) == { + "untouched": (1, 3), + "changedOne": (4, 7), + "changedTwo": (8, 8), + } + assert changed_viewer_functions( + viewer, + {"claude_tap/viewer_assets/viewer.js": {5, 8}}, + ) == {"changedOne", "changedTwo"} + + +def test_css_selector_ranges_and_changed_viewer_css_selectors_find_touched_rules(tmp_path) -> None: + viewer = tmp_path / "viewer.css" + viewer.write_text( + """.header, .toolbar { + display: flex; +} +.button:hover { color: blue; } +@media (max-width: 768px) { + #detail.mobile-fullwidth { width: 100%; } + .header { display: block; } +} +""", + encoding="utf-8", + ) + + assert css_selector_ranges(viewer.read_text(encoding="utf-8")) == { + ".header": [(1, 3), (7, 7)], + ".toolbar": [(1, 3)], + "#detail.mobile-fullwidth": [(6, 6)], + } + assert changed_viewer_css_selectors( + viewer, + {"claude_tap/viewer_assets/viewer.css": {2, 6, 7}}, + ) == {".header", ".toolbar", "#detail.mobile-fullwidth"} + assert changed_viewer_css_selectors( + viewer, + {"claude_tap/viewer_assets/viewer.css": {7}}, + ) == {".header"} + + +def test_viewer_script_functions_filters_top_level_wrapper_and_detects_coverage() -> None: + script = { + "functions": [ + {"functionName": "", "ranges": [{"startOffset": 0, "endOffset": 1000, "count": 1}]}, + {"functionName": "renderEmptyTraceState", "ranges": [{"startOffset": 100, "endOffset": 220, "count": 1}]}, + {"functionName": "initFileDropZone", "ranges": [{"startOffset": 240, "endOffset": 320, "count": 0}]}, + ] + } + + functions = _viewer_script_functions(script) + + assert [function["functionName"] for function in functions] == ["renderEmptyTraceState", "initFileDropZone"] + assert _is_function_covered(functions[0]) is True + assert _is_function_covered(functions[1]) is False + + +def test_check_viewer_css_coverage_enforces_changed_selector_matches() -> None: + results = check_viewer_css_coverage( + {".covered", ".missing"}, + selector_min=60.0, + diff_min=80.0, + coverage=(75.0, {".covered", ".other"}, 3, 4, 1), + ) + + assert results[0].name == "viewer_css_selectors" + assert results[0].passed is True + assert results[0].detail == "3/4 queryable CSS selectors matched; 1 state/pseudo selectors skipped" + assert results[1].name == "viewer_css_diff" + assert results[1].percent == 50.0 + assert results[1].passed is False + assert results[1].detail == "1/2 changed CSS selectors matched; missing: .missing" diff --git a/tests/test_check_legibility.py b/tests/test_check_legibility.py new file mode 100644 index 0000000..3382551 --- /dev/null +++ b/tests/test_check_legibility.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/check_legibility.py.""" + +from __future__ import annotations + +import datetime as dt +import importlib.util +import sys +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "check_legibility.py" +MODULE_NAME = "check_legibility" +AGENT_DOCS = Path(".agents") / "docs" + + +def _load_module(): + spec = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +def _write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _base_repo(tmp_path: Path, *, last_reviewed: str = "2026-03-01", plan_status: str = "active") -> Path: + _write( + tmp_path / AGENT_DOCS / "standards" / "policy.md", + (f"---\nowner: docs-team\nlast_reviewed: {last_reviewed}\nsource_of_truth: AGENTS.md\n---\n\n# Policy\n"), + ) + _write( + tmp_path / AGENT_DOCS / "architecture" / "manifest.yaml", + "expected_paths:\n - .agents/docs/standards/policy.md\n - .agents/docs/plans/plan.md\n", + ) + _write( + tmp_path / AGENT_DOCS / "plans" / "plan.md", + f"---\nstatus: {plan_status}\n---\n\n# Plan\n\nNo TODO items.\n", + ) + return tmp_path + + +def test_run_checks_happy_path(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path) + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert result.failures == [] + assert result.warnings == [] + + +def test_stale_standards_is_warning_in_mvp_mode(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path, last_reviewed="2025-01-01") + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert result.failures == [] + assert any("stale" in warning for warning in result.warnings) + + +def test_stale_standards_fails_in_strict_mode(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path, last_reviewed="2025-01-01") + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=True, + today=dt.date(2026, 3, 3), + ) + + assert result.warnings == [] + assert any("stale" in failure for failure in result.failures) + + +def test_missing_manifest_path_fails(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path) + _write( + repo_root / AGENT_DOCS / "architecture" / "manifest.yaml", + "expected_paths:\n - .agents/docs/standards/policy.md\n - docs/does-not-exist.md\n", + ) + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert any("expected path missing" in failure for failure in result.failures) + + +def test_manifest_rejects_absolute_expected_path(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path) + _write( + repo_root / AGENT_DOCS / "architecture" / "manifest.yaml", + "expected_paths:\n - /tmp/not-allowed.md\n", + ) + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert any("must be relative" in failure for failure in result.failures) + + +def test_manifest_rejects_expected_path_outside_repo_root(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path) + _write( + repo_root / AGENT_DOCS / "architecture" / "manifest.yaml", + "expected_paths:\n - ../escape.md\n", + ) + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert any("outside repo root" in failure for failure in result.failures) + + +def test_completed_plan_with_unchecked_todo_fails(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path, plan_status="completed") + _write( + repo_root / AGENT_DOCS / "plans" / "plan.md", + "---\nstatus: completed\n---\n\n# Plan\n\n- [ ] unresolved item\n", + ) + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert any("unchecked TODO" in failure for failure in result.failures) + + +def test_completed_plan_ignores_unchecked_todo_in_fenced_code(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path, plan_status="completed") + _write( + repo_root / AGENT_DOCS / "plans" / "plan.md", + ("---\nstatus: completed\n---\n\n# Plan\n\n```markdown\n- [ ] example only\n```\n\nAll work done.\n"), + ) + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert not any("unchecked TODO" in failure for failure in result.failures) + + +def test_public_docs_bilingual_pairs_pass(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path) + _write(repo_root / "README.md", "# English\n") + _write(repo_root / "README_zh.md", "# 中文\n") + _write(repo_root / "docs" / "guides" / "feature.md", "# Feature\n") + _write(repo_root / "docs" / "guides" / "feature.zh.md", "# 功能\n") + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert result.failures == [] + + +def test_public_docs_missing_chinese_counterpart_fails(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path) + _write(repo_root / "docs" / "guides" / "feature.md", "# Feature\n") + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert any("missing Simplified Chinese counterpart" in failure for failure in result.failures) + + +def test_public_docs_missing_english_counterpart_fails(tmp_path: Path) -> None: + module = _load_module() + repo_root = _base_repo(tmp_path) + _write(repo_root / "docs" / "guides" / "feature.zh.md", "# 功能\n") + + result = module.run_checks( + repo_root, + freshness_days=60, + strict_freshness=False, + today=dt.date(2026, 3, 3), + ) + + assert any("missing English counterpart" in failure for failure in result.failures) diff --git a/tests/test_check_pr_policy.py b/tests/test_check_pr_policy.py new file mode 100644 index 0000000..1193b82 --- /dev/null +++ b/tests/test_check_pr_policy.py @@ -0,0 +1,223 @@ +"""Unit tests for scripts/check_pr_policy.py.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import Any + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "check_pr_policy.py" +MODULE_NAME = "check_pr_policy" + + +def _load_module() -> Any: + spec = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_policy_requires_raw_screenshot_for_client_changes() -> None: + module = _load_module() + body = """## Summary +- Add a client. + +## Test plan +- `uv run pytest tests/ -x --timeout=60` +""" + + result = module.validate_policy(body, ["claude_tap/cli.py"]) + + assert result.ok is False + assert any("no raw.githubusercontent.com screenshot evidence" in item for item in result.failures) + + +def test_policy_accepts_raw_screenshot_for_runtime_changes() -> None: + module = _load_module() + body = """## Summary +- Update viewer behavior. + +## Validation +- `uv run pytest tests/ -x --timeout=60` + +## Evidence +![viewer](https://raw.githubusercontent.com/liaohch3/claude-tap/branch/.agents/evidence/pr/viewer.png) + +Trace viewer screenshot exported from `.traces/trace_120000.jsonl`. +""" + + result = module.validate_policy(body, ["claude_tap/viewer.html"]) + + assert result.ok is True + + +def test_policy_requires_raw_screenshot_for_package_runtime_changes() -> None: + module = _load_module() + body = """## Summary +- Update HTML export behavior. + +## Validation +- `uv run pytest tests/ -x --timeout=60` +""" + + result = module.validate_policy(body, ["claude_tap/export.py"]) + + assert result.ok is False + assert any("no raw.githubusercontent.com screenshot evidence" in item for item in result.failures) + + +def test_policy_rejects_non_raw_image_urls() -> None: + module = _load_module() + body = """## Summary +- Update viewer behavior. + +## Validation +- `uv run pytest tests/ -x --timeout=60` + +## Evidence +![viewer](https://github.com/liaohch3/claude-tap/blob/branch/viewer.png) +""" + + result = module.validate_policy(body, ["claude_tap/viewer.html"]) + + assert result.ok is False + assert "PR image evidence must use raw.githubusercontent.com URLs" in result.failures + + +def test_policy_requires_committed_evidence_image_for_runtime_changes() -> None: + module = _load_module() + body = """## Summary +- Update viewer behavior. + +## Validation +- `uv run pytest tests/ -x --timeout=60` + +## Evidence +![viewer](https://raw.githubusercontent.com/liaohch3/claude-tap/branch/tmp/viewer.png) + +Trace viewer screenshot exported from `.traces/trace_120000.jsonl`. +""" + + result = module.validate_policy(body, ["claude_tap/viewer.html"]) + + assert result.ok is False + assert "PR runtime screenshot evidence must link to committed .agents/evidence/pr/ images" in result.failures + + +def test_policy_rejects_test_output_art_for_runtime_changes() -> None: + module = _load_module() + body = """## Summary +- Update proxy behavior. + +## Validation +- `uv run pytest tests/ -x --timeout=60` + +## Evidence +![tests](https://raw.githubusercontent.com/liaohch3/claude-tap/branch/.agents/evidence/pr/227/package-noise-tests.png) + +Trace viewer screenshot exported from `.traces/trace_120000.jsonl`. +""" + + result = module.validate_policy(body, ["claude_tap/forward_proxy.py"]) + + assert result.ok is False + assert ( + "PR runtime screenshot evidence must be trace/viewer/dashboard evidence, not test-output art" in result.failures + ) + + +def test_policy_requires_real_trace_source_for_runtime_evidence() -> None: + module = _load_module() + body = """## Summary +- Update proxy behavior. + +## Validation +- `uv run pytest tests/ -x --timeout=60` + +## Evidence +![viewer](https://raw.githubusercontent.com/liaohch3/claude-tap/branch/.agents/evidence/pr/227/trace-viewer.png) +""" + + result = module.validate_policy(body, ["claude_tap/forward_proxy.py"]) + + assert result.ok is False + assert "PR runtime evidence must document the real trace/viewer source used for screenshots" in result.failures + + +def test_policy_does_not_accept_real_trace_markers_from_image_urls() -> None: + module = _load_module() + body = """## Summary +- Update proxy behavior. + +## Validation +- `uv run pytest tests/ -x --timeout=60` + +## Evidence +![viewer](https://raw.githubusercontent.com/liaohch3/claude-tap/branch/.agents/evidence/pr/227/dashboard-trace-viewer.png) +""" + + result = module.validate_policy(body, ["claude_tap/forward_proxy.py"]) + + assert result.ok is False + assert "PR runtime evidence must document the real trace/viewer source used for screenshots" in result.failures + + +def test_policy_blocks_raw_trace_artifacts() -> None: + module = _load_module() + body = """## Summary +- Add evidence. + +## Validation +- `uv run pytest tests/ -x --timeout=60` +""" + + result = module.validate_policy(body, [".traces/2026-05-20/trace_120000.jsonl"]) + + assert result.ok is False + assert "PR includes raw trace, generated viewer, log, or secret-like files" in result.failures + + +def test_policy_allows_docs_without_runtime_evidence() -> None: + module = _load_module() + body = """## Summary +- Clarify docs. + +## Validation +- Reviewed only. +""" + + result = module.validate_policy(body, ["docs/guides/example.md"]) + + assert result.ok is True + + +def test_policy_reads_github_event_body(tmp_path: Path, capsys) -> None: + module = _load_module() + event = tmp_path / "event.json" + files = tmp_path / "files.txt" + event.write_text( + json.dumps( + { + "pull_request": { + "body": """## Summary +- Add a client. + +## Validation +- `uv run pytest tests/ -x --timeout=60` +""" + } + } + ), + encoding="utf-8", + ) + files.write_text("claude_tap/cli.py\n", encoding="utf-8") + + exit_code = module.main(["--event-path", str(event), "--changed-files-file", str(files)]) + + output = capsys.readouterr().out + assert exit_code == 2 + assert "PR Policy: FAIL" in output diff --git a/tests/test_check_pr_script.py b/tests/test_check_pr_script.py new file mode 100644 index 0000000..b33d96e --- /dev/null +++ b/tests/test_check_pr_script.py @@ -0,0 +1,199 @@ +"""Focused tests for scripts/check_pr.sh verdict behavior.""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "check_pr.sh" + + +GH_STUB = """#!/bin/sh +set -eu + +cmd1=${1:-} +cmd2=${2:-} + +if [ "$cmd1" = "repo" ] && [ "$cmd2" = "view" ]; then + echo "octo/demo" + exit 0 +fi + +if [ "$cmd1" = "pr" ] && [ "$cmd2" = "view" ]; then + # Check if this is a body-only query + case "$*" in + *--json\\ body*) + if [ "${GH_BODY_MODE:-}" = "missing_evidence" ]; then + cat <<'EOF' +## Summary +- Improve merge readiness automation. + +## Validation +- `uv run pytest tests/ -x --timeout=60` +EOF + exit 0 + fi + cat <<'EOF' +## Summary +- Improve merge readiness automation. + +## Validation +- `uv run pytest tests/ -x --timeout=60` + +## Evidence +![trace](https://raw.githubusercontent.com/octo/demo/feature/checker/.agents/evidence/pr/trace.png) + +Trace viewer screenshot exported from `.traces/trace_120000.jsonl`. +EOF + exit 0 + ;; + *--json\\ files*) + echo 'claude_tap/viewer.html' + exit 0 + ;; + esac + cat <<'EOF' +Improve merge readiness automation +OPEN +false +CLEAN +feature/checker +main +https://github.com/octo/demo/pull/42 +EOF + exit 0 +fi + +if [ "$cmd1" = "pr" ] && [ "$cmd2" = "checks" ]; then + if [ "${GH_CHECKS_MODE:-}" = "pending_exit8" ]; then + cat <<'EOF' +pass lint success +pending test pending +EOF + exit 8 + fi + cat <<'EOF' +pass lint success +pass test success +EOF + exit 0 +fi + +echo "unexpected gh args: $*" >&2 +exit 1 +""" + +UV_STUB = """#!/bin/sh +set -eu + +if [ "${FAIL_GATE:-}" = "pytest" ] && [ "${1:-}" = "run" ] && [ "${2:-}" = "pytest" ]; then + exit 1 +fi +if [ "${FAIL_GATE:-}" = "ruff-check" ] && [ "${1:-}" = "run" ] && [ "${2:-}" = "ruff" ] && [ "${3:-}" = "check" ]; then + exit 1 +fi +if [ "${FAIL_GATE:-}" = "ruff-format" ] && [ "${1:-}" = "run" ] && [ "${2:-}" = "ruff" ] && [ "${3:-}" = "format" ]; then + exit 1 +fi +exit 0 +""" + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + mode = path.stat().st_mode + path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def test_check_pr_reports_ready_with_no_tests(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable(fake_bin / "gh", GH_STUB) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + + result = subprocess.run( + [str(SCRIPT_PATH), "42", "--no-tests"], + capture_output=True, + text=True, + check=False, + env=env, + cwd=SCRIPT_PATH.parent.parent, + ) + + assert result.returncode == 0 + assert "Checks: pass=2 fail=0 pending=0 total=2" in result.stdout + assert "VERDICT: READY" in result.stdout + + +def test_check_pr_reports_not_ready_when_local_gate_fails(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable(fake_bin / "gh", GH_STUB) + _write_executable(fake_bin / "uv", UV_STUB) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + env["FAIL_GATE"] = "pytest" + + result = subprocess.run( + [str(SCRIPT_PATH), "42"], + capture_output=True, + text=True, + check=False, + env=env, + cwd=SCRIPT_PATH.parent.parent, + ) + + assert result.returncode == 2 + assert "FAIL uv run pytest tests/ -x --timeout=60" in result.stdout + assert "VERDICT: NOT_READY - local gates failed" in result.stdout + + +def test_check_pr_handles_pending_checks_exit_code(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable(fake_bin / "gh", GH_STUB) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + env["GH_CHECKS_MODE"] = "pending_exit8" + + result = subprocess.run( + [str(SCRIPT_PATH), "42", "--no-tests"], + capture_output=True, + text=True, + check=False, + env=env, + cwd=SCRIPT_PATH.parent.parent, + ) + + assert result.returncode == 2 + assert "Checks: pass=1 fail=0 pending=1 total=2" in result.stdout + assert "VERDICT: NOT_READY - 1 CI check(s) pending" in result.stdout + + +def test_check_pr_reports_pr_policy_failure(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable(fake_bin / "gh", GH_STUB) + + env = os.environ.copy() + env["PATH"] = f"{fake_bin}{os.pathsep}{env['PATH']}" + env["GH_BODY_MODE"] = "missing_evidence" + + result = subprocess.run( + [str(SCRIPT_PATH), "42", "--no-tests"], + capture_output=True, + text=True, + check=False, + env=env, + cwd=SCRIPT_PATH.parent.parent, + ) + + assert result.returncode == 2 + assert "PR Policy: FAIL" in result.stdout + assert "VERDICT: NOT_READY - PR policy failed" in result.stdout diff --git a/tests/test_check_screenshots.py b/tests/test_check_screenshots.py new file mode 100644 index 0000000..7fc74a0 --- /dev/null +++ b/tests/test_check_screenshots.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/check_screenshots.py.""" + +from __future__ import annotations + +import binascii +import importlib.util +import shutil +import struct +import subprocess +import sys +import zlib +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "check_screenshots.py" +MODULE_NAME = "check_screenshots" + + +def _load_module(): + spec = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +def _png_chunk(chunk_type: bytes, data: bytes) -> bytes: + payload = chunk_type + data + crc = binascii.crc32(payload) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + payload + struct.pack(">I", crc) + + +def _write_png(path: Path, width: int, height: int, color: tuple[int, int, int]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + row = bytes(color) * width + raw_scanlines = b"".join(b"\x00" + row for _ in range(height)) + idat = zlib.compress(raw_scanlines) + png = b"\x89PNG\r\n\x1a\n" + _png_chunk(b"IHDR", ihdr) + _png_chunk(b"IDAT", idat) + _png_chunk(b"IEND", b"") + path.write_bytes(png) + + +def _write_noisy_png(path: Path, width: int, height: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + rows = [] + for y in range(height): + row = bytearray() + for x in range(width): + row.extend(((x * 17 + y * 31) % 256, (x * 29 + y * 11) % 256, (x * 7 + y * 19) % 256)) + rows.append(b"\x00" + bytes(row)) + png = ( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", ihdr) + + _png_chunk(b"IDAT", zlib.compress(b"".join(rows))) + + _png_chunk(b"IEND", b"") + ) + path.write_bytes(png) + + +def test_analyze_file_pass_for_wide_desktop_png(tmp_path: Path) -> None: + module = _load_module() + image = tmp_path / "wide.png" + _write_png(image, width=1440, height=900, color=(80, 120, 160)) + + result = module.analyze_file(image) + + assert result.status == "PASS" + assert result.failures == [] + assert result.warnings == [] + + +def test_analyze_file_warns_for_narrow_desktop_png(tmp_path: Path) -> None: + module = _load_module() + image = tmp_path / "narrow.png" + _write_png(image, width=1024, height=900, color=(80, 120, 160)) + + result = module.analyze_file(image) + + assert result.status == "WARN" + assert any("narrow desktop viewport" in warning for warning in result.warnings) + assert result.failures == [] + + +def test_analyze_file_fails_for_very_small_png(tmp_path: Path) -> None: + module = _load_module() + image = tmp_path / "small.png" + _write_png(image, width=399, height=600, color=(80, 120, 160)) + + result = module.analyze_file(image) + + assert result.status == "FAIL" + assert any("very small image" in failure for failure in result.failures) + + +def test_analyze_file_fails_for_mostly_blank_white_png(tmp_path: Path) -> None: + module = _load_module() + image = tmp_path / "blank.png" + _write_png(image, width=1440, height=900, color=(255, 255, 255)) + + result = module.analyze_file(image) + + assert result.status == "FAIL" + assert any("mostly blank/white image" in failure for failure in result.failures) + + +def test_parse_png_dominant_color_ratio_supports_sampling_large_images(tmp_path: Path) -> None: + module = _load_module() + image = tmp_path / "large-blank.png" + _write_png(image, width=1440, height=900, color=(255, 255, 255)) + + ratio, dominant_color = module.parse_png_dominant_color_ratio(image.read_bytes(), max_sample_pixels=128) + + assert ratio == 1 + assert dominant_color == (255, 255, 255, 255) + + +def test_main_warns_when_no_images_are_found(tmp_path: Path, capsys) -> None: + module = _load_module() + empty_dir = tmp_path / "no-images" + empty_dir.mkdir(parents=True, exist_ok=True) + + exit_code = module.main([str(empty_dir)]) + output = capsys.readouterr().out + + assert exit_code == 0 + assert "[WARN] No image files found for input paths." in output + + +def test_shell_wrapper_handles_multiple_passes(tmp_path: Path) -> None: + if not shutil.which("identify") and not shutil.which("sips"): + return + + images = [tmp_path / "one.png", tmp_path / "two.png"] + for image in images: + _write_noisy_png(image, width=1440, height=900) + + result = subprocess.run( + ["bash", "scripts/check_screenshots.sh", *(str(image) for image in images)], + check=False, + cwd=SCRIPT_PATH.parent.parent, + text=True, + capture_output=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Results: 2 passed, 0 failed, 0 warnings" in result.stdout diff --git a/tests/test_client_config_framework.py b/tests/test_client_config_framework.py new file mode 100644 index 0000000..134bdb8 --- /dev/null +++ b/tests/test_client_config_framework.py @@ -0,0 +1,542 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from claude_tap import cli_clients, parse_args +from claude_tap.cli import CLIENT_CONFIGS, ClientConfig, run_client + +SUPPORTED_CLIENTS = { + "agy", + "claude", + "codex", + "codexapp", + "gemini", + "kimi", + "kimi-code", + "mimo", + "opencode", + "openclaw", + "pi", + "hermes", + "cursor", + "qoder", + "codebuddy", +} + +SINGLE_REVERSE_ENV_CLIENTS = SUPPORTED_CLIENTS - {"claude", "gemini", "kimi-code", "openclaw", "codexapp"} + +SUPPORTED_DEFAULT_PROXY_MODES = { + "agy": "forward", + "claude": "reverse", + "codex": "reverse", + "codexapp": "transcript", + "gemini": "forward", + "kimi": "reverse", + "kimi-code": "reverse", + "mimo": "forward", + "opencode": "forward", + "openclaw": "reverse", + "pi": "forward", + "hermes": "forward", + "cursor": "forward", + "qoder": "forward", + "codebuddy": "reverse", +} + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_client_matrix_contains_only_supported_clients() -> None: + assert set(CLIENT_CONFIGS) == SUPPORTED_CLIENTS + + +def test_only_agy_auto_trusts_ca_on_macos() -> None: + auto_trust_clients = {client for client, cfg in CLIENT_CONFIGS.items() if cfg.auto_trust_ca_macos} + + assert auto_trust_clients == {"agy"} + + +@pytest.mark.parametrize("client", sorted(SUPPORTED_CLIENTS)) +def test_supported_client_default_proxy_modes_are_unchanged( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + client: str, +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home")) + + args = parse_args(["--tap-client", client]) + + assert args.client == client + assert args.proxy_mode == SUPPORTED_DEFAULT_PROXY_MODES[client] + + +@pytest.mark.parametrize("client", sorted(SINGLE_REVERSE_ENV_CLIENTS)) +def test_single_env_clients_keep_single_reverse_base_url_env(client: str) -> None: + cfg = CLIENT_CONFIGS[client] + + assert cfg.reverse_base_url_envs == (cfg.base_url_env,) + + +def test_gemini_declares_both_reverse_base_url_envs() -> None: + cfg = CLIENT_CONFIGS["gemini"] + + assert cfg.reverse_base_url_envs == ("GOOGLE_GEMINI_BASE_URL", "GOOGLE_VERTEX_BASE_URL") + assert cfg.reverse_base_url_env_map(43123) == { + "GOOGLE_GEMINI_BASE_URL": "http://127.0.0.1:43123", + "GOOGLE_VERTEX_BASE_URL": "http://127.0.0.1:43123", + } + + +def test_claude_declares_provider_reverse_base_url_envs() -> None: + cfg = CLIENT_CONFIGS["claude"] + + assert cfg.reverse_base_url_envs == ( + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BEDROCK_BASE_URL", + "ANTHROPIC_VERTEX_BASE_URL", + ) + + +def test_agy_declares_cloud_code_bridge_env() -> None: + cfg = CLIENT_CONFIGS["agy"] + + assert cfg.base_url_env == "CLOUD_CODE_URL" + assert cfg.default_target == "https://daily-cloudcode-pa.googleapis.com" + assert cfg.forward_base_url_envs == ("CLOUD_CODE_URL",) + assert cfg.forward_base_url_allowed_path_prefixes == ("/v1internal",) + + +def test_codexapp_declares_transcript_only_mode() -> None: + cfg = CLIENT_CONFIGS["codexapp"] + + assert cfg.label == "Codex App" + assert cfg.default_target == "codex-app://sessions" + assert cfg.default_proxy_mode == "transcript" + assert cfg.transcript_only is True + + +def test_parse_args_codexapp_rejects_proxy_mode() -> None: + with pytest.raises(SystemExit): + parse_args(["--tap-client", "codexapp", "--tap-proxy-mode", "forward"]) + + +def test_parse_args_codexapp_rejects_trust_ca() -> None: + with pytest.raises(SystemExit): + parse_args(["--tap-client", "codexapp", "--tap-trust-ca"]) + + +def test_openclaw_declares_multi_provider_reverse_envs() -> None: + cfg = CLIENT_CONFIGS["openclaw"] + + assert cfg.default_proxy_mode == "reverse" + assert cfg.reverse_base_url_envs == ( + "OPENAI_BASE_URL", + "ANTHROPIC_BASE_URL", + "GOOGLE_GEMINI_BASE_URL", + "OPENROUTER_BASE_URL", + "CUSTOM_BASE_URL", + ) + + +def test_reverse_base_url_envs_deduplicate_primary_and_extra_envs() -> None: + cfg = ClientConfig( + cmd="multi-cli", + label="Multi CLI", + install_url="https://example.com", + base_url_env="PRIMARY_BASE_URL", + extra_base_url_envs=("SECONDARY_BASE_URL", "PRIMARY_BASE_URL"), + base_url_suffix="/v1", + default_target="https://example.com", + ) + + assert cfg.reverse_base_url_envs == ("PRIMARY_BASE_URL", "SECONDARY_BASE_URL") + assert cfg.reverse_base_url_env_map(43123) == { + "PRIMARY_BASE_URL": "http://127.0.0.1:43123/v1", + "SECONDARY_BASE_URL": "http://127.0.0.1:43123/v1", + } + + +@pytest.mark.asyncio +async def test_run_client_reverse_sets_all_base_url_envs_and_settings( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + cfg = ClientConfig( + cmd="multi-cli", + label="Multi CLI", + install_url="https://example.com", + base_url_env="PRIMARY_BASE_URL", + extra_base_url_envs=("SECONDARY_BASE_URL",), + base_url_suffix="/v1", + default_target="https://example.com", + inject_settings_env=True, + ) + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setitem(CLIENT_CONFIGS, "multi-env", cfg) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda name: f"/tmp/{name}") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--flag"], client="multi-env", proxy_mode="reverse") + + assert code == 0 + base_url = "http://127.0.0.1:43123/v1" + env = captured["env"] + assert env["PRIMARY_BASE_URL"] == base_url + assert env["SECONDARY_BASE_URL"] == base_url + + cmd = captured["cmd"] + assert cmd[:3] == ( + "/tmp/multi-cli", + "--settings", + json.dumps({"env": cfg.reverse_base_url_env_map(43123)}, separators=(",", ":")), + ) + assert cmd[3:] == ("--flag",) + + out = capsys.readouterr().out + assert out.count("PRIMARY_BASE_URL=http://127.0.0.1:43123/v1") == 1 + assert out.count("SECONDARY_BASE_URL=http://127.0.0.1:43123/v1") == 1 + + +@pytest.mark.asyncio +async def test_run_client_openclaw_reverse_patches_temp_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / ".openclaw" / "openclaw.json" + config.parent.mkdir() + config.write_text( + json.dumps( + { + "agents": {"defaults": {"model": {"primary": "phistory/phistory-dummy"}}}, + "models": { + "providers": { + "phistory": { + "baseUrl": "https://relay.example.com/v1", + "api": "openai-responses", + } + } + }, + } + ), + encoding="utf-8", + ) + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + openclaw_config = Path(kwargs["env"]["OPENCLAW_CONFIG_PATH"]) + captured["config_text"] = openclaw_config.read_text(encoding="utf-8") + return _DummyProc() + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda name: f"/tmp/{name}") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["agent"], client="openclaw", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + temp_config = Path(env["OPENCLAW_CONFIG_PATH"]) + assert not temp_config.exists() + assert captured["cmd"] == ("/tmp/openclaw", "agent") + config_text = captured["config_text"] + assert isinstance(config_text, str) + patched_config = json.loads(config_text) + assert patched_config["models"]["providers"]["phistory"]["baseUrl"] == "http://127.0.0.1:43123/v1" + + +@pytest.mark.asyncio +async def test_run_client_openclaw_reverse_patches_model_arg_provider( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / ".openclaw" / "openclaw.json" + config.parent.mkdir() + config.write_text( + json.dumps( + { + "agents": {"defaults": {"model": {"primary": "openai-codex/gpt-5.4"}}}, + "models": { + "providers": { + "anthropic": { + "baseUrl": "https://api.anthropic.com", + "api": "anthropic-messages", + } + } + }, + } + ), + encoding="utf-8", + ) + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + openclaw_config = Path(kwargs["env"]["OPENCLAW_CONFIG_PATH"]) + captured["config_text"] = openclaw_config.read_text(encoding="utf-8") + return _DummyProc() + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda name: f"/tmp/{name}") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["agent", "--model", "anthropic/claude-opus-4-6"], + client="openclaw", + proxy_mode="reverse", + ) + + assert code == 0 + env = captured["env"] + assert "OPENCLAW_CONFIG_PATH" in env + config_text = captured["config_text"] + assert isinstance(config_text, str) + patched_config = json.loads(config_text) + assert patched_config["models"]["providers"]["anthropic"]["baseUrl"] == "http://127.0.0.1:43123" + + +@pytest.mark.asyncio +async def test_run_client_openclaw_reverse_cleans_temp_config_on_spawn_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / ".openclaw" / "openclaw.json" + config.parent.mkdir() + config.write_text( + json.dumps( + { + "agents": {"defaults": {"model": {"primary": "anthropic/claude-opus-4-6"}}}, + "models": { + "providers": { + "anthropic": { + "baseUrl": "https://api.anthropic.com", + "api": "anthropic-messages", + } + } + }, + } + ), + encoding="utf-8", + ) + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*_cmd, **kwargs): + captured["config_path"] = Path(kwargs["env"]["OPENCLAW_CONFIG_PATH"]) + raise OSError("spawn failed") + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda name: f"/tmp/{name}") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + with pytest.raises(OSError, match="spawn failed"): + await run_client(43123, ["agent"], client="openclaw", proxy_mode="reverse") + + config_path = captured["config_path"] + assert isinstance(config_path, Path) + assert not config_path.exists() + + +def test_openclaw_config_helpers_cover_paths_and_invalid_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + missing = tmp_path / "missing.json" + invalid = tmp_path / "invalid.json" + invalid.write_text("{", encoding="utf-8") + + assert cli_clients._read_openclaw_config(missing) is None + assert cli_clients._read_openclaw_config(invalid) is None + + explicit = tmp_path / "explicit.json" + monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(explicit)) + assert cli_clients._openclaw_config_path() == explicit + + monkeypatch.delenv("OPENCLAW_CONFIG_PATH") + monkeypatch.setenv("OPENCLAW_STATE_DIR", str(tmp_path / "state")) + assert cli_clients._openclaw_config_path() == tmp_path / "state" / "openclaw.json" + + +def test_openclaw_model_and_provider_helpers_cover_fallback_shapes() -> None: + assert cli_clients._openclaw_primary_model({}) is None + assert cli_clients._openclaw_primary_model({}, ["--model=anthropic/claude"]) == "anthropic/claude" + assert cli_clients._openclaw_primary_model({}, ["-m", "openai/gpt-5"]) == "openai/gpt-5" + assert cli_clients._openclaw_primary_model({"agents": {"defaults": {"model": "openai/gpt-5"}}}) == "openai/gpt-5" + assert ( + cli_clients._openclaw_primary_model({"agents": {"defaults": {"models": {"anthropic/claude": {}}}}}) + == "anthropic/claude" + ) + assert ( + cli_clients._openclaw_primary_model({"agents": {"defaults": {"model": {"primary": "openrouter/auto"}}}}) + == "openrouter/auto" + ) + + assert cli_clients._openclaw_provider_proxy_url({}, "http://127.0.0.1:43123") == "http://127.0.0.1:43123/v1" + assert ( + cli_clients._openclaw_provider_proxy_url({"api": "openai-responses"}, "http://127.0.0.1:43123") + == "http://127.0.0.1:43123/v1" + ) + assert ( + cli_clients._openclaw_provider_proxy_url({"api": "anthropic"}, "http://127.0.0.1:43123") + == "http://127.0.0.1:43123" + ) + assert ( + cli_clients._openclaw_provider_target_url({"api": "openai-responses"}, "https://relay.example.com/v1/") + == "https://relay.example.com" + ) + assert ( + cli_clients._openclaw_provider_target_url({"api": "anthropic"}, "https://relay.example.com/anthropic/") + == "https://relay.example.com/anthropic" + ) + + +def test_openclaw_config_patch_rejects_incomplete_configs() -> None: + proxy_url = "http://127.0.0.1:43123" + + assert cli_clients._openclaw_config_with_proxy({}, proxy_url) is None + assert cli_clients._openclaw_config_with_proxy({"agents": {"defaults": {"model": "claude"}}}, proxy_url) is None + assert cli_clients._openclaw_config_with_proxy({"agents": {"defaults": {"model": "openai/gpt"}}}, proxy_url) is None + assert ( + cli_clients._openclaw_config_with_proxy( + {"agents": {"defaults": {"model": "openai/gpt"}}, "models": {"providers": {}}}, + proxy_url, + ) + is None + ) + + +def test_openclaw_reverse_env_falls_back_without_patchable_config( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(tmp_path / "missing.json")) + + env = cli_clients._openclaw_reverse_env(43123) + + assert env == {"OPENAI_BASE_URL": "http://127.0.0.1:43123/v1"} + + assert cli_clients._openclaw_reverse_env(43123, ["--model", "anthropic/claude"]) == { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:43123" + } + + monkeypatch.setenv("OPENAI_API_KEY", "openai-token") + monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-token") + assert cli_clients._openclaw_reverse_env(43123) == {"OPENAI_BASE_URL": "http://127.0.0.1:43123/v1"} + + monkeypatch.delenv("OPENAI_API_KEY") + assert cli_clients._openclaw_reverse_env(43123) == {"ANTHROPIC_BASE_URL": "http://127.0.0.1:43123"} + + +def test_detect_openclaw_target_uses_config_then_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / "openclaw.json" + config.write_text( + json.dumps( + { + "agents": {"defaults": {"model": "openai/gpt-5"}}, + "models": {"providers": {"openai": {"baseUrl": "https://relay.example.com/v1/"}}}, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(config)) + + assert cli_clients._detect_openclaw_target() == "https://relay.example.com" + + config.write_text("{}", encoding="utf-8") + monkeypatch.setenv("OPENROUTER_API_KEY", "token") + + assert cli_clients._detect_openclaw_target() == "https://openrouter.ai/api/v1" + + +def test_detect_openclaw_target_uses_model_arg_provider( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config = tmp_path / "openclaw.json" + config.write_text( + json.dumps( + { + "agents": {"defaults": {"model": {"primary": "openai/default"}}}, + "models": { + "providers": { + "openai": {"baseUrl": "https://openai.example.com/v1", "api": "openai-responses"}, + "anthropic": {"baseUrl": "https://anthropic.example.com", "api": "anthropic-messages"}, + } + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(config)) + + assert cli_clients._detect_openclaw_target(["--model", "anthropic/claude-opus-4-6"]) == ( + "https://anthropic.example.com" + ) + + +@pytest.mark.asyncio +async def test_run_client_agy_forward_sets_proxy_ca_and_cloud_code_url( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda name: f"/tmp/{name}") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["--print", "ok"], + client="agy", + proxy_mode="forward", + ca_cert_path=Path("/tmp/claude-tap-ca.pem"), + ) + + assert code == 0 + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["CLOUD_CODE_URL"] == "http://127.0.0.1:43123" + assert "AGY_BASE_URL" not in env + assert captured["cmd"] == ("/tmp/agy", "--print", "ok") + + out = capsys.readouterr().out + assert "HTTPS_PROXY=http://127.0.0.1:43123" in out + assert "CLOUD_CODE_URL=http://127.0.0.1:43123" in out diff --git a/tests/test_codebuddy_launch.py b/tests/test_codebuddy_launch.py new file mode 100644 index 0000000..05539c4 --- /dev/null +++ b/tests/test_codebuddy_launch.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import asyncio +import json + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import ( + CLIENT_CONFIGS, + _detect_codebuddy_target, + _reverse_proxy_trace_options, + run_client, +) + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_codebuddy_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["codebuddy"] + assert cfg.cmd == "codebuddy" + assert cfg.label == "CodeBuddy" + # CodeBuddy's OpenAI client appends ``/v2`` to its product endpoint, so + # the reverse-proxy upstream must include that prefix. + assert cfg.default_target == "https://copilot.tencent.com/v2" + assert cfg.base_url_env == "CODEBUDDY_BASE_URL" + assert cfg.base_url_suffix == "" + assert cfg.default_proxy_mode == "reverse" + assert cfg.inject_settings_env is True + + +def test_parse_args_codebuddy_defaults_to_reverse_mode() -> None: + args = parse_args(["--tap-client", "codebuddy"]) + assert args.client == "codebuddy" + assert args.target == "https://copilot.tencent.com/v2" + assert args.proxy_mode == "reverse" + + +def test_parse_args_codebuddy_explicit_forward_overrides_default() -> None: + args = parse_args(["--tap-client", "codebuddy", "--tap-proxy-mode", "forward"]) + assert args.client == "codebuddy" + assert args.proxy_mode == "forward" + + +@pytest.mark.asyncio +async def test_run_client_codebuddy_reverse_sets_base_url_and_settings(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codebuddy") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["-p", "Reply OK"], client="codebuddy", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert env["CODEBUDDY_BASE_URL"] == "http://127.0.0.1:43123" + + # --settings should be injected for CodeBuddy (inject_settings_env=True) + cmd = captured["cmd"] + assert cmd[0] == "/tmp/codebuddy" + assert cmd[1] == "--settings" + settings = json.loads(cmd[2]) + assert settings["env"]["CODEBUDDY_BASE_URL"] == "http://127.0.0.1:43123" + assert cmd[3:] == ("-p", "Reply OK") + + +@pytest.mark.asyncio +async def test_run_client_codebuddy_reverse_does_not_inject_settings_when_already_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codebuddy") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["--settings", '{"env":{}}', "-p", "Reply OK"], + client="codebuddy", + proxy_mode="reverse", + ) + + assert code == 0 + cmd = captured["cmd"] + # --settings already present in user args; should not be duplicated + settings_count = sum(1 for arg in cmd if arg == "--settings") + assert settings_count == 1 + + +@pytest.mark.asyncio +async def test_run_client_codebuddy_forward_sets_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.delenv("CODEBUDDY_BASE_URL", raising=False) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codebuddy") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["-p", "hello"], client="codebuddy", proxy_mode="forward") + + assert code == 0 + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["http_proxy"] == "http://127.0.0.1:43123" + # In forward mode, CODEBUDDY_BASE_URL should NOT be set + assert "CODEBUDDY_BASE_URL" not in env + + +def test_codebuddy_reverse_trace_options_do_not_strip_path_prefix() -> None: + options = _reverse_proxy_trace_options("codebuddy", "https://copilot.tencent.com/v2") + + assert options == { + "strip_path_prefix": "", + "force_http": False, + } + + +def test_detect_codebuddy_target_reads_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CODEBUDDY_BASE_URL", "https://copilot.tencent.com/v2") + assert _detect_codebuddy_target() == "https://copilot.tencent.com/v2" + + +def test_detect_codebuddy_target_falls_back_to_default(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + monkeypatch.delenv("CODEBUDDY_BASE_URL", raising=False) + # Ensure no settings files and no endpoint cache are found. + monkeypatch.setattr("claude_tap.cli.Path.cwd", lambda: tmp_path) + monkeypatch.setattr("claude_tap.cli.Path.home", lambda: tmp_path) + assert _detect_codebuddy_target() == "https://copilot.tencent.com/v2" + + +def test_detect_codebuddy_target_reads_login_endpoint_cache(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """CodeBuddy writes its resolved endpoint to ``local_storage/entry_.info`` + after login. We honor it so internal/iOA/external users all work without + setting any env var.""" + monkeypatch.delenv("CODEBUDDY_BASE_URL", raising=False) + monkeypatch.setattr("claude_tap.cli.Path.cwd", lambda: tmp_path) + monkeypatch.setattr("claude_tap.cli.Path.home", lambda: tmp_path) + + cache_dir = tmp_path / ".codebuddy" / "local_storage" + cache_dir.mkdir(parents=True) + # md5("CodeBuddy-Endpoint-Cache") == 933d5543e80177622c17a73869c0fad7 + (cache_dir / "entry_933d5543e80177622c17a73869c0fad7.info").write_text('"https://www.codebuddy.ai"') + + assert _detect_codebuddy_target() == "https://www.codebuddy.ai/v2" + + +def test_detect_codebuddy_target_reads_settings_from_config_dir(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + """When users relocate CodeBuddy's config via ``CODEBUDDY_CONFIG_DIR``, + the settings lookup must follow the override instead of only reading + ``~/.codebuddy/settings.json``.""" + monkeypatch.delenv("CODEBUDDY_BASE_URL", raising=False) + monkeypatch.setattr("claude_tap.cli.Path.cwd", lambda: tmp_path / "cwd") + monkeypatch.setattr("claude_tap.cli.Path.home", lambda: tmp_path / "home") + + config_dir = tmp_path / "custom-codebuddy" + config_dir.mkdir() + (config_dir / "settings.json").write_text('{"env": {"CODEBUDDY_BASE_URL": "https://gateway.example.com/v2"}}') + monkeypatch.setenv("CODEBUDDY_CONFIG_DIR", str(config_dir)) + + assert _detect_codebuddy_target() == "https://gateway.example.com/v2" diff --git a/tests/test_codex_app_cdp.py b/tests/test_codex_app_cdp.py new file mode 100644 index 0000000..16fbd3c --- /dev/null +++ b/tests/test_codex_app_cdp.py @@ -0,0 +1,668 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import aiohttp +import pytest + +import claude_tap.codex_app_cdp as cdp +from claude_tap.cli import parse_args +from claude_tap.codex_app_cdp import ( + CodexAppCdpRecorder, + _CdpClient, + build_cdp_websocket_record, + capture_codex_app_cdp, + resolve_cdp_websocket_url, + select_cdp_target, + watch_codex_app_cdp, +) + + +class _FakeWriter: + def __init__(self) -> None: + self.count = 0 + self.records: list[dict] = [] + + async def write(self, record: dict) -> None: + self.records.append(record) + self.count += 1 + + async def write_next_turn(self, record: dict) -> None: + record["turn"] = self.count + 1 + await self.write(record) + + +def _frame(payload: dict) -> dict: + return {"response": {"opcode": 1, "payloadData": json.dumps(payload)}} + + +class _FakeHttpResponse: + def __init__(self, status: int, payload: object = None, json_error: Exception | None = None) -> None: + self.status = status + self._payload = payload + self._json_error = json_error + + async def __aenter__(self) -> "_FakeHttpResponse": + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + async def json(self, *, content_type: object = None) -> object: + if self._json_error: + raise self._json_error + return self._payload + + +class _FakeWsMessage: + def __init__(self, data: object, msg_type: aiohttp.WSMsgType = aiohttp.WSMsgType.TEXT) -> None: + self.type = msg_type + self.data = data + + +class _FakeWebSocket: + def __init__(self, messages: list[_FakeWsMessage]) -> None: + self._messages = list(messages) + self.sent: list[dict] = [] + + async def __aenter__(self) -> "_FakeWebSocket": + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + def __aiter__(self) -> "_FakeWebSocket": + return self + + async def __anext__(self) -> _FakeWsMessage: + if not self._messages: + raise StopAsyncIteration + return self._messages.pop(0) + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + +class _FakeClientSession: + def __init__(self, responses: list[_FakeHttpResponse], websocket: _FakeWebSocket | None = None) -> None: + self._responses = list(responses) + self.websocket = websocket + self.requested_urls: list[str] = [] + + async def __aenter__(self) -> "_FakeClientSession": + return self + + async def __aexit__(self, *exc_info: object) -> None: + return None + + def get(self, url: str, **_: object) -> _FakeHttpResponse: + self.requested_urls.append(url) + return self._responses.pop(0) + + def ws_connect(self, url: str, **_: object) -> _FakeWebSocket: + assert self.websocket is not None + self.requested_urls.append(url) + return self.websocket + + +def _ws_text(payload: dict) -> _FakeWsMessage: + return _FakeWsMessage(json.dumps(payload)) + + +def test_select_cdp_target_prefers_codex_app_surface() -> None: + targets = [ + { + "type": "background_page", + "title": "Codex Background", + "url": "chrome-extension://codex", + "webSocketDebuggerUrl": "ws://127.0.0.1/background", + }, + { + "type": "page", + "title": "DevTools", + "url": "devtools://devtools/bundled/inspector.html", + "webSocketDebuggerUrl": "ws://127.0.0.1/devtools", + }, + { + "type": "webview", + "title": "Codex", + "url": "file:///Applications/Codex.app/index.html", + "webSocketDebuggerUrl": "ws://127.0.0.1/codex", + }, + ] + + assert select_cdp_target(targets) == "ws://127.0.0.1/codex" + + +def test_select_cdp_target_scores_common_page_shapes() -> None: + targets = [ + {"type": "page", "title": "", "url": "about:blank", "webSocketDebuggerUrl": "ws://blank"}, + { + "type": "iframe", + "title": "Codex frame", + "url": "https://127.0.0.1/frame", + "webSocketDebuggerUrl": "ws://frame", + }, + {"type": "app", "title": "Codex", "url": "https://localhost/app", "webSocketDebuggerUrl": "ws://app"}, + {"type": "page", "title": "", "url": "", "webSocketDebuggerUrl": ""}, + {"type": "page", "title": "DevTools", "url": "devtools://inspector", "webSocketDebuggerUrl": "ws://devtools"}, + ] + + assert select_cdp_target(targets) == "ws://app" + assert ( + select_cdp_target([{"type": "service_worker", "title": "Codex", "webSocketDebuggerUrl": "ws://worker"}]) is None + ) + + +@pytest.mark.asyncio +async def test_resolve_cdp_websocket_url_accepts_direct_and_target_lists() -> None: + assert await resolve_cdp_websocket_url(" ws://127.0.0.1/devtools/page/1 ", _FakeClientSession([])) == ( + "ws://127.0.0.1/devtools/page/1" + ) + + session = _FakeClientSession( + [ + _FakeHttpResponse(500, []), + _FakeHttpResponse( + 200, + [{"type": "page", "title": "Codex", "url": "app://-/index.html", "webSocketDebuggerUrl": "ws://page"}], + ), + ] + ) + + assert await resolve_cdp_websocket_url("http://127.0.0.1:9238/", session) == "ws://page" + assert session.requested_urls == ["http://127.0.0.1:9238/json/list", "http://127.0.0.1:9238/json"] + + +@pytest.mark.asyncio +async def test_resolve_cdp_websocket_url_reports_missing_targets() -> None: + session = _FakeClientSession( + [ + _FakeHttpResponse(200, {"description": "no websocket target"}), + _FakeHttpResponse(200, [], json.JSONDecodeError("bad json", "", 0)), + ] + ) + + with pytest.raises(RuntimeError, match="no page target"): + await resolve_cdp_websocket_url("http://127.0.0.1:9238", session) + + +@pytest.mark.asyncio +async def test_cdp_recorder_writes_viewer_friendly_websocket_record() -> None: + writer = _FakeWriter() + recorder = CodexAppCdpRecorder(writer, store_stream_events=True, endpoint="http://127.0.0.1:9238") + + await recorder.handle_event( + "Network.webSocketCreated", + {"requestId": "ws-1", "url": "wss://chatgpt.com/backend-api/codex/responses?x=1"}, + ) + await recorder.handle_event( + "Network.webSocketWillSendHandshakeRequest", + {"requestId": "ws-1", "request": {"headers": {"Authorization": "Bearer secret-token-value"}}}, + ) + await recorder.handle_event( + "Network.webSocketHandshakeResponseReceived", + {"requestId": "ws-1", "response": {"status": 101, "headers": {"Server": "cloudflare"}}}, + ) + await recorder.handle_event( + "Network.webSocketFrameSent", + { + "requestId": "ws-1", + **_frame( + { + "type": "response.create", + "model": "gpt-5.5", + "instructions": "You are Codex.", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello from cdp"}], + } + ], + "stream": True, + } + ), + }, + ) + await recorder.handle_event( + "Network.webSocketFrameReceived", + {"requestId": "ws-1", **_frame({"type": "response.created", "response": {"id": "resp-1"}})}, + ) + await recorder.handle_event( + "Network.webSocketFrameReceived", + { + "requestId": "ws-1", + **_frame( + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hello back"}], + }, + } + ), + }, + ) + await recorder.handle_event( + "Network.webSocketFrameReceived", + { + "requestId": "ws-1", + **_frame( + { + "type": "response.completed", + "response": { + "id": "resp-1", + "model": "gpt-5.5", + "output": [], + "usage": {"input_tokens": 10, "output_tokens": 3, "total_tokens": 13}, + }, + } + ), + }, + ) + + assert len(writer.records) == 1 + record = writer.records[0] + assert record["transport"] == "websocket" + assert record["upstream_base_url"] == "https://chatgpt.com" + assert record["request"]["method"] == "WEBSOCKET" + assert record["request"]["path"] == "/backend-api/codex/responses?x=1" + assert record["request"]["headers"]["Authorization"] == "Bearer secre..." + assert record["request"]["body"]["model"] == "gpt-5.5" + assert record["request"]["body"]["input"][0]["content"][0]["text"] == "hello from cdp" + assert record["response"]["status"] == 101 + assert record["response"]["headers"]["Server"] == "cloudflare" + assert record["response"]["body"]["usage"]["output_tokens"] == 3 + assert record["response"]["body"]["output"][0]["content"][0]["text"] == "hello back" + assert record["request"]["ws_events"][0]["type"] == "response.create" + assert [event["type"] for event in record["response"]["ws_events"]] == [ + "response.created", + "response.output_item.done", + "response.completed", + ] + assert record["capture"]["source"] == "codexapp-cdp" + assert record["capture"]["cdp_request_id"] == "ws-1" + assert record["capture"]["cdp_endpoint"] == "http://127.0.0.1:9238" + + +@pytest.mark.asyncio +async def test_cdp_recorder_assigns_sequential_responses_to_request_frames() -> None: + writer = _FakeWriter() + recorder = CodexAppCdpRecorder(writer) + + await recorder.handle_event("Network.webSocketCreated", {"requestId": "ws-2", "url": "wss://api.test/v1/responses"}) + for index in range(2): + await recorder.handle_event( + "Network.webSocketFrameSent", + { + "requestId": "ws-2", + **_frame({"type": "response.create", "model": f"model-{index}", "input": [{"turn": index}]}), + }, + ) + for index in range(2): + response_id = f"resp-{index}" + await recorder.handle_event( + "Network.webSocketFrameReceived", + {"requestId": "ws-2", **_frame({"type": "response.created", "response": {"id": response_id}})}, + ) + await recorder.handle_event( + "Network.webSocketFrameReceived", + {"requestId": "ws-2", **_frame({"type": "response.completed", "response": {"id": response_id}})}, + ) + + assert [record["request"]["body"]["model"] for record in writer.records] == ["model-0", "model-1"] + assert [record["response"]["body"]["id"] for record in writer.records] == ["resp-0", "resp-1"] + assert [record["turn"] for record in writer.records] == [1, 2] + + +@pytest.mark.asyncio +async def test_cdp_recorder_ignores_duplicate_completed_events_for_flushed_response() -> None: + writer = _FakeWriter() + recorder = CodexAppCdpRecorder(writer) + + await recorder.handle_event("Network.webSocketCreated", {"requestId": "ws-dup", "url": "wss://api.test/v1"}) + await recorder.handle_event( + "Network.webSocketFrameSent", + {"requestId": "ws-dup", **_frame({"type": "response.create", "model": "gpt", "input": []})}, + ) + completed = {"requestId": "ws-dup", **_frame({"type": "response.completed", "response": {"id": "resp-dup"}})} + + await recorder.handle_event("Network.webSocketFrameReceived", completed) + await recorder.handle_event("Network.webSocketFrameReceived", completed) + + assert len(writer.records) == 1 + assert writer.records[0]["response"]["body"]["id"] == "resp-dup" + + +@pytest.mark.asyncio +async def test_cdp_recorder_flushes_incomplete_response_on_socket_close() -> None: + writer = _FakeWriter() + recorder = CodexAppCdpRecorder(writer) + + await recorder.handle_event("Network.webSocketWillSendHandshakeRequest", {"requestId": "missing"}) + await recorder.handle_event("Network.webSocketCreated", {"requestId": "ws-close", "url": "wss://api.test/v1"}) + await recorder.handle_event("Network.webSocketHandshakeResponseReceived", {"requestId": "missing"}) + await recorder.handle_event( + "Network.webSocketFrameSent", {"requestId": "ws-close", "response": {"payloadData": "not-json"}} + ) + await recorder.handle_event( + "Network.webSocketFrameReceived", {"requestId": "ws-close", "response": {"payloadData": "not-json"}} + ) + await recorder.handle_event( + "Network.webSocketFrameReceived", + {"requestId": "ws-close", **_frame({"type": "response.created", "response": {"id": "resp-close"}})}, + ) + await recorder.handle_event("Network.webSocketClosed", {"requestId": "ws-close"}) + await recorder.flush_all(error="unused") + await recorder._flush_socket("missing") + + assert len(writer.records) == 1 + record = writer.records[0] + assert record["response"]["body"]["id"] == "resp-close" + assert record["response"]["error"] == "CDP websocket closed before response.completed" + + +def test_build_cdp_websocket_record_omits_raw_events_by_default() -> None: + record = build_cdp_websocket_record( + url="ws://localhost/v1/responses", + cdp_request_id="ws-raw", + request_messages=[json.dumps({"type": "response.create", "model": "gpt"})], + response_events=[{"type": "response.completed", "response": {"id": "resp"}}], + request_headers={}, + response_headers={}, + response_status=101, + duration_ms=1, + turn=1, + store_stream_events=False, + endpoint="", + ) + + assert record["request"]["body"]["model"] == "gpt" + assert record["response"]["body"]["id"] == "resp" + assert "ws_events" not in record["request"] + assert "ws_events" not in record["response"] + + +def test_build_cdp_websocket_record_preserves_error_and_raw_request_events() -> None: + record = build_cdp_websocket_record( + url="wss://chatgpt.com", + cdp_request_id="ws-error", + request_messages=["not-json"], + response_events=[], + request_headers={"Cookie": "secret"}, + response_headers={}, + response_status=101, + duration_ms=1, + turn=2, + store_stream_events=True, + endpoint="", + error="closed", + ) + + assert record["upstream_base_url"] == "https://chatgpt.com" + assert record["request"]["path"] == "/" + assert record["request"]["ws_events"] == [{"raw": "not-json"}] + assert record["response"]["error"] == "closed" + + +@pytest.mark.asyncio +async def test_cdp_client_runs_network_enable_and_dispatches_events() -> None: + writer = _FakeWriter() + recorder = CodexAppCdpRecorder(writer) + websocket = _FakeWebSocket( + [ + _FakeWsMessage(b"binary", aiohttp.WSMsgType.BINARY), + _FakeWsMessage("not-json"), + _ws_text([]), + _ws_text({"id": 1, "result": {}}), + _ws_text( + {"method": "Network.webSocketCreated", "params": {"requestId": "ws-client", "url": "wss://api.test/v1"}} + ), + _ws_text( + { + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "ws-client", + **_frame({"type": "response.create", "model": "gpt", "input": [{"content": "hi"}]}), + }, + } + ), + _ws_text( + { + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "ws-client", + **_frame({"type": "response.created", "response": {"id": "resp-client"}}), + }, + } + ), + _ws_text( + { + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "ws-client", + **_frame({"type": "response.completed", "response": {"id": "resp-client"}}), + }, + } + ), + ] + ) + + await _CdpClient(websocket, recorder).run() + + assert websocket.sent == [{"id": 1, "method": "Network.enable", "params": {}}] + assert writer.records[0]["request"]["body"]["model"] == "gpt" + assert writer.records[0]["response"]["body"]["id"] == "resp-client" + + +@pytest.mark.asyncio +async def test_capture_codex_app_cdp_connects_and_flushes(monkeypatch: pytest.MonkeyPatch) -> None: + writer = _FakeWriter() + websocket = _FakeWebSocket( + [ + _ws_text({"id": 1, "result": {}}), + _ws_text( + { + "method": "Network.webSocketCreated", + "params": {"requestId": "ws-capture", "url": "wss://api.test/v1"}, + } + ), + _ws_text( + { + "method": "Network.webSocketFrameSent", + "params": { + "requestId": "ws-capture", + **_frame({"type": "response.create", "model": "gpt-capture", "input": [{"content": "hi"}]}), + }, + } + ), + _ws_text( + { + "method": "Network.webSocketFrameReceived", + "params": { + "requestId": "ws-capture", + **_frame({"type": "response.created", "response": {"id": "resp-capture"}}), + }, + } + ), + ] + ) + session = _FakeClientSession( + [ + _FakeHttpResponse( + 200, + [{"type": "page", "title": "Codex", "url": "app://-/index.html", "webSocketDebuggerUrl": "ws://page"}], + ) + ], + websocket=websocket, + ) + monkeypatch.setattr(cdp.aiohttp, "ClientSession", lambda: session) + + await capture_codex_app_cdp(writer, endpoint="http://127.0.0.1:9238") + + assert session.requested_urls == ["http://127.0.0.1:9238/json/list", "ws://page"] + assert writer.records[0]["response"]["body"]["id"] == "resp-capture" + + +@pytest.mark.asyncio +async def test_watch_codex_app_cdp_reconnects_and_propagates_cancel( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + calls = 0 + + async def fake_capture(*_: object, **__: object) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise RuntimeError("no cdp") + raise asyncio.CancelledError + + async def fake_sleep(_: float) -> None: + return None + + monkeypatch.setattr(cdp, "capture_codex_app_cdp", fake_capture) + monkeypatch.setattr(cdp.asyncio, "sleep", fake_sleep) + + caplog.set_level("DEBUG") + with pytest.raises(asyncio.CancelledError): + await watch_codex_app_cdp(_FakeWriter(), reconnect_interval=0) + + assert calls == 2 + assert "Codex App CDP capture unavailable: no cdp" in caplog.text + + +@pytest.mark.asyncio +async def test_async_main_codexapp_starts_cdp_enrichment_by_default(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + from claude_tap import async_main + + started = asyncio.Event() + cancelled = asyncio.Event() + calls: list[dict[str, object]] = [] + + async def fake_watch_codex_app_cdp(*args: object, **kwargs: object) -> None: + calls.append(kwargs) + started.set() + try: + await asyncio.Future() + except asyncio.CancelledError: + cancelled.set() + raise + + async def fake_watch_codex_app_transcripts_to_sessions(*args: object, **kwargs: object) -> None: + await asyncio.wait_for(started.wait(), timeout=1) + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "codexapp-auto-cdp.sqlite3")) + monkeypatch.setattr("claude_tap.cli.watch_codex_app_cdp", fake_watch_codex_app_cdp) + monkeypatch.setattr( + "claude_tap.cli.watch_codex_app_transcripts_to_sessions", + fake_watch_codex_app_transcripts_to_sessions, + ) + + args = parse_args(["--tap-client", "codexapp", "--tap-no-live", "--tap-no-open", "--tap-max-traces", "0"]) + + assert args.store_stream_events is False + assert await async_main(args) == 0 + assert cancelled.is_set() + assert calls == [{"endpoint": "http://127.0.0.1:9238", "store_stream_events": False}] + + +@pytest.mark.asyncio +async def test_async_main_codexapp_cleanup_protects_cdp_session( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from claude_tap import async_main + from claude_tap.trace_store import SessionQuery, get_trace_store + + cdp_written = asyncio.Event() + + async def fake_watch_codex_app_cdp(writer: object, **_: object) -> None: + await writer.write_next_turn( + { + "timestamp": "2026-06-14T00:00:03+00:00", + "request_id": "req_cdp_cleanup", + "duration_ms": 0, + "transport": "websocket", + "request": {"method": "WEBSOCKET", "path": "/backend-api/codex", "body": {"model": "gpt-cdp"}}, + "response": {"status": 200, "body": {"usage": {"input_tokens": 1, "output_tokens": 1}}}, + "capture": {"source": "codexapp-cdp"}, + } + ) + cdp_written.set() + await asyncio.Future() + + async def fake_watch_codex_app_transcripts_to_sessions(registry: object, **_: object) -> None: + await asyncio.wait_for(cdp_written.wait(), timeout=1) + for index in range(2): + app_session_id = f"codex-query-cleanup-{index}" + await registry.write_next_turn( + tmp_path / f"{app_session_id}.jsonl", + { + "timestamp": f"2026-06-14T00:00:0{index}+00:00", + "request_id": f"req_transcript_cleanup_{index}", + "duration_ms": 0, + "transport": "codex-app-transcript", + "request": { + "method": "CODEX_APP_TRANSCRIPT", + "path": "/v1/responses", + "headers": {"x-codex-app-session-id": app_session_id}, + "body": { + "model": "gpt-transcript", + "metadata": {"codex_app_session_id": app_session_id}, + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": f"query {index}"}], + } + ], + }, + }, + "response": {"status": 200, "body": {"usage": {"input_tokens": 1, "output_tokens": 1}}}, + }, + ) + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "codexapp-cleanup.sqlite3")) + monkeypatch.setattr("claude_tap.cli.watch_codex_app_cdp", fake_watch_codex_app_cdp) + monkeypatch.setattr( + "claude_tap.cli.watch_codex_app_transcripts_to_sessions", + fake_watch_codex_app_transcripts_to_sessions, + ) + + args = parse_args(["--tap-client", "codexapp", "--tap-no-live", "--tap-max-traces", "1"]) + + assert await async_main(args) == 0 + + store = get_trace_store() + rows = store.list_session_rows(query=SessionQuery(agent_clients=("codexapp",))) + records = [record for row in rows for record in store.load_records(row["id"])] + + assert len(rows) == 3 + assert any(record.get("capture", {}).get("source") == "codexapp-cdp" for record in records) + assert sorted( + record["request"]["body"]["metadata"]["codex_app_session_id"] + for record in records + if record["transport"] == "codex-app-transcript" + ) == ["codex-query-cleanup-0", "codex-query-cleanup-1"] + + +def test_parse_args_codexapp_uses_default_cdp_endpoint_for_automatic_capture() -> None: + args = parse_args(["--tap-client", "codexapp"]) + + assert args.client == "codexapp" + assert args.codexapp_cdp_endpoint == "http://127.0.0.1:9238" + + +def test_parse_args_rejects_custom_cdp_endpoint_for_non_codexapp() -> None: + with pytest.raises(SystemExit): + parse_args(["--tap-client", "codex", "--tap-codexapp-cdp-endpoint", "http://127.0.0.1:9999"]) + + +def test_parse_args_rejects_removed_cdp_capture_flag() -> None: + with pytest.raises(SystemExit): + parse_args(["--tap-client", "codexapp", "--tap-codexapp-cdp-capture"]) diff --git a/tests/test_codex_app_transcript.py b/tests/test_codex_app_transcript.py new file mode 100644 index 0000000..67ff082 --- /dev/null +++ b/tests/test_codex_app_transcript.py @@ -0,0 +1,564 @@ +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + +import pytest + +from claude_tap.codex_app_transcript import ( + CODEX_APP_TRANSPORT, + CodexAppTranscriptSessionRegistry, + build_codex_app_transcript_records, + codex_app_home, + find_codex_app_transcripts, + import_codex_app_transcripts, + import_codex_app_transcripts_to_sessions, + watch_codex_app_transcripts, +) +from claude_tap.trace import TraceWriter + + +def _write_jsonl(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(row) for row in rows), encoding="utf-8") + + +def _session_rows() -> list[dict]: + return [ + { + "type": "session_meta", + "timestamp": "2026-06-07T01:00:00Z", + "payload": { + "id": "session-123", + "cli_version": "0.1.0", + "source": "codex-app", + "cwd": "/tmp/work", + "base_instructions": {"text": "You are Codex."}, + "dynamic_tools": [ + { + "namespace": "functions", + "name": "exec_command", + "description": "Run a command.", + "inputSchema": {"type": "object"}, + } + ], + }, + }, + { + "type": "turn_context", + "timestamp": "2026-06-07T01:00:01Z", + "payload": {"model": "gpt-5.4-codex", "cwd": "/tmp/work"}, + }, + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:02Z", + "payload": { + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": "Follow repo rules."}], + }, + }, + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:03Z", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "inspect workspace"}], + }, + }, + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:04Z", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "I will inspect it."}], + }, + }, + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:05Z", + "payload": { + "type": "function_call", + "name": "functions.exec_command", + "call_id": "call-1", + "arguments": '{"cmd":"pwd"}', + }, + }, + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:06Z", + "payload": {"type": "function_call_output", "call_id": "call-1", "output": "/tmp/work"}, + }, + { + "type": "event_msg", + "timestamp": "2026-06-07T01:00:07Z", + "payload": { + "type": "token_count", + "info": { + "last_token_usage": { + "input_tokens": 10, + "output_tokens": 5, + "cached_input_tokens": 3, + "reasoning_output_tokens": 1, + "total_tokens": 15, + } + }, + }, + }, + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:08Z", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Workspace is /tmp/work."}], + }, + }, + { + "type": "event_msg", + "timestamp": "2026-06-07T01:00:09Z", + "payload": { + "type": "token_count", + "info": {"last_token_usage": {"input_tokens": 8, "output_tokens": 4, "total_tokens": 12}}, + }, + }, + ] + + +def test_build_codex_app_transcript_records_preserves_turn_context(tmp_path: Path) -> None: + transcript = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "rollout.jsonl" + _write_jsonl(transcript, _session_rows()) + + records = build_codex_app_transcript_records(transcript, start_turn=3) + + assert len(records) == 2 + first = records[0] + assert first["transport"] == CODEX_APP_TRANSPORT + assert first["turn"] == 3 + assert first["request"]["method"] == "CODEX_APP_TRANSCRIPT" + assert first["request"]["path"] == "/v1/responses" + assert first["request"]["headers"]["x-codex-app-session-id"] == "session-123" + + body = first["request"]["body"] + assert body["model"] == "gpt-5.4-codex" + assert body["instructions"] == "You are Codex." + assert body["metadata"] == { + "codex_app_session_id": "session-123", + "codex_app_source": "codex-app", + "cwd": "/tmp/work", + } + assert body["tools"][0]["name"] == "functions.exec_command" + assert [item["role"] for item in body["input"]] == ["developer", "user"] + + output = first["response"]["body"]["output"] + assert output[0]["role"] == "assistant" + assert output[1]["type"] == "function_call" + assert first["response"]["body"]["usage"] == { + "input_tokens": 10, + "output_tokens": 5, + "total_tokens": 15, + "cache_read_input_tokens": 3, + "reasoning_output_tokens": 1, + } + + second_input = records[1]["request"]["body"]["input"] + assert [item["type"] for item in second_input[-3:]] == ["message", "function_call", "function_call_output"] + assert records[1]["response"]["body"]["output"][0]["content"][0]["text"] == "Workspace is /tmp/work." + + +def test_codex_app_home_and_transcript_discovery(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + custom_home = tmp_path / "custom-codex" + monkeypatch.setenv("CODEX_HOME", str(custom_home)) + + assert codex_app_home() == custom_home + assert codex_app_home(tmp_path) == tmp_path / ".codex" + assert codex_app_home(tmp_path / ".codex") == tmp_path / ".codex" + assert find_codex_app_transcripts(since=0, home=tmp_path / "missing") == [] + + older = tmp_path / ".codex" / "sessions" / "older.jsonl" + newer = tmp_path / ".codex" / "sessions" / "nested" / "newer.jsonl" + _write_jsonl(older, [{"type": "session_meta", "payload": {}}]) + _write_jsonl(newer, [{"type": "session_meta", "payload": {}}]) + os.utime(older, (10, 10)) + os.utime(newer, (20, 20)) + + assert find_codex_app_transcripts(since=0, home=tmp_path) == [older, newer] + assert find_codex_app_transcripts(since=15, home=tmp_path) == [newer] + + +def test_build_codex_app_transcript_records_tolerates_noisy_rows(tmp_path: Path) -> None: + missing = tmp_path / ".codex" / "sessions" / "missing.jsonl" + assert build_codex_app_transcript_records(missing, start_turn=1) == [] + + transcript = tmp_path / ".codex" / "sessions" / "noisy.jsonl" + transcript.parent.mkdir(parents=True, exist_ok=True) + transcript.write_text( + "\n".join( + [ + "", + "not-json", + "[]", + json.dumps({"type": "session_meta", "timestamp": "2026-06-07T01:00:00Z", "payload": []}), + json.dumps( + { + "type": "session_meta", + "timestamp": "2026-06-07T01:00:01Z", + "payload": { + "id": "", + "originator": "codex-desktop", + "base_instructions": " Be helpful. ", + "dynamic_tools": [ + "bad", + {}, + {"name": ""}, + {"name": "read_file", "input_schema": {"type": "object"}}, + ], + }, + } + ), + json.dumps( + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:02Z", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + } + ), + json.dumps( + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:03Z", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}], + }, + } + ), + json.dumps( + {"type": "event_msg", "timestamp": "2026-06-07T01:00:04Z", "payload": {"type": "token_count"}} + ), + json.dumps( + { + "type": "response_item", + "timestamp": "2026-06-07T01:00:05Z", + "payload": { + "type": "reasoning", + "summary": [{"text": "thinking"}], + }, + } + ), + json.dumps( + { + "type": "event_msg", + "timestamp": "2026-06-07T01:00:06Z", + "payload": {"type": "token_count", "info": {}}, + } + ), + ] + ), + encoding="utf-8", + ) + + records = build_codex_app_transcript_records(transcript, start_turn=1) + + assert len(records) == 2 + assert records[0]["request"]["body"]["instructions"] == "Be helpful." + assert records[0]["request"]["body"]["metadata"]["codex_app_source"] == "codex-desktop" + assert records[0]["request"]["body"]["tools"] == [ + {"type": "function", "name": "read_file", "parameters": {"type": "object"}} + ] + assert "usage" not in records[0]["response"]["body"] + assert records[1]["response"]["body"]["output"][0]["type"] == "reasoning" + + +@pytest.mark.asyncio +async def test_import_codex_app_transcripts_appends_only_new_completed_records(trace_db, tmp_path: Path) -> None: + transcript = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "rollout.jsonl" + rows = _session_rows() + _write_jsonl(transcript, rows[:8]) + + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codexapp", proxy_mode="transcript") + writer = TraceWriter(session_id, store=store, metadata={"client": "codexapp", "proxy_mode": "transcript"}) + state = {} + + imported = await import_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + state=state, + include_incomplete=False, + ) + assert imported == 1 + first_offset = state[transcript].offset + assert state[transcript].parser.response_count == 1 + + imported = await import_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + state=state, + include_incomplete=False, + ) + assert imported == 0 + + _write_jsonl(transcript, rows) + imported = await import_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + state=state, + include_incomplete=False, + ) + writer.close() + + assert imported == 1 + assert state[transcript].offset > first_offset + assert state[transcript].parser.response_count == 2 + records = store.load_records(session_id) + assert len(records) == 2 + assert [record["turn"] for record in records] == [1, 2] + assert records[0]["capture"] == {"client": "codexapp", "proxy_mode": "transcript"} + assert records[1]["response"]["body"]["usage"]["output_tokens"] == 4 + + +@pytest.mark.asyncio +async def test_import_codex_app_transcripts_can_append_live_incomplete_records(trace_db, tmp_path: Path) -> None: + transcript = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "rollout.jsonl" + rows = _session_rows() + _write_jsonl(transcript, rows[:5]) + + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codexapp", proxy_mode="transcript") + writer = TraceWriter(session_id, store=store, metadata={"client": "codexapp", "proxy_mode": "transcript"}) + state = {} + + imported = await import_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + state=state, + include_incomplete=True, + ) + assert imported == 1 + + imported = await import_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + state=state, + include_incomplete=True, + ) + assert imported == 0 + + _write_jsonl(transcript, rows[:8]) + imported = await import_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + state=state, + include_incomplete=True, + ) + writer.close() + + assert imported == 1 + records = store.load_records(session_id) + assert len(records) == 2 + assert records[0]["response"]["body"]["status"] == "in_progress" + assert records[0]["capture"] == { + "client": "codexapp", + "proxy_mode": "transcript", + "codex_app_partial": True, + } + assert records[0]["response"]["body"]["output"][0]["content"][0]["text"] == "I will inspect it." + assert records[1]["response"]["body"]["status"] == "completed" + assert records[1]["response"]["body"]["usage"]["output_tokens"] == 5 + + +@pytest.mark.asyncio +async def test_import_codex_app_transcripts_resets_state_when_file_shrinks(trace_db, tmp_path: Path) -> None: + transcript = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "rollout.jsonl" + _write_jsonl(transcript, _session_rows()[:8]) + + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codexapp", proxy_mode="transcript") + writer = TraceWriter(session_id, store=store, metadata={"client": "codexapp", "proxy_mode": "transcript"}) + state = {transcript: 99} + + imported = await import_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + state=state, + include_incomplete=False, + ) + writer.close() + + assert imported == 1 + assert state[transcript].parser.response_count == 1 + assert len(store.load_records(session_id)) == 1 + + +@pytest.mark.asyncio +async def test_import_codex_app_transcripts_to_sessions_splits_codex_queries(trace_db, tmp_path: Path) -> None: + first = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "first.jsonl" + second = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "second.jsonl" + first_rows = _session_rows() + second_rows = _session_rows() + first_rows[0]["payload"]["id"] = "codex-query-alpha" + first_rows[3]["payload"]["content"][0]["text"] = "write runtime wiki" + second_rows[0]["payload"]["id"] = "codex-query-beta" + second_rows[3]["payload"]["content"][0]["text"] = "add Codex App listener" + _write_jsonl(first, first_rows[:8]) + _write_jsonl(second, second_rows[:8]) + + from claude_tap.trace_store import SessionQuery, get_trace_store + + store = get_trace_store() + registry = CodexAppTranscriptSessionRegistry( + store=store, + metadata={"client": "codexapp", "proxy_mode": "transcript"}, + ) + + imported = await import_codex_app_transcripts_to_sessions( + registry, + since=0, + home=tmp_path, + include_incomplete=False, + ) + registry.close() + + assert imported == 2 + rows = store.list_session_rows(query=SessionQuery(agent_clients=("codexapp",))) + assert len(rows) == 2 + records_by_session = {row["id"]: store.load_records(row["id"]) for row in rows} + assert sorted(len(records) for records in records_by_session.values()) == [1, 1] + prompts = sorted( + records[0]["request"]["body"]["input"][1]["content"][0]["text"] for records in records_by_session.values() + ) + assert prompts == ["add Codex App listener", "write runtime wiki"] + app_session_ids = sorted( + records[0]["request"]["body"]["metadata"]["codex_app_session_id"] for records in records_by_session.values() + ) + assert app_session_ids == ["codex-query-alpha", "codex-query-beta"] + capture_app_session_ids = sorted( + records[0]["capture"]["codex_app_session_id"] for records in records_by_session.values() + ) + assert capture_app_session_ids == ["codex-query-alpha", "codex-query-beta"] + + +@pytest.mark.asyncio +async def test_import_codex_app_transcripts_to_sessions_reuses_session_after_restart( + trace_db, + tmp_path: Path, +) -> None: + transcript = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "rollout.jsonl" + rows = _session_rows() + rows[0]["payload"]["id"] = "codex-query-restart" + _write_jsonl(transcript, rows[:8]) + + from claude_tap.trace_store import SessionQuery, get_trace_store + + store = get_trace_store() + first_registry = CodexAppTranscriptSessionRegistry( + store=store, + metadata={"client": "codexapp", "proxy_mode": "transcript"}, + ) + imported = await import_codex_app_transcripts_to_sessions( + first_registry, + since=0, + home=tmp_path, + include_incomplete=False, + ) + first_registry.close() + + assert imported == 1 + first_rows = store.list_session_rows(query=SessionQuery(agent_clients=("codexapp",))) + assert len(first_rows) == 1 + session_id = first_rows[0]["id"] + + _write_jsonl(transcript, rows) + restarted_registry = CodexAppTranscriptSessionRegistry( + store=store, + metadata={"client": "codexapp", "proxy_mode": "transcript"}, + ) + imported = await import_codex_app_transcripts_to_sessions( + restarted_registry, + since=0, + home=tmp_path, + include_incomplete=False, + ) + restarted_registry.close() + + assert imported == 1 + restarted_rows = store.list_session_rows(query=SessionQuery(agent_clients=("codexapp",))) + assert len(restarted_rows) == 1 + assert restarted_rows[0]["id"] == session_id + assert restarted_rows[0]["record_count"] == 2 + records = store.load_records(session_id) + assert [record["turn"] for record in records] == [1, 2] + assert records[1]["request"]["body"]["metadata"]["codex_app_session_id"] == "codex-query-restart" + + repeated_registry = CodexAppTranscriptSessionRegistry( + store=store, + metadata={"client": "codexapp", "proxy_mode": "transcript"}, + ) + imported = await import_codex_app_transcripts_to_sessions( + repeated_registry, + since=0, + home=tmp_path, + include_incomplete=False, + ) + repeated_registry.close() + + assert imported == 0 + repeated_rows = store.list_session_rows(query=SessionQuery(agent_clients=("codexapp",))) + assert len(repeated_rows) == 1 + assert repeated_rows[0]["id"] == session_id + assert repeated_rows[0]["record_count"] == 2 + + +@pytest.mark.asyncio +async def test_watch_codex_app_transcripts_flushes_incomplete_record_on_cancel(trace_db, tmp_path: Path) -> None: + transcript = tmp_path / ".codex" / "sessions" / "2026" / "06" / "07" / "rollout.jsonl" + rows = _session_rows()[:5] + _write_jsonl(transcript, rows) + + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codexapp", proxy_mode="transcript") + writer = TraceWriter(session_id, store=store, metadata={"client": "codexapp", "proxy_mode": "transcript"}) + + task = asyncio.create_task( + watch_codex_app_transcripts( + writer, + since=0, + home=tmp_path, + poll_interval=0.01, + ) + ) + await asyncio.sleep(0.03) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + writer.close() + + records = store.load_records(session_id) + assert len(records) == 1 + assert records[0]["response"]["body"]["status"] == "in_progress" + assert records[0]["response"]["body"]["output"][0]["content"][0]["text"] == "I will inspect it." diff --git a/tests/test_codex_launch.py b/tests/test_codex_launch.py new file mode 100644 index 0000000..2741402 --- /dev/null +++ b/tests/test_codex_launch.py @@ -0,0 +1,645 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from claude_tap.cli import _reverse_proxy_trace_options, _toml_dotted_key_segment, parse_args, run_client + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +_CODEX_PROXY_BASE_URL = "http://127.0.0.1:43123/v1" + + +def _builtin_codex_http_args(*tail: str) -> tuple[str, ...]: + provider = "model_providers.claude-tap-openai" + return ( + "/tmp/codex", + "-c", + 'model_provider="claude-tap-openai"', + "-c", + f'{provider}.name="claude-tap"', + "-c", + f'{provider}.base_url="{_CODEX_PROXY_BASE_URL}"', + "-c", + f'{provider}.wire_api="responses"', + "-c", + f"{provider}.requires_openai_auth=true", + "-c", + f"{provider}.supports_websockets=false", + *tail, + ) + + +def _custom_codex_http_args(provider: str, *tail: str) -> tuple[str, ...]: + provider_key = f"model_providers.{provider}" + return ( + "/tmp/codex", + "-c", + f'{provider_key}.base_url="{_CODEX_PROXY_BASE_URL}"', + "-c", + f"{provider_key}.supports_websockets=false", + *tail, + ) + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_forces_builtin_provider_to_http(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("claude_tap.cli_clients._codex_selected_provider_base_url_key", lambda _: None) + + code = await run_client(43123, ["exec", "hello"], client="codex", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == _builtin_codex_http_args("exec", "hello") + assert captured["env"]["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_isolates_legacy_openai_base_override(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("claude_tap.cli_clients._codex_selected_provider_base_url_key", lambda _: None) + + code = await run_client( + 43123, + ["-c", 'openai_base_url="http://example.invalid/v1"', "exec", "hello"], + client="codex", + proxy_mode="reverse", + ) + + assert code == 0 + assert captured["cmd"] == _builtin_codex_http_args( + "-c", + 'openai_base_url="http://example.invalid/v1"', + "exec", + "hello", + ) + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_replaces_builtin_provider_override(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("claude_tap.cli_clients._codex_selected_provider_base_url_key", lambda _: None) + + code = await run_client( + 43123, + ["--config", 'model_provider="openai"', "exec", "hello"], + client="codex", + proxy_mode="reverse", + ) + + assert code == 0 + assert captured["cmd"] == _builtin_codex_http_args("exec", "hello") + + +@pytest.mark.asyncio +async def test_run_client_codex_forward_sets_rust_tls_ca_env(monkeypatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["exec", "hello"], client="codex", proxy_mode="forward", ca_cert_path=ca_path) + + assert code == 0 + assert captured["env"]["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert captured["env"]["SSL_CERT_FILE"] == str(ca_path) + assert captured["env"]["CODEX_CA_CERTIFICATE"] == str(ca_path) + + +def test_toml_dotted_key_segment_quotes_non_ascii_provider_ids() -> None: + assert _toml_dotted_key_segment("newapi") == "newapi" + assert _toml_dotted_key_segment("new-api") == "new-api" + assert _toml_dotted_key_segment("new.api") == '"new.api"' + assert _toml_dotted_key_segment("模型") == '"\\u6a21\\u578b"' + + +def test_parse_args_codex_auto_detects_chatgpt_target(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text('{"auth_mode":"chatgpt"}\n', encoding="utf-8") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + args = parse_args(["--tap-client", "codex"]) + + assert args.target == "https://chatgpt.com/backend-api/codex" + + +def test_parse_args_codex_auto_detects_custom_provider_target(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text('{"OPENAI_API_KEY":"sk-test"}\n', encoding="utf-8") + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model = "gpt-5.4"', + 'model_provider = "newapi"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + 'name = "Custom"', + "requires_openai_auth = true", + 'wire_api = "responses"', + "", + ] + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + args = parse_args(["--tap-client", "codex"]) + + assert args.target == "https://new-api.example.test/v1" + + +def test_parse_args_codex_custom_provider_precedes_openai_base_url_env(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text('{"OPENAI_API_KEY":"sk-test"}\n', encoding="utf-8") + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "newapi"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("OPENAI_BASE_URL", "https://stale-env.example.test/v1") + + args = parse_args(["--tap-client", "codex"]) + + assert args.target == "https://new-api.example.test/v1" + + +def test_parse_args_codex_auto_detects_custom_provider_from_profile(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text('{"OPENAI_API_KEY":"sk-test"}\n', encoding="utf-8") + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "openai"', + "", + "[profiles.staging]", + 'model_provider = "newapi"', + "", + "[model_providers.openai]", + 'base_url = "https://api.openai.com/v1"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + args = parse_args(["--tap-client", "codex", "--", "--profile", "staging"]) + + assert args.target == "https://new-api.example.test/v1" + + +def test_parse_args_codex_auto_detects_custom_provider_from_profile_file(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text('{"auth_mode":"chatgpt"}\n', encoding="utf-8") + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "openai"', + "", + "[model_providers.openai]", + 'base_url = "https://api.openai.com/v1"', + "", + ] + ), + encoding="utf-8", + ) + (codex_home / "staging.config.toml").write_text( + "\n".join( + [ + 'model_provider = "newapi"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + args = parse_args(["--tap-client", "codex", "--", "--profile", "staging"]) + + assert args.target == "https://new-api.example.test/v1" + + +def test_parse_args_codex_provider_base_url_override_precedes_config(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "newapi"', + "", + "[model_providers.newapi]", + 'base_url = "https://production.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + args = parse_args( + [ + "--tap-client", + "codex", + "--", + "-c", + 'model_providers.newapi.base_url="https://staging.example.test/v1"', + ] + ) + + assert args.target == "https://staging.example.test/v1" + + +def test_parse_args_codex_openai_base_url_override_precedes_default(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + args = parse_args(["--tap-client", "codex", "--", "-c", 'openai_base_url="https://gateway.example.test/v1"']) + + assert args.target == "https://gateway.example.test/v1" + + +def test_parse_args_codex_auto_detects_custom_provider_from_model_provider_override(monkeypatch, tmp_path) -> None: + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "auth.json").write_text('{"OPENAI_API_KEY":"sk-test"}\n', encoding="utf-8") + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "openai"', + "", + "[model_providers.openai]", + 'base_url = "https://api.openai.com/v1"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + args = parse_args(["--tap-client", "codex", "--", "-c", 'model_provider="newapi"']) + + assert args.target == "https://new-api.example.test/v1" + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_injects_selected_provider_base_url(monkeypatch, tmp_path) -> None: + captured: dict[str, object] = {} + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "newapi"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["exec", "hello"], client="codex", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == _custom_codex_http_args("newapi", "exec", "hello") + assert captured["env"]["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_injects_profile_provider_base_url(monkeypatch, tmp_path) -> None: + captured: dict[str, object] = {} + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "openai"', + "", + "[profiles.staging]", + 'model_provider = "newapi"', + "", + "[model_providers.openai]", + 'base_url = "https://api.openai.com/v1"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--profile", "staging", "exec", "hello"], client="codex", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == _custom_codex_http_args("newapi", "--profile", "staging", "exec", "hello") + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_injects_profile_file_provider_base_url(monkeypatch, tmp_path) -> None: + captured: dict[str, object] = {} + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "openai"', + "", + "[model_providers.openai]", + 'base_url = "https://api.openai.com/v1"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + (codex_home / "staging.config.toml").write_text('model_provider = "newapi"\n', encoding="utf-8") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--profile", "staging", "exec", "hello"], client="codex", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == _custom_codex_http_args("newapi", "--profile", "staging", "exec", "hello") + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_injects_provider_from_model_provider_override(monkeypatch, tmp_path) -> None: + captured: dict[str, object] = {} + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "openai"', + "", + "[model_providers.openai]", + 'base_url = "https://api.openai.com/v1"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["-c", 'model_provider="newapi"', "exec", "hello"], + client="codex", + proxy_mode="reverse", + ) + + assert code == 0 + assert captured["cmd"] == _custom_codex_http_args( + "newapi", + "-c", + 'model_provider="newapi"', + "exec", + "hello", + ) + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_quotes_non_ascii_provider_base_url_key(monkeypatch, tmp_path) -> None: + captured: dict[str, object] = {} + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "模型"', + "", + '[model_providers."模型"]', + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["exec", "hello"], client="codex", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == _custom_codex_http_args('"\\u6a21\\u578b"', "exec", "hello") + + +@pytest.mark.asyncio +async def test_run_client_codex_reverse_replaces_conflicting_provider_overrides(monkeypatch, tmp_path) -> None: + captured: dict[str, object] = {} + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + "\n".join( + [ + 'model_provider = "newapi"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + "", + ] + ), + encoding="utf-8", + ) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + [ + "-c", + 'model_providers.newapi.base_url="http://example.invalid/v1"', + "--config=model_providers.newapi.supports_websockets=true", + "exec", + "hello", + ], + client="codex", + proxy_mode="reverse", + ) + + assert code == 0 + assert captured["cmd"] == _custom_codex_http_args("newapi", "exec", "hello") + + +def test_parse_args_claude_uses_env_base_url(monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://gateway.example.test/v1/anthropic") + + args = parse_args([]) + + assert args.target == "https://gateway.example.test/v1/anthropic" + + +def test_parse_args_claude_uses_project_settings_base_url(monkeypatch, tmp_path) -> None: + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + home = tmp_path / "home" + project = tmp_path / "project" + home_settings = home / ".claude" + project_settings = project / ".claude" + home_settings.mkdir(parents=True) + project_settings.mkdir(parents=True) + (home_settings / "settings.json").write_text( + '{"env":{"ANTHROPIC_BASE_URL":"https://global.example.test/v1/anthropic"}}\n', + encoding="utf-8", + ) + (project_settings / "settings.local.json").write_text( + '{"env":{"ANTHROPIC_BASE_URL":"https://project.example.test/v1/anthropic"}}\n', + encoding="utf-8", + ) + monkeypatch.setattr("pathlib.Path.home", lambda: home) + monkeypatch.chdir(project) + + args = parse_args([]) + + assert args.target == "https://project.example.test/v1/anthropic" + + +def test_parse_args_claude_falls_back_to_default_target(monkeypatch, tmp_path) -> None: + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path / "home") + monkeypatch.chdir(tmp_path) + + args = parse_args([]) + + assert args.target == "https://api.anthropic.com" + + +def test_codex_reverse_trace_options_allow_websocket() -> None: + options = _reverse_proxy_trace_options("codex", "https://chatgpt.com/backend-api/codex") + + assert options == { + "strip_path_prefix": "/v1", + "force_http": False, + } diff --git a/tests/test_cursor_launch.py b/tests/test_cursor_launch.py new file mode 100644 index 0000000..42ffde4 --- /dev/null +++ b/tests/test_cursor_launch.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, run_client +from claude_tap.cursor_transcript import import_cursor_transcripts +from claude_tap.trace import TraceWriter + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_cursor_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["cursor"] + assert cfg.cmd == "cursor-agent" + assert cfg.default_target == "https://api2.cursor.sh" + assert cfg.default_proxy_mode == "forward" + + +def test_parse_args_cursor_defaults_to_forward_mode() -> None: + args = parse_args(["--tap-client", "cursor"]) + assert args.client == "cursor" + assert args.proxy_mode == "forward" + + +@pytest.mark.asyncio +async def test_run_client_cursor_forward_sets_proxy_ca_and_no_proxy(monkeypatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setenv("NO_PROXY", "example.com") + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/cursor-agent") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["-p", "--trust", "--model", "auto", "hello"], + client="cursor", + proxy_mode="forward", + ca_cert_path=ca_path, + ) + + assert code == 0 + assert captured["cmd"] == ("/tmp/cursor-agent", "-p", "--trust", "--model", "auto", "hello") + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["NODE_EXTRA_CA_CERTS"] == str(ca_path) + assert "example.com" in env["NO_PROXY"] + assert "localhost" in env["NO_PROXY"] + assert "127.0.0.1" in env["NO_PROXY"] + assert "::1" in env["NO_PROXY"] + assert env["no_proxy"] == env["NO_PROXY"] + + +@pytest.mark.asyncio +async def test_import_cursor_transcripts_appends_viewer_friendly_records(trace_db, tmp_path: Path) -> None: + session_id = "session-123" + transcript = ( + tmp_path / ".cursor" / "projects" / "project-one" / "agent-transcripts" / session_id / f"{session_id}.jsonl" + ) + transcript.parent.mkdir(parents=True) + rows = [ + { + "role": "user", + "message": { + "content": [ + { + "type": "text", + "text": "now\n\nhello cursor\n", + } + ] + }, + }, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "hello back"}]}}, + {"role": "user", "message": {"content": [{"type": "text", "text": "second turn"}]}}, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "second answer"}]}}, + ] + transcript.write_text("\n".join(json.dumps(row) for row in rows), encoding="utf-8") + + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="cursor", proxy_mode="reverse") + writer = TraceWriter(session_id, store=store, metadata={"client": "cursor", "proxy_mode": "reverse"}) + imported = await import_cursor_transcripts(writer, since=0, home=tmp_path) + writer.close() + + assert imported == 2 + records = store.load_records(session_id) + assert records[0]["transport"] == "cursor-transcript" + assert records[0]["request"]["body"]["messages"][0]["content"] == "hello cursor" + assert records[0]["response"]["body"]["content"][0]["text"] == "hello back" + assert records[1]["request"]["body"]["messages"][0]["content"] == "second turn" + assert records[1]["response"]["body"]["content"][0]["text"] == "second answer" + + +@pytest.mark.asyncio +async def test_import_cursor_transcripts_preserves_tool_uses(trace_db, tmp_path: Path) -> None: + session_id = "tool-session" + transcript = ( + tmp_path / ".cursor" / "projects" / "project-one" / "agent-transcripts" / session_id / f"{session_id}.jsonl" + ) + transcript.parent.mkdir(parents=True) + rows = [ + {"role": "user", "message": {"content": [{"type": "text", "text": "inspect files"}]}}, + { + "role": "assistant", + "message": { + "content": [ + {"type": "text", "text": "I will inspect the workspace."}, + { + "type": "tool_use", + "name": "Shell", + "input": {"command": "pwd && ls", "working_directory": "/tmp/work"}, + }, + ] + }, + }, + { + "role": "assistant", + "message": { + "content": [{"type": "tool_use", "name": "ReadFile", "input": {"path": "/tmp/work/sample.txt"}}] + }, + }, + {"role": "assistant", "message": {"content": [{"type": "text", "text": "done"}]}}, + ] + transcript.write_text("\n".join(json.dumps(row) for row in rows), encoding="utf-8") + + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="cursor", proxy_mode="reverse") + writer = TraceWriter(session_id, store=store, metadata={"client": "cursor", "proxy_mode": "reverse"}) + imported = await import_cursor_transcripts(writer, since=0, home=tmp_path) + writer.close() + + assert imported == 3 + records = store.load_records(session_id) + + assert records[0]["request"]["path"].endswith("/turn/1/step/1") + assert records[1]["request"]["path"].endswith("/turn/1/step/2") + assert records[2]["request"]["path"].endswith("/turn/1/step/3") + assert records[1]["request"]["body"]["messages"][0]["content"] == "inspect files" + + content = records[0]["response"]["body"]["content"] + assert content[0] == {"type": "text", "text": "I will inspect the workspace."} + assert content[1]["type"] == "tool_use" + assert content[1]["name"] == "Shell" + assert content[1]["id"] == "cursor_tool_1_2" + + assert records[1]["response"]["body"]["content"][0]["name"] == "ReadFile" + assert records[2]["response"]["body"]["content"] == [{"type": "text", "text": "done"}] diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py new file mode 100644 index 0000000..7f29881 --- /dev/null +++ b/tests/test_dashboard.py @@ -0,0 +1,2312 @@ +import asyncio +import base64 +import json +import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import aiohttp +import pytest +from aiohttp.test_utils import make_mocked_request + +from claude_tap.dashboard import ( + DASHBOARD_SUMMARY_VERSION, + _clean_user_prompt_text, + _content_text, + _event_payload, + _first_error, + _infer_agent, + _input_user_text, + _parts_text, + _preview, + _record_host, + _record_model, + _record_response_text, + _record_usage, + _request_user_text, + _response_events, + _response_text, + dashboard_trace_snapshot, + list_trace_agents, + list_trace_sessions, + load_trace_session, + read_dashboard_template, +) +from claude_tap.history import migrate_legacy_traces +from claude_tap.live import LiveViewerServer, _record_limit_from_request +from claude_tap.trace import TraceWriter +from claude_tap.trace_log_handler import SQLiteLogHandler +from claude_tap.trace_store import get_trace_store + + +def _write_jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) for record in records) + "\n", + encoding="utf-8", + ) + + +def test_record_limit_from_request_preserves_large_loaded_windows() -> None: + request = make_mocked_request("GET", "/api/sessions/example/records?limit=1500") + + assert _record_limit_from_request(request) == 1500 + + +def test_dashboard_lists_sessions_by_normalized_updated_at(trace_db) -> None: + store = get_trace_store() + older = store.create_session(started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc)) + newer = store.create_session(started_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc)) + conn = store._connect() + conn.execute("UPDATE sessions SET updated_at = '2026-05-01T10:00:00+09:00' WHERE id = ?", (older,)) + conn.execute("UPDATE sessions SET updated_at = '2026-05-01T02:30:00+00:00' WHERE id = ?", (newer,)) + conn.commit() + + assert [session["id"] for session in list_trace_sessions()][:2] == [newer, older] + + +def _anthropic_record(turn: int = 1) -> dict: + return { + "timestamp": "2026-05-20T08:00:00+00:00", + "request_id": "req_claude", + "turn": turn, + "duration_ms": 1200, + "capture": {"client": "claude", "proxy_mode": "reverse"}, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {"Host": "api.anthropic.com"}, + "body": { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Explain this repository"}], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "This is a trace viewer."}], + "usage": {"input_tokens": 42, "output_tokens": 9}, + }, + }, + } + + +def _antigravity_record() -> dict: + return { + "timestamp": "2026-05-20T09:00:00+00:00", + "request_id": "req_agy", + "turn": 1, + "duration_ms": 900, + "request": { + "method": "POST", + "path": "/v1internal:streamGenerateContent?alt=sse", + "headers": {"Host": "antigravity-unleash.goog"}, + "body": { + "request": { + "contents": [{"role": "user", "parts": [{"text": "What model are you?"}]}], + } + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "candidates": [{"content": {"parts": [{"text": "I am Sonnet."}]}}], + "usageMetadata": {"promptTokenCount": 100, "candidatesTokenCount": 5}, + }, + }, + } + + +def _bedrock_frame(payload: dict) -> str: + encoded = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode() + return "\x00\x00binary-prefix" + json.dumps({"bytes": encoded, "p": "abcdefghijk"}) + "\ufffd" + + +def _bedrock_stream_record() -> dict: + body = "".join( + [ + _bedrock_frame( + { + "type": "message_start", + "message": { + "id": "msg_dashboard_bedrock", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-6", + "content": [], + "usage": {"input_tokens": 3, "output_tokens": 0}, + }, + } + ), + _bedrock_frame({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}), + _bedrock_frame({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "OK"}}), + _bedrock_frame({"type": "content_block_stop", "index": 0}), + _bedrock_frame({"type": "message_delta", "delta": {"stop_reason": "end_turn"}}), + _bedrock_frame({"type": "message_stop", "amazon-bedrock-invocationMetrics": {"inputTokenCount": 3}}), + ] + ) + return { + "timestamp": "2026-05-20T08:30:00+00:00", + "request_id": "req_bedrock_dashboard", + "turn": 1, + "request": { + "method": "POST", + "path": "/model/global.anthropic.claude-opus-4-6-v1/invoke-with-response-stream", + "headers": {"Host": "bedrock-runtime.us-east-1.amazonaws.com"}, + "body": {"messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}]}, + }, + "response": {"status": 200, "headers": {"Content-Type": "application/vnd.amazon.eventstream"}, "body": body}, + } + + +def _seed_legacy(tmp_path: Path) -> None: + migrate_legacy_traces(tmp_path) + + +def _seed_dashboard_summary( + *, + session_id: str, + agent: str, + status: str, + record_count: int, + first_user: str, + updated_at: str, + date_key: str, +) -> str: + return json.dumps( + { + "id": session_id, + "summary_version": DASHBOARD_SUMMARY_VERSION, + "date": date_key, + "agent": agent, + "status": status, + "record_count": record_count, + "turn_count": record_count, + "updated_at": updated_at, + "first_user": first_user, + "last_response": "", + "model": "gpt-5.5", + "total_tokens": 0, + }, + ensure_ascii=False, + separators=(",", ":"), + ) + + +def test_dashboard_lists_sessions_across_agents(trace_db, tmp_path: Path) -> None: + claude_trace = tmp_path / "2026-05-20" / "trace_080000.jsonl" + agy_trace = tmp_path / "2026-05-20" / "trace_090000.jsonl" + _write_jsonl(claude_trace, [_anthropic_record()]) + _write_jsonl(agy_trace, [_antigravity_record()]) + + _seed_legacy(tmp_path) + + sessions = list_trace_sessions() + + assert [session["agent"] for session in sessions] == ["Antigravity", "Claude Code"] + assert sessions[0]["first_user"] == "What model are you?" + assert sessions[0]["last_response"] == "I am Sonnet." + assert sessions[1]["input_tokens"] == 42 + assert sessions[1]["output_tokens"] == 9 + + agents = list_trace_agents() + assert [(agent["label"], agent["sessions"]) for agent in agents] == [("Antigravity", 1), ("Claude Code", 1)] + + +def test_dashboard_indexes_sessions_in_sqlite(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + _write_jsonl(trace_path, [_anthropic_record()]) + _seed_legacy(tmp_path) + + sessions = list_trace_sessions() + + assert trace_db.exists() + assert sessions[0]["record_count"] == 1 + assert sessions[0]["first_user"] == "Explain this repository" + + second_path = tmp_path / "2026-05-20" / "trace_081500.jsonl" + _write_jsonl(second_path, [_anthropic_record(turn=2)]) + migrate_legacy_traces(tmp_path) + sessions = list_trace_sessions() + first_session = next(item for item in sessions if item["legacy_rel_path"] == "2026-05-20/trace_080000.jsonl") + payload = load_trace_session(first_session["id"]) + + assert len(sessions) == 2 + assert payload is not None + assert payload["records"][0]["turn"] == 1 + + +def test_dashboard_summarizes_null_usage_token_fields(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_082000.jsonl" + record = _anthropic_record() + record["response"]["body"]["usage"] = { + "input_tokens": 42, + "output_tokens": 9, + "cache_read_input_tokens": None, + "cache_creation_input_tokens": None, + } + _write_jsonl(trace_path, [record]) + _seed_legacy(tmp_path) + + summary = list_trace_sessions()[0] + + assert summary["input_tokens"] == 42 + assert summary["output_tokens"] == 9 + assert summary["cache_read_tokens"] == 0 + assert summary["cache_create_tokens"] == 0 + assert summary["total_tokens"] == 51 + + +def test_dashboard_load_session_can_page_sqlite_records(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + _write_jsonl(trace_path, [_anthropic_record(), _anthropic_record(turn=2), _anthropic_record(turn=3)]) + _seed_legacy(tmp_path) + session_id = list_trace_sessions()[0]["id"] + + payload = load_trace_session(session_id, record_limit=1, record_offset=1) + + assert payload is not None + assert payload["session"]["record_count"] == 3 + assert [record["turn"] for record in payload["records"]] == [2] + + +def test_dashboard_lists_stale_cached_summary_without_record_scan(trace_db, monkeypatch) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + conn = store._connect() + conn.execute( + """ + UPDATE sessions + SET status = 'active', + record_count = 25, + summary_json = ? + WHERE id = ? + """, + ( + json.dumps( + { + "agent": "Claude Code", + "agent_key": "claude-code", + "record_count": 25, + "total_tokens": 500, + "first_user": "Cached prompt", + }, + separators=(",", ":"), + ), + session_id, + ), + ) + conn.commit() + + def fail_load_records(*_args, **_kwargs): + raise AssertionError("list view must not load full records") + + monkeypatch.setattr(store, "load_records", fail_load_records) + + summary = list_trace_sessions()[0] + + assert summary["id"] == session_id + assert summary["record_count"] == 25 + assert summary["status"] == "active" + assert summary["first_user"] == "Cached prompt" + + +def test_dashboard_lists_uncached_session_without_record_scan(trace_db, monkeypatch) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + conn = store._connect() + conn.execute( + "UPDATE sessions SET status = 'complete', record_count = 7, summary_json = NULL WHERE id = ?", + (session_id,), + ) + conn.commit() + + def fail_load_records(*_args, **_kwargs): + raise AssertionError("list view must not load full records") + + monkeypatch.setattr(store, "load_records", fail_load_records) + + summary = list_trace_sessions()[0] + + assert summary["id"] == session_id + assert summary["record_count"] == 7 + assert summary["agent"] == "Codex" + assert summary["status"] == "complete" + + +def test_dashboard_list_bad_session_summary_does_not_empty_page(trace_db) -> None: + store = get_trace_store() + good_id = store.create_session(client="codex", proxy_mode="reverse") + bad_id = store.create_session(client="", proxy_mode="") + conn = store._connect() + conn.execute( + """ + UPDATE sessions + SET status = 'complete', record_count = 1, summary_json = ? + WHERE id = ? + """, + ( + json.dumps({"agent": "Codex", "status": "complete", "total_tokens": 10}, separators=(",", ":")), + good_id, + ), + ) + conn.execute( + """ + UPDATE sessions + SET status = 'error', record_count = 1, summary_json = ? + WHERE id = ? + """, + ( + json.dumps({"agent": "Unknown", "status": "error", "total_tokens": 0}, separators=(",", ":")), + bad_id, + ), + ) + conn.execute( + """ + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + VALUES (?, 1, 1, ?, ?) + """, + (bad_id, "2026-05-20T11:00:00+00:00", "{not-json"), + ) + conn.commit() + + sessions = list_trace_sessions() + by_id = {session["id"]: session for session in sessions} + + assert good_id in by_id + assert bad_id in by_id + assert by_id[bad_id]["agent"] == "Unknown" + assert by_id[bad_id]["record_count"] == 1 + assert by_id[bad_id]["status"] == "error" + + +def test_dashboard_agent_buckets_use_aggregate_query(trace_db, monkeypatch) -> None: + store = get_trace_store() + codex_id = store.create_session(client="codex", proxy_mode="reverse") + claude_id = store.create_session(client="claude", proxy_mode="reverse") + conn = store._connect() + conn.execute( + """ + UPDATE sessions + SET status = 'complete', record_count = 3, summary_json = ? + WHERE id = ? + """, + ( + _seed_dashboard_summary( + session_id=codex_id, + agent="Codex", + status="complete", + record_count=3, + first_user="Codex prompt", + updated_at="2026-06-01T10:00:00+00:00", + date_key="2026-06-01", + ), + codex_id, + ), + ) + conn.execute( + """ + UPDATE sessions + SET status = 'complete', record_count = 2, summary_json = ? + WHERE id = ? + """, + ( + _seed_dashboard_summary( + session_id=claude_id, + agent="Claude Code", + status="complete", + record_count=2, + first_user="Claude prompt", + updated_at="2026-06-01T11:00:00+00:00", + date_key="2026-06-01", + ), + claude_id, + ), + ) + conn.commit() + + def fail_list_session_rows(*_args, **_kwargs): + raise AssertionError("agent buckets must not load every session row") + + monkeypatch.setattr(store, "list_session_rows", fail_list_session_rows) + + agents = list_trace_agents() + + assert [(agent["label"], agent["sessions"], agent["records"]) for agent in agents] == [ + ("Claude Code", 1, 2), + ("Codex", 1, 3), + ] + + +def test_dashboard_detail_reads_from_sqlite(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + _write_jsonl(trace_path, [_anthropic_record()]) + _seed_legacy(tmp_path) + session_id = list_trace_sessions()[0]["id"] + + payload = load_trace_session(session_id) + + assert payload is not None + assert payload["records"][0]["request_id"] == "req_claude" + + +def test_dashboard_first_message_uses_first_user_prompt(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_100000.jsonl" + _write_jsonl( + trace_path, + [ + { + "timestamp": "2026-05-20T10:00:00+00:00", + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": { + "model": "gpt-5.5", + "input": [ + {"role": "developer", "content": [{"type": "input_text", "text": "developer setup"}]}, + {"type": "function_call_output", "output": "tool result"}, + { + "role": "user", + "content": [{"type": "input_text", "text": "# AGENTS.md instructions\nSkip"}], + }, + {"role": "user", "content": [{"type": "input_text", "text": "What is this project?"}]}, + ], + }, + }, + "response": {"status": 200, "body": {"model": "gpt-5.5", "usage": {"input_tokens": 1}}}, + } + ], + ) + + _seed_legacy(tmp_path) + summary = list_trace_sessions()[0] + + assert summary["first_user"] == "What is this project?" + + +def test_dashboard_first_message_skips_injected_user_content_blocks(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_101500.jsonl" + _write_jsonl( + trace_path, + [ + { + "timestamp": "2026-05-20T10:15:00+00:00", + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-sonnet-4-6", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "\nInjected context\n", + }, + {"type": "text", "text": "Fix the failing dashboard prompt preview."}, + ], + } + ], + }, + }, + "response": {"status": 200, "body": {"model": "claude-sonnet-4-6", "usage": {"input_tokens": 1}}}, + } + ], + ) + + _seed_legacy(tmp_path) + summary = list_trace_sessions()[0] + + assert summary["first_user"] == "Fix the failing dashboard prompt preview." + + +def test_dashboard_recomputes_stale_first_message_summary_cache(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session( + client="claude", + proxy_mode="reverse", + started_at=datetime(2026, 5, 20, 10, 15, tzinfo=timezone.utc), + ) + store.append_record( + session_id, + { + "timestamp": "2026-05-20T10:15:00+00:00", + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-sonnet-4-6", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "\nInjected context\n"}, + {"type": "tool_result", "content": "stale tool output"}, + {"type": "text", "text": "Fix the failing dashboard prompt preview."}, + ], + } + ], + }, + }, + "response": {"status": 200, "body": {"model": "claude-sonnet-4-6", "usage": {"input_tokens": 1}}}, + }, + ) + + stale_summary = { + "id": session_id, + "status": "complete", + "record_count": 1, + "updated_at": "2026-05-20T10:15:00+00:00", + "first_user": "stale tool output", + } + conn = store._connect() + conn.execute( + "UPDATE sessions SET status = 'complete', summary_json = ? WHERE id = ?", + (json.dumps(stale_summary, ensure_ascii=False, separators=(",", ":")), session_id), + ) + conn.commit() + + summary = list_trace_sessions()[0] + cached = json.loads(store.load_session_row(session_id)["summary_json"]) + + assert summary["first_user"] == "Fix the failing dashboard prompt preview." + assert cached["first_user"] == "Fix the failing dashboard prompt preview." + assert cached["summary_version"] == DASHBOARD_SUMMARY_VERSION + + +def test_dashboard_recomputes_stale_active_summary_cache_on_append(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session( + client="claude", + proxy_mode="reverse", + started_at=datetime(2026, 5, 20, 10, 15, tzinfo=timezone.utc), + ) + first_record = { + "timestamp": "2026-05-20T10:15:00+00:00", + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-sonnet-4-6", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "\nInjected context\n"}, + {"type": "tool_result", "content": "stale tool output"}, + {"type": "text", "text": "Fix the active dashboard prompt preview."}, + ], + } + ], + }, + }, + "response": {"status": 200, "body": {"model": "claude-sonnet-4-6", "usage": {"input_tokens": 1}}}, + } + store.append_record(session_id, first_record) + + stale_summary = { + "id": session_id, + "status": "active", + "record_count": 1, + "updated_at": "2026-05-20T10:15:00+00:00", + "first_user": "stale tool output", + } + conn = store._connect() + conn.execute( + "UPDATE sessions SET status = 'active', summary_json = ? WHERE id = ?", + (json.dumps(stale_summary, ensure_ascii=False, separators=(",", ":")), session_id), + ) + conn.commit() + store.append_record( + session_id, + { + "timestamp": "2026-05-20T10:16:00+00:00", + "turn": 2, + "request": {"method": "POST", "path": "/v1/messages", "body": {"model": "claude-sonnet-4-6"}}, + "response": {"status": 200, "body": {"model": "claude-sonnet-4-6", "usage": {"input_tokens": 1}}}, + }, + ) + + summary = list_trace_sessions(current_session_id=session_id)[0] + cached = json.loads(store.load_session_row(session_id)["summary_json"]) + + assert summary["first_user"] == "Fix the active dashboard prompt preview." + assert cached["first_user"] == "Fix the active dashboard prompt preview." + assert cached["summary_version"] == DASHBOARD_SUMMARY_VERSION + assert cached["record_count"] == 2 + + +def test_dashboard_loads_session_by_id(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + _write_jsonl(trace_path, [_anthropic_record()]) + _seed_legacy(tmp_path) + session_id = list_trace_sessions()[0]["id"] + + payload = load_trace_session(session_id) + + assert payload is not None + assert payload["session"]["legacy_rel_path"] == "2026-05-20/trace_080000.jsonl" + assert payload["records"][0]["request_id"] == "req_claude" + + +def test_dashboard_rejects_missing_session_ids(trace_db) -> None: + template = read_dashboard_template() + assert "session-list" in template + assert "lang-select" in template + assert "DASHBOARD_I18N" in template + assert 'data-i18n="table_first_message"' in template + assert "export_jsonl" in template + assert 'export_compact: "Export JSON"' in template + assert "export_log" not in template + assert "export_html" in template + assert "export_menu" in template + assert load_trace_session("not-a-valid-session-id") is None + + +def test_dashboard_detail_navigation_uses_lazy_shell_route() -> None: + template = read_dashboard_template() + + assert "function sessionDetailUrl(sessionId)" in template + assert "window.location.assign(sessionDetailUrl(sessionId))" in template + assert "/dashboard/session/" in template + assert "detailRecordTotal: 0" in template + assert 'detailFingerprint: ""' in template + assert "detailSession: null" in template + assert 'activeTab: "raw"' in template + assert "function detailRecordFetchLimit(sessionId, preserveLoaded)" in template + assert "function sessionDetailFingerprint(session)" in template + assert "function updateDetailSessionSummary(session)" in template + assert "function updateDetailI18n(session)" in template + assert 'params.set("search", search)' in template + assert 'params.set("agent", state.selectedAgent)' in template + assert "function refreshForFilters()" in template + assert 'state.view === "detail" && state.selectedSessionId' in template + assert "const detailLoaded = state.detailSessionId === state.selectedSessionId" in template + assert "updateDetailSessionSummary(selected)" in template + assert "updateDetailI18n(state.detailSession)" in template + assert "knownTotal > previousTotal && previousTotal <= loadedRecords" in template + assert "const limit = detailRecordFetchLimit(sessionId, preserveLoaded)" in template + assert "state.detailRecordTotal = totalRecords" in template + assert "state.detailFingerprint = sessionDetailFingerprint(session)" in template + assert "function ensureViewerFrame(session)" in template + assert "data-tab-toggle" in template + assert 'container.querySelector("[data-viewer-frame]")' in template + assert "setDetailTab(event.currentTarget.dataset.tab, session)" in template + + +def test_dashboard_template_exposes_session_delete_controls() -> None: + template = read_dashboard_template() + + assert 'data-i18n="table_actions"' in template + assert 'id="edit-sessions"' in template + assert 'id="select-all-sessions"' in template + assert 'id="delete-selected-sessions"' in template + assert "data-select-session" in template + assert "delete-session-modal" in template + assert "data-delete-session" not in template + assert "delete_active_session_title" in template + assert "session.active" in template + assert "function isSessionRowActionTarget(target)" in template + assert "event.target !== row" in template + assert "function confirmDeleteSession()" in template + assert "body: JSON.stringify({session_ids: sessionIds})" in template + assert 'method: "DELETE"' in template + + +def test_dashboard_template_exposes_quit_control() -> None: + template = read_dashboard_template() + + assert 'id="logo-version"' in template + assert 'const CLAUDE_TAP_VERSION = "";' in template + assert "function applyVersionBadge()" in template + assert 'id="dashboard-quit"' in template + assert "quit_dashboard_confirm" in template + assert "Stop dashboard service" in template + assert "function quitDashboard()" in template + assert 'const DASHBOARD_QUIT_TOKEN = "";' in template + assert "const DASHBOARD_CAN_STOP = false;" in template + assert '"X-Claude-Tap-Dashboard-Token": DASHBOARD_QUIT_TOKEN' in template + assert "let dashboardEvents = null;" in template + assert "function closeDashboardEvents()" in template + assert "if (state.quittingDashboard) return;" in template + + +def test_dashboard_summarize_session_and_migration(trace_db, tmp_path: Path) -> None: + assert dashboard_trace_snapshot() == {} + + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + trace_path.parent.mkdir(parents=True, exist_ok=True) + trace_path.write_text( + '\nnot-json\n[]\n{"request_id":"ok","request":{},"response":{}}\n', + encoding="utf-8", + ) + manifest_path = tmp_path / ".cloudtap-manifest.json" + manifest_path.write_text( + json.dumps( + { + "traces": [ + { + "client": "kimi", + "files": ["2026-05-20/trace_080000.jsonl"], + "created_at": "2026-05-20T00:00:00+00:00", + }, + ] + } + ), + encoding="utf-8", + ) + _seed_legacy(tmp_path) + summary = list_trace_sessions()[0] + assert summary["agent"] == "Kimi" + assert summary["record_count"] == 1 + + +def test_dashboard_parses_provider_fallbacks(trace_db, tmp_path: Path) -> None: + html_trace = tmp_path / "2026-05-20" / "trace_090000.jsonl" + html_trace.parent.mkdir(parents=True, exist_ok=True) + _write_jsonl(html_trace, [_antigravity_record()]) + _seed_legacy(tmp_path) + + session_id = list_trace_sessions()[0]["id"] + summary = list_trace_sessions(current_session_id=session_id)[0] + assert summary["status"] == "active" + assert summary["model"] == "unknown" + + provider_cases = [ + ({"metadata": {"client": "agy"}}, [], "Antigravity"), + ({}, [{"capture": {"client": "cursor"}}], "Cursor"), + ({}, [{"request": {"headers": {"host": "generativelanguage.googleapis.com"}}}], "Gemini"), + ({}, [{"request": {"path": "/v1/responses"}}], "Codex"), + ({}, [{"request": {"headers": {"Host": "api.moonshot.cn"}}}], "Kimi"), + ({}, [{"request": {"headers": {"Host": "qoder.example"}}}], "Qoder"), + ({}, [{"request": {"headers": {"Host": "opencode.example"}}}], "OpenCode"), + ({}, [{"request": {"headers": {"Host": "mimo.xiaomi.example"}}}], "MiMo Code"), + ({}, [{"request": {"headers": {"Host": "hermes.example"}}}], "Hermes"), + ({}, [{"upstream_base_url": "https://api.anthropic.com/v1"}], "Claude Code"), + ({}, [], "Unknown"), + ] + for manifest_entry, records, expected in provider_cases: + assert _infer_agent(records, manifest_entry) == expected + + assert _record_host({"request": {"headers": {"host": "lowercase.example"}}}) == "lowercase.example" + assert _record_host({"upstream_base_url": "https://upstream.example/path"}) == "upstream.example" + + +def test_dashboard_extracts_usage_models_errors_and_text() -> None: + assert _record_usage({"response": {"body": {"usageMetadata": {"promptTokenCount": 3}}}})["input_tokens"] == 3 + assert ( + _record_usage( + {"response": {"ws_events": [{"data": '{"response":{"usage":{"input_tokens":4,"output_tokens":2}}}'}]}} + )["output_tokens"] + == 2 + ) + assert _record_usage({"response": {"body": {"input_tokens": 5}}})["input_tokens"] == 5 + + assert _record_model({"request": {"body": {"modelId": "gemini-3.1"}}}) == "gemini-3.1" + assert _record_model({"request": {"body": {"request": {"model": "sonnet-4-6"}}}}) == "sonnet-4-6" + assert _record_model({"response": {"body": {"model": "gpt-oss"}}}) == "gpt-oss" + assert _record_model({"request": {"path": "/v1beta/models/gemini-pro:generateContent"}}) == "gemini-pro" + assert _record_model({}) == "" + + assert _first_error([{"response": {"error": "failed hard"}}]) == "failed hard" + assert _first_error([{"response": {"body": {"error": "body failed"}}}]) == "body failed" + assert _first_error([{"response": {"body": {"error": {"message": "nested failed"}}}}]) == "nested failed" + assert _first_error([{"response": {"body": {}}}]) == "" + + assert _request_user_text("raw prompt") == "raw prompt" + assert _request_user_text(None) == "" + assert _request_user_text({"prompt": "fallback prompt"}) == "fallback prompt" + assert _request_user_text({"messages": [{"role": "user", "content": "\nwrapped prompt\n"}]}) == ( + "wrapped prompt" + ) + assert ( + _request_user_text( + { + "request": { + "contents": [ + {"role": "user", "parts": [{"text": "\ncontext\n"}]}, + {"role": "user", "parts": [{"text": "actual Gemini prompt"}]}, + ] + } + } + ) + == "actual Gemini prompt" + ) + assert ( + _request_user_text( + { + "request": { + "contents": [ + { + "role": "user", + "parts": [ + { + "text": "\n--print-timeout\n\n" + "time" + } + ], + } + ] + } + } + ) + == "--print-timeout" + ) + assert ( + _request_user_text( + { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "\n--print-timeout\n"}, + {"type": "text", "text": "time"}, + ], + } + ] + } + ) + == "--print-timeout" + ) + assert _request_user_text({"input": [{"type": "message", "content": [{"text": "input text"}]}]}) == "input text" + assert ( + _request_user_text( + { + "input": [ + {"role": "developer", "content": [{"type": "input_text", "text": "developer setup"}]}, + {"type": "function_call_output", "output": "tool result"}, + {"role": "user", "content": [{"type": "input_text", "text": "raw user prompt"}]}, + ] + } + ) + == "raw user prompt" + ) + assert ( + _request_user_text( + { + "input": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "\nskip\n"}, + {"type": "text", "text": "actual response prompt"}, + ], + } + ] + } + ) + == "actual response prompt" + ) + assert _request_user_text({"messages": [{"role": "user", "content": ["hello", {"text": "world"}]}]}) == ( + "hello\nworld" + ) + assert ( + _request_user_text( + { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "\nskip\n"}, + {"type": "text", "text": "actual message prompt"}, + ], + } + ] + } + ) + == "actual message prompt" + ) + assert ( + _request_user_text( + { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "\nskip\n"}, + {"type": "tool_result", "content": "tool output should not be first prompt"}, + {"type": "function_call_output", "output": "function output should not be first prompt"}, + {"type": "text", "text": "actual prompt after tools"}, + ], + } + ] + } + ) + == "actual prompt after tools" + ) + assert ( + _request_user_text( + {"contents": [{"role": "model", "parts": [{"text": "skip"}]}, {"role": "USER", "parts": [{"text": "use"}]}]} + ) + == "use" + ) + assert ( + _request_user_text( + { + "contents": [ + { + "role": "user", + "parts": [ + {"text": "\nskip\n"}, + {"text": "actual gemini prompt"}, + ], + } + ] + } + ) + == "actual gemini prompt" + ) + + assert _response_text("raw response") == "raw response" + assert _response_text(None) == "" + assert _response_text({"choices": [{"message": {"content": "choice"}}]}) == "choice" + assert _response_text({"choices": [{"delta": {"content": [{"text": "delta"}]}}]}) == "delta" + assert _response_text({"output": [{"output_text": "out"}]}) == "out" + assert _response_text({"response": {"content": "response field"}}) == "response field" + assert _content_text({"text": ["nested", {"content": "dict"}]}) == "nested\ndict" + assert _content_text({"input_text": "typed prompt"}) == "typed prompt" + assert _content_text([{"type": "message", "content": [{"output_text": "message text"}]}]) == "message text" + assert _input_user_text([{"role": "developer", "content": "dev"}, {"content": "implicit user"}]) == "implicit user" + assert _clean_user_prompt_text('"quoted prompt"') == "quoted prompt" + assert _clean_user_prompt_text("\nskip\n") == "" + assert _parts_text("not-list") == "" + assert _preview(" a \n b ", 20) == "a b" + assert _preview("abcdef", 4) == "abc..." + + assert _response_events({"response": "bad"}) == [] + assert _response_events({"response": {"sse_events": [{"data": "{}"}, "bad"]}}) == [{"data": "{}"}] + assert _event_payload({"data": "not-json"}) == {} + assert _event_payload({"data": {"response": {"content": "payload"}}}) == {"content": "payload"} + assert _event_payload({"data": 1}) == {} + + assert _record_response_text({"response": {"body": "body text"}}) == "body text" + assert ( + _record_response_text( + {"response": {"ws_events": [{"item": {"content": "item text"}}, {"part": {"text": "part text"}}]}} + ) + == "part text" + ) + assert _record_response_text({"response": {"ws_events": [{"text": "event text"}]}}) == "event text" + assert ( + _record_response_text({"response": {"ws_events": [{"data": '{"content":"payload text"}'}]}}) == "payload text" + ) + assert _record_response_text({"response": {}}) == "" + + +def test_dashboard_preview_skips_auxiliary_auth_records(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_100000.jsonl" + _write_jsonl( + trace_path, + [ + { + "timestamp": "2026-05-20T10:00:00+00:00", + "turn": 1, + "request": { + "method": "POST", + "path": "/token", + "body": "refresh_token=secret-token&client_id=client", + }, + "response": {"status": 200, "body": {}}, + }, + { + "timestamp": "2026-05-20T10:00:01+00:00", + "turn": 2, + "request": {"method": "POST", "path": "/log?format=json", "body": {}}, + "response": {"status": 403, "body": " challenge page"}, + }, + { + "timestamp": "2026-05-20T10:00:02+00:00", + "turn": 3, + "request": { + "method": "POST", + "path": "/v1internal:streamGenerateContent?alt=sse", + "headers": {"Host": "generativelanguage.googleapis.com"}, + "body": { + "request": { + "contents": [ + { + "role": "user", + "parts": [{"text": "Gemini dashboard prompt"}], + } + ] + } + }, + }, + "response": { + "status": 200, + "body": { + "candidates": [ + { + "content": { + "parts": [{"text": "Gemini dashboard response."}], + } + } + ] + }, + }, + }, + ], + ) + + _seed_legacy(tmp_path) + summary = list_trace_sessions()[0] + + assert summary["first_user"] == "Gemini dashboard prompt" + assert summary["last_response"] == "Gemini dashboard response." + assert summary["status"] == "complete" + + +@pytest.mark.asyncio +async def test_dashboard_server_serves_session_api_and_exports(trace_db, tmp_path: Path) -> None: + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + second_trace_path = tmp_path / "2026-05-20" / "trace_081500.jsonl" + _write_jsonl(trace_path, [_anthropic_record()]) + _write_jsonl(second_trace_path, [_anthropic_record(turn=2), _anthropic_record(turn=3)]) + trace_path.with_suffix(".log").write_text("10:00:00 proxy log\n", encoding="utf-8") + _seed_legacy(tmp_path) + + server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/") as resp: + assert resp.status == 200 + html = await resp.text() + assert "session-list" in html + assert "export_jsonl" in html + assert 'export_compact: "Export JSON"' in html + assert "export_log" not in html + assert "export_html" in html + assert "export_menu" in html + + async with session.get(f"http://127.0.0.1:{port}/api/sessions") as resp: + assert resp.status == 200 + payload = await resp.json() + assert len(payload["sessions"]) == 2 + second_session_id = next(item["id"] for item in payload["sessions"] if item["record_count"] == 2) + session_id = next(item["id"] for item in payload["sessions"] if item["record_count"] == 1) + + async with session.get( + f"http://127.0.0.1:{port}/dashboard?session_id={session_id}", + allow_redirects=False, + ) as resp: + assert resp.status == 302 + assert resp.headers["Location"] == f"/dashboard/session/{session_id}" + + async with session.get(f"http://127.0.0.1:{port}/dashboard/session/{session_id}") as resp: + assert resp.status == 200 + html = await resp.text() + assert "session-list" in html + assert "back-to-list" not in html + assert "EMBEDDED_TRACE_COMPACT_DATA" not in html + assert "req_claude" not in html + assert "/api/sessions/${encodeURIComponent(session.id)}/html" in html + + async with session.get(f"http://127.0.0.1:{port}/api/agents") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["agents"][0]["label"] == "Claude Code" + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/records") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["records"][0]["request_id"] == "req_claude" + + async with session.get( + f"http://127.0.0.1:{port}/api/sessions/{session_id}/html", + allow_redirects=False, + ) as resp: + assert resp.status == 200 + html = await resp.text() + assert "EMBEDDED_TRACE_META" in html + assert "const EMBEDDED_TRACE_COMPACT_DATA =" not in html + assert f'const __TRACE_RECORDS_API__ = "/api/sessions/{session_id}/records";' in html + assert "req_claude" in html + assert f'const __TRACE_JSONL_PATH__ = "/api/sessions/{session_id}/export/compact";' in html + assert f'const __TRACE_HTML_PATH__ = "/dashboard/session/{session_id}";' in html + assert f"session-{session_id[:8]}.jsonl" not in html + assert f"session-{session_id[:8]}.html" not in html + assert f"/api/sessions/{session_id}/export/html" in html + assert f"/api/sessions/{session_id}/export/log" not in html + + async with session.get( + f"http://127.0.0.1:{port}/api/sessions/{second_session_id}/records?offset=1&limit=1" + ) as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["session"]["record_count"] == 2 + assert [record["turn"] for record in payload["records"]] == [3] + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/export/jsonl") as resp: + assert resp.status == 200 + body = await resp.text() + assert "req_claude" in body + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/export/compact") as resp: + assert resp.status == 200 + assert resp.content_type == "application/json" + body = await resp.text() + assert "__claude_tap_compact_trace__" in body + assert "req_claude" in body + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/export/log") as resp: + assert resp.status == 200 + assert resp.content_type == "text/plain" + assert resp.charset == "utf-8" + body = await resp.text() + assert body == "10:00:00 proxy log\n" + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/export/html") as resp: + assert resp.status == 200 + assert resp.content_type == "text/html" + assert resp.charset == "utf-8" + assert f'filename="trace_{session_id[:8]}.html"' in resp.headers["Content-Disposition"] + html = await resp.text() + assert "EMBEDDED_TRACE_COMPACT_DATA" in html + assert "const EMBEDDED_TRACE_DATA =" not in html + assert "req_claude" in html + assert f'const __TRACE_JSONL_PATH__ = "/api/sessions/{session_id}/export/compact";' in html + assert f'const __TRACE_HTML_PATH__ = "/api/sessions/{session_id}/export/html";' in html + assert f"session-{session_id[:8]}.jsonl" not in html + + async with session.delete(f"http://127.0.0.1:{port}/api/sessions/{second_session_id}") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["deleted_sessions"] == 1 + assert payload["deleted_records"] == 2 + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{second_session_id}/records") as resp: + assert resp.status == 404 + + async with session.get(f"http://127.0.0.1:{port}/api/sessions") as resp: + assert resp.status == 200 + payload = await resp.json() + assert [item["id"] for item in payload["sessions"]] == [session_id] + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/bad/records") as resp: + assert resp.status == 404 + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_export_html_normalizes_bedrock_eventstream(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(session_id, _bedrock_stream_record()) + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/export/html") as resp: + assert resp.status == 200 + html = await resp.text() + finally: + await server.stop() + + assert "EMBEDDED_TRACE_COMPACT_DATA" in html + assert '"sse_events":' in html + assert '"content":[{"type":"text","text":"OK"}]' in html + assert "binary-prefix" not in html + + +@pytest.mark.asyncio +async def test_dashboard_session_detail_redacts_sensitive_display_records(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="agy", proxy_mode="reverse") + store.append_record( + session_id, + { + "timestamp": "2026-05-20T10:00:00+00:00", + "request_id": "req_auth", + "turn": 1, + "request": { + "method": "POST", + "path": ( + "/login?redirect_uri=%2Foauth%2Fcallback%3Faccess_token%3Dredirect-secret" + "&access_token=path-secret&client_id=public-client" + ), + "url": "https://oauth.example/callback?id_token=url-secret&state=ok", + "body": ( + "client_id=public-client&client_secret=client-secret&refresh_token=refresh-secret" + "&redirect_uri=/oauth/callback?access_token=nested-secret" + ), + }, + "response": { + "status": 200, + "body": { + "access_token": "access-secret", + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + }, + }, + ) + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/records") as resp: + assert resp.status == 200 + payload = await resp.json() + record = payload["records"][0] + assert "redirect-secret" not in record["request"]["path"] + assert "path-secret" not in record["request"]["path"] + assert "redirect_uri=%2Foauth%2Fcallback%3Faccess_token%3DREDACTED" in record["request"]["path"] + assert "access_token=REDACTED" in record["request"]["path"] + assert record["request"]["url"] == "https://oauth.example/callback?id_token=REDACTED&state=ok" + assert "client_secret=REDACTED" in record["request"]["body"] + assert "refresh_token=REDACTED" in record["request"]["body"] + assert "nested-secret" not in record["request"]["body"] + assert "access_token%3DREDACTED" in record["request"]["body"] + assert record["response"]["body"]["access_token"] == "REDACTED" + assert record["response"]["body"]["usage"]["input_tokens"] == 3 + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/html") as resp: + assert resp.status == 200 + html = await resp.text() + assert "client-secret" not in html + assert "refresh-secret" not in html + assert "access-secret" not in html + assert "path-secret" not in html + assert "url-secret" not in html + assert "redirect-secret" not in html + assert "nested-secret" not in html + assert "REDACTED" in html + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/export/jsonl") as resp: + assert resp.status == 200 + body = await resp.text() + assert "client-secret" in body + assert "refresh-secret" in body + assert "access-secret" in body + assert "path-secret" in body + assert "url-secret" in body + assert "redirect-secret" in body + assert "nested-secret" in body + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_session_html_uses_remote_records_for_long_codex_prompt(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codexapp", proxy_mode="transcript") + long_prompt = ("sandbox_permissions=" * 1200) + "done" + store.append_record( + session_id, + { + "timestamp": "2026-06-13T09:00:00+00:00", + "request_id": "req_codex_app", + "turn": 1, + "request": { + "method": "CODEX_APP_TRANSCRIPT", + "path": "/v1/responses", + "headers": {"x-codex-app-session-id": "019ec061-b6cd-74b0-abdf-1d51267d1355"}, + "body": { + "type": "response.create", + "model": "gpt-5.5", + "input": [ + { + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": long_prompt}], + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Add Codex App listening"}], + }, + ], + }, + }, + "response": {"status": 200, "body": {"usage": {"input_tokens": 100, "output_tokens": 20}}}, + }, + ) + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/html") as resp: + assert resp.status == 200 + html = await resp.text() + assert len(html) < 500_000 + assert "EMBEDDED_TRACE_META" in html + assert "__TRACE_RECORDS_API__" in html + assert "const EMBEDDED_TRACE_COMPACT_DATA =" not in html + assert long_prompt not in html + assert "Add Codex App listening" in html + + async with session.get( + f"http://127.0.0.1:{port}/api/sessions/{session_id}/records?offset=0&limit=1" + ) as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["records"][0]["request"]["body"]["input"][0]["content"][0]["text"] == long_prompt + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_summary_preview_fields_are_redacted(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record( + session_id, + { + "timestamp": "2026-05-20T10:00:00+00:00", + "request_id": "req_summary", + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/messages", + "body": "client_id=public-client&client_secret=summary-secret&prompt=hi", + }, + "response": { + "status": 200, + "body": "message=ok&refresh_token=response-secret", + }, + }, + ) + + cached = json.loads(store.load_session_row(session_id)["summary_json"]) + listed = next(item for item in list_trace_sessions() if item["id"] == session_id) + + assert "summary-secret" not in cached["first_user"] + assert "response-secret" not in cached["last_response"] + assert "summary-secret" not in listed["first_user"] + assert "response-secret" not in listed["last_response"] + assert "client_secret=REDACTED" in listed["first_user"] + assert "refresh_token=REDACTED" in listed["last_response"] + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/records") as resp: + assert resp.status == 200 + payload = await resp.json() + assert "summary-secret" not in payload["session"]["first_user"] + assert "response-secret" not in payload["session"]["last_response"] + assert "client_secret=REDACTED" in payload["session"]["first_user"] + assert "refresh_token=REDACTED" in payload["session"]["last_response"] + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/{session_id}/export/jsonl") as resp: + assert resp.status == 200 + body = await resp.text() + assert "summary-secret" in body + assert "response-secret" in body + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_session_filters_apply_before_paging(trace_db) -> None: + store = get_trace_store() + conn = store._connect() + for index in range(120): + session_id = store.create_session( + client="claude", + proxy_mode="reverse", + started_at=datetime(2026, 6, 1, 12, index % 60, tzinfo=timezone.utc), + ) + updated_at = f"2026-06-01T12:{index % 60:02d}:{index // 60:02d}+00:00" + conn.execute( + """ + UPDATE sessions + SET status = 'complete', + record_count = 1, + updated_at = ?, + date_key = '2026-06-01', + summary_json = ? + WHERE id = ? + """, + ( + updated_at, + _seed_dashboard_summary( + session_id=session_id, + agent="Claude Code", + status="complete", + record_count=1, + first_user=f"Noise prompt {index}", + updated_at=updated_at, + date_key="2026-06-01", + ), + session_id, + ), + ) + target_id = store.create_session( + client="codex", + proxy_mode="reverse", + started_at=datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc), + ) + conn.execute( + """ + UPDATE sessions + SET status = 'error', + record_count = 7, + started_at = '2026-05-01T08:00:00+00:00', + updated_at = '2026-05-01T08:00:00+00:00', + date_key = '2026-05-01', + summary_json = ? + WHERE id = ? + """, + ( + _seed_dashboard_summary( + session_id=target_id, + agent="Codex", + status="error", + record_count=7, + first_user="Find me past the first page", + updated_at="2026-05-01T08:00:00+00:00", + date_key="2026-05-01", + ), + target_id, + ), + ) + conn.commit() + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/api/sessions?limit=100") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["total"] == 121 + assert payload["total_records"] == 127 + assert target_id not in {item["id"] for item in payload["sessions"]} + + for query in ( + "search=Find%20me%20past", + "date=2026-05-01", + "status=error", + "agent=codex", + ): + async with session.get(f"http://127.0.0.1:{port}/api/sessions?limit=10&{query}") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["total"] == 1 + assert payload["total_records"] == 7 + assert [item["id"] for item in payload["sessions"]] == [target_id] + assert "2026-05-01" in payload["dates"] + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_server_sse_events(trace_db) -> None: + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + timeout = aiohttp.ClientTimeout(total=3) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(f"http://127.0.0.1:{port}/api/agents") as resp: + assert resp.status == 200 + assert await resp.json() == {"agents": []} + + async with session.get(f"http://127.0.0.1:{port}/api/sessions") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["sessions"] == [] + assert payload["total"] == 0 + + async with session.get(f"http://127.0.0.1:{port}/api/sessions/anything/records") as resp: + assert resp.status == 404 + + async with session.get(f"http://127.0.0.1:{port}/dashboard/events") as resp: + assert resp.status == 200 + assert await asyncio.wait_for(resp.content.readline(), timeout=1) == b"event: ready\n" + ready_data = await asyncio.wait_for(resp.content.readline(), timeout=1) + assert b'"type":"ready"' in ready_data + assert await asyncio.wait_for(resp.content.readline(), timeout=1) == b"\n" + + await server._broadcast_dashboard_event({"type": "refresh"}) + assert await asyncio.wait_for(resp.content.readline(), timeout=1) == b"event: refresh\n" + refresh_data = await asyncio.wait_for(resp.content.readline(), timeout=1) + assert b'"type":"refresh"' in refresh_data + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_server_quit_route_stops_dashboard(trace_db) -> None: + from claude_tap.shared_dashboard import CLAUDE_TAP_VERSION, is_dashboard_healthy, wait_for_dashboard_stopped + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + timeout = aiohttp.ClientTimeout(total=3) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(f"http://127.0.0.1:{port}/dashboard") as resp: + assert resp.status == 200 + html = await resp.text() + assert f'const CLAUDE_TAP_VERSION = "{CLAUDE_TAP_VERSION}";' in html + assert f'const DASHBOARD_QUIT_TOKEN = "{server._dashboard_quit_token}";' in html + assert "const DASHBOARD_CAN_STOP = true;" in html + + async with session.post(f"http://127.0.0.1:{port}/dashboard/quit") as resp: + assert resp.status == 403 + payload = await resp.json() + assert payload["ok"] is False + + async with session.get(f"http://127.0.0.1:{port}/dashboard/health") as resp: + assert resp.status == 200 + health = await resp.json() + assert health["version"] == CLAUDE_TAP_VERSION + assert health["quit_token"] == server._dashboard_quit_token + + async with session.post( + f"http://127.0.0.1:{port}/dashboard/quit", + headers={"X-Claude-Tap-Dashboard-Token": health["quit_token"]}, + ) as resp: + assert resp.status == 200 + assert await resp.json() == {"ok": True} + + assert await wait_for_dashboard_stopped("127.0.0.1", port, timeout=2.0) is True + assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is False + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_quit_token_requires_trusted_host_and_origin(trace_db) -> None: + from claude_tap.shared_dashboard import CLAUDE_TAP_VERSION, is_dashboard_healthy + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + timeout = aiohttp.ClientTimeout(total=3) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get( + f"http://127.0.0.1:{port}/dashboard", + headers={"Host": f"attacker.example:{port}", "Origin": f"http://attacker.example:{port}"}, + ) as resp: + assert resp.status == 200 + html = await resp.text() + assert f'const CLAUDE_TAP_VERSION = "{CLAUDE_TAP_VERSION}";' in html + assert 'const DASHBOARD_QUIT_TOKEN = "";' in html + assert "const DASHBOARD_CAN_STOP = false;" in html + assert "session-list" in html + + async with session.get( + f"http://127.0.0.1:{port}/dashboard/health", + headers={"Host": f"attacker.example:{port}", "Origin": f"http://attacker.example:{port}"}, + ) as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["ok"] is True + assert payload["dashboard_mode"] is True + assert payload["version"] == CLAUDE_TAP_VERSION + assert "quit_token" not in payload + + async with session.get(f"http://127.0.0.1:{port}/dashboard/health") as resp: + assert resp.status == 200 + health = await resp.json() + token = health["quit_token"] + + for headers in ( + {"Host": f"attacker.example:{port}", "X-Claude-Tap-Dashboard-Token": token}, + { + "Origin": f"http://attacker.example:{port}", + "X-Claude-Tap-Dashboard-Token": token, + }, + { + "Origin": f"http://127.0.0.1:{port + 1}", + "X-Claude-Tap-Dashboard-Token": token, + }, + ): + async with session.post(f"http://127.0.0.1:{port}/dashboard/quit", headers=headers) as resp: + assert resp.status == 403 + payload = await resp.json() + assert payload["ok"] is False + + assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is True + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_quit_route_rejects_non_dashboard_server(trace_db) -> None: + server = LiveViewerServer(port=0, dashboard_mode=False) + port = await server.start() + try: + timeout = aiohttp.ClientTimeout(total=3) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(f"http://127.0.0.1:{port}/dashboard/quit") as resp: + assert resp.status == 403 + payload = await resp.json() + assert payload["ok"] is False + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_session_route_serves_standalone_viewer(trace_db, tmp_path: Path) -> None: + playwright = pytest.importorskip("playwright.async_api") + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + _write_jsonl(trace_path, [_anthropic_record(turn=turn) for turn in range(1, 13)]) + _seed_legacy(tmp_path) + session_id = list_trace_sessions()[0]["id"] + + server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + async with playwright.async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await browser.new_page(accept_downloads=True) + await page.goto( + f"http://127.0.0.1:{port}/dashboard/session/{session_id}", + wait_until="domcontentloaded", + ) + await page.wait_for_selector("#raw-tab .section", timeout=5000) + assert await page.locator(".header").count() == 1 + assert await page.locator(".viewer-frame").count() == 0 + assert await page.locator("#back-to-list").count() == 0 + assert await page.locator("#list-view.hidden").count() == 1 + assert await page.locator("#raw-tab .section").count() == 10 + assert await page.locator("[data-load-more]").count() == 1 + tab_toggle = page.locator("[data-tab-toggle]") + assert await tab_toggle.inner_text() == "Full viewer" + + await tab_toggle.click() + await page.wait_for_selector("#conversation-tab:not(.hidden) .viewer-frame", timeout=5000) + assert await page.locator(".viewer-frame").count() == 1 + frame = page.frame_locator(".viewer-frame") + await frame.locator(".sidebar-item").first.wait_for(timeout=5000) + await frame.locator("#detail .section").first.wait_for(timeout=5000) + assert not await frame.locator("#drop-zone").is_visible() + await page.locator(".viewer-frame").evaluate("(frame) => { frame.dataset.reuseMarker = 'kept'; }") + assert await tab_toggle.inner_text() == "Trace" + + await tab_toggle.click() + await page.wait_for_selector("#raw-tab:not(.hidden) .section", timeout=5000) + assert await page.locator(".viewer-frame").count() == 1 + assert await page.locator(".viewer-frame").get_attribute("data-reuse-marker") == "kept" + assert await tab_toggle.inner_text() == "Full viewer" + + await tab_toggle.click() + await page.wait_for_selector("#conversation-tab:not(.hidden) .viewer-frame", timeout=5000) + assert await page.locator(".viewer-frame").count() == 1 + assert await page.locator(".viewer-frame").get_attribute("data-reuse-marker") == "kept" + + export_button = page.locator(".detail-inspector-bar .export-menu > summary") + assert await export_button.count() == 1 + assert await export_button.inner_text() == "Export" + export_items = page.locator(".detail-inspector-bar .export-menu-item") + assert await export_items.count() == 2 + assert await export_items.all_text_contents() == ["Export JSON", "Export HTML"] + hrefs = await export_items.evaluate_all("(links) => links.map((link) => link.getAttribute('href'))") + assert f"/api/sessions/{session_id}/export/compact" in hrefs + assert f"/api/sessions/{session_id}/export/html" in hrefs + assert f"/api/sessions/{session_id}/export/log" not in hrefs + + async with page.expect_download() as download_info: + await export_button.click() + await page.locator('.detail-inspector-bar .export-menu-item[href$="/export/html"]').click() + download = await download_info.value + assert download.suggested_filename == f"trace_{session_id[:8]}.html" + download_path = await download.path() + assert download_path is not None + exported_html = Path(download_path).read_text(encoding="utf-8") + assert "EMBEDDED_TRACE_COMPACT_DATA" in exported_html + assert "const EMBEDDED_TRACE_DATA =" not in exported_html + assert "req_claude" in exported_html + finally: + await browser.close() + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_session_export_menu_is_not_clipped_on_mobile(trace_db, tmp_path: Path) -> None: + playwright = pytest.importorskip("playwright.async_api") + trace_path = tmp_path / "2026-05-20" / "trace_080000.jsonl" + _write_jsonl(trace_path, [_anthropic_record()]) + _seed_legacy(tmp_path) + session_id = list_trace_sessions()[0]["id"] + + server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + async with playwright.async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await browser.new_page(viewport={"width": 390, "height": 900}) + await page.goto( + f"http://127.0.0.1:{port}/dashboard/session/{session_id}", + wait_until="domcontentloaded", + ) + await page.wait_for_selector(".detail-inspector-bar .export-menu > summary", timeout=5000) + + await page.locator(".detail-inspector-bar .export-menu > summary").click() + menu = page.locator(".detail-inspector-bar .export-menu-list") + assert await menu.is_visible() + export_items = page.locator(".detail-inspector-bar .export-menu-item") + assert await export_items.count() == 2 + assert await export_items.all_text_contents() == ["Export JSON", "Export HTML"] + + layout = await page.evaluate( + """() => { + const actions = document.querySelector('.detail-inspector-bar .action-bar'); + const menu = document.querySelector('.detail-inspector-bar .export-menu-list'); + const actionsBox = actions.getBoundingClientRect(); + const menuBox = menu.getBoundingClientRect(); + const actionStyles = getComputedStyle(actions); + const menuStyles = getComputedStyle(menu); + return { + actionsBottom: actionsBox.bottom, + menuBottom: menuBox.bottom, + menuRight: menuBox.right, + viewportWidth: window.innerWidth, + menuHeight: menuBox.height, + overflowX: actionStyles.overflowX, + menuPosition: menuStyles.position, + }; + }""" + ) + assert layout["overflowX"] == "visible" + assert layout["menuHeight"] > 50 + assert layout["menuRight"] <= layout["viewportWidth"] + assert layout["menuBottom"] > layout["actionsBottom"] + finally: + await browser.close() + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_bulk_delete_edit_mode_focuses_confirmation_dialog(trace_db) -> None: + playwright = pytest.importorskip("playwright.async_api") + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(session_id, _anthropic_record()) + store.finalize_session(session_id, {"api_calls": 1}) + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with playwright.async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + try: + page = await browser.new_page() + await page.goto(f"http://127.0.0.1:{port}/dashboard", wait_until="domcontentloaded") + assert await page.locator("[data-delete-session]").count() == 0 + + await page.locator("#edit-sessions").click() + checkbox = page.locator(f'[data-select-session="{session_id}"]') + await checkbox.wait_for(state="visible", timeout=5000) + await checkbox.check() + assert await page.locator("#bulk-selected-count").inner_text() == "1 selected" + + await page.locator("#delete-selected-sessions").click() + await page.wait_for_selector("#delete-session-modal:not(.hidden)", timeout=5000) + assert page.url == f"http://127.0.0.1:{port}/dashboard" + assert await page.evaluate("document.activeElement && document.activeElement.id") == ( + "delete-session-cancel" + ) + + await page.locator("#delete-session-confirm").click() + await page.wait_for_selector("#delete-session-modal.hidden", state="attached", timeout=5000) + await page.wait_for_selector(f'[data-session="{session_id}"]', state="detached", timeout=5000) + finally: + await browser.close() + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_delete_current_live_session_is_protected(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + + server = LiveViewerServer(port=0, session_id=session_id, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.delete(f"http://127.0.0.1:{port}/api/sessions/{session_id}") as resp: + assert resp.status == 409 + payload = await resp.json() + assert payload["error"] == "Live session cannot be deleted" + assert store.load_session_row(session_id) is not None + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_delete_active_session_is_protected(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(session_id, _anthropic_record()) + conn = store._connect() + conn.execute( + "UPDATE sessions SET updated_at = ? WHERE id = ?", (datetime.now(timezone.utc).isoformat(), session_id) + ) + conn.commit() + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/api/sessions") as resp: + assert resp.status == 200 + payload = await resp.json() + active_session = next(item for item in payload["sessions"] if item["id"] == session_id) + assert active_session["active"] is True + assert active_session["status"] == "active" + + async with session.delete(f"http://127.0.0.1:{port}/api/sessions/{session_id}") as resp: + assert resp.status == 409 + payload = await resp.json() + assert payload["error"] == "Active session cannot be deleted" + assert store.load_session_row(session_id) is not None + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_lists_stale_active_session_as_complete(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(session_id, _anthropic_record()) + stale_updated_at = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + conn = store._connect() + conn.execute( + "UPDATE sessions SET updated_at = ?, summary_json = ? WHERE id = ?", + ( + stale_updated_at, + json.dumps( + { + "id": session_id, + "status": "active", + "agent": "Claude Code", + "record_count": 1, + "total_tokens": 51, + }, + separators=(",", ":"), + ), + session_id, + ), + ) + conn.commit() + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/api/sessions") as resp: + assert resp.status == 200 + payload = await resp.json() + stale_session = next(item for item in payload["sessions"] if item["id"] == session_id) + assert stale_session["active"] is False + assert stale_session["live"] is False + assert stale_session["status"] == "complete" + + row = store.load_session_row(session_id) + assert row is not None + assert row["status"] == "complete" + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_dashboard_delete_stale_active_session_is_allowed(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(session_id, _anthropic_record()) + stale_updated_at = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + conn = store._connect() + conn.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (stale_updated_at, session_id)) + conn.commit() + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.delete(f"http://127.0.0.1:{port}/api/sessions/{session_id}") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["deleted_sessions"] == 1 + assert payload["deleted_records"] == 1 + + assert store.load_session_row(session_id) is None + finally: + await server.stop() + + +def test_append_record_reactivates_stale_finalized_session(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(session_id, _anthropic_record()) + stale_updated_at = "2026-05-20T08:00:00+00:00" + conn = store._connect() + conn.execute( + "UPDATE sessions SET updated_at = ?, summary_json = ? WHERE id = ?", + ( + stale_updated_at, + json.dumps( + { + "id": session_id, + "status": "active", + "agent": "Claude Code", + "record_count": 1, + }, + separators=(",", ":"), + ), + session_id, + ), + ) + conn.commit() + + assert store.finalize_stale_active_sessions(now=datetime(2026, 5, 22, 9, 0, tzinfo=timezone.utc)) == 1 + assert store.load_session_row(session_id)["status"] == "complete" + + resumed = _anthropic_record(turn=2) + resumed["timestamp"] = "2026-05-22T09:05:00+00:00" + store.append_record(session_id, resumed) + + row = store.load_session_row(session_id) + assert row is not None + assert row["status"] == "active" + assert row["record_count"] == 2 + assert json.loads(row["summary_json"])["status"] == "active" + + +@pytest.mark.asyncio +async def test_dashboard_bulk_delete_skips_active_sessions(trace_db) -> None: + store = get_trace_store() + first_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(first_id, _anthropic_record()) + store.finalize_session(first_id, {"api_calls": 1}) + second_id = store.create_session(client="codex", proxy_mode="reverse") + store.append_record(second_id, _anthropic_record(turn=2)) + store.finalize_session(second_id, {"api_calls": 1}) + active_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(active_id, _anthropic_record(turn=3)) + conn = store._connect() + conn.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (datetime.now(timezone.utc).isoformat(), active_id)) + conn.commit() + stale_active_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record(stale_active_id, _anthropic_record(turn=4)) + stale_updated_at = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + conn.execute("UPDATE sessions SET updated_at = ? WHERE id = ?", (stale_updated_at, stale_active_id)) + conn.commit() + + server = LiveViewerServer(port=0, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.delete( + f"http://127.0.0.1:{port}/api/sessions", + json={"session_ids": [first_id, second_id, active_id, stale_active_id, "missing"]}, + ) as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["deleted_sessions"] == 3 + assert payload["deleted_records"] == 3 + assert payload["skipped_active_sessions"] == [active_id] + assert payload["missing_sessions"] == ["missing"] + + assert store.load_session_row(first_id) is None + assert store.load_session_row(second_id) is None + assert store.load_session_row(stale_active_id) is None + assert store.load_session_row(active_id) is not None + finally: + await server.stop() + + +def test_live_viewer_exposes_current_session_id(trace_db) -> None: + session_id = get_trace_store().create_session(client="claude", proxy_mode="reverse") + server = LiveViewerServer(session_id=session_id) + assert server.session_id == session_id + + assert LiveViewerServer().session_id is None + + +def test_sqlite_log_handler_exports_single_timestamp(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + handler = SQLiteLogHandler(session_id, store=store) + handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S")) + record = logging.LogRecord("test", logging.INFO, __file__, 1, "proxy started", (), None) + record.created = datetime(2026, 5, 20, 8, 0, tzinfo=timezone.utc).timestamp() + + handler.emit(record) + + assert store.export_log(session_id) == "08:00:00 proxy started\n" + + +@pytest.mark.asyncio +async def test_trace_writer_adds_capture_metadata(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="forward") + writer = TraceWriter(session_id, store=store, metadata={"client": "codex", "proxy_mode": "forward"}) + try: + await writer.write(_anthropic_record()) + finally: + writer.close() + + record = store.load_records(session_id)[0] + assert record["capture"] == {"client": "claude", "proxy_mode": "reverse"} + + session_id = store.create_session(client="codex", proxy_mode="forward") + writer = TraceWriter(session_id, store=store, metadata={"client": "codex", "proxy_mode": "forward"}) + try: + await writer.write({"request": {"body": {}}, "response": {"body": {}}}) + finally: + writer.close() + + records = store.load_records(session_id) + assert records[-1]["capture"] == {"client": "codex", "proxy_mode": "forward"} + + +@pytest.mark.asyncio +async def test_trace_writer_persists_records_to_sqlite(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + writer = TraceWriter(session_id, store=store, metadata={"client": "claude", "proxy_mode": "reverse"}) + try: + await writer.write(_anthropic_record()) + finally: + writer.close() + + records = store.load_records(session_id) + + assert len(records) == 1 + assert records[0]["capture"]["client"] == "claude" + + +def test_get_session_aggregates(trace_db) -> None: + from claude_tap.trace_store import SessionQuery + + store = get_trace_store() + conn = store._connect() + + # 1. Active session with no error + active_id = store.create_session(client="claude", proxy_mode="reverse") + conn.execute( + "UPDATE sessions SET status = 'active', record_count = 5, summary_json = ? WHERE id = ?", + ( + json.dumps({"agent": "Claude Code", "status": "active", "total_tokens": 120}, separators=(",", ":")), + active_id, + ), + ) + + # 2. Active session with error in summary_json + active_err_id = store.create_session(client="claude", proxy_mode="reverse") + conn.execute( + "UPDATE sessions SET status = 'active', record_count = 2, summary_json = ? WHERE id = ?", + ( + json.dumps({"agent": "Claude Code", "status": "error", "total_tokens": 80}, separators=(",", ":")), + active_err_id, + ), + ) + + # 3. Completed session with error status + completed_err_id = store.create_session(client="claude", proxy_mode="reverse") + conn.execute( + "UPDATE sessions SET status = 'error', record_count = 10, summary_json = ? WHERE id = ?", + ( + json.dumps({"agent": "Claude Code", "status": "error", "total_tokens": 300}, separators=(",", ":")), + completed_err_id, + ), + ) + + conn.commit() + + # Check global aggregates + aggregates = store.get_session_aggregates() + assert aggregates["total_sessions"] == 3 + assert aggregates["total_records"] == 17 + assert aggregates["total_tokens"] == 500 + assert aggregates["total_errors"] == 2 # active_err_id and completed_err_id + + # Check query-based status filtering for active status + active_query = SessionQuery(status="active") + active_aggs = store.get_session_aggregates(active_query) + assert active_aggs["total_sessions"] == 1 # only active_id + + # Check query-based status filtering for error status + error_query = SessionQuery(status="error") + error_aggs = store.get_session_aggregates(error_query) + assert error_aggs["total_sessions"] == 2 # active_err_id and completed_err_id + + +def test_agent_filter_values_resolves_custom_agents(trace_db) -> None: + from claude_tap.dashboard import _agent_filter_values + + store = get_trace_store() + conn = store._connect() + + # Create a session with a custom agent client "My-Agent" + custom_id = store.create_session(client="My-Agent", proxy_mode="reverse") + conn.execute( + "UPDATE sessions SET status = 'complete', record_count = 1, summary_json = ? WHERE id = ?", + ( + json.dumps({"agent": "My-Agent", "status": "complete", "total_tokens": 10}, separators=(",", ":")), + custom_id, + ), + ) + conn.commit() + + clients, labels = _agent_filter_values("my-agent") + assert "My-Agent" in clients + assert "My-Agent" in labels + + +@pytest.mark.asyncio +async def test_search_uncached_records_fallback(trace_db) -> None: + from claude_tap.trace_store import SessionQuery + + store = get_trace_store() + conn = store._connect() + + # Create a session with NULL summary_json (uncached) but records with a specific text in payload_json + session_id = store.create_session(client="claude", proxy_mode="reverse") + writer = TraceWriter(session_id, store=store, metadata={"client": "claude", "proxy_mode": "reverse"}) + try: + await writer.write( + { + "timestamp": "2026-05-20T10:15:00+00:00", + "request": {"method": "POST", "path": "/v1/messages", "body": "unusual_secret_string"}, + "response": {"status": 200, "body": {}}, + } + ) + finally: + writer.close() + + # Force summary_json to be NULL to simulate uncached session + conn.execute("UPDATE sessions SET summary_json = NULL WHERE id = ?", (session_id,)) + conn.commit() + + search_query = SessionQuery(search="unusual_secret_string") + sessions = store.list_session_rows(query=search_query) + assert len(sessions) == 1 + assert sessions[0]["id"] == session_id + + +def test_search_matches_compacted_record_blobs(trace_db) -> None: + from claude_tap.trace_store import SessionQuery + + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + hidden_term = "dashboard-needle-inside-compacted-user-message" + long_prompt = "intro " * 120 + hidden_term + store.append_record( + session_id, + { + "timestamp": "2026-07-08T10:15:00+00:00", + "request": { + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": long_prompt}], + }, + }, + "response": {"status": 200, "body": {"content": [{"type": "text", "text": "ok"}]}}, + }, + ) + + conn = store._connect() + payload_json = conn.execute( + "SELECT payload_json FROM records WHERE session_id = ?", + (session_id,), + ).fetchone()["payload_json"] + assert hidden_term not in payload_json + assert conn.execute("SELECT COUNT(*) FROM record_blobs WHERE session_id = ?", (session_id,)).fetchone()[0] > 0 + + sessions = store.list_session_rows(query=SessionQuery(search=hidden_term)) + + assert [row["id"] for row in sessions] == [session_id] + + +@pytest.mark.asyncio +async def test_kimi_code_model_probe_error_does_not_mark_successful_session_failed(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="kimi-code", proxy_mode="reverse") + writer = TraceWriter(session_id, store=store, metadata={"client": "kimi-code", "proxy_mode": "reverse"}) + try: + await writer.write( + { + "timestamp": "2026-06-09T08:00:00+00:00", + "request": {"method": "GET", "path": "/models", "body": {}}, + "response": {"status": 401, "body": {"error": "missing bearer token"}}, + } + ) + await writer.write( + { + "timestamp": "2026-06-09T08:00:02+00:00", + "request": { + "method": "POST", + "path": "/chat/completions", + "body": {"model": "kimi-code/kimi-for-coding", "messages": [{"role": "user", "content": "ping"}]}, + }, + "response": { + "status": 200, + "body": { + "model": "kimi-code/kimi-for-coding", + "choices": [{"message": {"content": "pong"}}], + "usage": {"prompt_tokens": 12, "completion_tokens": 3}, + }, + }, + } + ) + finally: + writer.close() + + conn = store._connect() + row = conn.execute("SELECT status, summary_json FROM sessions WHERE id = ?", (session_id,)).fetchone() + summary = json.loads(row["summary_json"]) + payload = load_trace_session(session_id) + + assert row["status"] == "complete" + assert summary["status"] == "complete" + assert payload is not None + assert payload["session"]["status"] == "complete" + assert payload["session"]["error"] == "" + assert payload["session"]["first_user"] == "ping" + assert payload["session"]["last_response"] == "pong" + + +@pytest.mark.asyncio +async def test_kimi_code_model_probe_error_marks_probe_only_session_failed(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="kimi-code", proxy_mode="reverse") + writer = TraceWriter(session_id, store=store, metadata={"client": "kimi-code", "proxy_mode": "reverse"}) + try: + await writer.write( + { + "timestamp": "2026-06-09T08:00:00+00:00", + "request": {"method": "GET", "path": "/models", "body": {}}, + "response": {"status": 401, "body": {"error": "missing bearer token"}}, + } + ) + finally: + writer.close() + + conn = store._connect() + row = conn.execute("SELECT status, summary_json FROM sessions WHERE id = ?", (session_id,)).fetchone() + summary = json.loads(row["summary_json"]) + + assert row["status"] == "error" + assert summary["status"] == "error" + + conn.execute("UPDATE sessions SET summary_json = NULL WHERE id = ?", (session_id,)) + conn.commit() + payload = load_trace_session(session_id) + + assert payload is not None + assert payload["session"]["status"] == "error" + assert payload["session"]["error"] == "missing bearer token" diff --git a/tests/test_deepseek_compat.py b/tests/test_deepseek_compat.py new file mode 100644 index 0000000..aa0f6f0 --- /dev/null +++ b/tests/test_deepseek_compat.py @@ -0,0 +1,44 @@ +import re + +from claude_tap.proxy import _normalize_request_body_for_upstream + + +def test_deepseek_metadata_user_id_is_normalized_for_anthropic_target() -> None: + claude_code_user_id = '{"device_id":"abc123","account_uuid":"","session_id":"ea0aec68-952f-485c-b39a-c6c982d2386d"}' + body = { + "model": "deepseek-v4-pro", + "metadata": { + "user_id": claude_code_user_id, + "other": "kept", + }, + } + + normalized = _normalize_request_body_for_upstream(body, "https://api.deepseek.com/anthropic") + + assert normalized is not body + assert normalized["metadata"]["other"] == "kept" + assert normalized["metadata"]["user_id"] != body["metadata"]["user_id"] + assert re.fullmatch(r"^[a-zA-Z0-9_-]+$", normalized["metadata"]["user_id"]) + assert body["metadata"]["user_id"].startswith('{"device_id"') + + +def test_deepseek_metadata_valid_user_id_is_left_unchanged() -> None: + body = { + "model": "deepseek-v4-pro", + "metadata": {"user_id": "valid-user_123"}, + } + + normalized = _normalize_request_body_for_upstream(body, "https://api.deepseek.com/anthropic") + + assert normalized is body + + +def test_metadata_user_id_is_not_normalized_for_default_anthropic_target() -> None: + body = { + "model": "claude-sonnet-4-6", + "metadata": {"user_id": '{"device_id":"abc123"}'}, + } + + normalized = _normalize_request_body_for_upstream(body, "https://api.anthropic.com") + + assert normalized is body diff --git a/tests/test_diff_matching.py b/tests/test_diff_matching.py new file mode 100644 index 0000000..b5a48aa --- /dev/null +++ b/tests/test_diff_matching.py @@ -0,0 +1,554 @@ +#!/usr/bin/env python3 +"""Tests for diff pair matching logic. + +The viewer's "diff" feature compares consecutive API requests to show what changed. +Current behavior: find the previous request with the same model + isMainTurn flag. +Problem: when Claude Code spawns subagents (parallel LLM calls), requests from +different conversation threads interleave. Time/model-based matching pairs unrelated +requests, producing meaningless diffs. + +Correct behavior: match by **message prefix** — if request B's messages[:N] == request A's +messages, then B extends A's conversation thread and they should be diffed together. +""" + +import hashlib +import json + +# ── Test data: real trace from trace_20260218_083822.jsonl ── +# Simplified to essential fields for testing + +TRACE_ENTRIES = [ + { # idx 0: initial opus request + "model": "claude-opus-4-6", + "system": "", + "messages": [{"role": "user", "content": "foo"}], + }, + { # idx 1: haiku subagent (different task) + "model": "claude-haiku-4-5-20251001", + "system": "", + "messages": [{"role": "user", "content": "quota"}], + }, + { # idx 2: haiku subagent (web search) + "model": "claude-haiku-4-5-20251001", + "system": [{"type": "text", "text": "You are a web search agent"}], + "messages": [{"role": "user", "content": "search the web for latest Claude Code news"}], + }, + { # idx 3: opus main thread continues + "model": "claude-opus-4-6", + "system": [{"type": "text", "text": "You are Claude Code"}], + "messages": [{"role": "user", "content": "system-reminder skills"}], + }, + { # idx 4: haiku subagent A (search query 1) + "model": "claude-haiku-4-5-20251001", + "system": [{"type": "text", "text": "Search agent v1"}], + "messages": [{"role": "user", "content": "Perform a web search for Claude Code updates"}], + }, + { # idx 5: haiku subagent B (same user msg, different system prompt) + "model": "claude-haiku-4-5-20251001", + "system": [{"type": "text", "text": "Search agent v2"}], + "messages": [{"role": "user", "content": "Perform a web search for Claude Code updates"}], + }, + { # idx 6: opus main thread turn 2 (extends idx 3) + "model": "claude-opus-4-6", + "system": [{"type": "text", "text": "You are Claude Code v2"}], + "messages": [ + {"role": "user", "content": "system-reminder skills"}, + {"role": "assistant", "content": "I found some news..."}, + {"role": "user", "content": "tell me more"}, + ], + }, + { # idx 7: opus main thread turn 3 (extends idx 6) + "model": "claude-opus-4-6", + "system": [{"type": "text", "text": "You are Claude Code v3"}], + "messages": [ + {"role": "user", "content": "system-reminder skills"}, + {"role": "assistant", "content": "I found some news..."}, + {"role": "user", "content": "tell me more"}, + {"role": "assistant", "content": "Here are the details..."}, + {"role": "user", "content": "suggestion mode"}, + ], + }, + { # idx 8: haiku subagent (unrelated) + "model": "claude-haiku-4-5-20251001", + "system": [{"type": "text", "text": "Haiku writer"}], + "messages": [{"role": "user", "content": "now write a haiku"}], + }, + { # idx 9: opus main thread (extends idx 6, parallel branch from idx 7) + "model": "claude-opus-4-6", + "system": [{"type": "text", "text": "You are Claude Code v4"}], + "messages": [ + {"role": "user", "content": "system-reminder skills"}, + {"role": "assistant", "content": "I found some news..."}, + {"role": "user", "content": "tell me more"}, + {"role": "assistant", "content": "Here are the details v2..."}, + {"role": "user", "content": "different follow up"}, + ], + }, + { # idx 10: opus main thread (extends idx 6, longer chain) + "model": "claude-opus-4-6", + "system": [{"type": "text", "text": "You are Claude Code v5"}], + "messages": [ + {"role": "user", "content": "system-reminder skills"}, + {"role": "assistant", "content": "I found some news..."}, + {"role": "user", "content": "tell me more"}, + {"role": "assistant", "content": "Here are the details..."}, + {"role": "user", "content": "ok summarize"}, + {"role": "assistant", "content": "Summary: ..."}, + {"role": "user", "content": "suggestion mode"}, + ], + }, +] + +# ── Expected results ── +# For each entry, what's the correct "previous request" to diff against? +# None means it's a new thread with no parent. + +EXPECTED_DIFF_PARENT = { + 0: None, # new thread + 1: None, # new thread (different content from idx 0) + 2: None, # new thread (subagent) + 3: None, # new thread (main agent start) + 4: None, # new thread (subagent A) + 5: 4, # same user message as idx 4 (but note: different system prompt) + 6: 3, # extends idx 3 (messages[0] matches) + 7: 6, # extends idx 6 (messages[:3] match) + 8: None, # new thread (unrelated subagent) + 9: 6, # extends idx 6 (messages[:3] match, diverges after) + 10: 6, # extends idx 6 (messages[:3] match) +} + +# What the CURRENT naive logic would produce (previous same-model request): +NAIVE_SAME_MODEL_PARENT = { + 0: None, # first opus + 1: None, # first haiku + 2: 1, # prev haiku = idx 1 ❌ (unrelated) + 3: 0, # prev opus = idx 0 ❌ (unrelated) + 4: 2, # prev haiku = idx 2 ❌ (unrelated) + 5: 4, # prev haiku = idx 4 ✅ (happens to be right) + 6: 3, # prev opus = idx 3 ✅ (happens to be right) + 7: 6, # prev opus = idx 6 ✅ + 8: 5, # prev haiku = idx 5 ❌ (unrelated) + 9: 7, # prev opus = idx 7 ❌ (should be 6!) + 10: 9, # prev opus = idx 9 ❌ (should be 6!) +} + + +def _msg_hash(msg: dict) -> str: + """Hash a message by role + content for comparison.""" + content = msg.get("content", "") + if isinstance(content, list): + content = json.dumps(content, sort_keys=True) + return hashlib.md5(f"{msg.get('role', '')}:{content}".encode()).hexdigest()[:8] + + +def _get_msg_hashes(entry: dict) -> list[str]: + """Get message hashes for an entry.""" + return [_msg_hash(m) for m in entry.get("messages", [])] + + +def _is_prefix_of(shorter: list[str], longer: list[str]) -> bool: + """Check if shorter is a prefix of longer.""" + if not shorter or len(longer) < len(shorter): + return False + return shorter == longer[: len(shorter)] + + +def find_diff_parent_by_prefix(entries: list[dict], idx: int) -> int | None: + """Find the best diff parent for entry at idx using message prefix matching. + + Returns the index of the entry whose messages are the longest prefix + of entries[idx]'s messages. Returns None if no prefix match found. + """ + target = entries[idx] + target_hashes = [_msg_hash(m) for m in target.get("messages", [])] + + best_parent = None + best_match_len = 0 + + for j in range(idx): + candidate = entries[j] + candidate_hashes = [_msg_hash(m) for m in candidate.get("messages", [])] + + if not candidate_hashes: + continue + + # Check if candidate's messages are a prefix of target's messages + if len(target_hashes) >= len(candidate_hashes): + if target_hashes[: len(candidate_hashes)] == candidate_hashes: + if len(candidate_hashes) > best_match_len: + best_match_len = len(candidate_hashes) + best_parent = j + + return best_parent + + +def find_next_by_prefix(entries: list[dict], idx: int) -> int | None: + """Find the next entry whose messages start with entries[idx]'s messages as prefix. + + Mirrors the JS findNextSameModel() — picks the closest (smallest) extension. + Returns None if no match found. + """ + current_hashes = _get_msg_hashes(entries[idx]) + if not current_hashes: + return None + + best_idx = None + best_len = float("inf") + + for i in range(idx + 1, len(entries)): + candidate_hashes = _get_msg_hashes(entries[i]) + if _is_prefix_of(current_hashes, candidate_hashes): + if len(candidate_hashes) < best_len: + best_len = len(candidate_hashes) + best_idx = i + + return best_idx + + +def compute_nav_button_states(entries: list[dict], cur_idx: int) -> tuple[bool, bool]: + """Compute whether prev/next nav buttons should be enabled for the diff at cur_idx. + + Mirrors the JS updateNavButtons() logic after the bug fix. + Returns (prev_enabled, next_enabled). + """ + prev_idx = find_diff_parent_by_prefix(entries, cur_idx) + if prev_idx is None: + # No diff can be shown for this entry + return (False, False) + + # prev button: enabled if prevIdx itself has a diff parent + prev_of_prev = find_diff_parent_by_prefix(entries, prev_idx) + prev_enabled = prev_of_prev is not None + + # next button: enabled if there's a next entry that has a valid diff parent + next_idx = find_next_by_prefix(entries, cur_idx) + if next_idx is not None: + next_prev = find_diff_parent_by_prefix(entries, next_idx) + next_enabled = next_prev is not None + else: + next_enabled = False + + return (prev_enabled, next_enabled) + + +def _response_id(entry: dict) -> str: + return entry.get("response_id", "") + + +def _previous_response_id(entry: dict) -> str: + return entry.get("previous_response_id", "") + + +def _codex_thread_key(entry: dict) -> str: + metadata = entry.get("client_metadata") or {} + turn_metadata = metadata.get("x-codex-turn-metadata") or {} + if isinstance(turn_metadata, str): + turn_metadata = json.loads(turn_metadata) + return f"{turn_metadata.get('session_id', '')}:{turn_metadata.get('thread_id', '')}" + + +def find_diff_parent_with_response_context(entries: list[dict], idx: int) -> int | None: + """Mirror viewer parent matching: response id, Codex thread, then prefix.""" + previous_id = _previous_response_id(entries[idx]) + if previous_id: + for candidate_idx in range(idx - 1, -1, -1): + if _response_id(entries[candidate_idx]) == previous_id: + return candidate_idx + + thread_key = _codex_thread_key(entries[idx]) + if thread_key != ":": + for candidate_idx in range(idx - 1, -1, -1): + if _codex_thread_key(entries[candidate_idx]) == thread_key: + return candidate_idx + + return find_diff_parent_by_prefix(entries, idx) + + +class TestDiffParentMatching: + """Test that message-prefix-based matching produces correct diff pairs.""" + + def test_new_threads_have_no_parent(self): + """Entries that start a new conversation thread should have no parent.""" + for idx in [0, 1, 2, 3, 4, 8]: + result = find_diff_parent_by_prefix(TRACE_ENTRIES, idx) + assert result is None, f"idx {idx} should be a new thread (no parent), got parent={result}" + + def test_continuation_finds_correct_parent(self): + """Entries that continue a thread should find their correct parent.""" + # idx 6 extends idx 3 + assert find_diff_parent_by_prefix(TRACE_ENTRIES, 6) == 3 + # idx 7 extends idx 6 (longest prefix match) + assert find_diff_parent_by_prefix(TRACE_ENTRIES, 7) == 6 + # idx 9 extends idx 6 (diverges from idx 7 at msg[3]) + assert find_diff_parent_by_prefix(TRACE_ENTRIES, 9) == 6 + # idx 10 extends idx 6 + assert find_diff_parent_by_prefix(TRACE_ENTRIES, 10) == 6 + + def test_same_content_different_system_prompt(self): + """idx 5 has same user message as idx 4 — prefix match should work.""" + result = find_diff_parent_by_prefix(TRACE_ENTRIES, 5) + assert result == 4 + + def test_prefers_longest_prefix(self): + """When multiple entries could be parents, pick the longest prefix match.""" + # idx 7's messages[:3] match idx 6, and messages[:1] match idx 3 + # Should pick idx 6 (longer match) + result = find_diff_parent_by_prefix(TRACE_ENTRIES, 7) + assert result == 6, "Should prefer idx 6 (3 msgs match) over idx 3 (1 msg match)" + + def test_naive_model_matching_is_wrong(self): + """Demonstrate that naive same-model matching produces wrong results.""" + wrong_cases = [] + for idx, expected in EXPECTED_DIFF_PARENT.items(): + find_diff_parent_by_prefix(TRACE_ENTRIES, idx) + naive = NAIVE_SAME_MODEL_PARENT[idx] + if naive != expected and expected is not None: + wrong_cases.append(idx) + + # At least 2 cases where naive is wrong (idx 9, 10) + assert len(wrong_cases) >= 2, f"Expected at least 4 wrong naive matches, got {len(wrong_cases)}: {wrong_cases}" + + def test_all_expected_parents(self): + """Verify all expected diff parents match.""" + for idx, expected in EXPECTED_DIFF_PARENT.items(): + actual = find_diff_parent_by_prefix(TRACE_ENTRIES, idx) + assert actual == expected, f"idx {idx}: expected parent={expected}, got {actual}" + + +class TestEdgeCases: + """Edge cases for diff matching.""" + + def test_empty_messages(self): + """Entries with empty messages should not match anything.""" + entries = [ + {"model": "opus", "messages": []}, + {"model": "opus", "messages": [{"role": "user", "content": "hi"}]}, + ] + assert find_diff_parent_by_prefix(entries, 0) is None + assert find_diff_parent_by_prefix(entries, 1) is None + + def test_single_entry(self): + """Single entry has no parent.""" + entries = [{"model": "opus", "messages": [{"role": "user", "content": "hi"}]}] + assert find_diff_parent_by_prefix(entries, 0) is None + + def test_exact_same_messages(self): + """If two entries have identical messages, the earlier one is parent.""" + entries = [ + {"model": "opus", "messages": [{"role": "user", "content": "hi"}]}, + {"model": "opus", "messages": [{"role": "user", "content": "hi"}]}, + ] + assert find_diff_parent_by_prefix(entries, 1) == 0 + + def test_cross_model_prefix_match(self): + """Prefix matching should work across different models (same thread, model upgrade).""" + entries = [ + {"model": "haiku", "messages": [{"role": "user", "content": "hi"}]}, + { + "model": "opus", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "more"}, + ], + }, + ] + assert find_diff_parent_by_prefix(entries, 1) == 0 + + +class TestFindNextByPrefix: + """Test findNextSameModel logic (find the next entry in the conversation chain).""" + + def test_finds_next_in_chain(self): + """idx 3 -> idx 6 (next entry whose messages extend idx 3).""" + assert find_next_by_prefix(TRACE_ENTRIES, 3) == 6 + + def test_finds_closest_extension(self): + """idx 6 has multiple successors (7, 9, 10); pick the closest (smallest msg count).""" + # idx 7 has 5 msgs, idx 9 has 5 msgs, idx 10 has 7 msgs + # Both 7 and 9 have 5 msgs — should pick whichever comes first (7) + result = find_next_by_prefix(TRACE_ENTRIES, 6) + assert result == 7 + + def test_no_next_for_leaf_node(self): + """Entries at the end of a chain with no successors return None.""" + # idx 10 has 7 messages — no entry after it extends this chain + assert find_next_by_prefix(TRACE_ENTRIES, 10) is None + + def test_no_next_for_isolated_entry(self): + """Isolated entries (no successors) return None.""" + # idx 8 is an unrelated haiku subagent + assert find_next_by_prefix(TRACE_ENTRIES, 8) is None + + def test_no_next_for_empty_messages(self): + """Entry with empty messages cannot have a next.""" + entries = [ + {"model": "opus", "messages": []}, + {"model": "opus", "messages": [{"role": "user", "content": "hi"}]}, + ] + assert find_next_by_prefix(entries, 0) is None + + def test_chain_traversal(self): + """Can traverse a full chain: 3 -> 6 -> 7.""" + assert find_next_by_prefix(TRACE_ENTRIES, 3) == 6 + assert find_next_by_prefix(TRACE_ENTRIES, 6) == 7 + + +class TestResponsesDiffParentMatching: + """Responses traces can carry stronger parent hints than message prefixes.""" + + def test_direct_previous_response_id_wins(self): + entries = [ + { + "messages": [{"role": "user", "content": "first"}], + "response_id": "resp_1", + }, + { + "messages": [{"role": "user", "content": "not a prefix"}], + "previous_response_id": "resp_1", + "response_id": "resp_2", + }, + ] + + assert find_diff_parent_with_response_context(entries, 1) == 0 + + def test_codex_hidden_prefetch_uses_nearest_visible_same_thread(self): + metadata = { + "x-codex-turn-metadata": json.dumps( + {"session_id": "session-a", "thread_id": "thread-a"}, separators=(",", ":") + ) + } + entries = [ + { + "messages": [{"role": "user", "content": "shared initial prompt"}], + "client_metadata": metadata, + "response_id": "resp_1_1", + }, + { + "messages": [ + {"role": "user", "content": "shared initial prompt"}, + {"role": "assistant", "content": "earlier final text"}, + {"role": "user", "content": "limit compare"}, + {"role": "assistant", "content": [{"type": "tool_use", "name": "tool_search"}]}, + {"role": "tool", "content": [{"type": "tool_result", "content": "github tools"}]}, + ], + "client_metadata": metadata, + "response_id": "resp_3_2", + }, + { + "messages": [ + {"role": "user", "content": "shared initial prompt"}, + {"role": "assistant", "content": "earlier final text"}, + {"role": "user", "content": "figma domain"}, + {"role": "tool", "content": [{"type": "tool_result", "content": "older github tools"}]}, + ], + "client_metadata": metadata, + "previous_response_id": "resp_hidden_prefetch", + "response_id": "resp_4_1", + }, + ] + + assert find_diff_parent_by_prefix(entries, 2) == 0 + assert find_diff_parent_with_response_context(entries, 2) == 1 + + def test_codex_thread_fallback_does_not_cross_threads(self): + entries = [ + { + "messages": [{"role": "user", "content": "shared"}], + "client_metadata": {"x-codex-turn-metadata": {"session_id": "session-a", "thread_id": "thread-a"}}, + "response_id": "resp_a", + }, + { + "messages": [{"role": "user", "content": "different"}], + "client_metadata": {"x-codex-turn-metadata": {"session_id": "session-b", "thread_id": "thread-b"}}, + "previous_response_id": "resp_hidden", + "response_id": "resp_b", + }, + ] + + assert find_diff_parent_with_response_context(entries, 1) is None + + +class TestNavButtonStates: + """Test diff navigation button enabled/disabled states. + + This tests the logic that was buggy: updateNavButtons() compared the object + returned by findPrevSameModel() directly to a number instead of using .idx, + causing the right button to always be disabled and the left button to never + be disabled. + """ + + def test_first_diff_in_chain_has_prev_disabled(self): + """When viewing turn 3→6 (first diff pair), prev should be disabled.""" + # cur_idx=6, prev_idx=3. prev of 3 is None, so prev button disabled. + prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 6) + assert not prev_enabled, "prev should be disabled at start of chain" + assert next_enabled, "next should be enabled (can go to 6→7)" + + def test_middle_of_chain_prev_enabled(self): + """When viewing turn 6→7, prev should be enabled (can go back to 3→6).""" + prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 7) + assert prev_enabled, "prev should be enabled (can go back to 3→6)" + # next is disabled because no entry after 7 extends its exact 5-message prefix + # (idx 10 diverges at msg[4]: "ok summarize" vs "suggestion mode") + assert not next_enabled + + def test_end_of_chain_has_next_disabled(self): + """At the last entry in a chain, next should be disabled.""" + prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 10) + assert prev_enabled, "prev should be enabled (can go back)" + assert not next_enabled, "next should be disabled at end of chain" + + def test_isolated_entry_with_parent_has_both_nav_correct(self): + """idx 5 (extends idx 4): no next, prev depends on idx 4 having a parent.""" + prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 5) + # idx 4 has no parent (new thread), so prev disabled + assert not prev_enabled, "prev should be disabled (idx 4 has no parent)" + assert not next_enabled, "next should be disabled (no successor extends idx 5)" + + def test_simple_two_entry_chain(self): + """Simple chain of 2 entries: prev disabled on first diff, next disabled too.""" + entries = [ + {"model": "opus", "messages": [{"role": "user", "content": "hi"}]}, + { + "model": "opus", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "more"}, + ], + }, + ] + prev_enabled, next_enabled = compute_nav_button_states(entries, 1) + assert not prev_enabled, "prev disabled (entry 0 has no parent)" + assert not next_enabled, "next disabled (no entry after 1)" + + def test_three_entry_chain(self): + """Chain of 3: A→B→C. At B, both enabled; at A→B, prev disabled; at B→C, next disabled.""" + entries = [ + {"model": "opus", "messages": [{"role": "user", "content": "hi"}]}, + { + "model": "opus", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + }, + { + "model": "opus", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + {"role": "user", "content": "more"}, + ], + }, + ] + # At entry 1 (diff 0→1): prev disabled (0 has no parent), next enabled (2 exists) + p, n = compute_nav_button_states(entries, 1) + assert not p + assert n + + # At entry 2 (diff 1→2): prev enabled (1 has parent 0), next disabled (no entry 3) + p, n = compute_nav_button_states(entries, 2) + assert p + assert not n diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..9f7764e --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,4698 @@ +#!/usr/bin/env python3 +"""End-to-end test for claude-tap. + +Creates a fake 'claude' script + a fake upstream API server, +then runs `python claude_tap.py` as a real subprocess and +verifies the full pipeline: proxy startup → claude launch → request +forwarding → JSONL recording. +""" + +import asyncio +import gzip +import ipaddress +import json +import os +import shutil +import sqlite3 +import stat +import subprocess +import sys +import tempfile +import threading +from pathlib import Path + +import pytest +from yarl import URL + +from claude_tap.trace import TraceWriter +from tests.conftest import e2e_env, read_proxy_log, read_trace_records + + +def _writer_for_dir(tmpdir: Path): + from claude_tap.trace_store import TraceStore + + store = TraceStore(tmpdir / "forward.sqlite3") + session_id = store.create_session() + return store, session_id, TraceWriter(session_id, store=store) + + +FAKE_UPSTREAM_PORT = 19199 +PROJECT_ROOT = Path(__file__).resolve().parents[1] + +FAKE_CLAUDE_SCRIPT = r'''#!/usr/bin/env python3 +"""Fake claude CLI — sends requests to ANTHROPIC_BASE_URL then exits.""" +import json, os, sys, urllib.request + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +url = f"{base}/v1/messages" + +# Turn 1: non-streaming request +req_body = json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", +}) +try: + with urllib.request.urlopen(req) as resp: + data = resp.read() + if resp.headers.get("Content-Encoding") == "gzip": + import gzip as gz + data = gz.decompress(data) + body = json.loads(data) + print(f"[fake-claude] Turn 1: {body.get('content', [{}])[0].get('text', '?')}") +except Exception as e: + print(f"[fake-claude] Turn 1 error: {e}", file=sys.stderr) + sys.exit(1) + +# Turn 2: streaming request +req_body2 = json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "stream": True, + "system": "streaming system prompt must remain stored when raw stream events are omitted", + "messages": [{"role": "user", "content": "count to 3"}], +}).encode() +req2 = urllib.request.Request(url, data=req_body2, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", +}) +try: + with urllib.request.urlopen(req2) as resp: + chunks = resp.read().decode() + print(f"[fake-claude] Turn 2: SSE ({len(chunks)} chars)") +except Exception as e: + print(f"[fake-claude] Turn 2 error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-claude] Done.") +''' + + +def run_fake_upstream_in_thread(): + """Start fake upstream in a background thread with its own event loop. + + Returns (stop_fn, actual_port) where actual_port is the OS-assigned port. + """ + from aiohttp import web + + ready = threading.Event() + loop = None + runner = None + actual_port_holder: list[int] = [] + + async def handler(request): + body = await request.read() + req = json.loads(body) if body else {} + + if req.get("stream"): + resp = web.StreamResponse( + status=200, + headers={"Content-Type": "text/event-stream"}, + ) + await resp.prepare(request) + events = [ + ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_stream_1", + "type": "message", + "role": "assistant", + "content": [], + "model": req.get("model", "test"), + "usage": {"input_tokens": 20, "output_tokens": 0}, + }, + }, + ), + ( + "content_block_start", + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + ), + ( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "1, "}}, + ), + ( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "2, "}}, + ), + ( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "3"}}, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 8}}, + ), + ("message_stop", {"type": "message_stop"}), + ] + for evt, data in events: + await resp.write(f"event: {evt}\ndata: {json.dumps(data)}\n\n".encode()) + await resp.write_eof() + return resp + else: + payload = json.dumps( + { + "id": "msg_nonstream_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello!"}], + "model": req.get("model", "test"), + "usage": {"input_tokens": 15, "output_tokens": 3}, + "stop_reason": "end_turn", + } + ).encode() + compressed = gzip.compress(payload) + return web.Response( + status=200, + body=compressed, + headers={"Content-Type": "application/json", "Content-Encoding": "gzip"}, + ) + + async def serve(): + nonlocal runner + app = web.Application() + app.router.add_route("*", "/{path_info:.*}", handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) # OS-assigned port + await site.start() + actual_port_holder.append(site._server.sockets[0].getsockname()[1]) + ready.set() + # Run forever until loop is stopped + while True: + await asyncio.sleep(3600) + + def thread_main(): + nonlocal loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(serve()) + except (asyncio.CancelledError, RuntimeError): + pass + finally: + try: + if runner: + loop.run_until_complete(runner.cleanup()) + loop.run_until_complete(loop.shutdown_asyncgens()) + except RuntimeError: + pass + loop.close() + + t = threading.Thread(target=thread_main, daemon=True) + t.start() + ready.wait(timeout=5) + + def stop(): + if loop and loop.is_running(): + # Clean up runner first to release the port + import concurrent.futures + + if runner: + fut = asyncio.run_coroutine_threadsafe(runner.cleanup(), loop) + try: + fut.result(timeout=3) + except (concurrent.futures.TimeoutError, RuntimeError): + pass + loop.call_soon_threadsafe(loop.stop) + t.join(timeout=3) + + return stop, actual_port_holder[0] if actual_port_holder else FAKE_UPSTREAM_PORT + + +def test_e2e(): + stop_upstream, upstream_port = run_fake_upstream_in_thread() + print(f"[test] Fake upstream on :{upstream_port}") + + try: + _run_test(upstream_port) + finally: + stop_upstream() + + +def test_e2e_store_stream_events_flag(): + stop_upstream, upstream_port = run_fake_upstream_in_thread() + print(f"[test] Fake upstream on :{upstream_port}") + + try: + _run_test(upstream_port, store_stream_events=True) + finally: + stop_upstream() + + +def _run_test(upstream_port, store_stream_events=False): + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_") + + # Create fake claude + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_") + fake_claude = Path(fake_bin_dir) / "claude" + fake_claude.write_text(FAKE_CLAUDE_SCRIPT) + fake_claude.chmod(fake_claude.stat().st_mode | stat.S_IEXEC) + + env = os.environ.copy() + env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "") + + env = e2e_env(env, trace_dir) + print(f"[test] Trace dir: {trace_dir}") + print("[test] Running: python -m claude_tap ...") + + try: + cmd = [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-no-open", + "--tap-target", + f"http://127.0.0.1:{upstream_port}", + ] + if store_stream_events: + cmd.append("--tap-store-stream-events") + proc = subprocess.run( + cmd, + cwd=str(project_dir), + env=env, + capture_output=True, + text=True, + timeout=30, + ) + except subprocess.TimeoutExpired: + print("[test] TIMEOUT — claude_tap.py did not exit in 30s") + _cleanup(trace_dir, fake_bin_dir, "e2e") + sys.exit(1) + + print(f"[test] Exit code: {proc.returncode}") + if proc.stdout.strip(): + print(f"[test] stdout:\n{proc.stdout.rstrip()}") + if proc.stderr.strip(): + print(f"[test] stderr:\n{proc.stderr.rstrip()}") + + # ── Assertions ── + + records = read_trace_records(trace_dir) + assert len(records) == 2, f"Expected 2 records, got {len(records)}" + + log_content = read_proxy_log(trace_dir) + assert log_content.strip(), "Expected proxy log lines in SQLite" + print(f"[test] Proxy log:\n{log_content.rstrip()}") + + print(f"[test] Recorded {len(records)} API calls") + + # ── Turn 1: non-streaming (gzip compressed upstream) ── + r1 = records[0] + assert r1["turn"] == 1 + assert r1["request"]["method"] == "POST" + assert "/v1/messages" in r1["request"]["path"] + assert r1["request"]["body"]["model"] == "claude-test-model" + assert r1["response"]["status"] == 200 + assert r1["response"]["body"]["content"][0]["text"] == "Hello!" + # API key redaction (header name may be title-cased) + hdrs = {k.lower(): v for k, v in r1["request"]["headers"].items()} + api_key = hdrs.get("x-api-key", "") + assert api_key.endswith("..."), f"API key not redacted: {api_key}" + assert "12345678" not in api_key + print(" ✅ Turn 1 (non-streaming, gzip): OK") + + # ── Turn 2: streaming (SSE) ── + r2 = records[1] + assert r2["turn"] == 2 + assert r2["request"]["body"]["stream"] is True + assert r2["request"]["body"]["system"] == ( + "streaming system prompt must remain stored when raw stream events are omitted" + ) + assert r2["request"]["body"]["messages"][0]["content"] == "count to 3" + assert r2["response"]["status"] == 200 + assert r2["response"]["body"]["content"][0]["text"] == "1, 2, 3" + assert r2["response"]["body"]["usage"]["output_tokens"] == 8 + assert r2["response"]["body"]["stop_reason"] == "end_turn" + if store_stream_events: + assert "sse_events" in r2["response"] + assert len(r2["response"]["sse_events"]) == 8 + print(" ✅ Turn 2 (streaming, opt-in raw SSE event storage): OK") + else: + assert "sse_events" not in r2["response"] + print(" ✅ Turn 2 (streaming, SSE reassembly without raw event storage): OK") + + # ── Terminal output is clean ── + assert "Trace summary" in proc.stdout + assert "API calls: 2" in proc.stdout + assert "[Turn" not in proc.stdout, "Proxy logs leaked to stdout!" + print(" ✅ Terminal output: clean") + + # ── Proxy log has details ── + assert "[Turn 1]" in log_content + assert "[Turn 2]" in log_content + print(" ✅ Proxy log: has Turn details") + assert "Session:" in proc.stdout or "Trace session:" in proc.stdout + print(" ✅ SQLite session persisted") + + print("\n✅ E2E test PASSED") + + _cleanup(trace_dir, fake_bin_dir, "e2e") + + +## --------------------------------------------------------------------------- +## Helper: cleanup (--keep aware) +## --------------------------------------------------------------------------- + +KEEP_DIR = None # set by __main__ when --keep is passed + + +def _cleanup(trace_dir, fake_bin_dir, test_name="test"): + """Clean up temp dirs. When KEEP_DIR is set, copy trace output there first.""" + if KEEP_DIR: + for f in Path(trace_dir).iterdir(): + dest = KEEP_DIR / f"{test_name}_{f.name}" + shutil.copy2(f, dest) + shutil.rmtree(trace_dir, ignore_errors=True) + shutil.rmtree(fake_bin_dir, ignore_errors=True) + + +## --------------------------------------------------------------------------- +## Helper: generic fake upstream starter (reusable across tests) +## --------------------------------------------------------------------------- + + +def _start_fake_upstream(port, handler_fn): + """Start a fake upstream server on `port` using `handler_fn` as the aiohttp handler. + Returns a stop() callable.""" + from aiohttp import web + + ready = threading.Event() + loop = None + runner = None + + async def serve(): + nonlocal runner + app = web.Application() + app.router.add_route("*", "/{path_info:.*}", handler_fn) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", port) + await site.start() + ready.set() + while True: + await asyncio.sleep(3600) + + def thread_main(): + nonlocal loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(serve()) + except (asyncio.CancelledError, RuntimeError): + pass + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except RuntimeError: + pass + loop.close() + + t = threading.Thread(target=thread_main, daemon=True) + t.start() + ready.wait(timeout=5) + + def stop(): + if loop and loop.is_running(): + loop.call_soon_threadsafe(loop.stop) + t.join(timeout=3) + + return stop + + +def _run_claude_tap(project_dir, trace_dir, fake_bin_dir, upstream_port, timeout=30, tap_client="claude"): + """Run claude_tap as a subprocess pointing at `upstream_port`. + Returns the CompletedProcess.""" + env = os.environ.copy() + env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "") + env["PYTHONPATH"] = str(PROJECT_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + + env = e2e_env(env, trace_dir) + cmd = [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-target", + f"http://127.0.0.1:{upstream_port}", + ] + if tap_client != "claude": + cmd.extend(["--tap-client", tap_client]) + + return subprocess.run( + cmd, + cwd=str(project_dir), + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _create_fake_claude(script_text): + """Write `script_text` into a temp dir as an executable 'claude' script. + Returns the temp dir path (string).""" + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_") + fake_claude = Path(fake_bin_dir) / "claude" + fake_claude.write_text(script_text) + fake_claude.chmod(fake_claude.stat().st_mode | stat.S_IEXEC) + return fake_bin_dir + + +def test_e2e_claude_vertex_base_url_autodetects_and_records_raw_predict(): + """Real subprocess E2E for Claude Code Vertex base URL reverse proxy support.""" + import socket + + from aiohttp import web + + received_paths: list[str] = [] + + async def handler(request): + received_paths.append(request.path) + body = await request.json() + if request.path.endswith(":streamRawPredict"): + resp = web.StreamResponse(status=200, headers={"Content-Type": "text/event-stream"}) + await resp.prepare(request) + events = [ + ( + "message_start", + { + "type": "message_start", + "message": { + "id": "msg_vertex_stream", + "type": "message", + "role": "assistant", + "content": [], + "model": body.get("model", "claude-opus-4-7"), + "usage": {"input_tokens": 7, "output_tokens": 0}, + }, + }, + ), + ( + "content_block_start", + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + ), + ( + "content_block_delta", + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "stream ok"}}, + ), + ("content_block_stop", {"type": "content_block_stop", "index": 0}), + ( + "message_delta", + {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 2}}, + ), + ("message_stop", {"type": "message_stop"}), + ] + for event, payload in events: + await resp.write(f"event: {event}\ndata: {json.dumps(payload)}\n\n".encode()) + await resp.write_eof() + return resp + return web.json_response( + { + "id": "msg_vertex_raw", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "vertex raw ok"}], + "model": body.get("model", "claude-opus-4-7"), + "usage": {"input_tokens": 5, "output_tokens": 3}, + "stop_reason": "end_turn", + } + ) + + fake_claude_script = r"""#!/usr/bin/env python3 +import json, os, sys, urllib.request + +base = os.environ.get("ANTHROPIC_VERTEX_BASE_URL") +if not base: + print("ANTHROPIC_VERTEX_BASE_URL missing", file=sys.stderr) + sys.exit(1) + +model_path = "/v1/projects/test-project/locations/us-east5/publishers/anthropic/models/claude-opus-4-7" + +for suffix, stream in [(":rawPredict", False), (":streamRawPredict", True)]: + req = urllib.request.Request( + f"{base}{model_path}{suffix}", + data=json.dumps({ + "model": "claude-opus-4-7", + "max_tokens": 32, + "stream": stream, + "messages": [{"role": "user", "content": "vertex test"}], + }).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer vertex-test-token-12345678", + "anthropic-version": "2023-06-01", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + print(resp.read().decode()[:80]) + except Exception as e: + print(f"Vertex request failed: {e}", file=sys.stderr) + sys.exit(1) +""" + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_vertex_") + fake_bin_dir = _create_fake_claude(fake_claude_script) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + upstream_port = sock.getsockname()[1] + stop = _start_fake_upstream(upstream_port, handler) + + try: + env = os.environ.copy() + env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "") + env["PYTHONPATH"] = str(PROJECT_ROOT) + os.pathsep + env.get("PYTHONPATH", "") + env["CLAUDE_CODE_USE_VERTEX"] = "1" + env["CLAUDE_CODE_USE_BEDROCK"] = "0" + env["ANTHROPIC_VERTEX_BASE_URL"] = f"http://127.0.0.1:{upstream_port}" + env["ANTHROPIC_BASE_URL"] = "https://anthropic.example.invalid" + env = e2e_env(env, trace_dir) + + proc = subprocess.run( + [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-no-live", + "--tap-no-open", + ], + cwd=str(PROJECT_ROOT), + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + assert proc.returncode == 0, f"vertex e2e failed: stdout={proc.stdout} stderr={proc.stderr}" + assert "ANTHROPIC_VERTEX_BASE_URL=http://127.0.0.1:" in proc.stdout + assert "ANTHROPIC_BASE_URL=http://127.0.0.1:" in proc.stdout + assert received_paths == [ + "/v1/projects/test-project/locations/us-east5/publishers/anthropic/models/claude-opus-4-7:rawPredict", + "/v1/projects/test-project/locations/us-east5/publishers/anthropic/models/claude-opus-4-7:streamRawPredict", + ] + + records = read_trace_records(trace_dir) + assert len(records) == 2 + assert records[0]["upstream_base_url"] == f"http://127.0.0.1:{upstream_port}" + assert records[0]["request"]["path"].endswith(":rawPredict") + assert records[0]["response"]["body"]["content"][0]["text"] == "vertex raw ok" + assert records[1]["request"]["path"].endswith(":streamRawPredict") + assert records[1]["response"]["body"]["content"][0]["text"] == "stream ok" + finally: + stop() + _cleanup(trace_dir, fake_bin_dir, "claude_vertex") + + +def test_e2e_tap_target_endpoint_path_is_not_duplicated(): + """Real subprocess E2E for users passing a full /v1/messages endpoint target.""" + import socket + + from aiohttp import web + + received_paths: list[str] = [] + + async def handler(request): + received_paths.append(request.path) + if request.path != "/gateway/v1/messages": + return web.json_response({"error": f"unexpected path {request.path}"}, status=404) + body = await request.json() + return web.json_response( + { + "id": "msg_endpoint_target", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": f"ok:{body.get('model', 'missing')}"}], + "model": body.get("model", "test"), + "usage": {"input_tokens": 1, "output_tokens": 1}, + "stop_reason": "end_turn", + } + ) + + fake_claude_script = r"""#!/usr/bin/env python3 +import json, os, sys, urllib.request + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +req = urllib.request.Request( + f"{base}/v1/messages", + data=json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], + }).encode(), + headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", + }, +) + +try: + with urllib.request.urlopen(req, timeout=10) as resp: + body = json.loads(resp.read()) + print(body["content"][0]["text"]) +except Exception as exc: + print(f"[fake-claude] error: {exc}", file=sys.stderr) + sys.exit(1) +""" + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_endpoint_target_") + fake_bin_dir = _create_fake_claude(fake_claude_script) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + upstream_port = sock.getsockname()[1] + stop_upstream = _start_fake_upstream(upstream_port, handler) + + env = os.environ.copy() + env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "") + env = e2e_env(env, trace_dir) + + try: + proc = subprocess.run( + [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-no-open", + "--tap-target", + f"http://127.0.0.1:{upstream_port}/gateway/v1/messages", + ], + cwd=str(PROJECT_ROOT), + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + print(f"[test_e2e_tap_target_endpoint_path_is_not_duplicated] exit: {proc.returncode}") + if proc.stdout.strip(): + print(proc.stdout.rstrip()) + if proc.stderr.strip(): + print(proc.stderr.rstrip()) + + assert proc.returncode == 0 + assert received_paths == ["/gateway/v1/messages"] + records = read_trace_records(trace_dir) + assert len(records) == 1 + assert records[0]["response"]["status"] == 200 + assert records[0]["response"]["body"]["content"][0]["text"] == "ok:claude-test-model" + finally: + stop_upstream() + _cleanup(trace_dir, fake_bin_dir, "endpoint_target") + + +## --------------------------------------------------------------------------- +## Test 2: test_upstream_error +## --------------------------------------------------------------------------- + +FAKE_UPSTREAM_ERROR_PORT = 19200 + +FAKE_CLAUDE_ERROR_SCRIPT = r'''#!/usr/bin/env python3 +"""Fake claude CLI — sends a request and expects a 500 error.""" +import json, os, sys, urllib.request, urllib.error + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +url = f"{base}/v1/messages" + +req_body = json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "trigger error"}], +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", +}) +try: + with urllib.request.urlopen(req) as resp: + print(f"[fake-claude] Unexpected success: {resp.status}", file=sys.stderr) + sys.exit(1) +except urllib.error.HTTPError as e: + body = e.read().decode() + print(f"[fake-claude] Got HTTP {e.code}: {body}") + # Exit 0 — we expected the error +except Exception as e: + print(f"[fake-claude] Unexpected error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-claude] Done.") +''' + + +def test_upstream_error(): + """Test that when upstream returns 500, the proxy forwards it correctly + and records it in the trace.""" + from aiohttp import web + + async def error_handler(request): + await request.read() + error_payload = json.dumps( + { + "type": "error", + "error": {"type": "internal_server_error", "message": "Something went wrong"}, + } + ).encode() + return web.Response( + status=500, + body=error_payload, + headers={"Content-Type": "application/json"}, + ) + + stop_upstream = _start_fake_upstream(FAKE_UPSTREAM_ERROR_PORT, error_handler) + print(f"\n[test_upstream_error] Fake upstream on :{FAKE_UPSTREAM_ERROR_PORT}") + + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_error_") + fake_bin_dir = _create_fake_claude(FAKE_CLAUDE_ERROR_SCRIPT) + + try: + proc = _run_claude_tap(project_dir, trace_dir, fake_bin_dir, FAKE_UPSTREAM_ERROR_PORT) + + print(f"[test_upstream_error] Exit code: {proc.returncode}") + if proc.stdout.strip(): + print(f"[test_upstream_error] stdout:\n{proc.stdout.rstrip()}") + if proc.stderr.strip(): + print(f"[test_upstream_error] stderr:\n{proc.stderr.rstrip()}") + + # Trace file exists + records = read_trace_records(trace_dir) + assert len(records) >= 1, f"Expected trace records in SQLite, got {records}" + + print(f"[test_upstream_error] Recorded {len(records)} API calls") + assert len(records) == 1, f"Expected 1 record, got {len(records)}" + + r = records[0] + assert r["turn"] == 1 + assert r["response"]["status"] == 500 + assert r["response"]["body"]["type"] == "error" + assert r["response"]["body"]["error"]["type"] == "internal_server_error" + assert r["request"]["body"]["messages"][0]["content"] == "trigger error" + print(" OK: 500 status recorded correctly in trace") + + # The proxy should still produce summary output + assert "Trace summary" in proc.stdout + assert "API calls: 1" in proc.stdout + print(" OK: proxy summary output present") + + print("\n test_upstream_error PASSED") + + except subprocess.TimeoutExpired: + print("[test_upstream_error] TIMEOUT") + sys.exit(1) + finally: + stop_upstream() + _cleanup(trace_dir, fake_bin_dir, "upstream_error") + + +## --------------------------------------------------------------------------- +## Test 3: test_malformed_sse +## --------------------------------------------------------------------------- + +FAKE_UPSTREAM_MALFORMED_PORT = 19201 + +FAKE_CLAUDE_MALFORMED_SCRIPT = r'''#!/usr/bin/env python3 +"""Fake claude CLI — sends a streaming request to a server with malformed SSE.""" +import json, os, sys, urllib.request + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +url = f"{base}/v1/messages" + +req_body = json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "stream": True, + "messages": [{"role": "user", "content": "malformed stream test"}], +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", +}) +try: + with urllib.request.urlopen(req) as resp: + chunks = resp.read().decode() + print(f"[fake-claude] Got SSE response ({len(chunks)} chars)") +except Exception as e: + print(f"[fake-claude] Error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-claude] Done.") +''' + + +def test_malformed_sse(): + """Test that when the SSE stream is malformed (missing event type, truncated + data, garbage lines), the proxy handles it gracefully without crashing and + still records what it can.""" + from aiohttp import web + + async def malformed_sse_handler(request): + body = await request.read() + req = json.loads(body) if body else {} + + resp = web.StreamResponse( + status=200, + headers={"Content-Type": "text/event-stream"}, + ) + await resp.prepare(request) + + # 1. Valid message_start event + valid_start = { + "type": "message_start", + "message": { + "id": "msg_malformed_1", + "type": "message", + "role": "assistant", + "content": [], + "model": req.get("model", "test"), + "usage": {"input_tokens": 10, "output_tokens": 0}, + }, + } + await resp.write(f"event: message_start\ndata: {json.dumps(valid_start)}\n\n".encode()) + + # 2. Data line without a preceding event: line — should be ignored + await resp.write(b'data: {"orphan": true}\n\n') + + # 3. Event with truncated/invalid JSON + await resp.write(b'event: content_block_delta\ndata: {"broken json\n\n') + + # 4. Random garbage line + await resp.write(b"this is not SSE at all\n\n") + + # 5. Valid content_block_start + delta + stop to produce some text + await resp.write( + f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': 0, 'content_block': {'type': 'text', 'text': ''}})}\n\n".encode() + ) + await resp.write( + f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': 'partial'}})}\n\n".encode() + ) + await resp.write( + f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': 0})}\n\n".encode() + ) + + # 6. Valid message_delta and message_stop + await resp.write( + f"event: message_delta\ndata: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': 'end_turn'}, 'usage': {'output_tokens': 2}})}\n\n".encode() + ) + await resp.write(f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n".encode()) + + await resp.write_eof() + return resp + + stop_upstream = _start_fake_upstream(FAKE_UPSTREAM_MALFORMED_PORT, malformed_sse_handler) + print(f"\n[test_malformed_sse] Fake upstream on :{FAKE_UPSTREAM_MALFORMED_PORT}") + + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_malformed_") + fake_bin_dir = _create_fake_claude(FAKE_CLAUDE_MALFORMED_SCRIPT) + + try: + proc = _run_claude_tap(project_dir, trace_dir, fake_bin_dir, FAKE_UPSTREAM_MALFORMED_PORT) + + print(f"[test_malformed_sse] Exit code: {proc.returncode}") + if proc.stdout.strip(): + print(f"[test_malformed_sse] stdout:\n{proc.stdout.rstrip()}") + if proc.stderr.strip(): + print(f"[test_malformed_sse] stderr:\n{proc.stderr.rstrip()}") + + # Proxy should NOT crash (exit code 0 from fake claude) + assert proc.returncode == 0, f"Expected exit code 0, got {proc.returncode}" + print(" OK: proxy did not crash") + + # Trace file exists + records = read_trace_records(trace_dir) + assert len(records) >= 1, f"Expected trace records in SQLite, got {records}" + + assert len(records) == 1, f"Expected 1 record, got {len(records)}" + r = records[0] + assert r["turn"] == 1 + assert r["response"]["status"] == 200 + assert r["request"]["body"]["stream"] is True + + # Raw SSE events are not persisted by default, but the reconstructed + # body should still be usable. + assert "sse_events" not in r["response"] + print(" OK: raw SSE events omitted by default") + + # The reconstructed body should still have the partial text from valid events + body = r["response"]["body"] + assert body is not None, "Expected reconstructed body, got None" + assert body["content"][0]["text"] == "partial" + print(" OK: reconstructed body has 'partial' text from valid events") + + assert "Trace summary" in proc.stdout + print(" OK: summary present") + + print("\n test_malformed_sse PASSED") + + except subprocess.TimeoutExpired: + print("[test_malformed_sse] TIMEOUT") + sys.exit(1) + finally: + stop_upstream() + _cleanup(trace_dir, fake_bin_dir, "malformed_sse") + + +## --------------------------------------------------------------------------- +## Test 4: test_large_payload +## --------------------------------------------------------------------------- + +FAKE_UPSTREAM_LARGE_PORT = 19202 + +# The script is generated dynamically to include a 100KB+ system prompt. +# We embed the large payload generation inline in the script. +FAKE_CLAUDE_LARGE_SCRIPT = r'''#!/usr/bin/env python3 +"""Fake claude CLI — sends a request with a very large system prompt (100KB+).""" +import json, os, sys, urllib.request + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +url = f"{base}/v1/messages" + +# Generate a large system prompt (over 100KB) +large_system = "You are a helpful assistant. " * 5000 # ~140KB + +req_body = json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "system": large_system, + "messages": [{"role": "user", "content": "hello"}], +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", +}) +try: + with urllib.request.urlopen(req) as resp: + data = resp.read() + if resp.headers.get("Content-Encoding") == "gzip": + import gzip as gz + data = gz.decompress(data) + body = json.loads(data) + print(f"[fake-claude] Large payload response: {body.get('content', [{}])[0].get('text', '?')}") +except Exception as e: + print(f"[fake-claude] Error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-claude] Done.") +''' + + +def test_large_payload(): + """Test with a very large system prompt (100KB+) to ensure the proxy handles + large request bodies correctly through forwarding and recording.""" + from aiohttp import web + + async def large_handler(request): + body = await request.read() + req = json.loads(body) if body else {} + + # Verify we received the large system prompt + system = req.get("system", "") + payload = json.dumps( + { + "id": "msg_large_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": f"Received system prompt of {len(system)} chars"}], + "model": req.get("model", "test"), + "usage": {"input_tokens": 50000, "output_tokens": 10}, + "stop_reason": "end_turn", + } + ).encode() + compressed = gzip.compress(payload) + return web.Response( + status=200, + body=compressed, + headers={"Content-Type": "application/json", "Content-Encoding": "gzip"}, + ) + + stop_upstream = _start_fake_upstream(FAKE_UPSTREAM_LARGE_PORT, large_handler) + print(f"\n[test_large_payload] Fake upstream on :{FAKE_UPSTREAM_LARGE_PORT}") + + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_large_") + fake_bin_dir = _create_fake_claude(FAKE_CLAUDE_LARGE_SCRIPT) + + try: + proc = _run_claude_tap(project_dir, trace_dir, fake_bin_dir, FAKE_UPSTREAM_LARGE_PORT) + + print(f"[test_large_payload] Exit code: {proc.returncode}") + if proc.stdout.strip(): + print(f"[test_large_payload] stdout:\n{proc.stdout.rstrip()}") + if proc.stderr.strip(): + print(f"[test_large_payload] stderr:\n{proc.stderr.rstrip()}") + + assert proc.returncode == 0, f"Expected exit code 0, got {proc.returncode}" + print(" OK: proxy handled large payload without crashing") + + # Trace file exists + records = read_trace_records(trace_dir) + assert len(records) >= 1, f"Expected trace records in SQLite, got {records}" + + assert len(records) == 1, f"Expected 1 record, got {len(records)}" + r = records[0] + + # Verify the large system prompt was captured in the trace + system_prompt = r["request"]["body"]["system"] + assert len(system_prompt) > 100_000, f"System prompt only {len(system_prompt)} chars, expected >100KB" + print(f" OK: system prompt recorded ({len(system_prompt)} chars)") + + # Verify response was forwarded and recorded + assert r["response"]["status"] == 200 + resp_text = r["response"]["body"]["content"][0]["text"] + assert "Received system prompt of" in resp_text + # Check the upstream reported the full prompt size + reported_len = int(resp_text.split("of ")[1].split(" ")[0]) + assert reported_len > 100_000, f"Upstream only received {reported_len} chars" + print(f" OK: upstream received full payload ({reported_len} chars)") + + assert "Trace summary" in proc.stdout + assert "API calls: 1" in proc.stdout + print(" OK: summary present") + + payload_size = sum(len(json.dumps(record)) for record in records) + assert payload_size > 100_000, f"Trace payload only {payload_size} bytes, expected >100KB" + print(f" OK: trace payload is {payload_size} bytes (contains full payload)") + + print("\n test_large_payload PASSED") + + except subprocess.TimeoutExpired: + print("[test_large_payload] TIMEOUT") + sys.exit(1) + finally: + stop_upstream() + _cleanup(trace_dir, fake_bin_dir, "large_payload") + + +## --------------------------------------------------------------------------- +## Test 5: test_concurrent_requests +## --------------------------------------------------------------------------- + +FAKE_UPSTREAM_CONCURRENT_PORT = 19203 + +FAKE_CLAUDE_CONCURRENT_SCRIPT = r'''#!/usr/bin/env python3 +"""Fake claude CLI — sends multiple requests concurrently using threads.""" +import json, os, sys, threading, urllib.request + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +url = f"{base}/v1/messages" + +NUM_THREADS = 5 +results = [None] * NUM_THREADS +errors = [None] * NUM_THREADS + +def send_request(idx): + req_body = json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": f"concurrent request {idx}"}], + }).encode() + req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", + }) + try: + with urllib.request.urlopen(req) as resp: + data = resp.read() + if resp.headers.get("Content-Encoding") == "gzip": + import gzip as gz + data = gz.decompress(data) + results[idx] = json.loads(data) + except Exception as e: + errors[idx] = str(e) + +threads = [] +for i in range(NUM_THREADS): + t = threading.Thread(target=send_request, args=(i,)) + threads.append(t) + t.start() + +for t in threads: + t.join(timeout=10) + +success = sum(1 for r in results if r is not None) +fail = sum(1 for e in errors if e is not None) +print(f"[fake-claude] {success} succeeded, {fail} failed") +for i, e in enumerate(errors): + if e: + print(f"[fake-claude] Thread {i} error: {e}", file=sys.stderr) + +if fail > 0: + sys.exit(1) +print("[fake-claude] Done.") +''' + + +def test_concurrent_requests(): + """Test that multiple simultaneous requests are handled correctly by the + proxy. Uses threads in the fake claude to send 5 requests at once.""" + from aiohttp import web + + # Use a counter to track requests (thread-safe via asyncio single-threaded loop) + request_count = {"n": 0} + + async def concurrent_handler(request): + body = await request.read() + req = json.loads(body) if body else {} + + request_count["n"] += 1 + n = request_count["n"] + + # Add a small delay to simulate real processing and ensure overlap + await asyncio.sleep(0.1) + + user_msg = "" + if isinstance(req.get("messages"), list) and req["messages"]: + user_msg = req["messages"][0].get("content", "") + + payload = json.dumps( + { + "id": f"msg_concurrent_{n}", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": f"Reply to: {user_msg}"}], + "model": req.get("model", "test"), + "usage": {"input_tokens": 10, "output_tokens": 5}, + "stop_reason": "end_turn", + } + ).encode() + compressed = gzip.compress(payload) + return web.Response( + status=200, + body=compressed, + headers={"Content-Type": "application/json", "Content-Encoding": "gzip"}, + ) + + stop_upstream = _start_fake_upstream(FAKE_UPSTREAM_CONCURRENT_PORT, concurrent_handler) + print(f"\n[test_concurrent_requests] Fake upstream on :{FAKE_UPSTREAM_CONCURRENT_PORT}") + + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_concurrent_") + fake_bin_dir = _create_fake_claude(FAKE_CLAUDE_CONCURRENT_SCRIPT) + + try: + proc = _run_claude_tap(project_dir, trace_dir, fake_bin_dir, FAKE_UPSTREAM_CONCURRENT_PORT) + + print(f"[test_concurrent_requests] Exit code: {proc.returncode}") + if proc.stdout.strip(): + print(f"[test_concurrent_requests] stdout:\n{proc.stdout.rstrip()}") + if proc.stderr.strip(): + print(f"[test_concurrent_requests] stderr:\n{proc.stderr.rstrip()}") + + assert proc.returncode == 0, f"Expected exit code 0, got {proc.returncode}" + print(" OK: proxy handled concurrent requests without crashing") + + # Trace file exists + records = read_trace_records(trace_dir) + assert len(records) >= 1, f"Expected trace records in SQLite, got {records}" + + print(f"[test_concurrent_requests] Recorded {len(records)} API calls") + assert len(records) == 5, f"Expected 5 records, got {len(records)}" + + # All records should have status 200 + for i, r in enumerate(records): + assert r["response"]["status"] == 200, f"Record {i}: status={r['response']['status']}" + + # Each record should have a unique turn number + turns = sorted([r["turn"] for r in records]) + assert turns == [1, 2, 3, 4, 5], f"Expected turns [1..5], got {turns}" + print(" OK: all 5 turns recorded with unique turn numbers") + + # Verify each response echoes back its request content + for r in records: + req_content = r["request"]["body"]["messages"][0]["content"] + resp_text = r["response"]["body"]["content"][0]["text"] + assert req_content in resp_text, f"Response '{resp_text}' does not contain request content '{req_content}'" + print(" OK: each response correctly matches its request") + + # All request IDs should be unique + req_ids = [r["request_id"] for r in records] + assert len(set(req_ids)) == 5, f"Expected 5 unique request IDs, got {len(set(req_ids))}" + print(" OK: all request IDs are unique") + + assert "Trace summary" in proc.stdout + assert "API calls: 5" in proc.stdout + print(" OK: summary present") + + print("\n test_concurrent_requests PASSED") + + except subprocess.TimeoutExpired: + print("[test_concurrent_requests] TIMEOUT") + sys.exit(1) + finally: + stop_upstream() + _cleanup(trace_dir, fake_bin_dir, "concurrent") + + +## --------------------------------------------------------------------------- +## --preview: regenerate HTML from real .traces files and open +## --------------------------------------------------------------------------- + + +def _cmd_preview(): + """Regenerate HTML viewer from existing .traces data using current viewer.html. + + Usage: + uv run python test_e2e.py --preview # latest trace + uv run python test_e2e.py --preview all # all traces + uv run python test_e2e.py --preview 002300 # match by partial name + """ + import subprocess as sp + + from claude_tap import _generate_html_viewer + + traces_dir = Path(__file__).parent / ".traces" + if not traces_dir.exists(): + print(f"Error: {traces_dir} does not exist") + sys.exit(1) + + target = sys.argv[2] if len(sys.argv) > 2 else "latest" + if target == "all": + jsonl_files = sorted(traces_dir.glob("*.jsonl")) + elif target == "latest": + jsonl_files = sorted(traces_dir.glob("*.jsonl"))[-1:] + else: + jsonl_files = [f for f in traces_dir.glob("*.jsonl") if target in f.name] + + if not jsonl_files: + print(f"No matching .jsonl in {traces_dir}") + sys.exit(1) + + for jf in jsonl_files: + html = jf.with_suffix(".html") + _generate_html_viewer(jf, html) + print(f"Generated: {html}") + + sp.run(["open", str(jsonl_files[-1].with_suffix(".html"))]) + + +## --------------------------------------------------------------------------- +## --dev: auto multi-turn via claude -p, then open HTML +## --------------------------------------------------------------------------- + + +def _cmd_dev(): + """Start claude-tap proxy, run multi-turn prompts non-interactively, open HTML. + + Usage: + uv run python test_e2e.py --dev # default prompts + uv run python test_e2e.py --dev "prompt1" "prompt2" ... # custom prompts + """ + import signal + import subprocess as sp + + project_dir = PROJECT_ROOT + traces_dir = project_dir / ".traces" + traces_dir.mkdir(exist_ok=True) + + # Collect prompts: custom or default + prompts = [a for a in sys.argv[2:] if not a.startswith("-")] + if not prompts: + prompts = [ + "Search the web for the latest Claude model release date and summarize in 2 sentences", + "Now search for how it compares to GPT-5.2 and give a short comparison table", + ] + + # Start proxy in background via --no-launch + # -u: unbuffered stdout so we can read the port line immediately + print("Starting claude-tap proxy...") + proxy_env = os.environ.copy() + proxy_env["PYTHONUNBUFFERED"] = "1" + proxy_proc = sp.Popen( + [sys.executable, "-u", "-m", "claude_tap", "--tap-output-dir", str(traces_dir), "--tap-no-launch"], + cwd=str(project_dir), + env=proxy_env, + stdout=sp.PIPE, + stderr=sp.STDOUT, + text=True, + ) + + # Read proxy output to get the port + port = None + for line in proxy_proc.stdout: + print(line, end="") + if "listening on" in line: + port = int(line.strip().rsplit(":", 1)[1]) + break + + if port is None: + print("Error: could not determine proxy port") + proxy_proc.terminate() + sys.exit(1) + + env = os.environ.copy() + env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}" + # Remove vars that make claude think it's inside a nested session + for k in ["CLAUDECODE", "CLAUDE_CODE_SSE_PORT"]: + env.pop(k, None) + + try: + for i, prompt in enumerate(prompts): + turn = i + 1 + print(f"\n{'=' * 50}") + print(f"Turn {turn}: {prompt[:70]}{'...' if len(prompt) > 70 else ''}") + print("=" * 50) + + cmd = ["claude", "-p", prompt] + if i > 0: + cmd.insert(2, "-c") # --continue: resume last conversation + + result = sp.run(cmd, env=env, capture_output=True, text=True, timeout=180) + if result.stdout: + lines = result.stdout.strip().split("\n") + preview = "\n".join(lines[:10]) + if len(lines) > 10: + preview += f"\n... ({len(lines) - 10} more lines)" + print(preview) + if result.returncode != 0 and result.stderr: + print(f"stderr: {result.stderr[:200]}") + except Exception as e: + print(f"\nError during prompts: {e}") + finally: + # Stop proxy + proxy_proc.send_signal(signal.SIGINT) + remaining = proxy_proc.stdout.read() + print(remaining, end="") + proxy_proc.wait(timeout=10) + + # Find and open the latest HTML + html_files = sorted(traces_dir.glob("*.html")) + if html_files: + latest = html_files[-1] + print(f"\nOpening: {latest}") + sp.run(["open", str(latest)]) + else: + print("\nNo HTML generated") + + +## --------------------------------------------------------------------------- +## Test 6: test_parse_args — argument passthrough with --tap-* prefix +## --------------------------------------------------------------------------- + + +def test_parse_args(monkeypatch, tmp_path): + """Test that --tap-* flags are consumed by claude-tap and everything else + is forwarded to claude via claude_args.""" + from claude_tap import parse_args + + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home")) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path / "home") + monkeypatch.chdir(tmp_path) + + # Basic: no args + a = parse_args([]) + assert a.claude_args == [] + assert a.port == 0 + assert a.output_dir == "./.traces" + assert a.client == "claude" + assert a.target == "https://api.anthropic.com" + assert a.no_launch is False + assert a.live_viewer is True + assert a.open_viewer is True + assert a.client_cmd is None + assert a.store_stream_events is False + print(" OK: defaults") + + # Codex defaults + a = parse_args(["--tap-client", "codex"]) + assert a.client == "codex" + assert a.target == "https://api.openai.com" + assert a.claude_args == [] + print(" OK: codex defaults") + + openclaw_config = tmp_path / "openclaw.json" + openclaw_config.write_text( + json.dumps( + { + "agents": {"defaults": {"model": {"primary": "openai/default"}}}, + "models": { + "providers": { + "openai": {"baseUrl": "https://openai.example.com/v1", "api": "openai-responses"}, + "anthropic": {"baseUrl": "https://anthropic.example.com", "api": "anthropic-messages"}, + } + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(openclaw_config)) + a = parse_args(["--tap-client", "openclaw", "--", "agent", "--model", "anthropic/claude"]) + assert a.target == "https://anthropic.example.com" + assert a.claude_args == ["agent", "--model", "anthropic/claude"] + print(" OK: openclaw target honors forwarded --model") + + # Claude flags pass through + a = parse_args(["-c"]) + assert a.claude_args == ["-c"] + print(" OK: -c forwarded") + + a = parse_args(["--model", "opus", "-c"]) + assert a.claude_args == ["--model", "opus", "-c"] + print(" OK: --model opus -c forwarded") + + # -p (claude's --print) should NOT be consumed by tap + a = parse_args(["-p"]) + assert a.claude_args == ["-p"] + assert a.port == 0 + print(" OK: -p forwarded (no conflict with old --port)") + + # Tap-specific flags consumed + a = parse_args(["--tap-port", "8080", "--tap-output-dir", "/tmp/t", "--tap-no-open", "--tap-target", "http://x"]) + assert a.port == 8080 + assert a.output_dir == "/tmp/t" + assert a.target == "http://x" + assert a.open_viewer is False + assert a.claude_args == [] + print(" OK: --tap-* flags consumed") + + # VSCode claudeProcessWrapper passes the bundled Claude binary as argv[0]. + wrapped_claude = tmp_path / "claude" + wrapped_claude.write_text("#!/bin/sh\n", encoding="utf-8") + a = parse_args([str(wrapped_claude), "--output-format", "stream-json", "--verbose"]) + assert a.client_cmd == str(wrapped_claude) + assert a.claude_args == ["--output-format", "stream-json", "--verbose"] + print(" OK: VSCode wrapper Claude binary path consumed") + + prompt_dir_named_claude = tmp_path / "context" / "claude" + prompt_dir_named_claude.mkdir(parents=True) + a = parse_args([str(prompt_dir_named_claude), "--output-format", "stream-json"]) + assert a.client_cmd is None + assert a.claude_args == [str(prompt_dir_named_claude), "--output-format", "stream-json"] + print(" OK: directory named claude is not consumed as wrapper binary") + + a = parse_args(["--tap-no-live"]) + assert a.live_viewer is False + assert a.claude_args == [] + print(" OK: --tap-no-live disables live viewer") + + a = parse_args(["--tap-store-stream-events"]) + assert a.store_stream_events is True + assert a.claude_args == [] + print(" OK: --tap-store-stream-events enables raw event storage") + + a = parse_args(["--tap-export-prompt", "prompt.md"]) + assert a.export_prompt == "prompt.md" + assert a.claude_args == [] + print(" OK: --tap-export-prompt consumed") + + # Mix: tap flags + claude flags + a = parse_args(["--tap-port", "9999", "-c", "--model", "sonnet"]) + assert a.port == 9999 + assert a.claude_args == ["-c", "--model", "sonnet"] + print(" OK: mixed tap + claude flags") + + # --tap-allow-path + a = parse_args(["--tap-allow-path", "/custom/api"]) + assert a.extra_allowed_paths == ["/custom/api"] + print(" OK: --tap-allow-path single") + + a = parse_args(["--tap-allow-path", "/custom/api", "--tap-allow-path", "/another/path"]) + assert a.extra_allowed_paths == ["/custom/api", "/another/path"] + print(" OK: --tap-allow-path multiple") + + # Complex claude flags + a = parse_args(["--tap-port", "0", "-p", "--model", "opus", "--system-prompt", "be brief", "-d"]) + assert a.port == 0 + assert a.claude_args == ["-p", "--model", "opus", "--system-prompt", "be brief", "-d"] + print(" OK: complex claude flags forwarded") + + print("\n test_parse_args PASSED") + + +@pytest.mark.asyncio +async def test_async_main_live_viewer_default_opens_when_allowed(monkeypatch, tmp_path, capsys): + """Default live viewer starts, and --tap-no-open controls browser opening.""" + from claude_tap import async_main, parse_args + from claude_tap.live import LiveViewerServer + + opened_urls = [] + spawned_servers: list[LiveViewerServer] = [] + + async def fake_run_client(*args, **kwargs): + return 0 + + async def fake_ensure_shared_dashboard(*, host, port, output_dir, open_browser, open_browser_fn): + server = LiveViewerServer(port=0, migrate_from=output_dir, dashboard_mode=True) + await server.start() + spawned_servers.append(server) + if open_browser: + open_browser_fn(server.url) + return server.url, True + + migration_calls = [] + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli._open_browser", opened_urls.append) + monkeypatch.setattr("claude_tap.cli.ensure_shared_dashboard", fake_ensure_shared_dashboard) + monkeypatch.setattr("claude_tap.cli.migrate_legacy_traces", migration_calls.append) + + args = parse_args(["--tap-output-dir", str(tmp_path)]) + try: + code = await async_main(args) + finally: + for server in spawned_servers: + await server.stop() + + assert code == 0 + assert len(opened_urls) == 1 + assert all(url.startswith("http://127.0.0.1:") for url in opened_urls) + assert migration_calls == [] + output = capsys.readouterr().out + assert "Stop dashboard: claude-tap dashboard stop" in output + + +@pytest.mark.asyncio +async def test_async_main_stop_hint_includes_custom_dashboard_address(monkeypatch, tmp_path, capsys): + """The dashboard stop hint should target the actual shared dashboard address.""" + from claude_tap import async_main, parse_args + + async def fake_run_client(*args, **kwargs): + return 0 + + async def fake_ensure_shared_dashboard(*, host, port, output_dir, open_browser, open_browser_fn): + return f"http://127.0.0.1:{port}", False + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli.ensure_shared_dashboard", fake_ensure_shared_dashboard) + + args = parse_args( + [ + "--tap-output-dir", + str(tmp_path), + "--tap-live-port", + "3000", + "--tap-host", + "0.0.0.0", + ] + ) + + code = await async_main(args) + + assert code == 0 + output = capsys.readouterr().out + assert "Stop dashboard: claude-tap dashboard stop --tap-live-port 3000 --tap-host 0.0.0.0" in output + + +@pytest.mark.asyncio +async def test_async_main_reuses_existing_dashboard_without_reopening_browser(monkeypatch, tmp_path): + """A second claude-tap run should attach to an existing dashboard without opening another tab.""" + from claude_tap import async_main, parse_args + from claude_tap.live import LiveViewerServer + + opened_urls = [] + spawned_servers: list[LiveViewerServer] = [] + attach_calls = {"count": 0} + + async def fake_run_client(*args, **kwargs): + return 0 + + async def fake_ensure_shared_dashboard(*, host, port, output_dir, open_browser, open_browser_fn): + attach_calls["count"] += 1 + if attach_calls["count"] == 1: + server = LiveViewerServer(port=0, migrate_from=output_dir, dashboard_mode=True) + await server.start() + spawned_servers.append(server) + if open_browser: + open_browser_fn(server.url) + return server.url, True + return f"http://{host}:{port}", False + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main-shared.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli._open_browser", opened_urls.append) + monkeypatch.setattr("claude_tap.cli.ensure_shared_dashboard", fake_ensure_shared_dashboard) + + args = parse_args(["--tap-output-dir", str(tmp_path)]) + try: + assert await async_main(args) == 0 + assert await async_main(args) == 0 + finally: + for server in spawned_servers: + await server.stop() + + assert len(opened_urls) == 1 + + +@pytest.mark.asyncio +async def test_async_main_live_viewer_respects_tap_host(monkeypatch, tmp_path): + """Shared dashboard startup should honor the configured tap host.""" + from claude_tap import async_main, parse_args + + dashboard_calls: list[dict[str, object]] = [] + + async def fake_run_client(*args, **kwargs): + return 0 + + async def fake_ensure_shared_dashboard(*, host, port, output_dir, open_browser, open_browser_fn): + dashboard_calls.append({"host": host, "port": port, "output_dir": output_dir, "open_browser": open_browser}) + return f"http://{host}:{port}", False + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main-host.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli.ensure_shared_dashboard", fake_ensure_shared_dashboard) + + args = parse_args( + [ + "--tap-output-dir", + str(tmp_path), + "--tap-host", + "0.0.0.0", + "--tap-no-open", + ] + ) + + assert await async_main(args) == 0 + assert dashboard_calls and dashboard_calls[0]["host"] == "0.0.0.0" + + +@pytest.mark.asyncio +async def test_async_main_continues_when_dashboard_migration_is_locked(monkeypatch, tmp_path, capsys): + """Dashboard storage failures must not prevent the configured client from running.""" + from claude_tap import async_main, parse_args + + client_calls = [] + + async def fake_run_client(*args, **kwargs): + client_calls.append((args, kwargs)) + return 0 + + async def fail_dashboard(**_kwargs): + raise sqlite3.OperationalError("database is locked") + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main-dashboard-lock.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli.ensure_shared_dashboard", fail_dashboard) + + args = parse_args(["--tap-output-dir", str(tmp_path), "--tap-no-open"]) + code = await async_main(args) + + assert code == 0 + assert len(client_calls) == 1 + assert "database is locked" in capsys.readouterr().err + + +@pytest.mark.asyncio +async def test_async_main_finalizes_session_when_proxy_startup_fails(monkeypatch, tmp_path): + """Startup bind failures should not leave active SQLite sessions behind.""" + from claude_tap import async_main, get_trace_store, parse_args + + async def fail_start(self): + raise OSError("bind failed") + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "startup-failure.sqlite3")) + monkeypatch.setattr("claude_tap.cli.web.TCPSite.start", fail_start) + + args = parse_args( + [ + "--tap-output-dir", + str(tmp_path), + "--tap-no-live", + "--tap-no-launch", + ] + ) + + with pytest.raises(OSError, match="bind failed"): + await async_main(args) + + rows = get_trace_store().list_session_rows() + assert len(rows) == 1 + assert rows[0]["status"] == "empty" + + +@pytest.mark.asyncio +async def test_async_main_no_live_and_no_open_restore_non_browser_mode(monkeypatch, tmp_path): + """--tap-no-live disables the live server and --tap-no-open prevents browser opens.""" + from claude_tap import async_main, parse_args + + opened_urls = [] + migration_calls = [] + + async def fake_run_client(*args, **kwargs): + return 0 + + from unittest.mock import AsyncMock + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main-no-live.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli._open_browser", opened_urls.append) + monkeypatch.setattr("claude_tap.cli.migrate_legacy_traces", migration_calls.append) + monkeypatch.setattr( + "claude_tap.cli.ensure_shared_dashboard", + AsyncMock(side_effect=AssertionError("dashboard should stay disabled")), + ) + + args = parse_args(["--tap-output-dir", str(tmp_path), "--tap-no-live", "--tap-no-open"]) + code = await async_main(args) + + assert code == 0 + assert opened_urls == [] + assert migration_calls == [tmp_path] + + +@pytest.mark.asyncio +async def test_async_main_no_live_continues_when_legacy_migration_is_locked(monkeypatch, tmp_path, capsys): + """A locked migration must not prevent the configured client from running.""" + from claude_tap import async_main, parse_args + + client_calls = [] + + async def fake_run_client(*args, **kwargs): + client_calls.append((args, kwargs)) + return 0 + + def fail_migration(_output_dir): + raise sqlite3.OperationalError("database is locked") + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main-locked-migration.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli.migrate_legacy_traces", fail_migration) + + args = parse_args(["--tap-output-dir", str(tmp_path), "--tap-no-live", "--tap-no-open"]) + code = await async_main(args) + + assert code == 0 + assert len(client_calls) == 1 + assert "legacy trace migration skipped" in capsys.readouterr().err + + +@pytest.mark.asyncio +async def test_async_main_export_prompt_preserves_client_failure(monkeypatch, tmp_path): + """Successful prompt export should not turn a failing client run into success.""" + from claude_tap import async_main, parse_args + + async def fake_run_client(*args, **kwargs): + return 7 + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "async-main-export-failure.sqlite3")) + monkeypatch.setattr("claude_tap.cli.run_client", fake_run_client) + monkeypatch.setattr("claude_tap.cli._export_prompt_from_session", lambda *_args: 0) + + args = parse_args( + [ + "--tap-output-dir", + str(tmp_path), + "--tap-no-live", + "--tap-no-open", + "--tap-export-prompt", + str(tmp_path / "prompt.md"), + ] + ) + + assert await async_main(args) == 7 + + +def test_parse_args_allow_path_validation(): + """Test --tap-allow-path validation rejects invalid prefixes.""" + import pytest + + from claude_tap import parse_args + + # Valid prefixes + a = parse_args(["--tap-allow-path", "/custom/api"]) + assert a.extra_allowed_paths == ["/custom/api"] + + # Empty prefix should fail + with pytest.raises(SystemExit): + parse_args(["--tap-allow-path", ""]) + + # Prefix not starting with / + with pytest.raises(SystemExit): + parse_args(["--tap-allow-path", "custom/api"]) + + # Root prefix / + with pytest.raises(SystemExit): + parse_args(["--tap-allow-path", "/"]) + + # Prefix ending with / + with pytest.raises(SystemExit): + parse_args(["--tap-allow-path", "/custom/api/"]) + + print(" test_parse_args_allow_path_validation PASSED") + + +FAKE_CODEX_SCRIPT = r"""#!/usr/bin/env python3 +# Fake codex CLI that sends one request via OPENAI_BASE_URL +import json, os, sys, urllib.request + +base = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") +url = f"{base}/messages" + +req_body = json.dumps({ + "model": "gpt-5-codex", + "input": "Reply with exactly: HELLO_CODEX", +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "Authorization": "Bearer sk-openai-test-key-12345678", +}) +try: + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read()) + print(f"[fake-codex] status={resp.status} id={body.get('id', '?')}") +except Exception as e: + print(f"[fake-codex] Error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-codex] Done.") +""" + + +def test_codex_client_reverse_proxy(): + """Test --tap-client codex in reverse mode using OPENAI_BASE_URL.""" + + async def handler(request): + body = await request.json() + assert request.path == "/messages" + from aiohttp import web + + return web.json_response( + { + "id": "resp_codex_1", + "output": [{"type": "message", "content": [{"type": "output_text", "text": "HELLO_CODEX"}]}], + "usage": {"input_tokens": 11, "output_tokens": 7}, + "model": body.get("model", "gpt-5-codex"), + } + ) + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_codex_") + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_codex_") + fake_codex = Path(fake_bin_dir) / "codex" + fake_codex.write_text(FAKE_CODEX_SCRIPT) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IEXEC) + stop = _start_fake_upstream(19242, handler) + + try: + proc = _run_claude_tap( + Path(__file__).parent, + trace_dir, + fake_bin_dir, + 19242, + tap_client="codex", + ) + + assert proc.returncode == 0, f"codex mode failed: stdout={proc.stdout} stderr={proc.stderr}" + records = read_trace_records(trace_dir) + assert len(records) >= 1 + assert len(records) == 1 + record = records[0] + assert record["request"]["path"] == "/v1/messages" + assert record["upstream_base_url"] == "http://127.0.0.1:19242" + assert record["request"]["body"]["model"] == "gpt-5-codex" + assert "OPENAI_BASE_URL=http://127.0.0.1:" in proc.stdout + finally: + stop() + _cleanup(trace_dir, fake_bin_dir, "codex") + + +FAKE_KIMI_SCRIPT = r"""#!/usr/bin/env python3 +# Fake Kimi CLI that sends one streaming Chat Completions request via KIMI_BASE_URL +import json, os, sys, urllib.request + +base = os.environ.get("KIMI_BASE_URL", "https://api.kimi.com/coding/v1") +url = f"{base}/chat/completions" + +req_body = json.dumps({ + "model": "kimi-k2-turbo-preview", + "messages": [{"role": "user", "content": "Reply with exactly: HELLO_KIMI"}], + "stream": True, +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "Authorization": "Bearer kimi-test-key-12345678", +}) +try: + with urllib.request.urlopen(req) as resp: + chunks = resp.read().decode() + print(f"[fake-kimi] status={resp.status} stream-bytes={len(chunks)}") +except Exception as e: + print(f"[fake-kimi] Error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-kimi] Done.") +""" + + +def test_kimi_client_reverse_proxy(): + """Test --tap-client kimi in reverse mode using KIMI_BASE_URL.""" + + async def handler(request): + body = await request.json() + assert request.path == "/chat/completions" + assert body["model"] == "kimi-k2-turbo-preview" + from aiohttp import web + + resp = web.StreamResponse(status=200, headers={"Content-Type": "text/event-stream"}) + await resp.prepare(request) + chunks = [ + { + "id": "kimi_chat_1", + "model": body["model"], + "choices": [{"delta": {"role": "assistant", "reasoning_content": "Need exact text."}}], + }, + { + "id": "kimi_chat_1", + "model": body["model"], + "choices": [{"delta": {"content": "HELLO_KIMI"}}], + }, + { + "id": "kimi_chat_1", + "model": body["model"], + "choices": [ + { + "delta": {}, + "finish_reason": "stop", + "usage": { + "prompt_tokens": 13, + "completion_tokens": 2, + "total_tokens": 15, + "cached_tokens": 5, + }, + } + ], + }, + ] + for chunk in chunks: + await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + await resp.write(b"data: [DONE]\n\n") + await resp.write_eof() + return resp + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_kimi_") + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_kimi_") + fake_kimi = Path(fake_bin_dir) / "kimi" + fake_kimi.write_text(FAKE_KIMI_SCRIPT) + fake_kimi.chmod(fake_kimi.stat().st_mode | stat.S_IEXEC) + stop = _start_fake_upstream(19244, handler) + + try: + proc = _run_claude_tap( + Path(__file__).parent, + trace_dir, + fake_bin_dir, + 19244, + tap_client="kimi", + ) + + assert proc.returncode == 0, f"kimi mode failed: stdout={proc.stdout} stderr={proc.stderr}" + records = read_trace_records(trace_dir) + assert len(records) >= 1 + assert len(records) == 1 + record = records[0] + assert record["request"]["path"] == "/chat/completions" + assert record["upstream_base_url"] == "http://127.0.0.1:19244" + assert record["request"]["body"]["model"] == "kimi-k2-turbo-preview" + assert record["response"]["body"]["content"][0]["type"] == "thinking" + assert record["response"]["body"]["content"][1]["text"] == "HELLO_KIMI" + assert record["response"]["body"]["usage"]["input_tokens"] == 13 + assert record["response"]["body"]["usage"]["cache_read_input_tokens"] == 5 + assert "KIMI_BASE_URL=http://127.0.0.1:" in proc.stdout + finally: + stop() + _cleanup(trace_dir, fake_bin_dir, "kimi") + + +FAKE_KIMI_MULTITURN_SCRIPT = r"""#!/usr/bin/env python3 +# Fake Kimi CLI that runs one continuous five-turn chat session. +import json, os, sys, urllib.request + +base = os.environ.get("KIMI_BASE_URL", "https://api.kimi.com/coding/v1") +url = f"{base}/chat/completions" +messages = [{"role": "system", "content": "Kimi CLI regression system prompt: keep one continuous session."}] +tools = [ + {"type": "function", "function": {"name": "read_file", "description": "Read a file.", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}}, + {"type": "function", "function": {"name": "search_code", "description": "Search source code.", "parameters": {"type": "object", "properties": {"query": {"type": "string"}}}}}, + {"type": "function", "function": {"name": "list_dir", "description": "List a directory.", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}}, + {"type": "function", "function": {"name": "run_tests", "description": "Run tests.", "parameters": {"type": "object", "properties": {"target": {"type": "string"}}}}}, + {"type": "function", "function": {"name": "inspect_git", "description": "Inspect git state.", "parameters": {"type": "object", "properties": {"scope": {"type": "string"}}}}}, + {"type": "function", "function": {"name": "parse_json", "description": "Parse JSON.", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}}, +] + +def collect_stream(stream_text): + tool_calls = {} + content = [] + for raw_line in stream_text.splitlines(): + line = raw_line.strip() + if not line.startswith("data:"): + continue + payload = line.removeprefix("data:").strip() + if payload == "[DONE]": + continue + event = json.loads(payload) + delta = event.get("choices", [{}])[0].get("delta", {}) + if delta.get("content"): + content.append(delta["content"]) + for call in delta.get("tool_calls", []): + idx = call.get("index", 0) + current = tool_calls.setdefault(idx, {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}) + if call.get("id"): + current["id"] = call["id"] + if call.get("type"): + current["type"] = call["type"] + fn = call.get("function") or {} + if fn.get("name"): + current["function"]["name"] += fn["name"] + if fn.get("arguments"): + current["function"]["arguments"] += fn["arguments"] + return "".join(content), [tool_calls[idx] for idx in sorted(tool_calls)] + +def send_request(turn, phase): + req_body = json.dumps({ + "model": "kimi-k2-turbo-preview", + "messages": messages, + "tools": tools, + "stream": True, + }).encode() + req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "Authorization": "Bearer kimi-test-key-12345678", + }) + try: + with urllib.request.urlopen(req) as resp: + chunks = resp.read().decode() + content, tool_calls = collect_stream(chunks) + print(f"[fake-kimi] turn={turn} phase={phase} status={resp.status} tool_calls={len(tool_calls)}") + return content, tool_calls + except Exception as e: + print(f"[fake-kimi] Turn {turn} {phase} error: {e}", file=sys.stderr) + sys.exit(1) + +for turn in range(1, 6): + messages.append({"role": "user", "content": f"Kimi continuous chat turn {turn}: use at least two tools before answering."}) + _, tool_calls = send_request(turn, "tools") + if len(tool_calls) != 2: + print(f"[fake-kimi] expected 2 tool calls, got {len(tool_calls)}", file=sys.stderr) + sys.exit(1) + messages.append({"role": "assistant", "content": "", "tool_calls": tool_calls}) + for call in tool_calls: + tool_name = call["function"]["name"] + messages.append({ + "role": "tool", + "tool_call_id": call["id"], + "content": f"{tool_name} result for turn {turn}", + }) + final_text, final_tool_calls = send_request(turn, "final") + if final_tool_calls: + print(f"[fake-kimi] expected final response without tool calls, got {len(final_tool_calls)}", file=sys.stderr) + sys.exit(1) + if f"Kimi final answer {turn}" not in final_text: + print(f"[fake-kimi] missing final answer text for turn {turn}: {final_text}", file=sys.stderr) + sys.exit(1) + messages.append({"role": "assistant", "content": final_text}) + +print("[fake-kimi] Multi-turn Done.") +""" + + +def test_kimi_multiturn_tool_calls_reverse_proxy(): + """Verify Kimi reverse mode captures one continuous tool-call chat. + + The fake CLI sends five consecutive user turns in one session. Each turn + first requests exactly two streamed tool calls, then sends the tool results + back and receives a final assistant answer. This yields ten trace nodes and + one accumulated Chat Completions message history. + """ + + tool_pairs = [ + ("read_file", "search_code"), + ("list_dir", "run_tests"), + ("inspect_git", "parse_json"), + ("read_file", "run_tests"), + ("search_code", "inspect_git"), + ] + + async def handler(request): + body = await request.json() + assert request.path == "/chat/completions" + assert body["model"] == "kimi-k2-turbo-preview" + from aiohttp import web + + assert body["messages"][0] == { + "role": "system", + "content": "Kimi CLI regression system prompt: keep one continuous session.", + } + user_messages = [msg for msg in body["messages"] if msg.get("role") == "user"] + turn = len(user_messages) + assert 1 <= turn <= 5 + is_final_request = body["messages"][-1].get("role") == "tool" + tool_results = [msg for msg in body["messages"] if msg.get("role") == "tool"] + if is_final_request: + assert len(tool_results) == turn * 2 + else: + assert body["messages"][-1]["content"].startswith(f"Kimi continuous chat turn {turn}:") + assert len(tool_results) == (turn - 1) * 2 + + names = tool_pairs[turn - 1] + + resp = web.StreamResponse(status=200, headers={"Content-Type": "text/event-stream"}) + await resp.prepare(request) + if is_final_request: + chunk = { + "id": f"kimi_multi_{turn}_final", + "model": body["model"], + "choices": [ + { + "delta": { + "role": "assistant", + "content": f"Kimi final answer {turn}: used {names[0]} and {names[1]}.", + }, + "finish_reason": "stop", + "usage": { + "prompt_tokens": 150 + turn, + "completion_tokens": 30 + turn, + "total_tokens": 180 + (turn * 2), + "cached_tokens": 20 + turn, + }, + } + ], + } + await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + else: + for idx, name in enumerate(names): + arguments = json.dumps({"turn": turn, "tool": name}) + chunk = { + "id": f"kimi_multi_{turn}_tools", + "model": body["model"], + "choices": [ + { + "delta": { + "role": "assistant" if idx == 0 else None, + "tool_calls": [ + { + "index": idx, + "id": f"call_{turn}_{idx + 1}", + "type": "function", + "function": {"name": name, "arguments": arguments}, + } + ], + } + } + ], + } + if idx == 1: + chunk["choices"][0]["finish_reason"] = "tool_calls" + chunk["choices"][0]["usage"] = { + "prompt_tokens": 100 + turn, + "completion_tokens": 20 + turn, + "total_tokens": 120 + (turn * 2), + "cached_tokens": 10 + turn, + } + await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + await resp.write(b"data: [DONE]\n\n") + await resp.write_eof() + return resp + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_kimi_multiturn_") + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_kimi_multiturn_") + fake_kimi = Path(fake_bin_dir) / "kimi" + fake_kimi.write_text(FAKE_KIMI_MULTITURN_SCRIPT) + fake_kimi.chmod(fake_kimi.stat().st_mode | stat.S_IEXEC) + stop = _start_fake_upstream(19245, handler) + + try: + proc = _run_claude_tap( + Path(__file__).parent, + trace_dir, + fake_bin_dir, + 19245, + tap_client="kimi", + ) + + assert proc.returncode == 0, f"kimi multi-turn mode failed: stdout={proc.stdout} stderr={proc.stderr}" + records = read_trace_records(trace_dir) + assert len(records) >= 1 + assert len(records) == 10 + + unique_tool_names = set() + total_tool_calls = 0 + for turn in range(1, 6): + tool_record = records[(turn - 1) * 2] + final_record = records[(turn - 1) * 2 + 1] + for record in (tool_record, final_record): + assert record["request"]["path"] == "/chat/completions" + assert record["upstream_base_url"] == "http://127.0.0.1:19245" + assert record["request"]["body"]["messages"][0]["role"] == "system" + + assert tool_record["request"]["body"]["messages"][-1]["content"].startswith( + f"Kimi continuous chat turn {turn}:" + ) + tool_blocks = [ + block for block in tool_record["response"]["body"]["content"] if block.get("type") == "tool_use" + ] + assert len(tool_blocks) == 2 + total_tool_calls += len(tool_blocks) + unique_tool_names.update(block["name"] for block in tool_blocks) + assert tool_record["response"]["body"]["usage"]["cache_read_input_tokens"] == 10 + turn + + assert final_record["request"]["body"]["messages"][-1]["role"] == "tool" + final_tool_blocks = [ + block for block in final_record["response"]["body"]["content"] if block.get("type") == "tool_use" + ] + assert final_tool_blocks == [] + final_text = " ".join( + block.get("text", "") + for block in final_record["response"]["body"]["content"] + if block.get("type") == "text" + ) + assert f"Kimi final answer {turn}" in final_text + assert final_record["response"]["body"]["usage"]["cache_read_input_tokens"] == 20 + turn + + expected_tool_names = {"read_file", "search_code", "list_dir", "run_tests", "inspect_git", "parse_json"} + assert total_tool_calls == 10 + assert unique_tool_names == expected_tool_names + assert "KIMI_BASE_URL=http://127.0.0.1:" in proc.stdout + finally: + stop() + _cleanup(trace_dir, fake_bin_dir, "kimi_multiturn") + + +FAKE_KIMI_CODE_SCRIPT = r"""#!/usr/bin/env python3 +# Fake Kimi Code CLI: read base_url from KIMI_CODE_HOME/config.toml +import json, os, sys, tomllib, urllib.request +from pathlib import Path + +home = Path(os.environ["KIMI_CODE_HOME"]) +config = tomllib.loads((home / "config.toml").read_text(encoding="utf-8")) +provider = config["providers"]["managed:kimi-code"] +base = provider["base_url"].rstrip("/") +url = f"{base}/chat/completions" + +req_body = json.dumps({ + "model": "kimi-k2-turbo-preview", + "messages": [{"role": "user", "content": "Reply with exactly: HELLO_KIMI_CODE"}], + "stream": True, +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "Authorization": "Bearer kimi-test-key-12345678", +}) +try: + with urllib.request.urlopen(req) as resp: + chunks = resp.read().decode() + print(f"[fake-kimi-code] status={resp.status} stream-bytes={len(chunks)}") +except Exception as e: + print(f"[fake-kimi-code] Error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-kimi-code] Done.") +""" + + +def test_kimi_code_client_reverse_proxy(): + """Test --tap-client kimi-code in reverse mode using KIMI_CODE_HOME sandbox.""" + + async def handler(request): + body = await request.json() + assert request.path == "/chat/completions" + assert body["model"] == "kimi-k2-turbo-preview" + from aiohttp import web + + resp = web.StreamResponse(status=200, headers={"Content-Type": "text/event-stream"}) + await resp.prepare(request) + chunks = [ + { + "id": "kimi_code_chat_1", + "model": body["model"], + "choices": [{"delta": {"role": "assistant", "reasoning_content": "Need exact text."}}], + }, + { + "id": "kimi_code_chat_1", + "model": body["model"], + "choices": [{"delta": {"content": "HELLO_KIMI_CODE"}}], + }, + { + "id": "kimi_code_chat_1", + "model": body["model"], + "choices": [ + { + "delta": {}, + "finish_reason": "stop", + "usage": { + "prompt_tokens": 13, + "completion_tokens": 2, + "total_tokens": 15, + "cached_tokens": 5, + }, + } + ], + }, + ] + for chunk in chunks: + await resp.write(f"data: {json.dumps(chunk)}\n\n".encode()) + await resp.write(b"data: [DONE]\n\n") + await resp.write_eof() + return resp + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_kimi_code_") + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_kimi_code_") + fake_kimi = Path(fake_bin_dir) / "kimi" + fake_kimi.write_text(FAKE_KIMI_CODE_SCRIPT) + fake_kimi.chmod(fake_kimi.stat().st_mode | stat.S_IEXEC) + stop = _start_fake_upstream(19246, handler) + + try: + proc = _run_claude_tap( + Path(__file__).parent, + trace_dir, + fake_bin_dir, + 19246, + tap_client="kimi-code", + ) + + assert proc.returncode == 0, f"kimi-code mode failed: stdout={proc.stdout} stderr={proc.stderr}" + records = read_trace_records(trace_dir) + assert len(records) == 1 + record = records[0] + assert record["request"]["path"] == "/chat/completions" + assert record["upstream_base_url"] == "http://127.0.0.1:19246" + assert record["response"]["body"]["content"][1]["text"] == "HELLO_KIMI_CODE" + assert "KIMI_CODE_HOME=" in proc.stdout + assert "KIMI_CODE_BASE_URL=http://127.0.0.1:" in proc.stdout + finally: + stop() + _cleanup(trace_dir, fake_bin_dir, "kimi_code") + + +## --------------------------------------------------------------------------- +## Test 6b: test_codex_zstd_request_body — proxy decompresses zstd request bodies +## --------------------------------------------------------------------------- + + +def test_codex_zstd_request_body(): + """Verify the proxy decompresses zstd-encoded request bodies from Codex CLI.""" + received_bodies: list[dict] = [] + + async def handler(request): + body = await request.json() + received_bodies.append(body) + # Content-Encoding: zstd should have been stripped by the proxy + assert "zstd" not in request.headers.get("Content-Encoding", "").lower() + from aiohttp import web + + return web.json_response( + { + "id": "resp_zstd_1", + "output": [{"type": "message", "content": [{"type": "output_text", "text": "OK"}]}], + "usage": {"input_tokens": 5, "output_tokens": 2}, + "model": "gpt-5-codex", + } + ) + + # Build a fake codex script that sends a zstd-compressed body + zstd_codex_script = r"""#!/usr/bin/env python3 +import json, os, sys, urllib.request +import backports.zstd + +base = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1") +url = f"{base}/responses" +payload = json.dumps({"model": "gpt-5-codex", "input": "zstd test"}).encode() +compressed = backports.zstd.compress(payload) + +req = urllib.request.Request(url, data=compressed, headers={ + "Content-Type": "application/json", + "Content-Encoding": "zstd", + "Authorization": "Bearer sk-test", +}) +try: + with urllib.request.urlopen(req) as resp: + print(f"[fake-codex] status={resp.status}") +except Exception as e: + print(f"[fake-codex] Error: {e}", file=sys.stderr) + sys.exit(1) +""" + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_zstd_") + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_zstd_") + fake_codex = Path(fake_bin_dir) / "codex" + fake_codex.write_text(zstd_codex_script) + fake_codex.chmod(fake_codex.stat().st_mode | stat.S_IEXEC) + stop = _start_fake_upstream(19243, handler) + + try: + proc = _run_claude_tap( + Path(__file__).parent, + trace_dir, + fake_bin_dir, + 19243, + tap_client="codex", + ) + + assert proc.returncode == 0, f"zstd test failed: stdout={proc.stdout} stderr={proc.stderr}" + assert len(received_bodies) == 1 + assert received_bodies[0]["input"] == "zstd test" + finally: + stop() + _cleanup(trace_dir, fake_bin_dir, "codex") + + +## --------------------------------------------------------------------------- +## Test 6b: test_codex_zstd_request_body — proxy decompresses zstd request bodies +## --------------------------------------------------------------------------- +def test_filter_headers(): + """Test filter_headers strips hop-by-hop headers and optionally redacts secrets.""" + from claude_tap import filter_headers + + headers = { + "Content-Type": "application/json", + "x-api-key": "sk-ant-api03-very-long-secret-key-12345678", + "Authorization": "Bearer sk-ant-secret-token-abcdef", + "Cookie": "session=qoder-cookie-secret", + "Set-Cookie": "acw_tc=qoder-response-cookie-secret; Path=/", + "Cosy-Key": "cosy-signature-secret-value", + "Cosy-MachineToken": "qoder-machine-token-secret-value", + "Cosy-MachineId": "qoder-machine-id-secret-value", + "Cosy-MachineType": "qoder-machine-type-secret-value", + "Cosy-User": "qoder-user-id-secret-value", + "X-Amz-Security-Token": "aws-session-token-secret-value", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + "X-Custom": "custom-value", + } + + # Without redaction + out = filter_headers(headers, redact_keys=False) + assert "Transfer-Encoding" not in out, "hop-by-hop not filtered" + assert "Connection" not in out, "hop-by-hop not filtered" + assert out["x-api-key"] == headers["x-api-key"], "should not redact without flag" + assert out["Cosy-Key"] == headers["Cosy-Key"], "should not redact without flag" + assert out["X-Custom"] == "custom-value" + print(" OK: hop-by-hop filtered, no redaction") + + # With redaction + out = filter_headers(headers, redact_keys=True) + assert out["x-api-key"].endswith("...") + assert "very-long-secret" not in out["x-api-key"] + assert out["Authorization"].endswith("...") + assert "secret-token" not in out["Authorization"] + assert out["Cookie"] == "***" + assert "cookie-secret" not in out["Cookie"] + assert out["Set-Cookie"] == "***" + assert "response-cookie-secret" not in out["Set-Cookie"] + assert out["Cosy-Key"] == "***" + assert "signature-secret" not in out["Cosy-Key"] + assert out["Cosy-MachineToken"] == "***" + assert "machine-token-secret" not in out["Cosy-MachineToken"] + assert out["Cosy-MachineId"] == "***" + assert "machine-id-secret" not in out["Cosy-MachineId"] + assert out["Cosy-MachineType"] == "***" + assert "machine-type-secret" not in out["Cosy-MachineType"] + assert out["Cosy-User"] == "***" + assert "user-id-secret" not in out["Cosy-User"] + assert out["X-Amz-Security-Token"] == "***" + assert "session-token-secret" not in out["X-Amz-Security-Token"] + assert out["Content-Type"] == "application/json" + assert out["X-Custom"] == "custom-value" + print(" OK: secrets redacted") + + # Short key gets fully masked + short_headers = {"x-api-key": "short"} + out = filter_headers(short_headers, redact_keys=True) + assert out["x-api-key"] == "***" + print(" OK: short key masked") + + print("\n test_filter_headers PASSED") + + +## --------------------------------------------------------------------------- +## Test: double-serialized request body decoding +## --------------------------------------------------------------------------- + + +def test_double_serialized_request_body(): + """Verify proxy decodes double-serialized JSON request bodies into dicts.""" + import socket + + received_bodies: list[object] = [] + + async def handler(request): + body = await request.json() + received_bodies.append(body) + from aiohttp import web + + return web.json_response( + { + "id": "msg_test", + "type": "message", + "content": [{"type": "text", "text": "OK"}], + "usage": {"input_tokens": 5, "output_tokens": 2}, + "model": "claude-test", + } + ) + + # Build a fake claude script that sends a double-serialized body + double_serial_script = r'''#!/usr/bin/env python3 +"""Fake claude CLI — sends a double-serialized JSON body.""" +import json, os, sys, urllib.request + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +url = f"{base}/v1/messages" +inner = {"model": "claude-test", "messages": [{"role": "user", "content": "hi"}]} +# Double-serialize: the outer JSON is a string wrapping the real object +payload = json.dumps(json.dumps(inner)).encode() + +req = urllib.request.Request(url, data=payload, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-test", +}) +try: + resp = urllib.request.urlopen(req) + print(resp.read().decode()) +except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) +''' + + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_double_serial_") + fake_bin_dir = tempfile.mkdtemp(prefix="fake_bin_double_serial_") + fake_claude = Path(fake_bin_dir) / "claude" + fake_claude.write_text(double_serial_script) + fake_claude.chmod(fake_claude.stat().st_mode | stat.S_IEXEC) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + upstream_port = sock.getsockname()[1] + stop = _start_fake_upstream(upstream_port, handler) + + try: + proc = _run_claude_tap( + Path(__file__).parent, + trace_dir, + fake_bin_dir, + upstream_port, + ) + + assert proc.returncode == 0, f"double-serial test failed: stdout={proc.stdout} stderr={proc.stderr}" + assert len(received_bodies) == 1 + assert isinstance(received_bodies[0], str) + upstream_body = json.loads(received_bodies[0]) + assert upstream_body["model"] == "claude-test" + + # Verify the trace record stored the body as a dict, not a string + records = read_trace_records(trace_dir) + assert len(records) >= 1 + req_body = records[0]["request"]["body"] + assert isinstance(req_body, dict), f"Expected dict body, got {type(req_body)}: {req_body!r}" + assert req_body["model"] == "claude-test" + finally: + stop() + _cleanup(trace_dir, fake_bin_dir, "claude") + + print("\n test_double_serialized_request_body PASSED") + + +## --------------------------------------------------------------------------- +## Test 8: test_sse_reassembler — unit test SSE parsing edge cases +## --------------------------------------------------------------------------- + + +def test_sse_reassembler(): + """Test SSEReassembler handles various edge cases correctly.""" + from claude_tap import SSEReassembler + + # Basic: valid events + r = SSEReassembler() + r.feed_bytes( + b'event: message_start\ndata: {"type":"message_start","message":{"id":"m1","type":"message","role":"assistant","content":[],"model":"test","usage":{"input_tokens":10,"output_tokens":0}}}\n\n' + ) + r.feed_bytes( + b'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n' + ) + r.feed_bytes( + b'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\n\n' + ) + r.feed_bytes(b'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n') + r.feed_bytes( + b'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}\n\n' + ) + r.feed_bytes(b'event: message_stop\ndata: {"type":"message_stop"}\n\n') + body = r.reconstruct() + assert body is not None + assert body["content"][0]["text"] == "hello" + assert len(r.events) == 6 + print(" OK: basic SSE parsing") + + # Bare data line (no event: prefix) — must be emitted as a default-type + # event so OpenAI Chat Completions streams (which never send event: headers) + # don't get silently dropped. + r2 = SSEReassembler() + r2.feed_bytes(b'data: {"orphan": true}\n\n') + # Bare data lines (no event: prefix) are emitted as default-type events. + # OpenAI Chat Completions streams use exactly this shape — the previous + # "ignored" behavior silently dropped every such response body. + assert len(r2.events) == 1 + assert r2.events[0]["event"] == "message" + assert r2.events[0]["data"] == {"orphan": True} + # Snapshot reconstruction stays a no-op for non-Anthropic/Responses schemas. + assert r2.reconstruct() is None + print(" OK: bare data emitted as default-type event") + + # Partial chunks (data split across feed_bytes calls) + r3 = SSEReassembler() + r3.feed_bytes(b"event: message_st") + r3.feed_bytes(b'art\ndata: {"type":"mess') + r3.feed_bytes( + b'age_start","message":{"id":"m2","type":"message","role":"assistant","content":[],"model":"t","usage":{"input_tokens":1,"output_tokens":0}}}\n\n' + ) + assert len(r3.events) == 1 + assert r3.events[0]["event"] == "message_start" + print(" OK: chunked data reassembly") + + # Invalid JSON in data — stored as string + r4 = SSEReassembler() + r4.feed_bytes(b"event: bad_event\ndata: {broken json\n\n") + assert len(r4.events) == 1 + assert r4.events[0]["data"] == "{broken json" + print(" OK: invalid JSON stored as string") + + # Empty stream + r5 = SSEReassembler() + r5.feed_bytes(b"") + assert len(r5.events) == 0 + assert r5.reconstruct() is None + print(" OK: empty stream") + + print("\n test_sse_reassembler PASSED") + + +## --------------------------------------------------------------------------- +## Test 9: test_upstream_unreachable — proxy returns 502 +## --------------------------------------------------------------------------- + +FAKE_UPSTREAM_UNREACHABLE_PORT = 19204 + +FAKE_CLAUDE_UNREACHABLE_SCRIPT = r'''#!/usr/bin/env python3 +"""Fake claude CLI — sends a request to a dead upstream.""" +import json, os, sys, urllib.request, urllib.error + +base = os.environ.get("ANTHROPIC_BASE_URL", "https://api.anthropic.com") +url = f"{base}/v1/messages" + +req_body = json.dumps({ + "model": "claude-test-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], +}).encode() +req = urllib.request.Request(url, data=req_body, headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", +}) +try: + with urllib.request.urlopen(req) as resp: + print(f"[fake-claude] Got response: {resp.status}") +except urllib.error.HTTPError as e: + print(f"[fake-claude] HTTP {e.code}: {e.read().decode()}") +except Exception as e: + print(f"[fake-claude] Error: {e}", file=sys.stderr) + sys.exit(1) + +print("[fake-claude] Done.") +''' + + +def test_upstream_unreachable(): + """Test that when upstream is unreachable (connection refused), the proxy + returns 502 and the trace contains no records (since we can't reach upstream).""" + + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_unreachable_") + fake_bin_dir = _create_fake_claude(FAKE_CLAUDE_UNREACHABLE_SCRIPT) + + # Point --tap-target at a port that nothing is listening on + env = os.environ.copy() + env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "") + + env = e2e_env(env, trace_dir) + try: + proc = subprocess.run( + [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-no-open", + "--tap-target", + f"http://127.0.0.1:{FAKE_UPSTREAM_UNREACHABLE_PORT}", + ], + cwd=str(project_dir), + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + print(f"[test_upstream_unreachable] Exit code: {proc.returncode}") + if proc.stdout.strip(): + print(f"[test_upstream_unreachable] stdout:\n{proc.stdout.rstrip()}") + if proc.stderr.strip(): + print(f"[test_upstream_unreachable] stderr:\n{proc.stderr.rstrip()}") + + # The proxy should still produce summary output + assert "Trace summary" in proc.stdout + print(" OK: proxy did not crash") + + # No trace records (502 returned in-process, not from upstream) + records = read_trace_records(trace_dir) + assert len(records) == 0, f"Expected 0 records, got {len(records)}" + print(" OK: no trace records (upstream unreachable, 502 returned)") + + log_content = read_proxy_log(trace_dir) + assert log_content.strip() + assert "upstream error" in log_content.lower() or "connect" in log_content.lower(), ( + f"Expected upstream error in log, got: {log_content[:200]}" + ) + print(" OK: upstream error logged") + + print("\n test_upstream_unreachable PASSED") + + except subprocess.TimeoutExpired: + print("[test_upstream_unreachable] TIMEOUT") + sys.exit(1) + finally: + _cleanup(trace_dir, fake_bin_dir, "unreachable") + + +@pytest.mark.asyncio +async def test_reverse_proxy_ssl_error_returns_ca_diagnostics(): + """Real localhost reverse proxy test for upstream TLS verification failures.""" + import aiohttp + from aiohttp import web + + from claude_tap.proxy import proxy_handler + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + upstream_port = await _start_fake_https_upstream(tmpdir_path) + store, session_id, writer = _writer_for_dir(tmpdir_path) + session = aiohttp.ClientSession(auto_decompress=False, trust_env=False) + + app = web.Application(client_max_size=0) + app["trace_ctx"] = { + "target_url": f"https://127.0.0.1:{upstream_port}", + "writer": writer, + "session": session, + "turn_counter": 0, + "store_stream_events": False, + } + app.router.add_route("*", "/{path_info:.*}", proxy_handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + proxy_port = site._server.sockets[0].getsockname()[1] + + try: + async with aiohttp.ClientSession(auto_decompress=False) as client: + async with client.post( + f"http://127.0.0.1:{proxy_port}/v1/messages", + json={ + "model": "claude-test-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], + }, + ) as resp: + assert resp.status == 502 + text = await resp.text() + + assert "SSL_CERT_FILE" in text + assert "provider base URL" in text + assert "Configured target: https://127.0.0.1:" in text + assert "Upstream URL: https://127.0.0.1:" in text + writer.close() + assert store.export_jsonl(session_id) == "" + finally: + await runner.cleanup() + await session.close() + writer.close() + + +def test_detect_installer(): + """Test _detect_installer returns 'uv' or 'pip'.""" + from claude_tap import _detect_installer + + result = _detect_installer() + assert result in ("uv", "pip"), f"Unexpected installer: {result}" + print(f" test_detect_installer: detected '{result}' — PASSED") + + +## --------------------------------------------------------------------------- +## Test: startup does not check for updates +## --------------------------------------------------------------------------- + +FAKE_CLAUDE_NOOP_SCRIPT = r'''#!/usr/bin/env python3 +"""Fake claude CLI -- exits immediately (no network calls).""" +print("[fake-claude] noop exit") +''' + + +@pytest.mark.slow +def test_startup_does_not_contact_pypi(): + """Starting a traced client must not perform update-related network I/O.""" + from http.server import BaseHTTPRequestHandler, HTTPServer + + requests: list[str] = [] + + class FakePyPI(BaseHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"info": {"version": "99.0.0"}}).encode()) + + def log_message(self, format, *args): + pass # silence logs + + server = HTTPServer(("127.0.0.1", 0), FakePyPI) + pypi_port = server.server_port + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_update_") + fake_bin_dir = _create_fake_claude(FAKE_CLAUDE_NOOP_SCRIPT) + + try: + env = os.environ.copy() + env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "") + env = e2e_env(env, trace_dir) + env["CLAUDE_TAP_PYPI_URL"] = f"http://127.0.0.1:{pypi_port}/pypi/claude-tap/json" + + proc = subprocess.run( + [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-no-open", + "--tap-no-live", + "--tap-target", + "http://127.0.0.1:1", # dummy target; noop client never connects + ], + cwd=str(project_dir), + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + assert proc.returncode == 0, proc.stderr + assert "[fake-claude] noop exit" in proc.stdout + assert "Update available" not in proc.stdout + assert requests == [] + print(" test_startup_does_not_contact_pypi PASSED") + except subprocess.TimeoutExpired as exc: + raise AssertionError("claude_tap subprocess timed out (30s) — possible port conflict or hang") from exc + finally: + server.shutdown() + server.server_close() + _cleanup(trace_dir, fake_bin_dir, "no_update_check") + + +## --------------------------------------------------------------------------- +## Test: trace cleanup +## --------------------------------------------------------------------------- + + +def test_trace_cleanup(): + """Test cleanup_trace_sessions removes oldest sessions while keeping newest.""" + from claude_tap import cleanup_trace_sessions, get_trace_store, reset_trace_store + + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "cleanup.sqlite3" + os.environ["CLOUDTAP_DB"] = str(db_path) + reset_trace_store() + store = get_trace_store() + session_ids = [store.create_session(client="claude", proxy_mode="reverse") for _ in range(5)] + for session_id in session_ids: + store.finalize_session(session_id, {"api_calls": 1}) + + removed = cleanup_trace_sessions(3) + assert removed == 2, f"Expected 2 removed, got {removed}" + assert len(store.list_session_rows()) == 3 + remaining = {row["id"] for row in store.list_session_rows()} + assert session_ids[0] not in remaining + assert session_ids[1] not in remaining + assert session_ids[-1] in remaining + + print(" test_trace_cleanup PASSED") + + +def test_trace_tagging_safety(): + """Test that cleanup only removes stored sessions.""" + from claude_tap import cleanup_trace_sessions, get_trace_store, reset_trace_store + + with tempfile.TemporaryDirectory() as tmpdir: + db_path = Path(tmpdir) / "cleanup.sqlite3" + os.environ["CLOUDTAP_DB"] = str(db_path) + reset_trace_store() + store = get_trace_store() + for _ in range(5): + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.finalize_session(session_id, {"api_calls": 1}) + + removed = cleanup_trace_sessions(2) + assert removed == 3 + assert len(store.list_session_rows()) == 2 + + print(" test_trace_tagging_safety PASSED") + + +def test_manifest_migration(): + """Test that existing trace files without SQLite rows are migrated.""" + from claude_tap import get_trace_store, migrate_legacy_traces, reset_trace_store + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) + db_path = output_dir / "migrate.sqlite3" + os.environ["CLOUDTAP_DB"] = str(db_path) + reset_trace_store() + + for i in range(4): + ts = f"20260218_02000{i}" + date_dir = output_dir / "2026-02-18" + date_dir.mkdir(parents=True, exist_ok=True) + (date_dir / f"trace_{ts.split('_')[1]}.jsonl").write_text( + json.dumps({"request_id": ts, "request": {}, "response": {}}) + "\n" + ) + (date_dir / f"trace_{ts.split('_')[1]}.log").write_text("log") + + imported = migrate_legacy_traces(output_dir) + assert imported == 4 + assert len(get_trace_store().list_session_rows()) == 4 + + print(" test_manifest_migration PASSED") + + +def test_e2e_with_cleanup(): + """E2E test: pre-fill sessions, run claude-tap with --tap-max-traces, verify cleanup.""" + from claude_tap import get_trace_store, reset_trace_store + + stop_upstream, upstream_port = run_fake_upstream_in_thread() + + project_dir = PROJECT_ROOT + trace_dir = tempfile.mkdtemp(prefix="claude_tap_test_cleanup_") + output_dir = Path(trace_dir) + fake_bin_dir = _create_fake_claude(FAKE_CLAUDE_SCRIPT) + + try: + db_path = output_dir / "claude-tap-test.sqlite3" + os.environ["CLOUDTAP_DB"] = str(db_path) + reset_trace_store() + store = get_trace_store() + for _ in range(4): + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.finalize_session(session_id, {"api_calls": 1}) + + env = os.environ.copy() + env["PATH"] = fake_bin_dir + ":" + env.get("PATH", "") + env = e2e_env(env, trace_dir) + + proc = subprocess.run( + [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + trace_dir, + "--tap-no-open", + "--tap-target", + f"http://127.0.0.1:{upstream_port}", + "--tap-max-traces", + "3", + ], + cwd=str(project_dir), + env=env, + capture_output=True, + text=True, + timeout=30, + ) + + print(f"[test_e2e_with_cleanup] Exit code: {proc.returncode}") + if proc.stdout.strip(): + print(f"[test_e2e_with_cleanup] stdout:\n{proc.stdout.rstrip()}") + + assert proc.returncode == 0 + assert "Cleaned up" in proc.stdout, f"Expected cleanup message in stdout:\n{proc.stdout}" + reset_trace_store() + os.environ["CLOUDTAP_DB"] = str(db_path) + assert len(get_trace_store().list_session_rows()) == 3 + + print(" test_e2e_with_cleanup PASSED") + + except subprocess.TimeoutExpired: + print(" TIMEOUT") + finally: + stop_upstream() + _cleanup(trace_dir, fake_bin_dir, "e2e_cleanup") + + +## --------------------------------------------------------------------------- +## Test: viewer bug fixes (HTML content verification) +## --------------------------------------------------------------------------- + + +def test_live_viewer_scroll_preservation(): + """Verify viewer.html contains preserveDetail parameter chain for scroll fix.""" + from claude_tap.viewer import _read_viewer_template + + html = _read_viewer_template() + + # selectEntry should accept opts parameter + assert "function selectEntry(idx, opts)" in html, "selectEntry should accept opts parameter" + # renderApp should accept preserveDetail + assert "function renderApp(preserveDetail)" in html, "renderApp should accept preserveDetail" + # applyFilter should accept preserveDetail + assert "function applyFilter(preserveDetail)" in html, "applyFilter should accept preserveDetail" + # renderSidebar should accept preserveDetail + assert "function renderSidebar(preserveDetail)" in html, "renderSidebar should accept preserveDetail" + # currentDetailRequestId tracking + assert "currentDetailRequestId" in html, "Should track currentDetailRequestId" + # SSE handler should pass true to renderApp + assert "renderApp(true)" in html, "SSE handler should call renderApp(true)" + + print(" test_live_viewer_scroll_preservation PASSED") + + +def test_live_viewer_diff_nav_update(): + """Verify viewer.html contains dynamic diff nav button update logic.""" + from claude_tap.viewer import _read_viewer_template + + html = _read_viewer_template() + + # updateNavButtons function should exist + assert "function updateNavButtons()" in html, "Should have updateNavButtons function" + # setInterval for live mode + assert "setInterval(updateNavButtons" in html, "Should have setInterval for updateNavButtons in live mode" + # clearInterval on close + assert "clearInterval(navInterval)" in html, "Should clear interval on close" + + print(" test_live_viewer_diff_nav_update PASSED") + + +@pytest.mark.asyncio +async def test_live_viewer_sse_incremental(): + """Test that LiveViewerServer correctly handles incremental SSE broadcasts.""" + import aiohttp + + from claude_tap import LiveViewerServer + + with tempfile.TemporaryDirectory() as tmpdir: + os.environ["CLOUDTAP_DB"] = str(Path(tmpdir) / "live.sqlite3") + from claude_tap.trace_store import get_trace_store, reset_trace_store + + reset_trace_store() + session_id = get_trace_store().create_session() + server = LiveViewerServer(session_id=session_id, port=0) + port = await server.start() + + try: + # Broadcast multiple records + for i in range(5): + await server.broadcast({"turn": i + 1, "request_id": f"req_{i}", "request": {"method": "POST"}}) + + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/records") as resp: + records = await resp.json() + assert len(records) == 5, f"Expected 5 records, got {len(records)}" + assert records[0]["turn"] == 1 + assert records[4]["turn"] == 5 + print(" OK: 5 incremental records via /records") + + finally: + await server.stop() + + print(" test_live_viewer_sse_incremental PASSED") + + +## --------------------------------------------------------------------------- +## Test: parse_args with new flags +## --------------------------------------------------------------------------- + + +def test_parse_args_new_flags(): + """Test storage flags and legacy update flag compatibility.""" + from claude_tap import parse_args + + # Defaults + a = parse_args([]) + assert a.max_traces == 50 + print(" OK: new flag defaults") + + # Set max traces + a = parse_args(["--tap-max-traces", "100"]) + assert a.max_traces == 100 + print(" OK: --tap-max-traces 100") + + # Unlimited traces + a = parse_args(["--tap-max-traces", "0"]) + assert a.max_traces == 0 + print(" OK: --tap-max-traces 0") + + # Removed update flags remain accepted as hidden no-ops so existing scripts + # do not accidentally forward them to the wrapped client. + a = parse_args(["--tap-max-traces", "20", "--tap-no-update-check", "--tap-no-auto-update", "-c", "--model", "opus"]) + assert a.max_traces == 20 + assert a.claude_args == ["-c", "--model", "opus"] + print(" OK: legacy update flags are hidden no-ops") + + print(" test_parse_args_new_flags PASSED") + + +def test_parse_dashboard_args(): + """Test standalone dashboard argument parsing.""" + from claude_tap import parse_dashboard_args + + a = parse_dashboard_args([]) + assert a.command is None + assert a.output_dir == "./.traces" + assert a.live_port == 0 + assert a.host == "127.0.0.1" + assert a.open_viewer is True + + a = parse_dashboard_args( + ["--tap-output-dir", "/tmp/t", "--tap-live-port", "3000", "--tap-host", "0.0.0.0", "--tap-no-open"] + ) + assert a.output_dir == "/tmp/t" + assert a.live_port == 3000 + assert a.host == "0.0.0.0" + assert a.open_viewer is False + + a = parse_dashboard_args(["stop", "--tap-live-port", "3000"]) + assert a.command == "stop" + assert a.live_port == 3000 + + a = parse_dashboard_args(["quit", "--tap-live-port", "3000"]) + assert a.command == "quit" + assert a.live_port == 3000 + + print(" test_parse_dashboard_args PASSED") + + +## --------------------------------------------------------------------------- +## Test: CA certificate generation +## --------------------------------------------------------------------------- + + +def test_cert_generation(): + """Test CA and per-host certificate generation.""" + from claude_tap.certs import CertificateAuthority, ensure_ca + + with tempfile.TemporaryDirectory() as tmpdir: + ca_dir = Path(tmpdir) + ca_cert_path, ca_key_path = ensure_ca(ca_dir) + + # CA files exist + assert ca_cert_path.exists(), "CA cert not created" + assert ca_key_path.exists(), "CA key not created" + assert ca_cert_path.name == "ca.pem" + assert ca_key_path.name == "ca-key.pem" + print(" OK: CA files created") + + # Key has restricted permissions (owner-only) + key_mode = ca_key_path.stat().st_mode & 0o777 + assert key_mode == 0o600, f"CA key permissions too open: {oct(key_mode)}" + print(" OK: CA key permissions restricted") + + # Calling ensure_ca again reuses existing files + ca_cert_path2, ca_key_path2 = ensure_ca(ca_dir) + assert ca_cert_path2 == ca_cert_path + assert ca_cert_path2.read_bytes() == ca_cert_path.read_bytes() + print(" OK: ensure_ca reuses existing CA") + + # Generate host cert + ca = CertificateAuthority(ca_cert_path, ca_key_path) + cert_pem, key_pem = ca.get_host_cert_pem("api.anthropic.com") + assert b"BEGIN CERTIFICATE" in cert_pem + assert b"BEGIN RSA PRIVATE KEY" in key_pem + print(" OK: host cert generated for api.anthropic.com") + + # Cache hit + cert_pem2, key_pem2 = ca.get_host_cert_pem("api.anthropic.com") + assert cert_pem2 is cert_pem # Same object (cached) + print(" OK: host cert cached") + + # Different host gets different cert + cert_pem3, _ = ca.get_host_cert_pem("example.com") + assert cert_pem3 != cert_pem + print(" OK: different host gets different cert") + + # SSL context creation + ssl_ctx = ca.make_ssl_context("api.anthropic.com") + import ssl + + assert isinstance(ssl_ctx, ssl.SSLContext) + print(" OK: SSL context created") + + print(" test_cert_generation PASSED") + + +def test_parse_args_proxy_mode(): + """Test --tap-proxy-mode flag parsing.""" + from claude_tap import parse_args + + # Default is reverse + a = parse_args([]) + assert a.proxy_mode == "reverse" + print(" OK: default proxy_mode is 'reverse'") + + # Explicit reverse + a = parse_args(["--tap-proxy-mode", "reverse"]) + assert a.proxy_mode == "reverse" + print(" OK: --tap-proxy-mode reverse") + + # Forward mode + a = parse_args(["--tap-proxy-mode", "forward"]) + assert a.proxy_mode == "forward" + print(" OK: --tap-proxy-mode forward") + + # Mix with other flags + a = parse_args(["--tap-proxy-mode", "forward", "--tap-port", "8080", "-p", "hello"]) + assert a.proxy_mode == "forward" + assert a.port == 8080 + assert a.claude_args == ["-p", "hello"] + print(" OK: forward mode with other flags") + + print(" test_parse_args_proxy_mode PASSED") + + +## --------------------------------------------------------------------------- +## Test: Codex upstream URL construction for all (target × path) combos +## --------------------------------------------------------------------------- + + +def test_codex_upstream_url_construction(monkeypatch, tmp_path): + """Verify that strip_path_prefix produces correct upstream URLs for all Codex backends. + + This is a regression guard for the bug where strip_path_prefix="/v1" combined + with target="https://api.openai.com" produced wrong URLs like + https://api.openai.com/responses instead of https://api.openai.com/v1/responses. + + See: .agents/docs/error-experience/entries/2026-03-10-codex-strip-prefix-url-mismatch.md + """ + from claude_tap import parse_args + + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home")) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + + def _build_upstream(target: str, strip_prefix: str, request_path: str) -> str: + fwd_path = request_path + if strip_prefix and fwd_path.startswith(strip_prefix): + fwd_path = fwd_path[len(strip_prefix) :] or "/" + return target.rstrip("/") + "/" + fwd_path.lstrip("/") + + # Case 1: API Key mode — target=api.openai.com, should NOT strip /v1 + args = parse_args(["--tap-client", "codex"]) + strip = "/v1" if args.client == "codex" and "api.openai.com" not in args.target else "" + url = _build_upstream(args.target, strip, "/v1/responses") + assert url == "https://api.openai.com/v1/responses", f"API Key mode URL wrong: {url}" + print(" OK: api.openai.com + /v1/responses → correct") + + # Case 2: OAuth mode — target=chatgpt.com, should strip /v1 + args = parse_args(["--tap-client", "codex", "--tap-target", "https://chatgpt.com/backend-api/codex"]) + strip = "/v1" if args.client == "codex" and "api.openai.com" not in args.target else "" + url = _build_upstream(args.target, strip, "/v1/responses") + assert url == "https://chatgpt.com/backend-api/codex/responses", f"OAuth mode URL wrong: {url}" + print(" OK: chatgpt.com + /v1/responses → correct") + + # Case 3: API Key mode with /v1/models path + args = parse_args(["--tap-client", "codex"]) + strip = "/v1" if args.client == "codex" and "api.openai.com" not in args.target else "" + url = _build_upstream(args.target, strip, "/v1/models") + assert url == "https://api.openai.com/v1/models", f"API Key models URL wrong: {url}" + print(" OK: api.openai.com + /v1/models → correct") + + # Case 4: OAuth mode with /v1/models path + args = parse_args(["--tap-client", "codex", "--tap-target", "https://chatgpt.com/backend-api/codex"]) + strip = "/v1" if args.client == "codex" and "api.openai.com" not in args.target else "" + url = _build_upstream(args.target, strip, "/v1/models") + assert url == "https://chatgpt.com/backend-api/codex/models", f"OAuth models URL wrong: {url}" + print(" OK: chatgpt.com + /v1/models → correct") + + # Case 5: Claude client should never strip + args = parse_args(["--tap-client", "claude"]) + strip = "/v1" if args.client == "codex" and "api.openai.com" not in args.target else "" + assert strip == "", "Claude client should never strip path prefix" + print(" OK: claude client has no strip prefix") + + print(" test_codex_upstream_url_construction PASSED") + + +## --------------------------------------------------------------------------- +## Test: Forward proxy CONNECT handler +## --------------------------------------------------------------------------- + + +def test_forward_proxy_trace_skip_rules_are_narrow(): + from claude_tap.forward_proxy import _should_skip_trace_record + + json_headers = {"Content-Type": "application/json"} + binary_headers = {"Content-Type": "application/octet-stream"} + npm_headers = {"User-Agent": "npm/10.8.2 node/v22.0.0 linux x64 workspaces/false"} + + assert _should_skip_trace_record("https://registry.npmjs.org:443/effect", "/effect", json_headers) + assert _should_skip_trace_record( + "https://registry.npmjs.org:443/@opencode-ai%2fsdk", + "/@opencode-ai%2fsdk", + json_headers, + ) + assert _should_skip_trace_record( + "https://cdn.example.test:443/effect/-/effect-4.0.0-beta.59.tgz", + "/effect/-/effect-4.0.0-beta.59.tgz", + binary_headers, + ) + assert _should_skip_trace_record( + "https://npm.mycorp.internal:443/private-pkg", + "/private-pkg", + json_headers, + npm_headers, + "GET", + ) + + assert not _should_skip_trace_record("https://api.anthropic.com:443/v1/messages", "/v1/messages", json_headers) + assert not _should_skip_trace_record( + "https://chatgpt.com:443/backend-api/codex/responses", + "/backend-api/codex/responses", + json_headers, + ) + assert not _should_skip_trace_record( + "https://npm.mycorp.internal:443/private-pkg", + "/private-pkg", + json_headers, + ) + assert not _should_skip_trace_record( + "https://api.anthropic.com:443/v1/messages", + "/v1/messages", + json_headers, + npm_headers, + "POST", + ) + + +@pytest.mark.asyncio +async def test_forward_proxy_unrecorded_response_closes_upstream_on_client_disconnect(): + from claude_tap.forward_proxy import ForwardProxyServer + + class FakeContent: + async def iter_chunked(self, size): + assert size == 65536 + yield b"package-bytes" + + class FakeResponse: + status = 200 + reason = "OK" + headers = {"Content-Type": "application/json"} + content = FakeContent() + + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + class DisconnectingWriter: + def __init__(self) -> None: + self.drain_calls = 0 + self.writes: list[bytes] = [] + + def write(self, data: bytes) -> None: + self.writes.append(data) + + async def drain(self) -> None: + self.drain_calls += 1 + if self.drain_calls > 1: + raise ConnectionError("client disconnected") + + upstream_resp = FakeResponse() + writer = DisconnectingWriter() + + with pytest.raises(ConnectionError, match="client disconnected"): + await ForwardProxyServer._relay_unrecorded_response(object(), upstream_resp, writer) + + assert upstream_resp.closed is True + + +@pytest.mark.asyncio +async def test_forward_proxy_connect(): + """Test the forward proxy CONNECT/TLS flow with a fake HTTPS upstream.""" + import ssl + + import aiohttp + + from claude_tap.certs import CertificateAuthority, ensure_ca + from claude_tap.forward_proxy import ForwardProxyServer + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + ca_dir = tmpdir / "ca" + + # Generate CA + ca_cert_path, ca_key_path = ensure_ca(ca_dir) + ca = CertificateAuthority(ca_cert_path, ca_key_path) + + # Start a fake HTTPS upstream server + upstream_port = await _start_fake_https_upstream(tmpdir) + print(f" Fake HTTPS upstream on port {upstream_port}") + + # Start forward proxy (disable SSL verify for the upstream session + # since our fake upstream uses a self-signed cert) + store, session_id, writer = _writer_for_dir(tmpdir) + upstream_ssl_ctx = ssl.create_default_context() + upstream_ssl_ctx.check_hostname = False + upstream_ssl_ctx.verify_mode = ssl.CERT_NONE + upstream_conn = aiohttp.TCPConnector(ssl=upstream_ssl_ctx) + session = aiohttp.ClientSession(connector=upstream_conn, auto_decompress=False) + + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=ca, + writer=writer, + session=session, + ) + proxy_port = await server.start() + print(f" Forward proxy on port {proxy_port}") + + try: + # Create an SSL context that trusts our CA + ssl_ctx = ssl.create_default_context() + ssl_ctx.load_verify_locations(str(ca_cert_path)) + + # Use aiohttp with our proxy to make an HTTPS request + # We connect to 127.0.0.1: through the proxy + conn = aiohttp.TCPConnector(ssl=ssl_ctx) + proxy_url = f"http://127.0.0.1:{proxy_port}" + + async with aiohttp.ClientSession(connector=conn, auto_decompress=False) as client: + # Make request through the proxy to our fake upstream + async with client.post( + f"https://127.0.0.1:{upstream_port}/v1/messages", + proxy=proxy_url, + json={ + "model": "claude-test-model", + "max_tokens": 100, + "messages": [{"role": "user", "content": "hello"}], + }, + headers={ + "x-api-key": "sk-ant-test-key-12345678", + "anthropic-version": "2023-06-01", + }, + ) as resp: + assert resp.status == 200, f"Expected 200, got {resp.status}" + body = await resp.json() + assert body["content"][0]["text"] == "Hello from HTTPS!" + print(" OK: CONNECT + TLS termination works") + + # Allow trace to be written + await asyncio.sleep(0.1) + + # Check trace was recorded + writer.close() + trace_text = store.export_jsonl(session_id).strip() + assert trace_text, "No trace recorded" + records = [json.loads(line) for line in trace_text.splitlines()] + assert len(records) == 1 + assert records[0]["request"]["method"] == "POST" + assert "/v1/messages" in records[0]["request"]["path"] + assert records[0]["response"]["status"] == 200 + print(" OK: trace recorded correctly") + + # Check header redaction + hdrs = {k.lower(): v for k, v in records[0]["request"]["headers"].items()} + api_key = hdrs.get("x-api-key", "") + assert api_key.endswith("..."), f"API key not redacted: {api_key}" + print(" OK: API key redacted in trace") + + finally: + await server.stop() + await session.close() + + print(" test_forward_proxy_connect PASSED") + + +@pytest.mark.asyncio +async def test_forward_proxy_skips_package_noise_but_keeps_long_model_payloads(monkeypatch): + """Real localhost proxy test for long npm noise and long model responses.""" + import ssl + + import aiohttp + + from claude_tap.certs import CertificateAuthority, ensure_ca + from claude_tap.forward_proxy import ForwardProxyServer + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + ca_cert_path, ca_key_path = ensure_ca(tmpdir_path / "ca") + ca = CertificateAuthority(ca_cert_path, ca_key_path) + + upstream_port, upstream_runner = await _start_fake_long_payload_https_upstream(tmpdir_path) + store, session_id, writer = _writer_for_dir(tmpdir_path) + + upstream_ssl_ctx = ssl.create_default_context() + upstream_ssl_ctx.check_hostname = False + upstream_ssl_ctx.verify_mode = ssl.CERT_NONE + upstream_conn = aiohttp.TCPConnector(ssl=upstream_ssl_ctx) + session = aiohttp.ClientSession(connector=upstream_conn, auto_decompress=False) + original_request = session.request + + async def _rewrite_to_localhost(method, url, **kwargs): + rewritten = URL(str(url)).with_host("127.0.0.1").with_port(upstream_port) + return await original_request(method=method, url=rewritten, **kwargs) + + monkeypatch.setattr(session, "request", _rewrite_to_localhost) + + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=ca, + writer=writer, + session=session, + ) + proxy_port = await server.start() + + try: + ssl_ctx = ssl.create_default_context() + ssl_ctx.load_verify_locations(str(ca_cert_path)) + proxy_url = f"http://127.0.0.1:{proxy_port}" + connector = aiohttp.TCPConnector(ssl=ssl_ctx) + + async with aiohttp.ClientSession(connector=connector, auto_decompress=False) as client: + async with client.get( + f"https://registry.npmjs.org:{upstream_port}/effect", + proxy=proxy_url, + ) as resp: + assert resp.status == 200 + metadata = await resp.json() + assert len(metadata["versions"]) >= 100 + + async with client.get( + f"https://cdn.example.test:{upstream_port}/effect/-/effect-4.0.0-beta.59.tgz", + proxy=proxy_url, + ) as resp: + assert resp.status == 200 + assert len(await resp.read()) == 1024 * 1024 + + async with client.post( + f"https://api.anthropic.test:{upstream_port}/v1/messages", + proxy=proxy_url, + json={ + "model": "claude-3-5-sonnet-test", + "max_tokens": 200000, + "messages": [{"role": "user", "content": "write a very long answer"}], + }, + ) as resp: + assert resp.status == 200 + anthropic_body = await resp.json() + assert len(anthropic_body["content"][0]["text"]) > 200000 + + async with client.post( + f"https://api.openai.test:{upstream_port}/v1/chat/completions", + proxy=proxy_url, + json={ + "model": "gpt-5-test", + "messages": [{"role": "user", "content": "write a very long answer"}], + }, + ) as resp: + assert resp.status == 200 + openai_body = await resp.json() + assert len(openai_body["choices"][0]["message"]["content"]) > 200000 + + await asyncio.sleep(0.1) + writer.close() + records = [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] + + paths = [record["request"]["path"] for record in records] + assert paths == ["/v1/messages", "/v1/chat/completions"] + assert all("effect" not in path for path in paths) + + anthropic_record = records[0] + openai_record = records[1] + assert anthropic_record["request"]["body"]["model"] == "claude-3-5-sonnet-test" + assert len(anthropic_record["response"]["body"]["content"][0]["text"]) > 200000 + assert openai_record["request"]["body"]["model"] == "gpt-5-test" + assert len(openai_record["response"]["body"]["choices"][0]["message"]["content"]) > 200000 + finally: + await server.stop() + await session.close() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_forward_proxy_local_reverse_bridge(): + """Test local origin requests bridged through forward proxy to a target.""" + import ssl + + import aiohttp + + from claude_tap.certs import CertificateAuthority, ensure_ca + from claude_tap.forward_proxy import ForwardProxyServer + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + ca_cert_path, ca_key_path = ensure_ca(tmpdir_path / "ca") + ca = CertificateAuthority(ca_cert_path, ca_key_path) + + upstream_port = await _start_fake_https_upstream(tmpdir_path) + store, session_id, writer = _writer_for_dir(tmpdir_path) + upstream_ssl_ctx = ssl.create_default_context() + upstream_ssl_ctx.check_hostname = False + upstream_ssl_ctx.verify_mode = ssl.CERT_NONE + upstream_conn = aiohttp.TCPConnector(ssl=upstream_ssl_ctx) + session = aiohttp.ClientSession(connector=upstream_conn, auto_decompress=False) + + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=ca, + writer=writer, + session=session, + local_reverse_target=f"https://127.0.0.1:{upstream_port}", + local_reverse_allowed_path_prefixes=("/v1internal",), + capture_only=True, + ) + proxy_port = await server.start() + + try: + + async def chunked_body(): + yield b'{"request":{"contents":[' + yield b'{"role":"user","parts":[{"text":"hello"}]}' + yield b"]}}" + + async with aiohttp.ClientSession(auto_decompress=False) as client: + async with client.post( + f"http://127.0.0.1:{proxy_port}/v1internal:loadCodeAssist", + data=chunked_body(), + headers={"Content-Type": "application/json"}, + ) as resp: + assert resp.status == 200 + body = await resp.json() + assert body["content"][0]["text"] == "Hello from HTTPS!" + + await asyncio.sleep(0.1) + writer.close() + records = [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] + assert len(records) == 1 + assert records[0]["request"]["path"] == "/v1internal:loadCodeAssist" + assert records[0]["request"]["body"]["request"]["contents"][0]["role"] == "user" + assert records[0]["response"]["status"] == 200 + finally: + await server.stop() + await session.close() + + print(" test_forward_proxy_local_reverse_bridge PASSED") + + +@pytest.mark.asyncio +async def test_forward_proxy_records_upstream_error(): + """Test forward proxy records a 502 trace record when upstream is unreachable.""" + import socket + + import aiohttp + + from claude_tap.certs import CertificateAuthority, ensure_ca + from claude_tap.forward_proxy import ForwardProxyServer + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + ca_cert_path, ca_key_path = ensure_ca(tmpdir_path / "ca") + ca = CertificateAuthority(ca_cert_path, ca_key_path) + + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + unreachable_port = sock.getsockname()[1] + sock.close() + + store, session_id, writer = _writer_for_dir(tmpdir_path) + session = aiohttp.ClientSession(auto_decompress=False) + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=ca, + writer=writer, + session=session, + local_reverse_target=f"http://127.0.0.1:{unreachable_port}", + local_reverse_allowed_path_prefixes=("/v1internal",), + ) + proxy_port = await server.start() + + try: + async with aiohttp.ClientSession(auto_decompress=False) as client: + async with client.post( + f"http://127.0.0.1:{proxy_port}/v1internal:listExperiments", + json={"request": {"client": "agy"}}, + ) as resp: + assert resp.status == 502 + assert await resp.text() + + await asyncio.sleep(0.1) + writer.close() + records = [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] + assert len(records) == 1 + assert records[0]["turn"] == 1 + assert records[0]["request"]["path"] == "/v1internal:listExperiments" + assert records[0]["request"]["body"]["request"]["client"] == "agy" + assert records[0]["response"]["status"] == 502 + assert records[0]["response"]["body"]["error"] + finally: + await server.stop() + await session.close() + + print(" test_forward_proxy_records_upstream_error PASSED") + + +@pytest.mark.asyncio +async def test_forward_proxy_connect_websocket(): + """Test the forward proxy CONNECT/TLS flow with a fake WSS upstream.""" + import ssl + + import aiohttp + + from claude_tap.certs import CertificateAuthority, ensure_ca + from claude_tap.forward_proxy import ForwardProxyServer + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + ca_dir = tmpdir / "ca" + + ca_cert_path, ca_key_path = ensure_ca(ca_dir) + ca = CertificateAuthority(ca_cert_path, ca_key_path) + + upstream_port = await _start_fake_wss_upstream(tmpdir) + print(f" Fake WSS upstream on port {upstream_port}") + + store, session_id, writer = _writer_for_dir(tmpdir) + upstream_ssl_ctx = ssl.create_default_context() + upstream_ssl_ctx.check_hostname = False + upstream_ssl_ctx.verify_mode = ssl.CERT_NONE + upstream_conn = aiohttp.TCPConnector(ssl=upstream_ssl_ctx) + session = aiohttp.ClientSession(connector=upstream_conn, auto_decompress=False) + + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=ca, + writer=writer, + session=session, + store_stream_events=True, + ) + proxy_port = await server.start() + print(f" Forward proxy on port {proxy_port}") + + try: + ssl_ctx = ssl.create_default_context() + ssl_ctx.load_verify_locations(str(ca_cert_path)) + proxy_url = f"http://127.0.0.1:{proxy_port}" + + async with aiohttp.ClientSession(auto_decompress=False) as client: + ws = await client.ws_connect( + f"https://127.0.0.1:{upstream_port}/v1/responses", + proxy=proxy_url, + ssl=ssl_ctx, + ) + await ws.send_json({"model": "gpt-test", "input": "hello"}) + + received = [] + binary_received = False + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=5) + if msg.type == aiohttp.WSMsgType.TEXT: + received.append(json.loads(msg.data)) + elif msg.type == aiohttp.WSMsgType.BINARY: + binary_received = msg.data == b"binary-over-wss" + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + await ws.close() + + assert len(received) == 3 + assert binary_received + assert received[0]["type"] == "response.created" + assert received[-1]["type"] == "response.completed" + print(" OK: CONNECT + WSS upgrade works") + + await asyncio.sleep(0.1) + writer.close() + + trace_text = store.export_jsonl(session_id).strip() + assert trace_text, "No WS trace recorded" + records = [json.loads(line) for line in trace_text.splitlines()] + assert len(records) == 1 + assert records[0]["transport"] == "websocket" + assert records[0]["request"]["method"] == "WEBSOCKET" + assert records[0]["request"]["path"] == "/v1/responses" + assert records[0]["response"]["status"] == 101 + assert records[0]["response"]["body"]["status"] == "completed" + assert records[0]["response"]["body"]["output"][0]["content"][0]["text"] == "Hello over WSS" + assert records[0]["request"]["ws_events"][0]["model"] == "gpt-test" + assert len(records[0]["response"]["ws_events"]) == 3 + print(" OK: WS trace recorded correctly") + finally: + await server.stop() + await session.close() + + print(" test_forward_proxy_connect_websocket PASSED") + + +@pytest.mark.asyncio +async def test_forward_proxy_connect_websocket_capture_only(monkeypatch: pytest.MonkeyPatch): + """Capture-only WebSocket mode should synthesize a local response and trace the client prompt.""" + import ssl + + import aiohttp + + from claude_tap.certs import CertificateAuthority, ensure_ca + from claude_tap.forward_proxy import ForwardProxyServer + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + ca_dir = tmpdir / "ca" + + ca_cert_path, ca_key_path = ensure_ca(ca_dir) + ca = CertificateAuthority(ca_cert_path, ca_key_path) + + upstream_port = await _start_fake_wss_upstream(tmpdir) + store, session_id, writer = _writer_for_dir(tmpdir) + upstream_ssl_ctx = ssl.create_default_context() + upstream_ssl_ctx.check_hostname = False + upstream_ssl_ctx.verify_mode = ssl.CERT_NONE + upstream_conn = aiohttp.TCPConnector(ssl=upstream_ssl_ctx) + session = aiohttp.ClientSession(connector=upstream_conn, auto_decompress=False) + + def fail_ws_connect(*args, **kwargs): + raise AssertionError("capture-only websocket should not connect upstream") + + monkeypatch.setattr(session, "ws_connect", fail_ws_connect) + + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=ca, + writer=writer, + session=session, + store_stream_events=True, + capture_only=True, + ) + proxy_port = await server.start() + + try: + ssl_ctx = ssl.create_default_context() + ssl_ctx.load_verify_locations(str(ca_cert_path)) + proxy_url = f"http://127.0.0.1:{proxy_port}" + + async with aiohttp.ClientSession(auto_decompress=False) as client: + ws = await client.ws_connect( + f"https://127.0.0.1:{upstream_port}/v1/responses", + proxy=proxy_url, + ssl=ssl_ctx, + ) + await ws.send_json({"type": "session.update", "tools": []}) + await ws.send_json({"model": "gpt-test", "instructions": "ws system", "input": "hello"}) + + received = [] + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=5) + if msg.type == aiohttp.WSMsgType.TEXT: + received.append(json.loads(msg.data)) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + await ws.close() + + assert [event["type"] for event in received] == ["response.created", "response.completed"] + assert received[-1]["response"]["status"] == "completed" + assert received[-1]["response"]["output"][0]["content"][0]["text"] == "captured" + + await asyncio.sleep(0.1) + writer.close() + records = [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] + assert len(records) == 1 + assert records[0]["transport"] == "websocket" + assert records[0]["request"]["body"]["instructions"] == "ws system" + assert records[0]["response"]["status"] == 101 + assert records[0]["response"]["body"]["status"] == "completed" + assert len(records[0]["request"]["ws_events"]) == 2 + assert records[0]["request"]["ws_events"][1]["model"] == "gpt-test" + assert [event["type"] for event in records[0]["response"]["ws_events"]] == [ + "response.created", + "response.completed", + ] + finally: + await server.stop() + await session.close() + + print(" test_forward_proxy_connect_websocket_capture_only PASSED") + + +@pytest.mark.asyncio +async def test_forward_proxy_connect_websocket_honors_env_proxy(monkeypatch): + """Forward proxy should pass env-derived proxy settings into upstream ws_connect.""" + import ssl + + import aiohttp + + from claude_tap.certs import CertificateAuthority, ensure_ca + from claude_tap.forward_proxy import ForwardProxyServer + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + ca_dir = tmpdir / "ca" + + ca_cert_path, ca_key_path = ensure_ca(ca_dir) + ca = CertificateAuthority(ca_cert_path, ca_key_path) + + upstream_port = await _start_fake_wss_upstream(tmpdir) + store, session_id, writer = _writer_for_dir(tmpdir) + upstream_ssl_ctx = ssl.create_default_context() + upstream_ssl_ctx.check_hostname = False + upstream_ssl_ctx.verify_mode = ssl.CERT_NONE + upstream_conn = aiohttp.TCPConnector(ssl=upstream_ssl_ctx) + session = aiohttp.ClientSession(connector=upstream_conn, auto_decompress=False, trust_env=True) + + monkeypatch.setattr( + "claude_tap.forward_proxy._get_ws_proxy_settings", + lambda _url: (URL("http://proxy.local:8080"), aiohttp.BasicAuth("user", "pass")), + ) + + ws_connect_calls: list[dict] = [] + original_ws_connect = session.ws_connect + + async def _spy_ws_connect(*args, **kwargs): + ws_connect_calls.append(dict(kwargs)) + kwargs.pop("proxy", None) + kwargs.pop("proxy_auth", None) + return await original_ws_connect(*args, **kwargs) + + session.ws_connect = _spy_ws_connect # type: ignore[method-assign] + + server = ForwardProxyServer( + host="127.0.0.1", + port=0, + ca=ca, + writer=writer, + session=session, + ) + proxy_port = await server.start() + + try: + ssl_ctx = ssl.create_default_context() + ssl_ctx.load_verify_locations(str(ca_cert_path)) + + async with aiohttp.ClientSession(auto_decompress=False) as client: + ws = await client.ws_connect( + f"https://127.0.0.1:{upstream_port}/v1/responses", + proxy=f"http://127.0.0.1:{proxy_port}", + ssl=ssl_ctx, + ) + await ws.send_json({"model": "gpt-test", "input": "hello"}) + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=5) + if msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + await ws.close() + + assert ws_connect_calls, "Expected upstream ws_connect to be called" + assert ws_connect_calls[0]["proxy"] == URL("http://proxy.local:8080") + assert ws_connect_calls[0]["proxy_auth"] is not None + assert ws_connect_calls[0]["proxy_auth"].login == "user" + assert ws_connect_calls[0]["proxy_auth"].password == "pass" + finally: + await server.stop() + await session.close() + + +async def _start_fake_https_upstream(tmpdir: Path) -> int: + """Start a fake HTTPS server for testing. Returns the port.""" + import ssl as ssl_module + + # Generate a self-signed cert for the fake upstream + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + import datetime + + now = datetime.datetime.now(datetime.timezone.utc) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("127.0.0.1"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + # Python 3.13/OpenSSL may enforce AKI/SKI presence for custom test certs. + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) + .sign(key, hashes.SHA256()) + ) + + cert_path = tmpdir / "upstream.pem" + key_path = tmpdir / "upstream-key.pem" + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + ssl_ctx = ssl_module.SSLContext(ssl_module.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(str(cert_path), str(key_path)) + + async def handle_client(reader, writer): + try: + await asyncio.wait_for(reader.readline(), timeout=10) + # Read headers + headers = {} + while True: + line = await asyncio.wait_for(reader.readline(), timeout=5) + if line in (b"\r\n", b"\n", b""): + break + decoded = line.decode("utf-8", errors="replace").strip() + if ":" in decoded: + k, v = decoded.split(":", 1) + headers[k.strip()] = v.strip() + + # Read body (drain it so the connection is clean) + cl = headers.get("Content-Length") or headers.get("content-length", "0") + try: + length = int(cl) + if length > 0: + await asyncio.wait_for(reader.readexactly(length), timeout=10) + except (ValueError, asyncio.IncompleteReadError): + pass + + # Return a simple JSON response + resp_body = json.dumps( + { + "id": "msg_test_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello from HTTPS!"}], + "model": "claude-test-model", + "usage": {"input_tokens": 10, "output_tokens": 5}, + "stop_reason": "end_turn", + } + ).encode() + + content_length_line = f"Content-Length: {len(resp_body)}\r\n".encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + + b"Content-Type: application/json\r\n" + + content_length_line + + b"\r\n" + + resp_body + ) + writer.write(response) + await writer.drain() + except Exception: + pass + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + server = await asyncio.start_server(handle_client, "127.0.0.1", 0, ssl=ssl_ctx) + port = server.sockets[0].getsockname()[1] + return port + + +async def _start_fake_long_payload_https_upstream(tmpdir: Path): + """Start a fake HTTPS upstream with package noise and long model payloads.""" + import datetime + import ssl as ssl_module + + from aiohttp import web + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.datetime.now(datetime.timezone.utc) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("127.0.0.1"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) + .sign(key, hashes.SHA256()) + ) + + cert_path = tmpdir / "long-upstream.pem" + key_path = tmpdir / "long-upstream-key.pem" + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + ssl_ctx = ssl_module.SSLContext(ssl_module.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(str(cert_path), str(key_path)) + + async def long_payload_handler(request): + if request.path == "/effect": + package_padding = "npm-metadata-padding-" * 1000 + versions = { + f"4.0.0-beta.{index}": { + "name": "effect", + "dist": {"tarball": f"https://registry.npmjs.org/effect/-/effect-{index}.tgz"}, + "readme": package_padding, + } + for index in range(120) + } + return web.json_response({"name": "effect", "versions": versions}) + + if request.path.endswith(".tgz"): + return web.Response(body=b"x" * 1024 * 1024, content_type="application/octet-stream") + + if request.path == "/v1/messages": + req_body = await request.json() + long_text = "anthropic-long-response " * 10000 + return web.json_response( + { + "id": "msg_long_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": long_text}], + "model": req_body["model"], + "usage": { + "input_tokens": 120000, + "output_tokens": 50000, + "cache_read_input_tokens": 90000, + }, + "stop_reason": "end_turn", + } + ) + + if request.path == "/v1/chat/completions": + req_body = await request.json() + long_text = "openai-long-response " * 11000 + return web.json_response( + { + "id": "chatcmpl_long_1", + "object": "chat.completion", + "model": req_body["model"], + "choices": [{"index": 0, "message": {"role": "assistant", "content": long_text}}], + "usage": { + "prompt_tokens": 130000, + "completion_tokens": 55000, + "prompt_tokens_details": {"cached_tokens": 95000}, + }, + } + ) + + return web.Response(status=404, text="not found") + + app = web.Application() + app.router.add_route("*", "/{tail:.*}", long_payload_handler) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_ctx) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return port, runner + + +async def _start_fake_wss_upstream(tmpdir: Path) -> int: + """Start a fake WSS upstream server for websocket proxy tests.""" + import ssl as ssl_module + + import aiohttp + from aiohttp import web + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from cryptography.x509.oid import NameOID + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + import datetime + + now = datetime.datetime.now(datetime.timezone.utc) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")]) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("127.0.0.1"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) + .sign(key, hashes.SHA256()) + ) + + cert_path = tmpdir / "upstream-ws.pem" + key_path = tmpdir / "upstream-ws-key.pem" + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + ssl_ctx = ssl_module.SSLContext(ssl_module.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(str(cert_path), str(key_path)) + + async def ws_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + data = json.loads(msg.data) + model = data.get("model", "gpt-test") + await ws.send_json( + { + "type": "response.created", + "response": {"id": "resp_ws_1", "model": model, "status": "in_progress"}, + } + ) + await ws.send_bytes(b"binary-over-wss") + await ws.send_json({"type": "response.output_text.delta", "delta": "Hello over WSS"}) + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_ws_1", + "model": model, + "status": "completed", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Hello over WSS"}], + } + ], + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + } + ) + await ws.close() + break + return ws + + app = web.Application() + app.router.add_get("/v1/responses", ws_handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_ctx) + await site.start() + return site._server.sockets[0].getsockname()[1] + + +## --------------------------------------------------------------------------- +## Run all tests +## --------------------------------------------------------------------------- + +if __name__ == "__main__": + if "--preview" in sys.argv: + _cmd_preview() + sys.exit(0) + if "--dev" in sys.argv: + _cmd_dev() + sys.exit(0) + + # Unit tests (fast, no subprocesses) + test_parse_args() + test_parse_args_new_flags() + test_parse_args_proxy_mode() + test_cert_generation() + test_filter_headers() + test_sse_reassembler() + test_detect_installer() + test_trace_cleanup() + test_trace_tagging_safety() + test_manifest_migration() + test_live_viewer_scroll_preservation() + test_live_viewer_diff_nav_update() + + # E2E tests (subprocess-based) + test_e2e() + test_upstream_error() + test_malformed_sse() + test_large_payload() + test_concurrent_requests() + test_upstream_unreachable() + test_startup_does_not_contact_pypi() + test_e2e_with_cleanup() + print("\n" + "=" * 60) + print(" ALL TESTS PASSED") + print("=" * 60) + + +## --------------------------------------------------------------------------- +## LiveViewerServer tests +## --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_live_viewer_server(): + """Test LiveViewerServer SSE functionality.""" + import aiohttp + + from claude_tap import LiveViewerServer + + with tempfile.TemporaryDirectory() as tmpdir: + os.environ["CLOUDTAP_DB"] = str(Path(tmpdir) / "live.sqlite3") + from claude_tap.trace_store import get_trace_store, reset_trace_store + + reset_trace_store() + session_id = get_trace_store().create_session() + server = LiveViewerServer(session_id=session_id, port=0) + port = await server.start() + assert port > 0 + print(f" LiveViewerServer started on port {port}") + + async with aiohttp.ClientSession() as session: + # Test index page + async with session.get(f"http://127.0.0.1:{port}/") as resp: + assert resp.status == 200 + html = await resp.text() + assert "LIVE_MODE = true" in html + print(" OK: index returns live mode HTML") + + # Test records endpoint (empty initially) + async with session.get(f"http://127.0.0.1:{port}/records") as resp: + assert resp.status == 200 + records = await resp.json() + assert records == [] + print(" OK: /records returns empty list") + + # Broadcast a record + test_record = {"turn": 1, "request": {"method": "POST"}} + await server.broadcast(test_record) + + # Verify record is stored + async with session.get(f"http://127.0.0.1:{port}/records") as resp: + records = await resp.json() + assert len(records) == 1 + assert records[0]["turn"] == 1 + print(" OK: broadcast adds record to /records") + + await server.stop() + print(" test_live_viewer_server PASSED") + + +@pytest.mark.asyncio +async def test_dashboard_main_serves_viewer(monkeypatch, tmp_path): + """Test the dashboard command starts a standalone dashboard server.""" + import socket + from unittest.mock import AsyncMock + + import aiohttp + + from claude_tap import dashboard_main, parse_dashboard_args + + opened_urls: list[str] = [] + monkeypatch.setattr("claude_tap.cli._open_browser", opened_urls.append) + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli.is_dashboard_healthy", AsyncMock(return_value=False)) + monkeypatch.setattr( + "claude_tap.cli.migrate_legacy_traces", + lambda _output_dir: (_ for _ in ()).throw(AssertionError("dashboard_main should not pre-migrate")), + ) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + dashboard_port = sock.getsockname()[1] + + args = parse_dashboard_args(["--tap-output-dir", str(tmp_path), "--tap-live-port", str(dashboard_port)]) + task = asyncio.create_task(dashboard_main(args)) + try: + for _ in range(50): + if opened_urls: + break + await asyncio.sleep(0.05) + assert opened_urls, "dashboard should open the browser" + async with aiohttp.ClientSession() as session: + async with session.get(opened_urls[0]) as resp: + assert resp.status == 200 + html = await resp.text() + assert "session-list" in html + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_dashboard_main_bind_all_opens_loopback_url(monkeypatch, tmp_path): + """A bind-all dashboard should open through loopback so token checks pass.""" + import socket + from unittest.mock import AsyncMock + + import aiohttp + + from claude_tap import dashboard_main, parse_dashboard_args + + opened_urls: list[str] = [] + monkeypatch.setattr("claude_tap.cli._open_browser", opened_urls.append) + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli.is_dashboard_healthy", AsyncMock(return_value=False)) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + dashboard_port = sock.getsockname()[1] + + args = parse_dashboard_args( + ["--tap-output-dir", str(tmp_path), "--tap-live-port", str(dashboard_port), "--tap-host", "0.0.0.0"] + ) + task = asyncio.create_task(dashboard_main(args)) + try: + for _ in range(50): + if opened_urls: + break + await asyncio.sleep(0.05) + assert opened_urls, "dashboard should open the browser" + assert opened_urls[0].startswith("http://127.0.0.1:") + async with aiohttp.ClientSession() as session: + async with session.get(opened_urls[0]) as resp: + assert resp.status == 200 + html = await resp.text() + assert "session-list" in html + assert "DASHBOARD_QUIT_TOKEN" in html + finally: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_dashboard_main_opens_reused_dashboard(monkeypatch, tmp_path): + """The standalone dashboard command should honor browser opens when reusing a server.""" + from unittest.mock import AsyncMock + + from claude_tap import dashboard_main, parse_dashboard_args + + opened_urls: list[str] = [] + migration_calls: list[Path] = [] + monkeypatch.setattr("claude_tap.cli._open_browser", opened_urls.append) + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli.is_dashboard_healthy", AsyncMock(return_value=True)) + monkeypatch.setattr("claude_tap.cli.migrate_legacy_traces", migration_calls.append) + + args = parse_dashboard_args(["--tap-output-dir", str(tmp_path), "--tap-live-port", "23456"]) + + assert await dashboard_main(args) == 0 + assert opened_urls == ["http://127.0.0.1:23456"] + assert migration_calls == [tmp_path] + + +@pytest.mark.asyncio +async def test_dashboard_main_stops_stale_dashboard_before_start(monkeypatch, tmp_path): + """The standalone dashboard command should replace stale dashboard processes.""" + from unittest.mock import AsyncMock + + from claude_tap import dashboard_main, parse_dashboard_args + + calls: list[tuple[str, object]] = [] + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli._is_dashboard_reusable", AsyncMock(return_value=False)) + + async def fake_stop_stale(host: str, port: int, url: str) -> None: + calls.append(("stop_stale", (host, port, url))) + + class FakeServer: + url = "http://127.0.0.1:23456" + + def __init__(self, *, port: int, host: str, migrate_from: Path, dashboard_mode: bool) -> None: + calls.append(("server_init", (host, port, migrate_from, dashboard_mode))) + + async def start(self) -> int: + calls.append(("start", None)) + return 23456 + + async def wait_stopped(self) -> None: + calls.append(("wait_stopped", None)) + + async def stop(self) -> None: + calls.append(("stop", None)) + + monkeypatch.setattr("claude_tap.cli.stop_incompatible_dashboard_if_running", fake_stop_stale) + monkeypatch.setattr("claude_tap.cli.LiveViewerServer", FakeServer) + + args = parse_dashboard_args(["--tap-output-dir", str(tmp_path), "--tap-live-port", "23456", "--tap-no-open"]) + + assert await dashboard_main(args) == 0 + assert calls == [ + ("stop_stale", ("127.0.0.1", 23456, "http://127.0.0.1:23456")), + ("server_init", ("127.0.0.1", 23456, tmp_path, True)), + ("start", None), + ("wait_stopped", None), + ("stop", None), + ] + + +@pytest.mark.asyncio +async def test_dashboard_main_stops_running_dashboard(monkeypatch, tmp_path): + """The dashboard stop command should stop an existing dashboard.""" + from unittest.mock import AsyncMock + + from claude_tap import dashboard_main, parse_dashboard_args + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli.is_dashboard_healthy", AsyncMock(return_value=True)) + stop_dashboard = AsyncMock(return_value=True) + monkeypatch.setattr("claude_tap.cli.stop_dashboard_service", stop_dashboard) + + args = parse_dashboard_args(["stop", "--tap-live-port", "23456"]) + + assert await dashboard_main(args) == 0 + stop_dashboard.assert_awaited_once_with("127.0.0.1", 23456) + + +@pytest.mark.asyncio +async def test_dashboard_main_quit_alias_stops_running_dashboard(monkeypatch, tmp_path): + """The dashboard quit alias should route to the same stop flow.""" + from unittest.mock import AsyncMock + + from claude_tap import dashboard_main, parse_dashboard_args + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli.is_dashboard_healthy", AsyncMock(return_value=True)) + stop_dashboard = AsyncMock(return_value=True) + monkeypatch.setattr("claude_tap.cli.stop_dashboard_service", stop_dashboard) + + args = parse_dashboard_args(["quit", "--tap-live-port", "23456"]) + + assert await dashboard_main(args) == 0 + stop_dashboard.assert_awaited_once_with("127.0.0.1", 23456) + + +@pytest.mark.asyncio +async def test_dashboard_main_stop_reports_missing_dashboard(monkeypatch, tmp_path): + """The dashboard stop command should fail clearly when no dashboard is running.""" + from unittest.mock import AsyncMock + + from claude_tap import dashboard_main, parse_dashboard_args + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli.is_dashboard_healthy", AsyncMock(return_value=False)) + stop_dashboard = AsyncMock(return_value=True) + monkeypatch.setattr("claude_tap.cli.stop_dashboard_service", stop_dashboard) + + args = parse_dashboard_args(["stop", "--tap-live-port", "23456"]) + + assert await dashboard_main(args) == 1 + stop_dashboard.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_dashboard_main_stop_reports_stop_failure(monkeypatch, tmp_path): + """The dashboard stop command should report stop failures after health succeeds.""" + from unittest.mock import AsyncMock + + from claude_tap import dashboard_main, parse_dashboard_args + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + monkeypatch.setattr("claude_tap.cli.is_dashboard_healthy", AsyncMock(return_value=True)) + stop_dashboard = AsyncMock(return_value=False) + monkeypatch.setattr("claude_tap.cli.stop_dashboard_service", stop_dashboard) + + args = parse_dashboard_args(["stop", "--tap-live-port", "23456"]) + + assert await dashboard_main(args) == 1 + stop_dashboard.assert_awaited_once_with("127.0.0.1", 23456) diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..0f62782 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,523 @@ +"""Tests for trace export formats.""" + +from __future__ import annotations + +import base64 +import json + +import pytest + +from claude_tap.export import export_main + + +def _write_trace(tmp_path): + trace_path = tmp_path / "trace.jsonl" + record = { + "timestamp": "2026-04-28T12:00:00", + "turn": 1, + "duration_ms": 123, + "request": { + "body": { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hello from trace"}], + } + }, + "response": { + "body": { + "content": [{"type": "text", "text": "hello from assistant"}], + "usage": {"input_tokens": 10, "output_tokens": 5}, + } + }, + } + trace_path.write_text(json.dumps(record) + "\n", encoding="utf-8") + return trace_path + + +def test_export_html_inferred_from_output_suffix(tmp_path, capsys) -> None: + trace_path = _write_trace(tmp_path) + html_path = tmp_path / "trace.html" + + assert export_main([str(trace_path), "-o", str(html_path)]) == 0 + + html = html_path.read_text(encoding="utf-8") + assert "" in html + assert "EMBEDDED_TRACE_COMPACT_DATA" in html + assert "const EMBEDDED_TRACE_DATA =" not in html + assert "hello from trace" in html + assert f"Exported 1 turns to {html_path}" in capsys.readouterr().out + + +def test_export_html_includes_iframe_embed_query_support(tmp_path, capsys) -> None: + trace_path = _write_trace(tmp_path) + html_path = tmp_path / "trace.html" + + assert export_main([str(trace_path), "-o", str(html_path)]) == 0 + + html = html_path.read_text(encoding="utf-8") + assert "parseEmbedQueryOptions" in html + assert "embed-hide-header" in html + assert "hideControls" in html + assert "density') === 'compact'" in html + assert f"Exported 1 turns to {html_path}" in capsys.readouterr().out + + +def test_export_html_format_defaults_to_trace_html_path(tmp_path, capsys) -> None: + trace_path = _write_trace(tmp_path) + html_path = trace_path.with_suffix(".html") + + assert export_main([str(trace_path), "--format", "html"]) == 0 + + assert html_path.exists() + assert "hello from assistant" in html_path.read_text(encoding="utf-8") + assert f"Exported 1 turns to {html_path}" in capsys.readouterr().out + + +def test_export_markdown_maps_responses_cached_tokens(tmp_path, capsys) -> None: + trace_path = tmp_path / "trace.jsonl" + record = { + "timestamp": "2026-05-09T00:00:00", + "turn": 1, + "duration_ms": 12, + "request": { + "body": { + "model": "gpt-5.4", + "input": [{"role": "user", "content": "hi"}], + } + }, + "response": { + "body": { + "output": [{"type": "message", "content": [{"type": "output_text", "text": "hello"}]}], + "usage": { + "input_tokens": 11767, + "input_tokens_details": {"cached_tokens": 11648}, + "output_tokens": 6, + "total_tokens": 11773, + }, + } + }, + } + trace_path.write_text(json.dumps(record) + "\n", encoding="utf-8") + + assert export_main([str(trace_path), "--format", "markdown"]) == 0 + + output = capsys.readouterr().out + assert "- **Cache read tokens**: 11,648" in output + assert "*Tokens: in=11,767 / out=6 / cache_read=11,648*" in output + + +def test_export_help_mentions_html(capsys) -> None: + with pytest.raises(SystemExit) as exc_info: + export_main(["--help"]) + + assert exc_info.value.code == 0 + help_text = capsys.readouterr().out + assert "{markdown,json,html,compact,prompt-md}" in help_text + assert "default: compact" in help_text + + +def test_export_defaults_to_compact_trace(tmp_path, capsys) -> None: + from claude_tap.compact_trace import load_compact_trace + + trace_path = _write_trace(tmp_path) + compact_path = tmp_path / "trace.json" + + assert export_main([str(trace_path), "-o", str(compact_path)]) == 0 + + compact_text = compact_path.read_text(encoding="utf-8") + assert "__claude_tap_compact_trace__" in compact_text + assert load_compact_trace(compact_text)[0]["request"]["body"]["messages"][0]["content"] == "hello from trace" + assert f"Exported 1 turns to {compact_path}" in capsys.readouterr().out + + +def test_export_stdout_defaults_to_compact_trace(tmp_path, capsys) -> None: + from claude_tap.compact_trace import load_compact_trace + + trace_path = _write_trace(tmp_path) + + assert export_main([str(trace_path)]) == 0 + + output = capsys.readouterr().out + assert "__claude_tap_compact_trace__" in output + assert load_compact_trace(output)[0]["request"]["body"]["messages"][0]["content"] == "hello from trace" + + +def test_export_compact_trace_is_standalone_and_html_renderable(tmp_path, capsys) -> None: + from claude_tap.compact_trace import load_compact_trace + + trace_path = tmp_path / "trace.jsonl" + compact_path = tmp_path / "trace.ctap.json" + html_path = tmp_path / "trace.html" + repeated_tools = [{"type": "function", "name": "shell", "description": "tool schema " * 200}] + repeated_input = { + "role": "user", + "content": [{"type": "input_text", "text": "shared compact input payload " * 200}], + } + records = [] + for turn in range(1, 4): + records.append( + { + "timestamp": f"2026-05-30T10:00:0{turn}+00:00", + "turn": turn, + "request": { + "body": { + "model": "gpt-5.5", + "instructions": "shared instructions " * 200, + "tools": repeated_tools, + "input": [repeated_input, {"role": "user", "content": f"turn {turn}"}], + } + }, + "response": { + "body": { + "output": [{"type": "message", "content": [{"type": "output_text", "text": f"ok {turn}"}]}] + } + }, + } + ) + raw_jsonl = "\n".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) for record in records) + "\n" + trace_path.write_text(raw_jsonl, encoding="utf-8") + + assert export_main([str(trace_path), "--format", "compact", "-o", str(compact_path)]) == 0 + + compact_text = compact_path.read_text(encoding="utf-8") + assert "__claude_tap_compact_trace__" in compact_text + assert "__claude_tap_blob_ref__" in compact_text + assert "shared compact input payload" in compact_text + assert compact_text.count('"role":"user","content":[{"type":"input_text","text":"shared compact') == 1 + assert len(compact_text.encode("utf-8")) < len(raw_jsonl.encode("utf-8")) * 0.5 + assert load_compact_trace(compact_text) == records + + assert export_main([str(compact_path), "-o", str(html_path)]) == 0 + html = html_path.read_text(encoding="utf-8") + assert "EMBEDDED_TRACE_COMPACT_DATA" in html + assert "turn 3" in html + assert "shared compact input payload" in html + assert f"Exported 3 turns to {compact_path}" in capsys.readouterr().out + + +def _bedrock_frame(event: dict) -> str: + payload = json.dumps(event).encode("utf-8") + return json.dumps({"bytes": base64.b64encode(payload).decode("ascii")}) + + +def test_export_json_tolerates_null_request_body_and_stream_text_response(tmp_path, capsys) -> None: + trace_path = tmp_path / "trace.jsonl" + json_path = tmp_path / "trace.export.json" + records = [ + { + "timestamp": "2026-04-28T12:00:00", + "turn": 2, + "request": {"method": "GET", "path": "/inference-profiles", "body": None}, + "response": {"status": 200, "body": {"profiles": []}}, + }, + { + "timestamp": "2026-04-28T12:00:01", + "turn": 1, + "request": { + "method": "POST", + "path": "/model/test/invoke-with-response-stream", + "body": {"model": "claude-haiku-4-5", "messages": [{"role": "user", "content": "hello"}]}, + }, + "response": { + "status": 200, + "body": "".join( + [ + _bedrock_frame( + { + "type": "message_start", + "message": {"id": "msg_1", "type": "message", "role": "assistant", "content": []}, + } + ), + _bedrock_frame( + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""}, + } + ), + _bedrock_frame( + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": "hello from stream"}, + } + ), + _bedrock_frame( + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn"}, + "usage": {"input_tokens": 4, "output_tokens": 3}, + } + ), + ] + ), + }, + }, + ] + trace_path.write_text("\n".join(json.dumps(record) for record in records), encoding="utf-8") + + assert export_main([str(trace_path), "--format", "json", "-o", str(json_path)]) == 0 + + exported = json.loads(json_path.read_text(encoding="utf-8")) + assert [entry["turn"] for entry in exported] == [1, 2] + assert exported[0]["response"]["content"] == [{"type": "text", "text": "hello from stream"}] + assert exported[0]["response"]["usage"] == {"input_tokens": 4, "output_tokens": 3} + assert exported[1]["model"] is None + assert exported[1]["messages"] == [] + assert f"Exported 2 turns to {json_path}" in capsys.readouterr().out + + +def test_export_accepts_positional_sqlite_session_id(trace_db, tmp_path, capsys) -> None: + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + store.append_record( + session_id, + { + "timestamp": "2026-05-24T10:00:00+00:00", + "turn": 1, + "request": { + "body": { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "hello from session"}], + } + }, + "response": { + "body": { + "content": [{"type": "text", "text": "stored response"}], + "usage": {"input_tokens": 3, "output_tokens": 2}, + } + }, + }, + ) + json_path = tmp_path / "session-export.json" + + assert export_main([session_id, "--format", "json", "-o", str(json_path)]) == 0 + + exported = json.loads(json_path.read_text(encoding="utf-8")) + assert exported[0]["messages"] == [{"role": "user", "content": "hello from session"}] + assert exported[0]["response"]["content"] == [{"type": "text", "text": "stored response"}] + assert f"Exported 1 turns to {json_path}" in capsys.readouterr().out + + +def test_export_session_html_does_not_materialize_jsonl_file(trace_db, tmp_path, capsys, monkeypatch) -> None: + from claude_tap.trace_store import TraceStore, get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + store.append_record( + session_id, + { + "timestamp": "2026-05-30T10:00:00+00:00", + "turn": 1, + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "body": { + "model": "gpt-5.5", + "instructions": "system instructions " * 100, + "tools": [{"type": "function", "name": "shell", "description": "tool " * 200}], + "input": [{"role": "user", "content": "hello"}], + }, + }, + "response": { + "status": 101, + "body": { + "output": [{"type": "message", "content": [{"type": "output_text", "text": "ok"}]}], + }, + }, + }, + ) + + def fail_export_jsonl(self, session_id): # noqa: ANN001, ARG001 + raise AssertionError("HTML export should not materialize a full JSONL file first") + + monkeypatch.setattr(TraceStore, "export_jsonl", fail_export_jsonl) + html_path = tmp_path / "session.html" + + assert export_main([session_id, "--format", "html", "-o", str(html_path)]) == 0 + + html = html_path.read_text(encoding="utf-8") + assert "EMBEDDED_TRACE_COMPACT_DATA" in html + assert "gpt-5.5" in html + assert "Exported 1 turns" in capsys.readouterr().out + + +def test_export_prompt_markdown_matches_prompt_snapshot_format(tmp_path, capsys) -> None: + trace_path = tmp_path / "trace.jsonl" + prompt_path = tmp_path / "prompt.md" + record = { + "timestamp": "2026-05-21T10:00:00+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 1, + "request": { + "method": "POST", + "path": "/v1/messages?beta=true", + "headers": {}, + "body": { + "model": "claude-opus", + "system": [{"type": "text", "text": "main system"}], + "messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + "tools": [ + { + "name": "Bash", + "description": "Run shell commands", + "input_schema": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + } + ], + }, + }, + "response": {"status": 200, "headers": {}, "body": {}}, + } + trace_path.write_text(json.dumps(record, ensure_ascii=False) + "\n", encoding="utf-8") + + assert export_main([str(trace_path), "--format", "prompt-md", "-o", str(prompt_path)]) == 0 + + assert prompt_path.read_text(encoding="utf-8") == ( + "# System Prompt\n\n" + "main system\n\n" + "# User Message\n\n" + "hello\n\n" + "# Tools\n\n" + "## Bash\n\n" + "Run shell commands\n\n" + "```json\n" + '{\n "type": "object",\n "properties": {\n "cmd": {\n "type": "string"\n }\n }\n}\n' + "```\n" + ) + assert f"Exported 1 turns to {prompt_path}" in capsys.readouterr().out + + +def test_export_prompt_markdown_accepts_sqlite_session(trace_db, tmp_path) -> None: + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + store.append_record( + session_id, + { + "timestamp": "2026-05-21T10:00:00+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": {}, + "body": { + "model": "gpt-5", + "instructions": "system instructions", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}], + "tools": [{"type": "function", "name": "shell", "description": "Run shell"}], + }, + }, + "response": {"status": 200, "headers": {}, "body": {}}, + }, + ) + prompt_path = tmp_path / "prompt.md" + + assert export_main([session_id, "--format", "prompt-md", "-o", str(prompt_path)]) == 0 + + text = prompt_path.read_text(encoding="utf-8") + assert "# System Prompt\n\nsystem instructions" in text + assert "# User Message\n\nhello" in text + assert "## shell" in text + + +def test_export_prompt_from_session_also_writes_raw_trace(trace_db, tmp_path, capsys) -> None: + from claude_tap.cli import _export_prompt_from_session + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + record = { + "timestamp": "2026-05-21T10:00:00+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": {}, + "body": { + "model": "gpt-5", + "instructions": "system instructions", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}], + }, + }, + "response": {"status": 200, "headers": {}, "body": {}}, + } + store.append_record(session_id, record) + prompt_path = tmp_path / "prompt.md" + + assert _export_prompt_from_session(store, session_id, str(prompt_path)) == 0 + + trace_path = tmp_path / "trace.jsonl" + assert prompt_path.exists() + assert trace_path.exists() + assert trace_path.read_text(encoding="utf-8") == store.export_jsonl(session_id) + assert json.loads(trace_path.read_text(encoding="utf-8"))["request_id"] == "req_1" + output = capsys.readouterr().out + assert f"Prompt snapshot: {prompt_path}" in output + assert f"Raw trace: {trace_path}" in output + + +def test_export_prompt_from_session_stdout_and_missing_prompt(trace_db, capsys) -> None: + from claude_tap.cli import _export_prompt_from_session + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + store.append_record( + session_id, + { + "timestamp": "2026-05-21T10:00:00+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": {}, + "body": {"model": "gpt-5", "instructions": "system instructions"}, + }, + "response": {"status": 200, "headers": {}, "body": {}}, + }, + ) + + assert _export_prompt_from_session(store, session_id, "-") == 0 + assert "# System Prompt\n\nsystem instructions" in capsys.readouterr().out + + empty_session_id = store.create_session(client="codex", proxy_mode="reverse") + assert _export_prompt_from_session(store, empty_session_id, "-") == 1 + assert "no prompt-bearing request found in trace" in capsys.readouterr().err + + +def test_export_prompt_from_session_uses_stemmed_trace_name(trace_db, tmp_path) -> None: + from claude_tap.cli import _export_prompt_from_session + from claude_tap.trace_store import get_trace_store + + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + store.append_record( + session_id, + { + "timestamp": "2026-05-21T10:00:00+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": {}, + "body": {"model": "gpt-5", "instructions": "system instructions"}, + }, + "response": {"status": 200, "headers": {}, "body": {}}, + }, + ) + + assert _export_prompt_from_session(store, session_id, str(tmp_path / "snapshot.md")) == 0 + + assert (tmp_path / "snapshot.md").exists() + assert (tmp_path / "snapshot.trace.jsonl").exists() diff --git a/tests/test_gemini_launch.py b/tests/test_gemini_launch.py new file mode 100644 index 0000000..5f26f0e --- /dev/null +++ b/tests/test_gemini_launch.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, _reverse_proxy_trace_options, run_client + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_gemini_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["gemini"] + + assert cfg.cmd == "gemini" + assert cfg.label == "Gemini CLI" + assert cfg.default_target == "https://generativelanguage.googleapis.com" + assert cfg.base_url_env == "GOOGLE_GEMINI_BASE_URL" + assert cfg.extra_base_url_envs == ("GOOGLE_VERTEX_BASE_URL",) + assert cfg.base_url_suffix == "" + assert cfg.default_proxy_mode == "forward" + + +def test_parse_args_gemini_defaults_to_forward_mode() -> None: + args = parse_args(["--tap-client", "gemini"]) + + assert args.client == "gemini" + assert args.target == "https://generativelanguage.googleapis.com" + assert args.proxy_mode == "forward" + + +def test_parse_args_gemini_explicit_reverse_overrides_default() -> None: + args = parse_args(["--tap-client", "gemini", "--tap-proxy-mode", "reverse"]) + + assert args.proxy_mode == "reverse" + + +@pytest.mark.asyncio +async def test_run_client_gemini_forward_sets_proxy_ca_and_skips_base_url_envs(monkeypatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.delenv("GOOGLE_GEMINI_BASE_URL", raising=False) + monkeypatch.delenv("GOOGLE_VERTEX_BASE_URL", raising=False) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/gemini") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["-p", "hello"], client="gemini", proxy_mode="forward", ca_cert_path=ca_path) + + assert code == 0 + assert captured["cmd"] == ("/tmp/gemini", "-p", "hello") + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["NODE_EXTRA_CA_CERTS"] == str(ca_path) + assert env["SSL_CERT_FILE"] == str(ca_path) + assert "GOOGLE_GEMINI_BASE_URL" not in env + assert "GOOGLE_VERTEX_BASE_URL" not in env + + +@pytest.mark.asyncio +async def test_run_client_gemini_reverse_sets_both_base_url_envs(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/gemini") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["-p", "hello"], client="gemini", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == ("/tmp/gemini", "-p", "hello") + env = captured["env"] + assert env["GOOGLE_GEMINI_BASE_URL"] == "http://127.0.0.1:43123" + assert env["GOOGLE_VERTEX_BASE_URL"] == "http://127.0.0.1:43123" + + +def test_gemini_reverse_trace_options_do_not_strip_path_prefix() -> None: + options = _reverse_proxy_trace_options("gemini", "https://generativelanguage.googleapis.com") + + assert options == { + "strip_path_prefix": "", + "force_http": False, + } diff --git a/tests/test_gemini_viewer.py b/tests/test_gemini_viewer.py new file mode 100644 index 0000000..40ef5f4 --- /dev/null +++ b/tests/test_gemini_viewer.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from claude_tap.usage import normalize_usage +from claude_tap.viewer import _extract_metadata, _extract_request_messages, _generate_html_viewer + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + + +def _sse_frame(payload: dict) -> str: + return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + + +def _gemini_record() -> dict: + return { + "timestamp": "2026-05-13T12:00:00+00:00", + "request_id": "req_gemini", + "turn": 1, + "duration_ms": 1234, + "request": { + "method": "POST", + "path": "/v1internal:streamGenerateContent?alt=sse", + "headers": {"Host": "cloudcode-pa.googleapis.com"}, + "body": { + "model": "gemini-3-flash-preview", + "project": "test-project", + "request": { + "systemInstruction": { + "role": "user", + "parts": [ + { + "text": "You are Gemini CLI, an autonomous CLI agent specializing in software engineering tasks." + } + ], + }, + "contents": [ + { + "role": "user", + "parts": [ + {"text": "Workspace: /repo"}, + {"text": "Use shell to inspect the workspace."}, + ], + }, + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "run_shell_command", + "args": {"command": "pwd", "description": "Check current directory."}, + } + } + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "id": "run_shell_command_1", + "name": "run_shell_command", + "response": {"output": "Output: /repo\nProcess Group PGID: 123"}, + } + } + ], + }, + ], + "tools": [ + { + "functionDeclarations": [ + { + "name": "run_shell_command", + "description": "Runs a shell command.", + "parametersJsonSchema": { + "type": "object", + "properties": {"command": {"type": "string"}}, + "required": ["command"], + }, + } + ] + } + ], + }, + }, + }, + "response": { + "status": 200, + "body": ( + _sse_frame( + { + "response": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + {"thought": True, "text": "I need"}, + ], + } + } + ], + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 4, + "cachedContentTokenCount": 40, + }, + } + } + ) + + _sse_frame( + { + "response": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + {"thought": True, "text": " to run a shell command."}, + { + "functionCall": { + "name": "run_shell_command", + "args": {"command": "pwd"}, + } + }, + ], + } + } + ], + "usageMetadata": { + "promptTokenCount": 100, + "candidatesTokenCount": 8, + "cachedContentTokenCount": 40, + }, + } + } + ) + + _sse_frame( + { + "response": { + "candidates": [ + {"content": {"role": "model", "parts": [{"text": "Final OK from Gemini."}]}} + ], + "usageMetadata": { + "promptTokenCount": 110, + "candidatesTokenCount": 12, + "cachedContentTokenCount": 40, + }, + } + } + ) + ), + }, + } + + +def test_normalize_usage_maps_gemini_usage_metadata() -> None: + usage = normalize_usage( + { + "promptTokenCount": 110, + "candidatesTokenCount": 12, + "cachedContentTokenCount": 40, + } + ) + + assert usage["input_tokens"] == 110 + assert usage["output_tokens"] == 12 + assert usage["cache_read_input_tokens"] == 40 + + +def test_extract_request_messages_normalizes_gemini_contents() -> None: + messages = _extract_request_messages(_gemini_record()["request"]["body"]) + + assert [message["role"] for message in messages] == ["user", "assistant", "tool"] + assert messages[0]["content"][0]["text"].startswith("") + assert messages[1]["content"][0] == { + "type": "tool_use", + "id": "", + "name": "run_shell_command", + "input": {"command": "pwd", "description": "Check current directory."}, + } + assert messages[2]["content"][0]["type"] == "tool_result" + assert messages[2]["content"][0]["tool_use_id"] == "run_shell_command_1" + assert "Output: /repo" in messages[2]["content"][0]["content"] + + +def test_extract_metadata_understands_gemini_system_tools_output_and_usage() -> None: + meta = _extract_metadata(json.dumps(_gemini_record(), ensure_ascii=False)) + + assert meta is not None + assert meta["model"] == "gemini-3-flash-preview" + assert meta["has_system"] is True + assert meta["sys_hint"].startswith("You are Gemini CLI") + assert meta["message_count"] == 3 + assert meta["tool_names"] == ["run_shell_command"] + assert meta["response_tool_names"] == ["run_shell_command"] + assert meta["input_tokens"] == 110 + assert meta["output_tokens"] == 12 + assert meta["cache_read_input_tokens"] == 40 + + +def _direct_gemini_native_record() -> dict: + record = json.loads(json.dumps(_gemini_record())) + record["request"]["path"] = "/v1beta/models/gemini-3.5-flash:generateContent" + record["request"]["body"] = record["request"]["body"]["request"] + return record + + +def test_extract_request_messages_normalizes_direct_gemini_native_body() -> None: + messages = _extract_request_messages(_direct_gemini_native_record()["request"]["body"]) + + assert [message["role"] for message in messages] == ["user", "assistant", "tool"] + assert messages[1]["content"][0]["name"] == "run_shell_command" + assert messages[2]["content"][0]["tool_use_id"] == "run_shell_command_1" + + +def test_extract_metadata_understands_direct_gemini_native_body() -> None: + meta = _extract_metadata(json.dumps(_direct_gemini_native_record(), ensure_ascii=False)) + + assert meta is not None + assert meta["model"] == "gemini-3.5-flash" + assert meta["tool_names"] == ["run_shell_command"] + assert meta["response_tool_names"] == ["run_shell_command"] + + +@pytest.fixture(scope="module") +def gemini_html_file() -> Path: + trace_path = Path(tempfile.mktemp(suffix=".jsonl")) + html_path = Path(tempfile.mktemp(suffix=".html")) + trace_path.write_text(json.dumps(_gemini_record(), ensure_ascii=False) + "\n", encoding="utf-8") + _generate_html_viewer(trace_path, html_path) + yield html_path + trace_path.unlink(missing_ok=True) + html_path.unlink(missing_ok=True) + + +@pytest.mark.skipif(pw_missing, reason="playwright not installed") +def test_viewer_renders_gemini_semantic_sections(gemini_html_file: Path) -> None: + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{gemini_html_file}", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.locator(".sidebar-item").first.click() + page.wait_for_selector("#detail .section", timeout=5000) + + result = page.evaluate( + """() => { + const entry = entries[0]; + const body = entry.request.body; + return { + tier: pathTier(entry.request.path), + primary: isPathPrimary(entry.request.path), + system: extractSystem(body), + roles: getMessages(body).map(message => message.role), + tools: getRequestTools(body).map(toolDisplayName), + output: getResponseOutput(entry).content, + usage: getUsage(entry), + eventCount: getResponseEvents(entry).length, + detail: document.querySelector('#detail').innerText, + }; + }""" + ) + browser.close() + + assert result["tier"] == 0 + assert result["primary"] is True + assert result["system"].startswith("You are Gemini CLI") + assert result["roles"] == ["user", "assistant", "tool"] + assert result["tools"] == ["run_shell_command"] + assert [block["type"] for block in result["output"]] == ["thinking", "tool_use", "text"] + assert result["output"][0]["thinking"] == "I need to run a shell command." + assert result["output"][1]["name"] == "run_shell_command" + assert result["usage"]["input_tokens"] == 110 + assert result["usage"]["output_tokens"] == 12 + assert result["usage"]["cache_read_input_tokens"] == 40 + assert result["eventCount"] == 3 + detail = result["detail"] + assert "System Prompt" in detail + assert "Messages" in detail + assert "Tools" in detail + assert "Response" in detail + assert "You are Gemini CLI" in detail + assert "Use shell to inspect the workspace." in detail + assert "Output: /repo" in detail + assert "run_shell_command" in detail + assert "Final OK from Gemini." in detail + + +@pytest.mark.skipif(pw_missing, reason="playwright not installed") +def test_viewer_keeps_gemini_native_paths_visible_with_chat_completions(tmp_path: Path) -> None: + from playwright.sync_api import sync_playwright + + gemini_record = json.loads(json.dumps(_gemini_record())) + gemini_record["request"]["path"] = "/v1beta/models/gemini-3.5-flash:generateContent" + gemini_record["request"]["body"]["model"] = "gemini-3.5-flash" + + chat_record = { + "timestamp": "2026-05-13T12:01:00+00:00", + "request_id": "req_grading", + "turn": 2, + "duration_ms": 456, + "request": { + "method": "POST", + "path": "/v1/chat/completions", + "headers": {"Host": "api.openai.test"}, + "body": { + "model": "gemini-3.5-flash", + "messages": [{"role": "user", "content": "Grade this answer."}], + }, + }, + "response": { + "status": 200, + "body": { + "model": "gemini-3.5-flash", + "choices": [{"message": {"role": "assistant", "content": "score: 0"}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 2}, + }, + }, + } + + trace_path = tmp_path / "mixed-gemini-native-and-chat.jsonl" + html_path = tmp_path / "mixed-gemini-native-and-chat.html" + trace_path.write_text( + "".join(json.dumps(record, ensure_ascii=False) + "\n" for record in (gemini_record, chat_record)), + encoding="utf-8", + ) + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{html_path}", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + + result = page.evaluate( + """() => ({ + turns: document.querySelector('#stat-turns').textContent, + activePaths: Array.from(activePaths).sort(), + filteredPaths: filtered.map(getPath).sort(), + pathFilter: document.querySelector('#path-filter').innerText.replace(/\\s+/g, ' ').trim(), + })""" + ) + browser.close() + + assert result["turns"] == "2" + assert result["activePaths"] == [ + "/v1/chat/completions", + "/v1beta/models/gemini-3.5-flash:generateContent", + ] + assert result["filteredPaths"] == result["activePaths"] + assert "+1 more" not in result["pathFilter"] + + +@pytest.mark.skipif(pw_missing, reason="playwright not installed") +def test_viewer_renders_direct_gemini_native_body(tmp_path: Path) -> None: + from playwright.sync_api import sync_playwright + + trace_path = tmp_path / "direct-gemini-native.jsonl" + html_path = tmp_path / "direct-gemini-native.html" + trace_path.write_text(json.dumps(_direct_gemini_native_record(), ensure_ascii=False) + "\n", encoding="utf-8") + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{html_path}", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + page.locator(".sidebar-item").first.click() + page.wait_for_selector("#detail .section", timeout=5000) + + result = page.evaluate( + """() => { + const entry = entries[0]; + const body = entry.request.body; + return { + roles: getMessages(body).map(message => message.role), + tools: getRequestTools(body).map(toolDisplayName), + output: getResponseOutput(entry).content, + detail: document.querySelector('#detail').innerText, + }; + }""" + ) + browser.close() + + assert result["roles"] == ["user", "assistant", "tool"] + assert result["tools"] == ["run_shell_command"] + assert result["output"][1]["name"] == "run_shell_command" + assert "unknown" not in "\n".join(result["tools"]).lower() + assert "run_shell_command" in result["detail"] + assert "Final OK from Gemini." in result["detail"] diff --git a/tests/test_global_inject.py b/tests/test_global_inject.py new file mode 100644 index 0000000..6e4cbaf --- /dev/null +++ b/tests/test_global_inject.py @@ -0,0 +1,539 @@ +"""Tests for global_inject: config injection with byte-exact restore.""" + +from __future__ import annotations + +import json +import os +import signal +from pathlib import Path + +import pytest + +from claude_tap import global_inject +from claude_tap.cli import main_entry + + +@pytest.fixture(autouse=True) +def _home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("CODEX_HOME", raising=False) + return tmp_path + + +def test_enable_creates_configs_when_absent(_home: Path) -> None: + assert global_inject.claude_home_exists() is False + assert global_inject.codex_home_exists() is False + + global_inject.enable(claude_port=8788, codex_port=8789) + + settings = json.loads((_home / ".claude" / "settings.json").read_text()) + assert settings["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8788" + + codex = (_home / ".codex" / "config.toml").read_text() + assert 'openai_base_url = "http://127.0.0.1:8789/v1"' in codex + assert global_inject.is_active() is True + assert global_inject.claude_home_exists() is True + assert global_inject.codex_home_exists() is True + + +def test_disable_removes_files_that_did_not_exist(_home: Path) -> None: + global_inject.enable(claude_port=8788, codex_port=8789) + global_inject.disable() + + assert not (_home / ".claude" / "settings.json").exists() + assert not (_home / ".codex" / "config.toml").exists() + assert global_inject.is_active() is False + + +def test_disable_restores_existing_files_byte_for_byte(_home: Path) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + original_settings = '{\n "env": {\n "FOO": "bar"\n },\n "model": "opus"\n}\n' + settings_path.write_text(original_settings) + + codex_path = _home / ".codex" / "config.toml" + codex_path.parent.mkdir(parents=True) + original_codex = '# my config\nmodel = "gpt-5"\n\n[tui]\ntheme = "dark"\n' + codex_path.write_text(original_codex) + + global_inject.enable(claude_port=8788, codex_port=8789) + # While active the base URLs are present. + assert "127.0.0.1:8788" in settings_path.read_text() + assert "127.0.0.1:8789" in codex_path.read_text() + + global_inject.disable() + # After disable the originals return exactly. + assert settings_path.read_text() == original_settings + assert codex_path.read_text() == original_codex + + +def test_enable_preserves_other_claude_settings(_home: Path) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text(json.dumps({"env": {"FOO": "bar"}, "model": "opus"})) + + global_inject.enable(claude_port=8788) + data = json.loads(settings_path.read_text()) + assert data["env"]["FOO"] == "bar" + assert data["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8788" + assert data["model"] == "opus" + + +def test_codex_replaces_existing_top_level_key(_home: Path) -> None: + codex_path = _home / ".codex" / "config.toml" + codex_path.parent.mkdir(parents=True) + codex_path.write_text('openai_base_url = "https://old.example/v1"\nmodel = "gpt-5"\n') + + global_inject.enable(codex_port=8789) + text = codex_path.read_text() + assert 'openai_base_url = "http://127.0.0.1:8789/v1"' in text + assert "https://old.example/v1" not in text + assert text.count("openai_base_url") == 1 + assert 'model = "gpt-5"' in text + + +def test_codex_inserts_before_first_table(_home: Path) -> None: + codex_path = _home / ".codex" / "config.toml" + codex_path.parent.mkdir(parents=True) + codex_path.write_text('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n') + + global_inject.enable(codex_port=8789) + lines = codex_path.read_text().splitlines() + table_idx = lines.index("[tui]") + url_idx = next(i for i, ln in enumerate(lines) if ln.startswith("openai_base_url")) + assert url_idx < table_idx + + +def test_enable_tolerates_invalid_claude_json(_home: Path) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text("not json{{{") + + global_inject.enable(claude_port=8788) + data = json.loads(settings_path.read_text()) + assert data["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8788" + + +def test_enable_replaces_non_object_claude_settings(_home: Path) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text('["not", "an", "object"]') + + global_inject.enable(claude_port=8788) + + data = json.loads(settings_path.read_text()) + assert data == {"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8788"}} + + +def test_enable_twice_then_disable_restores_original(_home: Path) -> None: + codex_path = _home / ".codex" / "config.toml" + codex_path.parent.mkdir(parents=True) + original = 'model = "gpt-5"\n' + codex_path.write_text(original) + + global_inject.enable(codex_port=8789) + global_inject.enable(codex_port=9999) # second enable must re-baseline backup + assert "127.0.0.1:9999" in codex_path.read_text() + + global_inject.disable() + assert codex_path.read_text() == original + + +def test_enable_overwrites_stale_backup_before_restore(_home: Path) -> None: + codex_path = _home / ".codex" / "config.toml" + codex_path.parent.mkdir(parents=True) + codex_path.write_text('model = "current"\n') + codex_path.with_name("config.toml.tap-backup").write_text('model = "stale"\n') + + global_inject.enable(codex_port=8789) + global_inject.disable() + + assert codex_path.read_text() == 'model = "current"\n' + + +def test_enable_rolls_back_partial_injection_on_failure(_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + original_settings = '{"env":{"FOO":"bar"}}\n' + settings_path.write_text(original_settings) + + def fail_codex(*_args: object, **_kwargs: object) -> None: + raise OSError("codex write failed") + + monkeypatch.setattr(global_inject, "_inject_codex", fail_codex) + + with pytest.raises(OSError, match="codex write failed"): + global_inject.enable(claude_port=8788, codex_port=8789) + + assert settings_path.read_text() == original_settings + assert not settings_path.with_name("settings.json.tap-backup").exists() + assert global_inject.is_active() is False + + +def test_backup_preserves_existing_config_permissions(_home: Path) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text('{"env":{"ANTHROPIC_AUTH_TOKEN":"secret"}}\n') + settings_path.chmod(0o600) + + global_inject.enable(claude_port=8788) + + backup_path = settings_path.with_name("settings.json.tap-backup") + assert backup_path.exists() + assert (backup_path.stat().st_mode & 0o777) == 0o600 + + +def test_codex_injects_selected_custom_provider_base_url(_home: Path) -> None: + codex_path = _home / ".codex" / "config.toml" + codex_path.parent.mkdir(parents=True) + codex_path.write_text( + "\n".join( + [ + 'model_provider = "newapi"', + "", + "[model_providers.newapi]", + 'base_url = "https://new-api.example.test/v1"', + 'name = "Custom"', + "", + ] + ) + ) + + global_inject.enable(codex_port=8789) + + text = codex_path.read_text() + assert 'openai_base_url = "http://127.0.0.1:8789/v1"' in text + assert 'base_url = "http://127.0.0.1:8789/v1"' in text + assert 'name = "Custom"' in text + assert "https://new-api.example.test/v1" not in text + + +def test_toml_dotted_string_inserts_before_next_table() -> None: + text = global_inject._set_toml_dotted_string( + "\n".join( + [ + "[model_providers.newapi]", + 'name = "Custom"', + "", + "[tui]", + 'theme = "dark"', + "", + ] + ), + "model_providers.newapi.base_url", + "http://127.0.0.1:8789/v1", + ) + + lines = text.splitlines() + provider_idx = lines.index("[model_providers.newapi]") + base_url_idx = lines.index('base_url = "http://127.0.0.1:8789/v1"') + tui_idx = lines.index("[tui]") + assert provider_idx < base_url_idx < tui_idx + + +def test_toml_dotted_string_uses_top_level_key_when_table_is_missing() -> None: + text = global_inject._set_toml_dotted_string( + 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n', + "model_providers.newapi.base_url", + "http://127.0.0.1:8789/v1", + ) + + assert 'model_providers.newapi.base_url = "http://127.0.0.1:8789/v1"' in text + + +def test_claude_injects_custom_bedrock_gateway_when_bedrock_mode_is_enabled(_home: Path) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text( + json.dumps( + { + "env": { + "CLAUDE_CODE_USE_BEDROCK": "1", + "ANTHROPIC_BEDROCK_BASE_URL": "https://ai-gateway.internal.example.com/bedrock", + } + } + ) + ) + + global_inject.enable(claude_port=8788) + + env = json.loads(settings_path.read_text())["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8788" + assert env["ANTHROPIC_BEDROCK_BASE_URL"] == "http://127.0.0.1:8788" + + +def test_claude_does_not_rewrite_native_aws_bedrock_url(_home: Path) -> None: + settings_path = _home / ".claude" / "settings.json" + settings_path.parent.mkdir(parents=True) + settings_path.write_text( + json.dumps( + { + "env": { + "CLAUDE_CODE_USE_BEDROCK": "1", + "ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock-runtime.us-east-1.amazonaws.com", + } + } + ) + ) + + global_inject.enable(claude_port=8788) + + env = json.loads(settings_path.read_text())["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8788" + assert env["ANTHROPIC_BEDROCK_BASE_URL"] == "https://bedrock-runtime.us-east-1.amazonaws.com" + + +def test_disable_can_terminate_recorded_monitor_processes(_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + state_file = _home / ".claude-tap" / "monitor-state.json" + state_file.parent.mkdir(parents=True) + state_file.write_text(json.dumps({"files": [], "processes": [{"pid": 4321, "role": "claude proxy"}]})) + signals: list[tuple[int, signal.Signals]] = [] + + monkeypatch.setattr(global_inject, "_monitor_process_command", lambda _pid: "python -m claude_tap --tap-no-launch") + monkeypatch.setattr(global_inject, "_pid_exists", lambda _pid: False) + monkeypatch.setattr(os, "kill", lambda pid, sig: signals.append((pid, signal.Signals(sig)))) + + global_inject.disable(terminate_processes=True) + + assert signals == [(4321, signal.SIGTERM)] + assert not state_file.exists() + + +def test_terminate_proxies_on_ports_kills_matching_listeners(monkeypatch: pytest.MonkeyPatch) -> None: + listeners = {19528: [4321], 19529: [5432]} + commands = { + 4321: "python -m claude_tap --tap-no-launch --tap-client claude --tap-port 19528", + 5432: "python -m claude_tap --tap-no-launch --tap-client codex --tap-port 19529", + } + killed: list[int] = [] + monkeypatch.setattr(global_inject, "_listening_pids_for_port", lambda port: listeners.get(port, [])) + monkeypatch.setattr(global_inject, "_monitor_process_command", lambda pid: commands[pid]) + monkeypatch.setattr(global_inject, "_terminate_pid", lambda pid: killed.append(pid)) + + global_inject.terminate_proxies_on_ports(claude_port=19528, codex_port=19529) + + assert killed == [4321, 5432] + + +def test_terminate_proxies_on_ports_skips_self_and_unrelated_listeners(monkeypatch: pytest.MonkeyPatch) -> None: + self_pid = os.getpid() + listeners = {19528: [self_pid, 9999, 4321]} + commands = { + self_pid: "python -m claude_tap --tap-no-launch --tap-client claude --tap-port 19528", + 9999: "nginx: worker process", # unrelated process happens to hold the port + 4321: "python -m claude_tap --tap-no-launch --tap-client claude --tap-port 19528", + } + killed: list[int] = [] + monkeypatch.setattr(global_inject, "_listening_pids_for_port", lambda port: listeners.get(port, [])) + monkeypatch.setattr(global_inject, "_monitor_process_command", lambda pid: commands[pid]) + monkeypatch.setattr(global_inject, "_terminate_pid", lambda pid: killed.append(pid)) + + global_inject.terminate_proxies_on_ports(claude_port=19528, codex_port=None) + + assert killed == [4321] + + +def test_recorded_proxy_processes_are_running_requires_both_proxy_roles( + _home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state_file = _home / ".claude-tap" / "monitor-state.json" + state_file.parent.mkdir(parents=True) + state_file.write_text( + json.dumps( + { + "files": [], + "processes": [ + {"pid": 1111, "role": "dashboard"}, + {"pid": 2222, "role": "claude proxy"}, + {"pid": 3333, "role": "codex proxy"}, + ], + } + ) + ) + commands = { + 1111: "python -m claude_tap dashboard", + 2222: "python -m claude_tap --tap-no-launch --tap-client claude", + 3333: "python -m claude_tap --tap-no-launch --tap-client codex", + } + monkeypatch.setattr(global_inject, "_monitor_process_command", lambda pid: commands[pid]) + + assert global_inject.recorded_proxy_processes_are_running() is True + + +def test_recorded_proxy_processes_are_running_rejects_missing_or_stale_proxy( + _home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state_file = _home / ".claude-tap" / "monitor-state.json" + state_file.parent.mkdir(parents=True) + state_file.write_text( + json.dumps( + { + "files": [], + "processes": [ + {"pid": 2222, "role": "claude proxy"}, + {"pid": 3333, "role": "codex proxy"}, + ], + } + ) + ) + commands = { + 2222: "python -m claude_tap --tap-no-launch --tap-client claude", + 3333: "", + } + monkeypatch.setattr(global_inject, "_monitor_process_command", lambda pid: commands[pid]) + + assert global_inject.recorded_proxy_processes_are_running() is False + + +def test_recorded_proxy_processes_are_running_accepts_live_proxy_ports_with_stale_state_pids( + _home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state_file = _home / ".claude-tap" / "monitor-state.json" + state_file.parent.mkdir(parents=True) + state_file.write_text( + json.dumps( + { + "files": [], + "processes": [ + {"pid": 2222, "role": "claude proxy"}, + {"pid": 3333, "role": "codex proxy"}, + ], + } + ) + ) + commands = { + 4444: "python -m claude_tap --tap-no-launch --tap-client claude --tap-port 19528", + 5555: "python -m claude_tap --tap-no-launch --tap-client codex --tap-port 19529", + } + monkeypatch.setattr(global_inject, "_listening_pids_for_port", lambda port: {19528: [4444], 19529: [5555]}[port]) + monkeypatch.setattr(global_inject, "_monitor_process_command", lambda pid: commands[pid]) + + assert global_inject.recorded_proxy_processes_are_running(claude_port=19528, codex_port=19529) is True + + +def test_recorded_proxy_processes_are_running_rejects_wrong_live_proxy_port( + _home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state_file = _home / ".claude-tap" / "monitor-state.json" + state_file.parent.mkdir(parents=True) + state_file.write_text(json.dumps({"files": [], "processes": []})) + monkeypatch.setattr(global_inject, "_listening_pids_for_port", lambda _port: [4444]) + monkeypatch.setattr( + global_inject, + "_monitor_process_command", + lambda _pid: "python -m claude_tap --tap-no-launch --tap-client claude --tap-port 19528", + ) + + assert global_inject.recorded_proxy_processes_are_running(claude_port=19528, codex_port=19529) is False + + +def test_disable_ignores_invalid_state_and_restores_no_files(_home: Path) -> None: + state_file = _home / ".claude-tap" / "monitor-state.json" + state_file.parent.mkdir(parents=True) + state_file.write_text("not-json{{") + + global_inject.disable() + + assert not state_file.exists() + + +def test_restore_files_skips_invalid_entries_and_missing_backups(_home: Path) -> None: + created_path = _home / ".claude" / "settings.json" + created_path.parent.mkdir(parents=True) + created_path.write_text("{}") + + existing_path = _home / ".codex" / "config.toml" + existing_path.parent.mkdir(parents=True) + existing_path.write_text('model = "gpt-5"\n') + + global_inject._restore_files( + [ + "invalid", + {"path": str(existing_path), "existed": True, "backup": str(existing_path.with_suffix(".missing"))}, + {"path": str(created_path), "existed": False}, + ] + ) + + assert existing_path.read_text() == 'model = "gpt-5"\n' + assert not created_path.exists() + + +def test_terminate_recorded_processes_filters_and_kills_stubborn_process( + _home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state_file = _home / ".claude-tap" / "monitor-state.json" + state_file.parent.mkdir(parents=True) + state_file.write_text( + json.dumps( + { + "files": [], + "processes": [ + "invalid", + {"pid": -1}, + {"pid": os.getpid()}, + {"pid": 1111}, + {"pid": 2222}, + {"pid": 3333}, + ], + } + ) + ) + commands = { + 1111: "python unrelated.py", + 2222: "python -m claude_tap dashboard", + 3333: "python -m claude_tap --tap-no-launch", + } + alive_checks = {2222: [True, True, True], 3333: [True, False]} + signals: list[tuple[int, signal.Signals]] = [] + + def fake_pid_exists(pid: int) -> bool: + checks = alive_checks.get(pid) + if not checks: + return False + return checks.pop(0) + + def fake_kill(pid: int, sig: int) -> None: + signals.append((pid, signal.Signals(sig))) + if pid == 3333: + raise OSError("gone") + + monkeypatch.setattr(global_inject, "_monitor_process_command", lambda pid: commands[pid]) + monkeypatch.setattr(global_inject, "_pid_exists", fake_pid_exists) + monkeypatch.setattr(global_inject.time, "monotonic", iter([0.0, 1.0, 6.0, 10.0, 11.0]).__next__) + monkeypatch.setattr(global_inject.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(os, "kill", fake_kill) + + global_inject.disable(terminate_processes=True) + + assert signals == [(2222, signal.SIGTERM), (2222, signal.SIGKILL), (3333, signal.SIGTERM)] + + +def test_monitor_process_helpers_handle_failures(monkeypatch: pytest.MonkeyPatch) -> None: + def fail_run(*_args: object, **_kwargs: object) -> None: + raise OSError("ps failed") + + monkeypatch.setattr(global_inject.subprocess, "run", fail_run) + + assert global_inject._monitor_process_command(1234) == "" + assert global_inject._looks_like_monitor_process("") is False + + +def test_disable_is_noop_without_state(_home: Path) -> None: + global_inject.disable() # should not raise + assert global_inject.is_active() is False + + +def test_main_entry_routes_monitor_restore(monkeypatch: pytest.MonkeyPatch) -> None: + restored: list[str] = [] + + monkeypatch.setattr("sys.argv", ["claude-tap", "monitor-restore"]) + monkeypatch.setattr( + "claude_tap.global_inject.disable", + lambda *, terminate_processes=False: restored.append(str(terminate_processes)), + ) + + with pytest.raises(SystemExit) as excinfo: + main_entry() + + assert excinfo.value.code == 0 + assert restored == ["True"] diff --git a/tests/test_hermes_launch.py b/tests/test_hermes_launch.py new file mode 100644 index 0000000..46efe6a --- /dev/null +++ b/tests/test_hermes_launch.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, ClientConfig, run_client + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_client_config_default_proxy_mode_defaults_to_reverse() -> None: + cfg = ClientConfig( + cmd="x", + label="X", + install_url="https://example.com", + base_url_env="X_BASE_URL", + base_url_suffix="", + default_target="https://example.com", + ) + assert cfg.default_proxy_mode == "reverse" + + +def test_hermes_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["hermes"] + assert cfg.cmd == "hermes" + assert cfg.label == "Hermes Agent" + assert cfg.default_target == "https://api.openai.com" + assert cfg.base_url_env == "OPENAI_BASE_URL" + assert cfg.base_url_suffix == "/v1" + assert cfg.default_proxy_mode == "forward" + + +def test_claude_default_proxy_mode_unchanged() -> None: + assert CLIENT_CONFIGS["claude"].default_proxy_mode == "reverse" + + +def test_codex_default_proxy_mode_unchanged() -> None: + assert CLIENT_CONFIGS["codex"].default_proxy_mode == "reverse" + + +def test_parse_args_hermes_defaults_to_forward_mode() -> None: + args = parse_args(["--tap-client", "hermes"]) + assert args.client == "hermes" + assert args.proxy_mode == "forward" + + +def test_parse_args_hermes_explicit_reverse_overrides_default() -> None: + args = parse_args(["--tap-client", "hermes", "--tap-proxy-mode", "reverse"]) + assert args.client == "hermes" + assert args.proxy_mode == "reverse" + + +def test_parse_args_claude_default_unchanged() -> None: + args = parse_args([]) + assert args.client == "claude" + assert args.proxy_mode == "reverse" + + +def test_parse_args_codex_default_unchanged() -> None: + args = parse_args(["--tap-client", "codex"]) + assert args.client == "codex" + assert args.proxy_mode == "reverse" + + +@pytest.mark.asyncio +async def test_run_client_hermes_forward_sets_python_ca_env(monkeypatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/hermes") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["chat"], client="hermes", proxy_mode="forward", ca_cert_path=ca_path) + + assert code == 0 + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + # hermes uses httpx/requests; both honor SSL_CERT_FILE; requests also reads REQUESTS_CA_BUNDLE + assert env["SSL_CERT_FILE"] == str(ca_path) + assert env["REQUESTS_CA_BUNDLE"] == str(ca_path) + + +@pytest.mark.asyncio +async def test_run_client_codex_forward_still_sets_existing_ca_env(monkeypatch) -> None: + """Regression: codex still gets SSL_CERT_FILE and CODEX_CA_CERTIFICATE.""" + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/codex") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["exec", "hi"], client="codex", proxy_mode="forward", ca_cert_path=ca_path) + + assert code == 0 + env = captured["env"] + assert env["SSL_CERT_FILE"] == str(ca_path) + assert env["CODEX_CA_CERTIFICATE"] == str(ca_path) + + +# --------------------------------------------------------------------------- +# argv rewrite: hermes recent versions delegate `gateway start` to launchd / +# systemd, which spawns the gateway in a fresh env that does NOT inherit +# HTTPS_PROXY / CA. We rewrite to `gateway run` (foreground) so the spawned +# process is our child and inherits the injected env. +# --------------------------------------------------------------------------- + + +async def _capture_cmd(monkeypatch, which: str = "/tmp/hermes") -> dict[str, object]: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: which) + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + return captured + + +@pytest.mark.asyncio +async def test_run_client_hermes_rewrites_gateway_start_to_gateway_run(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client(43123, ["gateway", "start"], client="hermes", proxy_mode="forward") + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "gateway", "run") + + +@pytest.mark.asyncio +async def test_run_client_hermes_rewrite_preserves_trailing_flags(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client( + 43123, + ["gateway", "start", "--profile", "coder", "--replace"], + client="hermes", + proxy_mode="forward", + ) + assert code == 0 + assert captured["cmd"] == ( + "/tmp/hermes", + "gateway", + "run", + "--profile", + "coder", + "--replace", + ) + + +@pytest.mark.asyncio +async def test_run_client_hermes_rewrite_after_long_global_option(monkeypatch) -> None: + # `hermes --profile work gateway start` is the documented shape; + # the rewrite must skip the leading global option before matching. + captured = await _capture_cmd(monkeypatch) + code = await run_client( + 43123, + ["--profile", "work", "gateway", "start"], + client="hermes", + proxy_mode="forward", + ) + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "--profile", "work", "gateway", "run") + + +@pytest.mark.asyncio +async def test_run_client_hermes_rewrite_after_short_profile_option(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client( + 43123, + ["-p", "work", "gateway", "start"], + client="hermes", + proxy_mode="forward", + ) + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "-p", "work", "gateway", "run") + + +@pytest.mark.asyncio +async def test_run_client_hermes_rewrite_after_profile_equals_form(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client( + 43123, + ["--profile=work", "gateway", "start"], + client="hermes", + proxy_mode="forward", + ) + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "--profile=work", "gateway", "run") + + +@pytest.mark.asyncio +async def test_run_client_hermes_rewrite_after_boolean_global_flag(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client( + 43123, + ["--ignore-user-config", "gateway", "start"], + client="hermes", + proxy_mode="forward", + ) + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "--ignore-user-config", "gateway", "run") + + +@pytest.mark.asyncio +async def test_run_client_hermes_rewrite_with_global_option_and_trailing_flags(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client( + 43123, + ["--profile", "work", "gateway", "start", "--replace"], + client="hermes", + proxy_mode="forward", + ) + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "--profile", "work", "gateway", "run", "--replace") + + +@pytest.mark.asyncio +async def test_run_client_hermes_gateway_run_passthrough_unchanged(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client(43123, ["gateway", "run"], client="hermes", proxy_mode="forward") + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "gateway", "run") + + +@pytest.mark.asyncio +async def test_run_client_hermes_other_subcommands_unchanged(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch) + code = await run_client(43123, ["chat"], client="hermes", proxy_mode="forward") + assert code == 0 + assert captured["cmd"] == ("/tmp/hermes", "chat") + + +@pytest.mark.asyncio +async def test_run_client_codex_not_affected_by_hermes_rewrite(monkeypatch) -> None: + captured = await _capture_cmd(monkeypatch, which="/tmp/codex") + code = await run_client(43123, ["gateway", "start"], client="codex", proxy_mode="forward") + assert code == 0 + # The hermes rewrite must not fire for codex + assert captured["cmd"] == ("/tmp/codex", "gateway", "start") + + +@pytest.mark.asyncio +async def test_run_client_hermes_reverse_sets_openai_base_url(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/hermes") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("CUSTOM_BASE_URL", raising=False) + + code = await run_client(43123, ["chat"], client="hermes", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" + assert "ANTHROPIC_BASE_URL" not in env + assert "OPENROUTER_BASE_URL" not in env + assert "CUSTOM_BASE_URL" not in env + # Reverse mode for hermes must not inject the codex-only -c flag + assert captured["cmd"] == ("/tmp/hermes", "chat") + + +@pytest.mark.asyncio +async def test_run_client_hermes_capture_only_reverse_sets_multi_provider_urls(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/hermes") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["chat"], client="hermes", proxy_mode="reverse", capture_only=True) + + assert code == 0 + env = captured["env"] + assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:43123" + assert env["OPENROUTER_BASE_URL"] == "http://127.0.0.1:43123/v1" + assert env["CUSTOM_BASE_URL"] == "http://127.0.0.1:43123/v1" + # Reverse mode for hermes must not inject the codex-only -c flag + assert captured["cmd"] == ("/tmp/hermes", "chat") diff --git a/tests/test_hermes_viewer.py b/tests/test_hermes_viewer.py new file mode 100644 index 0000000..9955d3e --- /dev/null +++ b/tests/test_hermes_viewer.py @@ -0,0 +1,89 @@ +"""Playwright test: hermes traces should label as 'Hermes' even when the +system prompt mentions other agent brand names (Claude Code, OpenClaw, etc.).""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + +pytestmark = pytest.mark.skipif(pw_missing, reason="playwright not installed") + + +HERMES_SOUL_WITH_BRAND_MENTIONS = ( + "You are Hermes Agent, an intelligent AI assistant created by Nous Research. " + "You are helpful, knowledgeable, and direct. You assist users with a wide " + "range of tasks. For comparison, you may have used Claude Code or OpenClaw " + "in the past — those are different agents. You communicate clearly and " + "prioritize being genuinely useful." +) + + +def _build_hermes_trace_html() -> Path: + from claude_tap.viewer import _generate_html_viewer + + entry = { + "timestamp": "2026-05-02T10:00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 500, + "request": { + "method": "POST", + "path": "/v1/chat/completions", + "headers": {}, + "body": { + "model": "openai/gpt-4", + "messages": [ + {"role": "system", "content": HERMES_SOUL_WITH_BRAND_MENTIONS}, + {"role": "user", "content": "hi"}, + ], + }, + }, + "response": { + "status": 200, + "body": { + "choices": [{"message": {"role": "assistant", "content": "Hello!"}}], + "model": "openai/gpt-4", + "usage": {"prompt_tokens": 80, "completion_tokens": 5}, + }, + }, + } + + with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False, mode="w", encoding="utf-8") as trace_f: + trace_f.write(json.dumps(entry) + "\n") + trace_path = Path(trace_f.name) + + html_path = Path(tempfile.mktemp(suffix=".html")) + _generate_html_viewer(trace_path, html_path) + trace_path.unlink(missing_ok=True) + return html_path + + +def test_hermes_trace_labels_as_hermes_not_claude_code() -> None: + from playwright.sync_api import sync_playwright + + html_path = _build_hermes_trace_html() + try: + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{html_path}", timeout=10000) + page.wait_for_selector(".sidebar-item .si-task", timeout=5000) + label = page.locator(".sidebar-item .si-task").first.text_content() + assert label == "Hermes", ( + f"Expected sidebar label 'Hermes', got {label!r}. The hermes " + f"system prompt mentions 'Claude Code' and 'OpenClaw' in passing — " + f"the self-id phrase 'You are Hermes Agent' must win over those " + f"generic substring matches." + ) + browser.close() + finally: + html_path.unlink(missing_ok=True) diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..601faad --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,445 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from claude_tap.history import cleanup_trace_sessions, delete_trace_history, migrate_legacy_traces +from claude_tap.trace_store import TraceStore, get_trace_store, reset_trace_store + + +def _write_legacy_session(base: Path, stem: str, *, date: str = "2026-05-01") -> Path: + date_dir = base / date if date != "legacy" else base + date_dir.mkdir(parents=True, exist_ok=True) + jsonl = date_dir / f"{stem}.jsonl" + jsonl.write_text( + json.dumps({"request_id": stem, "turn": 1, "request": {}, "response": {}}) + "\n", encoding="utf-8" + ) + (date_dir / f"{stem}.log").write_text("10:00:00 proxy log", encoding="utf-8") + return jsonl + + +def _write_v2_database(db_path: Path) -> None: + with sqlite3.connect(db_path) as conn: + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + date_key TEXT NOT NULL, + client TEXT NOT NULL DEFAULT '', + proxy_mode TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + record_count INTEGER NOT NULL DEFAULT 0, + summary_json TEXT, + legacy_rel_path TEXT UNIQUE + ); + CREATE TABLE records ( + session_id TEXT NOT NULL, + record_index INTEGER NOT NULL, + turn INTEGER, + timestamp TEXT, + payload_json TEXT NOT NULL, + PRIMARY KEY (session_id, record_index) + ); + CREATE TABLE proxy_logs ( + session_id TEXT NOT NULL, + line_no INTEGER NOT NULL, + logged_at TEXT, + level TEXT, + message TEXT NOT NULL, + PRIMARY KEY (session_id, line_no) + ); + CREATE TABLE migration_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT INTO sessions ( + id, started_at, updated_at, date_key, client, proxy_mode, + status, record_count, summary_json, legacy_rel_path + ) + VALUES ( + 'old-session', '2026-05-01T12:00:00+00:00', '2026-05-01T12:00:00+00:00', + '2026-05-01', 'claude', 'reverse', 'complete', 1, NULL, '2026-05-01/trace_same.jsonl' + ); + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + VALUES ('old-session', 1, 1, '2026-05-01T12:00:00+00:00', '{"turn":1}'); + INSERT INTO proxy_logs (session_id, line_no, logged_at, level, message) + VALUES ('old-session', 1, '12:00:00', 'INFO', 'legacy log'); + PRAGMA user_version = 2; + """ + ) + + +def test_migrate_legacy_directory_imports_jsonl_and_logs(trace_db, tmp_path: Path) -> None: + _write_legacy_session(tmp_path, "trace_old") + imported = migrate_legacy_traces(tmp_path) + + assert imported == 1 + sessions = get_trace_store().list_session_rows() + assert len(sessions) == 1 + assert sessions[0]["legacy_rel_path"] == "2026-05-01/trace_old.jsonl" + assert get_trace_store().export_log(sessions[0]["id"]).startswith("10:00:00") + + +def test_migrate_legacy_directory_dedupes_per_output_dir(trace_db, tmp_path: Path) -> None: + project_a = tmp_path / "project-a" + project_b = tmp_path / "project-b" + _write_legacy_session(project_a, "trace_same") + _write_legacy_session(project_b, "trace_same") + + assert migrate_legacy_traces(project_a) == 1 + assert migrate_legacy_traces(project_a) == 0 + assert migrate_legacy_traces(project_b) == 1 + + sessions = get_trace_store().list_session_rows() + assert len(sessions) == 2 + assert [row["legacy_rel_path"] for row in sessions].count("2026-05-01/trace_same.jsonl") == 2 + + +def test_migrate_legacy_directory_treats_duplicate_insert_as_already_imported( + trace_db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_legacy_session(tmp_path, "trace_same") + store = get_trace_store() + monkeypatch.setattr(store, "_legacy_session_exists", lambda _source, _rel_path: False) + + assert store.migrate_legacy_directory(tmp_path) == 1 + assert store.migrate_legacy_directory(tmp_path) == 0 + + sessions = get_trace_store().list_session_rows() + assert len(sessions) == 1 + assert sessions[0]["legacy_rel_path"] == "2026-05-01/trace_same.jsonl" + + +def test_migrate_legacy_directory_upgrades_v2_schema_for_source_key_dedupe( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + db_path = tmp_path / "v2.sqlite3" + _write_v2_database(db_path) + monkeypatch.setenv("CLOUDTAP_DB", str(db_path)) + reset_trace_store() + project = tmp_path / "project" + _write_legacy_session(project, "trace_same") + + assert migrate_legacy_traces(project) == 1 + + sessions = get_trace_store().list_session_rows() + assert len(sessions) == 2 + assert [row["legacy_rel_path"] for row in sessions].count("2026-05-01/trace_same.jsonl") == 2 + conn = get_trace_store()._connect() + assert conn.execute("PRAGMA foreign_keys").fetchone()[0] == 1 + conn.execute("DELETE FROM sessions WHERE id = 'old-session'") + conn.commit() + assert conn.execute("SELECT COUNT(*) FROM records WHERE session_id = 'old-session'").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM proxy_logs WHERE session_id = 'old-session'").fetchone()[0] == 0 + + +def test_v2_schema_migration_rolls_back_on_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + db_path = tmp_path / "v2-failure.sqlite3" + _write_v2_database(db_path) + store = TraceStore(db_path) + + def fail_indexes(_conn: sqlite3.Connection) -> None: + raise RuntimeError("index failure") + + monkeypatch.setattr(store, "_create_v3_indexes", fail_indexes) + + with pytest.raises(RuntimeError, match="index failure"): + store.list_session_rows() + + with sqlite3.connect(db_path) as conn: + assert conn.execute("PRAGMA user_version").fetchone()[0] == 2 + tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} + assert {"sessions", "records", "proxy_logs"} <= tables + assert not any(name.startswith("sessions_v2_") for name in tables) + columns = {row[1] for row in conn.execute("PRAGMA table_info(sessions)")} + assert "legacy_source_key" not in columns + + +def test_migrate_legacy_directory_reads_manifest_once( + trace_db, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_legacy_session(tmp_path, "trace_one") + _write_legacy_session(tmp_path, "trace_two") + (tmp_path / ".cloudtap-manifest.json").write_text( + json.dumps( + { + "traces": [ + {"files": ["2026-05-01/trace_one.jsonl"], "client": "claude"}, + {"files": ["2026-05-01/trace_two.jsonl"], "client": "codex"}, + ] + } + ), + encoding="utf-8", + ) + original_read_text = Path.read_text + manifest_reads: list[Path] = [] + + def counted_read_text(path: Path, *args: object, **kwargs: object) -> str: + if path.name == ".cloudtap-manifest.json": + manifest_reads.append(path) + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", counted_read_text) + + assert migrate_legacy_traces(tmp_path) == 2 + assert len(manifest_reads) == 1 + + +def test_append_log_refreshes_active_session_heartbeat(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + stale = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat() + conn = store._connect() + conn.execute("UPDATE sessions SET updated_at = ?, status = 'complete' WHERE id = ?", (stale, session_id)) + conn.commit() + + store.append_log(session_id, "proxy still alive", logged_at="12:00:00") + + row = store.load_session_row(session_id) + assert row is not None + assert row["updated_at"] > stale + assert row["status"] == "active" + + +def test_finalize_session_refreshes_cached_summary_timestamp(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + old_timestamp = "2026-05-01T12:00:00+00:00" + store.store_summary(session_id, {"id": session_id, "status": "active", "updated_at": old_timestamp}) + + store.finalize_session(session_id, {"api_calls": 0}) + + row = store.load_session_row(session_id) + assert row is not None + summary = json.loads(row["summary_json"]) + assert summary["updated_at"] == row["updated_at"] + assert summary["updated_at"] != old_timestamp + + +def test_session_rows_sort_by_normalized_updated_at(trace_db) -> None: + store = get_trace_store() + older = store.create_session(started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc)) + newer = store.create_session(started_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc)) + conn = store._connect() + conn.execute("UPDATE sessions SET updated_at = '2026-05-01T10:00:00+09:00' WHERE id = ?", (older,)) + conn.execute("UPDATE sessions SET updated_at = '2026-05-01T02:30:00+00:00' WHERE id = ?", (newer,)) + conn.commit() + + assert [row["id"] for row in store.list_session_rows()][:2] == [newer, older] + + +def test_cleanup_trace_sessions_prunes_by_normalized_started_at(trace_db) -> None: + store = get_trace_store() + oldest = store.create_session(started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc)) + middle = store.create_session(started_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc)) + newest = store.create_session(started_at=datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)) + for session_id in (oldest, middle, newest): + store.finalize_session(session_id, {"api_calls": 1}) + conn = store._connect() + conn.execute("UPDATE sessions SET started_at = '2026-05-01T10:00:00+09:00' WHERE id = ?", (oldest,)) + conn.execute("UPDATE sessions SET started_at = '2026-05-01T02:30:00+00:00' WHERE id = ?", (middle,)) + conn.execute("UPDATE sessions SET started_at = '2026-05-01T03:00:00+00:00' WHERE id = ?", (newest,)) + conn.commit() + + assert cleanup_trace_sessions(2) == 1 + assert {row["id"] for row in store.list_session_rows()} == {middle, newest} + + +def test_delete_trace_history_removes_selected_date_sessions(trace_db, tmp_path: Path) -> None: + _write_legacy_session(tmp_path, "trace_old", date="2026-05-01") + _write_legacy_session(tmp_path, "trace_active", date="2026-05-01") + _write_legacy_session(tmp_path, "trace_other", date="2026-05-02") + migrate_legacy_traces(tmp_path) + + sessions = {row["legacy_rel_path"]: row["id"] for row in get_trace_store().list_session_rows()} + protected = {sessions["2026-05-01/trace_active.jsonl"]} + + result = delete_trace_history("2026-05-01", protected_session_ids=protected) + + assert result["deleted_sessions"] == 1 + assert result["deleted_files"] == 1 + assert result["skipped_sessions"] == 1 + assert result["skipped_files"] == 1 + remaining = {row["legacy_rel_path"] for row in get_trace_store().list_session_rows()} + assert "2026-05-01/trace_old.jsonl" not in remaining + assert "2026-05-01/trace_active.jsonl" in remaining + assert "2026-05-02/trace_other.jsonl" in remaining + + +def test_cleanup_trace_sessions_keeps_newest(trace_db, tmp_path: Path) -> None: + for index in range(4): + _write_legacy_session(tmp_path, f"trace_{index:02d}", date="2026-05-01") + migrate_legacy_traces(tmp_path) + + removed = cleanup_trace_sessions(2) + + assert removed == 2 + assert len(get_trace_store().list_session_rows()) == 2 + + +def test_cleanup_trace_sessions_skips_protected_and_continues(trace_db) -> None: + store = get_trace_store() + session_ids = [ + store.create_session( + client="claude", + proxy_mode="reverse", + started_at=datetime(2026, 5, 1, 12, index, tzinfo=timezone.utc), + ) + for index in range(5) + ] + for session_id in session_ids: + store.finalize_session(session_id, {"api_calls": 1}) + + removed = cleanup_trace_sessions(2, protected_session_id=session_ids[0]) + + assert removed == 3 + remaining = {row["id"] for row in store.list_session_rows()} + assert remaining == {session_ids[0], session_ids[-1]} + + +def test_cleanup_trace_sessions_skips_protected_session_set(trace_db) -> None: + store = get_trace_store() + session_ids = [ + store.create_session( + client="codexapp", + proxy_mode="transcript", + started_at=datetime(2026, 5, 1, 12, index, tzinfo=timezone.utc), + ) + for index in range(5) + ] + for session_id in session_ids: + store.finalize_session(session_id, {"api_calls": 1}) + + removed = cleanup_trace_sessions(2, protected_session_ids={session_ids[0], session_ids[1]}) + + assert removed == 3 + remaining = {row["id"] for row in store.list_session_rows()} + assert remaining == {session_ids[0], session_ids[1]} + + +def test_cleanup_trace_sessions_skips_active_sessions(trace_db) -> None: + store = get_trace_store() + now = datetime.now(timezone.utc) + session_ids = [ + store.create_session( + client="claude", + proxy_mode="reverse", + started_at=now.replace(minute=index, second=0, microsecond=0), + ) + for index in range(4) + ] + for session_id in (session_ids[0], session_ids[2], session_ids[3]): + store.finalize_session(session_id, {"api_calls": 1}) + + removed = cleanup_trace_sessions(2) + + assert removed == 2 + rows = {row["id"]: row["status"] for row in store.list_session_rows()} + assert rows == {session_ids[1]: "active", session_ids[3]: "complete"} + + +def test_cleanup_trace_sessions_removes_stale_active_sessions(trace_db) -> None: + store = get_trace_store() + session_ids = [ + store.create_session( + client="claude", + proxy_mode="reverse", + started_at=datetime(2026, 5, 1, 12, index, tzinfo=timezone.utc), + ) + for index in range(4) + ] + + removed = cleanup_trace_sessions(2) + + assert removed == 2 + remaining = {row["id"] for row in store.list_session_rows()} + assert remaining == {session_ids[2], session_ids[3]} + + +@pytest.mark.asyncio +async def test_live_viewer_delete_history_endpoint(trace_db, tmp_path: Path) -> None: + import aiohttp + + from claude_tap import LiveViewerServer + + _write_legacy_session(tmp_path, "trace_delete_me", date="2026-05-01") + migrate_legacy_traces(tmp_path) + active_session = get_trace_store().create_session(client="claude", proxy_mode="reverse") + + server = LiveViewerServer(session_id=active_session, port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.delete(f"http://127.0.0.1:{port}/api/traces/2026-05-01") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["deleted_sessions"] == 1 + assert payload["deleted_files"] == 1 + + async with session.get(f"http://127.0.0.1:{port}/api/traces/2026-05-01") as resp: + assert resp.status == 200 + assert await resp.json() == [] + + async with session.delete(f"http://127.0.0.1:{port}/api/traces/not-a-date") as resp: + assert resp.status == 400 + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_shared_dashboard_delete_history_requires_force_for_active_sessions( + trace_db, + tmp_path: Path, +) -> None: + import aiohttp + + from claude_tap import LiveViewerServer + + _write_legacy_session(tmp_path, "trace_delete_me", date="2026-05-01") + migrate_legacy_traces(tmp_path) + active_session = get_trace_store().create_session( + client="claude", + proxy_mode="reverse", + started_at=datetime(2026, 5, 1, 12, 30, tzinfo=timezone.utc), + ) + conn = get_trace_store()._connect() + conn.execute( + "UPDATE sessions SET updated_at = ? WHERE id = ?", + (datetime.now(timezone.utc).isoformat(), active_session), + ) + conn.commit() + + server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.delete(f"http://127.0.0.1:{port}/api/traces/2026-05-01") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["deleted_sessions"] == 1 + assert payload["skipped_sessions"] == 1 + + remaining = {row["id"] for row in get_trace_store().list_session_rows()} + assert active_session in remaining + + async with session.delete(f"http://127.0.0.1:{port}/api/traces/2026-05-01?force=1") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["deleted_sessions"] == 1 + assert payload["skipped_sessions"] == 0 + + remaining = {row["id"] for row in get_trace_store().list_session_rows()} + assert active_session not in remaining + finally: + await server.stop() diff --git a/tests/test_kimi_code_launch.py b/tests/test_kimi_code_launch.py new file mode 100644 index 0000000..0e74b53 --- /dev/null +++ b/tests/test_kimi_code_launch.py @@ -0,0 +1,912 @@ +from __future__ import annotations + +import asyncio +import json +import shutil +import tomllib +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli_clients import ( + _KIMI_CODE_SKIP_MIGRATION_MARKER, + CLIENT_CONFIGS, + _detect_kimi_code_target, + _kimi_code_migration_already_handled, + _materialize_kimi_code_session_index, + _merge_kimi_code_session_index, + _normalize_kimi_code_fs_path, + _patch_kimi_code_config_text, + _persist_kimi_code_sandbox, + _prepare_kimi_code_reverse_sandbox, + _remap_kimi_code_sandbox_paths, + _reverse_proxy_trace_options, + run_client, +) + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_kimi_code_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["kimi-code"] + assert cfg.cmd == "kimi" + assert cfg.label == "Kimi Code CLI" + assert cfg.install_url == "https://github.com/MoonshotAI/kimi-code" + assert cfg.base_url_env == "KIMI_CODE_BASE_URL" + assert cfg.default_target == "https://api.kimi.com/coding/v1" + assert cfg.default_proxy_mode == "reverse" + + +def test_kimi_legacy_unchanged_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["kimi"] + assert cfg.base_url_env == "KIMI_BASE_URL" + assert cfg.install_url == "https://github.com/MoonshotAI/kimi-cli" + + +def test_parse_args_kimi_code_defaults_to_reverse_mode() -> None: + args = parse_args(["--tap-client", "kimi-code"]) + assert args.client == "kimi-code" + assert args.target == "https://api.kimi.com/coding/v1" + assert args.proxy_mode == "reverse" + + +def test_detect_kimi_code_target_reads_managed_provider(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + home = tmp_path / "kimi-code-home" + home.mkdir() + (home / "config.toml").write_text( + """ +default_model = "kimi-code/kimi-for-coding" + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "" + +[models."kimi-code/kimi-for-coding"] +provider = "managed:kimi-code" +model = "kimi-for-coding" +max_context_size = 262144 +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + assert _detect_kimi_code_target() == "https://api.kimi.com/coding/v1" + + +def test_detect_kimi_code_target_uses_shell_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KIMI_BASE_URL", "https://gateway.example.com/v1") + + assert _detect_kimi_code_target() == "https://gateway.example.com/v1" + + +def test_detect_kimi_code_target_uses_kimi_code_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KIMI_CODE_BASE_URL", "https://managed.example.com/coding/v1") + + assert _detect_kimi_code_target() == "https://managed.example.com/coding/v1" + + +def test_detect_kimi_code_target_model_arg_overrides_stale_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + home = tmp_path / "kimi-code-home" + home.mkdir() + (home / "config.toml").write_text( + """ +[models.selected] +provider = "managed:kimi-code" +model = "kimi-for-coding" + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://selected.example.com/coding/v1" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + monkeypatch.setenv("KIMI_MODEL_NAME", "stale") + monkeypatch.setenv("KIMI_MODEL_BASE_URL", "https://stale.example.com/v1") + + assert _detect_kimi_code_target(["-m", "selected"]) == "https://selected.example.com/coding/v1" + + +def test_detect_kimi_code_target_model_arg_without_base_url_uses_default( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = tmp_path / "kimi-code-home" + home.mkdir() + (home / "config.toml").write_text( + """ +[models.selected] +provider = "managed:kimi-code" +model = "kimi-for-coding" + +[providers."managed:kimi-code"] +type = "kimi" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + monkeypatch.setenv("KIMI_MODEL_BASE_URL", "https://stale.example.com/v1") + + assert _detect_kimi_code_target(["-m", "selected"]) == "https://api.kimi.com/coding/v1" + + +def test_detect_kimi_code_target_ignores_inactive_model_base_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = tmp_path / "kimi-code-home" + home.mkdir() + (home / "config.toml").write_text( + """ +default_model = "selected" + +[models.selected] +provider = "managed:kimi-code" +model = "kimi-for-coding" + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://selected.example.com/coding/v1" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + monkeypatch.setenv("KIMI_MODEL_BASE_URL", "https://stale.example.com/v1") + + assert _detect_kimi_code_target() == "https://selected.example.com/coding/v1" + + +def test_detect_kimi_code_target_reads_config_file_arg(tmp_path: Path) -> None: + override_config = tmp_path / "override.toml" + override_config.write_text( + """ +default_model = "custom/model" + +[providers."custom"] +type = "kimi" +base_url = "https://custom.example.com/v1" + +[models."custom/model"] +provider = "custom" +model = "model" +""".strip(), + encoding="utf-8", + ) + + assert _detect_kimi_code_target(["--config-file", str(override_config)]) == "https://custom.example.com/v1" + + +def test_patch_kimi_code_config_text_rewrites_provider_base_url() -> None: + source = """ +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "" +""".strip() + patched, providers = _patch_kimi_code_config_text(source, "http://127.0.0.1:43123") + assert 'base_url = "http://127.0.0.1:43123"' in patched + assert providers == ["managed:kimi-code"] + + +def test_patch_kimi_code_config_text_rewrites_custom_gateway_provider() -> None: + source = """ +[providers."custom-gateway"] +type = "kimi" +base_url="https://gateway.example.com/v1" +api_key = "" +""".strip() + patched, providers = _patch_kimi_code_config_text(source, "http://127.0.0.1:43123") + assert 'base_url="http://127.0.0.1:43123"' in patched + assert providers == ["custom-gateway"] + + +def test_patch_kimi_code_config_text_rewrites_env_kimi_base_url() -> None: + source = """ +[providers."env-only"] +type = "kimi" + +[providers."env-only".env] +KIMI_BASE_URL = "https://gateway.example.com/v1" +""".strip() + patched, providers = _patch_kimi_code_config_text(source, "http://127.0.0.1:43123") + assert 'KIMI_BASE_URL = "http://127.0.0.1:43123"' in patched + assert providers == ["env-only"] + + +def test_patch_kimi_code_config_text_rewrites_inline_comments() -> None: + source = """ +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" # production endpoint +api_key = "" +""".strip() + patched, providers = _patch_kimi_code_config_text(source, "http://127.0.0.1:43123") + + assert 'base_url = "http://127.0.0.1:43123" # production endpoint' in patched + assert providers == ["managed:kimi-code"] + + +def test_patch_kimi_code_config_text_inserts_missing_provider_base_url() -> None: + source = """ +[providers."managed:kimi-code"] +type = "kimi" +api_key = "" +""".strip() + patched, providers = _patch_kimi_code_config_text(source, "http://127.0.0.1:43123") + + assert '[providers."managed:kimi-code"]\nbase_url = "http://127.0.0.1:43123"\n' in patched + assert providers == ["managed:kimi-code"] + + +def test_patch_kimi_code_config_text_only_rewrites_selected_provider() -> None: + source = """ +default_model = "custom/model" + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "" + +[providers."custom"] +type = "kimi" +base_url = "https://gateway.example.com/v1" +api_key = "" + +[models."custom/model"] +provider = "custom" +model = "model" +max_context_size = 1000 +""".strip() + patched, providers = _patch_kimi_code_config_text(source, "http://127.0.0.1:43123") + + assert 'base_url = "https://api.kimi.com/coding/v1"' in patched + assert 'base_url = "http://127.0.0.1:43123"' in patched + assert providers == ["custom"] + + +@pytest.mark.asyncio +async def test_run_client_kimi_code_reverse_sets_kimi_code_home( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + home = tmp_path / "source-home" + home.mkdir() + (home / "config.toml").write_text( + """ +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "sk-test" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("claude_tap.cli_clients.shutil.rmtree", lambda *_args, **_kwargs: None) + + code = await run_client(43123, ["--thinking"], client="kimi-code", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == ("/tmp/kimi", "--thinking") + env = captured["env"] + sandbox = Path(env["KIMI_CODE_HOME"]) + assert sandbox.is_dir() + assert env["KIMI_CODE_BASE_URL"] == "http://127.0.0.1:43123" + assert env["KIMI_BASE_URL"] == "http://127.0.0.1:43123" + config = tomllib.loads((sandbox / "config.toml").read_text(encoding="utf-8")) + provider = config["providers"]["managed:kimi-code"] + assert provider["base_url"] == "http://127.0.0.1:43123" + + +@pytest.mark.asyncio +async def test_run_client_kimi_code_reverse_rewrites_config_file_arg( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + home = tmp_path / "source-home" + home.mkdir() + override_config = tmp_path / "override.toml" + override_config.write_text( + """ +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://gateway.example.com/v1" +api_key = "sk-test" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("claude_tap.cli_clients.shutil.rmtree", lambda *_args, **_kwargs: None) + + code = await run_client( + 43123, + ["--config-file", str(override_config), "--prompt", "hi"], + client="kimi-code", + proxy_mode="reverse", + ) + + assert code == 0 + cmd = captured["cmd"] + assert cmd[1] == "--config-file" + patched_config = Path(cmd[2]) + assert "claude_tap_kimi_code_" in str(patched_config) + assert patched_config.read_text(encoding="utf-8").count("http://127.0.0.1:43123") == 1 + assert str(override_config) not in cmd + + +@pytest.mark.asyncio +async def test_run_client_kimi_code_reverse_rewrites_model_env_override( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + home = tmp_path / "source-home" + home.mkdir() + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + monkeypatch.setenv("KIMI_MODEL_NAME", "env-model") + monkeypatch.setenv("KIMI_MODEL_BASE_URL", "https://gateway.example.com/v1") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--prompt", "hi"], client="kimi-code", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert env["KIMI_MODEL_BASE_URL"] == "http://127.0.0.1:43123" + assert env["KIMI_BASE_URL"] == "http://127.0.0.1:43123" + + +@pytest.mark.asyncio +async def test_run_client_kimi_code_reverse_drops_inactive_model_base_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + home = tmp_path / "source-home" + home.mkdir() + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + monkeypatch.setenv("KIMI_MODEL_BASE_URL", "https://stale.example.com/v1") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--prompt", "hi"], client="kimi-code", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert "KIMI_MODEL_BASE_URL" not in env + assert env["KIMI_BASE_URL"] == "http://127.0.0.1:43123" + + +@pytest.mark.asyncio +async def test_run_client_kimi_code_reverse_does_not_proxy_non_kimi_model_env_without_base_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + home = tmp_path / "source-home" + home.mkdir() + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + monkeypatch.setenv("KIMI_MODEL_NAME", "claude-env") + monkeypatch.setenv("KIMI_MODEL_PROVIDER_TYPE", "anthropic") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--prompt", "hi"], client="kimi-code", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert "KIMI_MODEL_BASE_URL" not in env + assert env["KIMI_MODEL_NAME"] == "claude-env" + assert env["KIMI_BASE_URL"] == "http://127.0.0.1:43123" + + +@pytest.mark.asyncio +async def test_run_client_kimi_code_model_arg_clears_model_env_override( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured: dict[str, object] = {} + home = tmp_path / "source-home" + home.mkdir() + (home / "config.toml").write_text( + """ +[models.selected] +provider = "managed:kimi-code" +model = "kimi-for-coding" + +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://selected.example.com/coding/v1" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + monkeypatch.setenv("KIMI_MODEL_NAME", "stale") + monkeypatch.setenv("KIMI_MODEL_BASE_URL", "https://stale.example.com/v1") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["-m", "selected", "--prompt", "hi"], client="kimi-code", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert "KIMI_MODEL_NAME" not in env + assert "KIMI_MODEL_BASE_URL" not in env + assert env["KIMI_BASE_URL"] == "http://127.0.0.1:43123" + + +def test_kimi_code_migration_already_handled_reads_legacy_marker( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + real_home = tmp_path / "real-kimi-code" + real_home.mkdir() + legacy = tmp_path / ".kimi" + legacy.mkdir() + (legacy / ".migrated-to-kimi-code").write_text( + json.dumps({"target_path": str(real_home)}), + encoding="utf-8", + ) + monkeypatch.setattr("claude_tap.cli_clients.Path.home", lambda: tmp_path) + + assert _kimi_code_migration_already_handled(real_home) is True + assert _kimi_code_migration_already_handled(tmp_path / "other-home") is False + + +def test_prepare_kimi_code_reverse_sandbox_writes_skip_marker_when_migrated( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + real_home = tmp_path / "real-kimi-code" + real_home.mkdir() + legacy = tmp_path / ".kimi" + legacy.mkdir() + (legacy / ".migrated-to-kimi-code").write_text( + json.dumps({"target_path": str(real_home)}), + encoding="utf-8", + ) + (real_home / "config.toml").write_text( + '[providers."managed:kimi-code"]\ntype = "kimi"\nbase_url = "https://api.kimi.com/coding/v1"\n', + encoding="utf-8", + ) + monkeypatch.setattr("claude_tap.cli_clients.Path.home", lambda: tmp_path) + monkeypatch.setenv("KIMI_CODE_HOME", str(real_home)) + + sandbox, _, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + assert (sandbox / _KIMI_CODE_SKIP_MIGRATION_MARKER).is_file() + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_prepare_kimi_code_reverse_sandbox_copies_existing_skip_marker( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + real_home = tmp_path / "real-kimi-code" + real_home.mkdir() + (real_home / _KIMI_CODE_SKIP_MIGRATION_MARKER).write_text("keep", encoding="utf-8") + (real_home / "config.toml").write_text( + '[providers."managed:kimi-code"]\ntype = "kimi"\nbase_url = "https://api.kimi.com/coding/v1"\n', + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(real_home)) + + sandbox, _, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + assert (sandbox / _KIMI_CODE_SKIP_MIGRATION_MARKER).read_text(encoding="utf-8") == "keep" + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_prepare_kimi_code_reverse_sandbox_replaces_placeholder_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + real_home = tmp_path / "real-kimi-code" + real_home.mkdir() + (real_home / "config.toml").write_text( + "# Placeholder created by kimi-code before login.\n", + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(real_home)) + + sandbox, providers, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + config = tomllib.loads((sandbox / "config.toml").read_text(encoding="utf-8")) + assert providers == ["managed:kimi-code"] + assert config["providers"]["managed:kimi-code"]["base_url"] == "http://127.0.0.1:43123" + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_prepare_kimi_code_reverse_sandbox_symlinks_auth_dirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + home = tmp_path / "source-home" + oauth_dir = home / "oauth" + oauth_dir.mkdir(parents=True) + (oauth_dir / "kimi-code").write_text("", encoding="utf-8") + credentials_dir = home / "credentials" + credentials_dir.mkdir(parents=True) + (credentials_dir / "kimi-code.json").write_text('{"access_token":"test"}', encoding="utf-8") + (home / "config.toml").write_text( + '[providers."managed:kimi-code"]\ntype = "kimi"\nbase_url = "https://api.kimi.com/coding/v1"\n', + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + sandbox, providers, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + assert (sandbox / "oauth").is_symlink() + assert (sandbox / "oauth").resolve() == oauth_dir.resolve() + assert (sandbox / "credentials").is_symlink() + assert (sandbox / "credentials").resolve() == credentials_dir.resolve() + assert providers == ["managed:kimi-code"] + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_prepare_kimi_code_reverse_sandbox_creates_auth_dirs_for_first_login( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = tmp_path / "source-home" + home.mkdir() + (home / "config.toml").write_text( + '[providers."managed:kimi-code"]\ntype = "kimi"\nbase_url = "https://api.kimi.com/coding/v1"\n', + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + sandbox, _, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + assert (home / "oauth").is_dir() + assert (home / "credentials").is_dir() + assert (sandbox / "oauth").is_symlink() + assert (sandbox / "credentials").is_symlink() + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_prepare_kimi_code_reverse_sandbox_preserves_non_kimi_provider_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = tmp_path / "source-home" + home.mkdir() + (home / "config.toml").write_text( + """ +default_model = "gpt/test" + +[models."gpt/test"] +provider = "openai" +model = "gpt-test" + +[providers.openai] +type = "openai" +base_url = "https://openai.example.com/v1" +api_key = "sk-test" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + sandbox, providers, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + config = (sandbox / "config.toml").read_text(encoding="utf-8") + assert providers == [] + assert 'type = "openai"' in config + assert 'base_url = "https://openai.example.com/v1"' in config + assert "managed:kimi-code" not in config + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_prepare_kimi_code_reverse_sandbox_links_sessions_and_mcp( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = tmp_path / "source-home" + sessions_dir = home / "sessions" + sessions_dir.mkdir(parents=True) + (sessions_dir / "abc.jsonl").write_text("{}", encoding="utf-8") + plugins_dir = home / "plugins" + plugins_dir.mkdir() + (plugins_dir / "installed.json").write_text("[]", encoding="utf-8") + skills_dir = home / "skills" + skills_dir.mkdir() + (skills_dir / "custom-skill.md").write_text("skill", encoding="utf-8") + (home / "AGENTS.md").write_text("agent instructions", encoding="utf-8") + (home / "mcp.json").write_text("{}", encoding="utf-8") + (home / "tui.toml").write_text('theme = "dark"\n', encoding="utf-8") + (home / "config.toml").write_text( + '[providers."managed:kimi-code"]\ntype = "kimi"\nbase_url = "https://api.kimi.com/coding/v1"\n', + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + sandbox, _, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + assert (sandbox / "sessions").is_symlink() + assert (sandbox / "sessions").resolve() == sessions_dir.resolve() + assert (sandbox / "plugins").is_symlink() + assert (sandbox / "plugins").resolve() == plugins_dir.resolve() + assert (sandbox / "skills").is_symlink() + assert (sandbox / "skills").resolve() == skills_dir.resolve() + assert (sandbox / "AGENTS.md").is_symlink() + assert (sandbox / "AGENTS.md").resolve() == (home / "AGENTS.md").resolve() + assert (sandbox / "mcp.json").is_symlink() + assert (sandbox / "mcp.json").resolve() == (home / "mcp.json").resolve() + assert (sandbox / "tui.toml").is_symlink() + assert (sandbox / "tui.toml").resolve() == (home / "tui.toml").resolve() + assert (sandbox / "session_index.jsonl").is_file() + assert not (sandbox / "session_index.jsonl").is_symlink() + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_prepare_kimi_code_reverse_sandbox_copies_when_symlinks_fail( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = tmp_path / "source-home" + oauth_dir = home / "oauth" + oauth_dir.mkdir(parents=True) + (oauth_dir / "kimi-code").write_text("oauth-token", encoding="utf-8") + credentials_dir = home / "credentials" + credentials_dir.mkdir() + (credentials_dir / "kimi-code.json").write_text('{"access_token":"test"}', encoding="utf-8") + (credentials_dir / "stale.json").write_text('{"access_token":"old"}', encoding="utf-8") + plugins_dir = home / "plugins" + plugins_dir.mkdir() + (plugins_dir / "installed.json").write_text('["old-plugin"]', encoding="utf-8") + skills_dir = home / "skills" + skills_dir.mkdir() + (skills_dir / "custom-skill.md").write_text("old skill", encoding="utf-8") + (home / "AGENTS.md").write_text("old instructions", encoding="utf-8") + sessions_dir = home / "sessions" + sessions_dir.mkdir() + (sessions_dir / "abc.jsonl").write_text("{}", encoding="utf-8") + (home / "mcp.json").write_text("{}", encoding="utf-8") + (home / "tui.toml").write_text('theme = "dark"\n', encoding="utf-8") + (home / "config.toml").write_text( + '[providers."managed:kimi-code"]\ntype = "kimi"\nbase_url = "https://api.kimi.com/coding/v1"\n', + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + def fail_symlink(self: Path, target: Path, target_is_directory: bool = False) -> None: + raise OSError("symlink unavailable") + + monkeypatch.setattr(Path, "symlink_to", fail_symlink) + + sandbox, _, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + assert not (sandbox / "oauth").is_symlink() + assert (sandbox / "oauth" / "kimi-code").read_text(encoding="utf-8") == "oauth-token" + assert (sandbox / "credentials" / "kimi-code.json").is_file() + assert (sandbox / "plugins" / "installed.json").is_file() + assert (sandbox / "skills" / "custom-skill.md").is_file() + assert (sandbox / "AGENTS.md").is_file() + assert (sandbox / "sessions" / "abc.jsonl").is_file() + assert (sandbox / "mcp.json").is_file() + assert (sandbox / "tui.toml").is_file() + + (sandbox / "oauth" / "new-login").write_text("persisted", encoding="utf-8") + (sandbox / "credentials" / "stale.json").unlink() + (sandbox / "plugins" / "installed.json").write_text('["new-plugin"]', encoding="utf-8") + (sandbox / "skills" / "custom-skill.md").write_text("new skill", encoding="utf-8") + (sandbox / "AGENTS.md").write_text("new instructions", encoding="utf-8") + (sandbox / "tui.toml").write_text('theme = "light"\n', encoding="utf-8") + _persist_kimi_code_sandbox(home, sandbox) + + assert (home / "oauth" / "new-login").read_text(encoding="utf-8") == "persisted" + assert not (home / "credentials" / "stale.json").exists() + assert (home / "plugins" / "installed.json").read_text(encoding="utf-8") == '["new-plugin"]' + assert (home / "skills" / "custom-skill.md").read_text(encoding="utf-8") == "new skill" + assert (home / "AGENTS.md").read_text(encoding="utf-8") == "new instructions" + assert (home / "tui.toml").read_text(encoding="utf-8") == 'theme = "light"\n' + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_persist_kimi_code_sandbox_writes_config_edits_without_proxy_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + home = tmp_path / "source-home" + home.mkdir() + (home / "config.toml").write_text( + """ +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("KIMI_CODE_HOME", str(home)) + + sandbox, _, _, _ = _prepare_kimi_code_reverse_sandbox(43123) + try: + sandbox_config = sandbox / "config.toml" + sandbox_config.write_text( + sandbox_config.read_text(encoding="utf-8") + '\n[ui]\ntheme = "dark"\n', + encoding="utf-8", + ) + + _persist_kimi_code_sandbox(home, sandbox) + + persisted = (home / "config.toml").read_text(encoding="utf-8") + assert "http://127.0.0.1:43123" not in persisted + assert 'base_url = "https://api.kimi.com/coding/v1"' in persisted + assert 'theme = "dark"' in persisted + finally: + shutil.rmtree(sandbox, ignore_errors=True) + + +def test_materialize_kimi_code_session_index_rewrites_session_dir(tmp_path: Path) -> None: + source_home = tmp_path / "home" + sandbox = tmp_path / "sandbox" + source_home.mkdir() + sandbox.mkdir() + session_dir = source_home / "sessions" / "wd_demo_abcd1234" / "session_test-id" + session_dir.mkdir(parents=True) + entry = { + "sessionId": "session_test-id", + "sessionDir": str(session_dir), + "workDir": "/tmp/demo", + } + (source_home / "session_index.jsonl").write_text(json.dumps(entry) + "\n", encoding="utf-8") + + _materialize_kimi_code_session_index(source_home, sandbox) + + index = json.loads((sandbox / "session_index.jsonl").read_text(encoding="utf-8").strip()) + expected_dir = _normalize_kimi_code_fs_path(str(sandbox / "sessions" / "wd_demo_abcd1234" / "session_test-id")) + assert index["sessionDir"] == expected_dir + assert not index["sessionDir"].startswith("/private/") + + +def test_materialize_kimi_code_session_index_skips_malformed_rows(tmp_path: Path) -> None: + source_home = tmp_path / "home" + sandbox = tmp_path / "sandbox" + source_home.mkdir() + sandbox.mkdir() + session_dir = source_home / "sessions" / "wd_demo_abcd1234" / "session_test-id" + session_dir.mkdir(parents=True) + entry = { + "sessionId": "session_test-id", + "sessionDir": str(session_dir), + "workDir": "/tmp/demo", + } + (source_home / "session_index.jsonl").write_text( + "{not json}\n" + json.dumps(entry) + "\n[1, 2, 3]\n", + encoding="utf-8", + ) + + _materialize_kimi_code_session_index(source_home, sandbox) + + lines = (sandbox / "session_index.jsonl").read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + index = json.loads(lines[0]) + expected_dir = _normalize_kimi_code_fs_path(str(sandbox / "sessions" / "wd_demo_abcd1234" / "session_test-id")) + assert index["sessionId"] == "session_test-id" + assert index["sessionDir"] == expected_dir + + +def test_merge_kimi_code_session_index_skips_malformed_rows(tmp_path: Path) -> None: + source_home = tmp_path / "home" + sandbox = tmp_path / "sandbox" + source_home.mkdir() + sandbox.mkdir() + source_session_dir = source_home / "sessions" / "wd_demo_source" / "session_source" + sandbox_session_dir = sandbox / "sessions" / "wd_demo_new" / "session_new" + source_session_dir.mkdir(parents=True) + sandbox_session_dir.mkdir(parents=True) + source_entry = { + "sessionId": "session_source", + "sessionDir": str(source_session_dir), + "workDir": "/tmp/source", + } + sandbox_entry = { + "sessionId": "session_new", + "sessionDir": str(sandbox_session_dir), + "workDir": "/tmp/new", + } + (source_home / "session_index.jsonl").write_text( + "{bad source row}\n" + json.dumps(source_entry) + "\n", + encoding="utf-8", + ) + (sandbox / "session_index.jsonl").write_text( + "{bad sandbox row}\n" + json.dumps(sandbox_entry) + "\n", + encoding="utf-8", + ) + + _merge_kimi_code_session_index(source_home, sandbox) + + entries = [ + json.loads(line) for line in (source_home / "session_index.jsonl").read_text(encoding="utf-8").splitlines() + ] + by_id = {entry["sessionId"]: entry for entry in entries} + assert set(by_id) == {"session_source", "session_new"} + assert by_id["session_source"]["sessionDir"] == _normalize_kimi_code_fs_path(str(source_session_dir)) + assert by_id["session_new"]["sessionDir"] == _normalize_kimi_code_fs_path( + str(source_home / "sessions" / "wd_demo_new" / "session_new") + ) + + +def test_remap_kimi_code_sandbox_paths_rewrites_session_index_and_state(tmp_path: Path) -> None: + source_home = tmp_path / "home" + sandbox = tmp_path / "claude_tap_kimi_code_test" + source_home.mkdir() + sandbox.mkdir() + session_dir = source_home / "sessions" / "wd_demo_abcd1234" / "session_test-id" + session_dir.mkdir(parents=True) + sandbox_session_dir = sandbox / "sessions" / "wd_demo_abcd1234" / "session_test-id" + state = { + "agents": { + "main": { + "homedir": str(sandbox_session_dir / "agents" / "main"), + } + } + } + (session_dir / "state.json").write_text(json.dumps(state), encoding="utf-8") + index_entry = { + "sessionId": "session_test-id", + "sessionDir": str(sandbox_session_dir), + "workDir": "/tmp/demo", + } + (source_home / "session_index.jsonl").write_text(json.dumps(index_entry) + "\n", encoding="utf-8") + + _remap_kimi_code_sandbox_paths(source_home, sandbox) + + index = json.loads((source_home / "session_index.jsonl").read_text(encoding="utf-8").strip()) + assert index["sessionDir"] == _normalize_kimi_code_fs_path(str(session_dir)) + updated_state = json.loads((session_dir / "state.json").read_text(encoding="utf-8")) + assert updated_state["agents"]["main"]["homedir"] == _normalize_kimi_code_fs_path( + str(session_dir / "agents" / "main") + ) + + +def test_kimi_code_reverse_trace_options_do_not_strip_path_prefix() -> None: + options = _reverse_proxy_trace_options("kimi-code", "https://api.kimi.com/coding/v1") + assert options == {"strip_path_prefix": "", "force_http": False} diff --git a/tests/test_kimi_launch.py b/tests/test_kimi_launch.py new file mode 100644 index 0000000..4e08c98 --- /dev/null +++ b/tests/test_kimi_launch.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, _reverse_proxy_trace_options, run_client + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_kimi_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["kimi"] + assert cfg.cmd == "kimi" + assert cfg.label == "Kimi Code CLI" + assert cfg.default_target == "https://api.kimi.com/coding/v1" + assert cfg.base_url_env == "KIMI_BASE_URL" + assert cfg.base_url_suffix == "" + assert cfg.default_proxy_mode == "reverse" + + +def test_parse_args_kimi_defaults_to_reverse_mode() -> None: + args = parse_args(["--tap-client", "kimi"]) + assert args.client == "kimi" + assert args.target == "https://api.kimi.com/coding/v1" + assert args.proxy_mode == "reverse" + + +def test_parse_args_accepts_every_registered_client(monkeypatch, tmp_path) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home")) + + for client in CLIENT_CONFIGS: + args = parse_args(["--tap-client", client]) + assert args.client == client + + +@pytest.mark.asyncio +async def test_run_client_kimi_reverse_sets_kimi_base_url(monkeypatch) -> None: + captured: dict[str, object] = {} + for key in ("MOONSHOT_BASE_URL", "OPENAI_BASE_URL", "OPENROUTER_BASE_URL"): + monkeypatch.delenv(key, raising=False) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--thinking"], client="kimi", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == ("/tmp/kimi", "--thinking") + env = captured["env"] + assert env["KIMI_BASE_URL"] == "http://127.0.0.1:43123" + assert "MOONSHOT_BASE_URL" not in env + assert "OPENAI_BASE_URL" not in env + assert "OPENROUTER_BASE_URL" not in env + + +@pytest.mark.asyncio +async def test_run_client_kimi_capture_only_reverse_sets_multi_provider_urls(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/kimi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--thinking"], client="kimi", proxy_mode="reverse", capture_only=True) + + assert code == 0 + env = captured["env"] + assert env["KIMI_BASE_URL"] == "http://127.0.0.1:43123" + assert env["MOONSHOT_BASE_URL"] == "http://127.0.0.1:43123/v1" + assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" + assert env["OPENROUTER_BASE_URL"] == "http://127.0.0.1:43123/v1" + + +def test_kimi_reverse_trace_options_do_not_strip_path_prefix() -> None: + options = _reverse_proxy_trace_options("kimi", "https://api.kimi.com/coding/v1") + + assert options == { + "strip_path_prefix": "", + "force_http": False, + } diff --git a/tests/test_loopback_target.py b/tests/test_loopback_target.py new file mode 100644 index 0000000..6cd31f0 --- /dev/null +++ b/tests/test_loopback_target.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from claude_tap.cli import _extend_no_proxy, _loopback_target_host + + +@pytest.mark.parametrize( + ("target", "expected_host"), + [ + ("http://127.0.0.1:23333/api/anthropic", "127.0.0.1"), + ("http://127.0.0.2:23333", "127.0.0.2"), + ("http://127.255.255.254:8080", "127.255.255.254"), + ("http://localhost:8080", "localhost"), + ("https://LOCALHOST:1/x", "localhost"), + ("http://[::1]:9000", "::1"), + ], +) +def test_loopback_target_host_detected(target: str, expected_host: str) -> None: + assert _loopback_target_host(target) == expected_host + + +@pytest.mark.parametrize( + "target", + [ + "https://api.anthropic.com", + "https://api.deepseek.com/anthropic", + "http://10.0.0.5:23333", + "http://8.8.8.8", + None, + "", + ], +) +def test_non_loopback_targets_return_none(target: str | None) -> None: + assert _loopback_target_host(target) is None + + +def test_extend_no_proxy_preserves_existing_entries() -> None: + env = {"NO_PROXY": "example.com"} + _extend_no_proxy(env, ("127.0.0.2",)) + entries = env["NO_PROXY"].split(",") + assert "example.com" in entries + assert "127.0.0.2" in entries + # Mirrored into the lowercase variant for tools that read it. + assert env["no_proxy"] == env["NO_PROXY"] + + +@pytest.mark.parametrize("key", ["NO_PROXY", "no_proxy"]) +def test_extend_no_proxy_preserves_wildcard_sentinel(key: str) -> None: + env = {key: "*"} + _extend_no_proxy(env, ("127.0.0.2",)) + assert env["NO_PROXY"] == "*" + assert env["no_proxy"] == "*" diff --git a/tests/test_macos_app.py b/tests/test_macos_app.py new file mode 100644 index 0000000..8a8c335 --- /dev/null +++ b/tests/test_macos_app.py @@ -0,0 +1,1060 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from claude_tap import macos_app +from claude_tap.cli import main_entry +from claude_tap.macos_app import ( + DashboardMonitorController, + MacOSMenuApp, + build_dashboard_command, + build_proxy_command, + parse_macos_app_args, +) + + +@pytest.fixture(autouse=True) +def _isolate_global_config(tmp_path_factory: pytest.TempPathFactory, monkeypatch: pytest.MonkeyPatch) -> None: + """Redirect $HOME so controller tests exercising the real ``global_inject`` + callables never read or write the developer's real ~/.claude, ~/.codex, or + ~/.claude-tap monitor state.""" + fake_home = tmp_path_factory.mktemp("home") + monkeypatch.setattr(Path, "home", lambda: fake_home) + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.setattr(macos_app, "_stop_incompatible_dashboard_sync", lambda _host, _port, _url: None) + + +def test_build_dashboard_command_starts_dashboard_without_opening_browser(tmp_path: Path) -> None: + cmd = build_dashboard_command( + python_executable="/usr/bin/python3", + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + ) + + assert cmd == [ + "/usr/bin/python3", + "-m", + "claude_tap", + "dashboard", + "--tap-live-port", + "19527", + "--tap-no-open", + "--tap-output-dir", + str(tmp_path), + ] + + +def test_build_dashboard_command_adds_non_default_host(tmp_path: Path) -> None: + cmd = build_dashboard_command( + python_executable="/usr/bin/python3", + host="0.0.0.0", + port=19527, + output_dir=tmp_path, + ) + + assert cmd[-2:] == ["--tap-host", "0.0.0.0"] + + +def test_build_dashboard_command_uses_frozen_executable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(sys, "frozen", True, raising=False) + + cmd = build_dashboard_command( + python_executable="/Applications/Claude Tap.app/Contents/Resources/claude-tap/claude-tap", + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + ) + + assert cmd[:4] == [ + "/Applications/Claude Tap.app/Contents/Resources/claude-tap/claude-tap", + "dashboard", + "--tap-live-port", + "19527", + ] + + +def test_build_proxy_command_starts_reverse_proxy_without_dashboard(tmp_path: Path) -> None: + cmd = build_proxy_command( + python_executable="/usr/bin/python3", + client="claude", + host="127.0.0.1", + port=19528, + output_dir=tmp_path, + ) + + assert cmd == [ + "/usr/bin/python3", + "-m", + "claude_tap", + "--tap-no-launch", + "--tap-client", + "claude", + "--tap-port", + "19528", + "--tap-host", + "127.0.0.1", + "--tap-no-live", + "--tap-output-dir", + str(tmp_path), + ] + + +def test_debug_log_helpers_resolve_enable_write_and_ignore_errors( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source_file = tmp_path / "src" / "claude_tap" / "macos_app.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("") + monkeypatch.setattr(macos_app, "__file__", str(source_file)) + monkeypatch.setattr(macos_app, "_DEBUG_LOG_PATH", None) + + log_path = macos_app._resolve_debug_log_path() + + assert log_path == tmp_path / "src" / "dist" / "claude-tap-macos-debug.log" + + macos_app._enable_debug_logging() + macos_app._debug_log("hello debug") + + assert log_path is not None + assert "hello debug" in log_path.read_text(encoding="utf-8") + + monkeypatch.setattr(macos_app, "_DEBUG_LOG_PATH", tmp_path) + macos_app._debug_log("ignored") + + +def test_monitor_controller_debug_state_reports_probe_errors(tmp_path: Path) -> None: + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + injection_is_active=lambda: (_ for _ in ()).throw(RuntimeError("state failed")), + ) + + assert "injection_active=error:RuntimeError('state failed')" in controller._debug_state() + + +def test_monitor_controller_reuses_healthy_dashboard_and_starts_proxies(tmp_path: Path) -> None: + spawned: list[list[str]] = [] + injected: list[tuple[int, int]] = [] + active = False + + class FakeProcess: + def poll(self) -> int | None: + return None + + def fake_popen(cmd: list[str], **_kwargs: object) -> FakeProcess: + spawned.append(cmd) + return FakeProcess() + + def fake_enable_injection(*, claude_port: int, codex_port: int, **_kwargs: object) -> None: + nonlocal active + injected.append((claude_port, codex_port)) + active = True + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable="/usr/bin/python3", + popen=fake_popen, + is_healthy=lambda _host, _port: True, + proxy_is_healthy=lambda _port, _client: False, + enable_injection=fake_enable_injection, + injection_is_active=lambda: active, + ) + + assert controller.start() == "http://127.0.0.1:19527" + assert len(spawned) == 2 + assert all("dashboard" not in cmd for cmd in spawned) + assert injected == [(19528, 19529)] + assert controller.is_running() is True + + +def test_monitor_controller_spawns_dashboard_process(tmp_path: Path) -> None: + spawned: list[tuple[list[str], dict[str, object]]] = [] + + class FakeProcess: + def poll(self) -> int | None: + return None + + def fake_popen(cmd: list[str], **kwargs: object) -> FakeProcess: + spawned.append((cmd, kwargs)) + return FakeProcess() + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=fake_popen, + is_healthy=lambda _host, _port: False, + proxy_is_healthy=lambda _port, _client: False, + ) + + assert controller.start() == "http://127.0.0.1:19527" + assert len(spawned) == 3 + cmd, kwargs = spawned[0] + assert cmd[:4] == [sys.executable, "-m", "claude_tap", "dashboard"] + assert kwargs["stdin"] == subprocess.DEVNULL + assert kwargs["stdout"] == subprocess.DEVNULL + assert kwargs["stderr"] == subprocess.DEVNULL + assert kwargs["start_new_session"] is True + + +def test_monitor_controller_start_spawns_proxies_and_enables_global_injection(tmp_path: Path) -> None: + spawned: list[list[str]] = [] + injected: list[tuple[int, int, list[dict[str, object]]]] = [] + + class FakeProcess: + def __init__(self, pid: int) -> None: + self.pid = pid + + def poll(self) -> int | None: + return None + + def fake_popen(cmd: list[str], **_kwargs: object) -> FakeProcess: + spawned.append(cmd) + return FakeProcess(4200 + len(spawned)) + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=fake_popen, + is_healthy=lambda _host, _port: False, + proxy_is_healthy=lambda _port, _client: False, + enable_injection=lambda *, claude_port, codex_port, processes: injected.append( + (claude_port, codex_port, processes) + ), + ) + + assert controller.start() == "http://127.0.0.1:19527" + + assert len(spawned) == 3 + assert spawned[0][:4] == [sys.executable, "-m", "claude_tap", "dashboard"] + assert spawned[1][3:7] == ["--tap-no-launch", "--tap-client", "claude", "--tap-port"] + assert spawned[1][7] == "19528" + assert spawned[2][3:7] == ["--tap-no-launch", "--tap-client", "codex", "--tap-port"] + assert spawned[2][7] == "19529" + assert injected == [ + ( + 19528, + 19529, + [ + {"pid": 4201, "role": "dashboard"}, + {"pid": 4202, "role": "claude proxy"}, + {"pid": 4203, "role": "codex proxy"}, + ], + ) + ] + + +def test_monitor_controller_reuses_healthy_proxy_instead_of_spawning_duplicate(tmp_path: Path) -> None: + spawned: list[list[str]] = [] + injected: list[tuple[int, int, list[dict[str, object]]]] = [] + + class FakeProcess: + def __init__(self, pid: int) -> None: + self.pid = pid + + def poll(self) -> int | None: + return None + + def fake_popen(cmd: list[str], **_kwargs: object) -> FakeProcess: + spawned.append(cmd) + return FakeProcess(4200 + len(spawned)) + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=fake_popen, + is_healthy=lambda _host, _port: False, + # A claude proxy left behind by a prior session is still serving 19528; + # codex has no listener yet. + proxy_is_healthy=lambda port, _client: port == 19528, + enable_injection=lambda *, claude_port, codex_port, processes: injected.append( + (claude_port, codex_port, processes) + ), + ) + + assert controller.start() == "http://127.0.0.1:19527" + + # Dashboard + codex spawn; the healthy claude proxy is reused, not duplicated. + assert len(spawned) == 2 + assert spawned[0][:4] == [sys.executable, "-m", "claude_tap", "dashboard"] + assert spawned[1][3:7] == ["--tap-no-launch", "--tap-client", "codex", "--tap-port"] + assert spawned[1][7] == "19529" + # Injection still covers both ports; only owned pids are recorded. + assert injected == [ + ( + 19528, + 19529, + [ + {"pid": 4201, "role": "dashboard"}, + {"pid": 4202, "role": "codex proxy"}, + ], + ) + ] + + +def test_monitor_controller_start_returns_existing_monitor_url(tmp_path: Path) -> None: + class FakeProcess: + def poll(self) -> int | None: + return None + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + popen=lambda _cmd, **_kwargs: pytest.fail("already-running monitor should not spawn"), + is_healthy=lambda _host, _port: True, + injection_is_active=lambda: True, + ) + controller._proxy_processes = [FakeProcess(), FakeProcess()] # type: ignore[list-item] + + assert controller.start() == "http://127.0.0.1:19527" + + +def test_monitor_controller_recognizes_active_monitor_after_app_relaunch(tmp_path: Path) -> None: + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + popen=lambda _cmd, **_kwargs: pytest.fail("already-running monitor should not spawn"), + is_healthy=lambda _host, _port: True, + injection_is_active=lambda: True, + recorded_proxy_processes_are_running=lambda **_kwargs: True, + ) + + assert controller.is_running() is True + + +def test_monitor_controller_running_when_dashboard_health_check_fails(tmp_path: Path) -> None: + """A reused/version-mismatched dashboard (health check False) must not make a + running monitor (active injection + live proxies) read as stopped.""" + + class FakeProcess: + def poll(self) -> int | None: + return None + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + popen=lambda _cmd, **_kwargs: pytest.fail("running monitor should not spawn"), + is_healthy=lambda _host, _port: False, + injection_is_active=lambda: True, + ) + controller._proxy_processes = [FakeProcess(), FakeProcess()] # type: ignore[list-item] + + assert controller.is_running() is True + + +def test_monitor_controller_open_dashboard_repairs_stale_dashboard_without_restarting_proxies( + tmp_path: Path, +) -> None: + opened: list[str] = [] + spawned: list[list[str]] = [] + stopped: list[tuple[str, int, str]] = [] + + class FakeProcess: + def poll(self) -> int | None: + return None + + def fake_popen(cmd: list[str], **_kwargs: object) -> FakeProcess: + spawned.append(cmd) + return FakeProcess() + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=fake_popen, + is_healthy=lambda _host, _port: False, + injection_is_active=lambda: True, + enable_injection=lambda **_kwargs: pytest.fail("running monitor should not re-inject"), + stop_incompatible_dashboard=lambda host, port, url: stopped.append((host, port, url)), + open_browser=opened.append, + ) + controller._proxy_processes = [FakeProcess(), FakeProcess()] # type: ignore[list-item] + + controller.open_dashboard() + + assert len(spawned) == 1 + assert spawned[0][:4] == [sys.executable, "-m", "claude_tap", "dashboard"] + assert stopped == [("127.0.0.1", 19527, "http://127.0.0.1:19527")] + assert opened == ["http://127.0.0.1:19527"] + + +def test_monitor_controller_stop_clears_dead_owned_processes(tmp_path: Path) -> None: + class DeadProcess: + def poll(self) -> int: + return 0 + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + is_healthy=lambda _host, _port: False, + injection_is_active=lambda: False, + ) + controller._process = DeadProcess() # type: ignore[assignment] + controller._proxy_processes = [DeadProcess()] # type: ignore[list-item] + controller._proxy_process_names = ["claude"] + + assert controller.stop() is False + assert controller._process is None + assert controller._proxy_processes == [] + assert controller._proxy_process_names == [] + + +def test_monitor_controller_kills_process_that_ignores_terminate(tmp_path: Path) -> None: + events: list[str] = [] + + class StubbornProcess: + def __init__(self) -> None: + self.waits = 0 + + def poll(self) -> None: + return None + + def terminate(self) -> None: + events.append("terminate") + + def kill(self) -> None: + events.append("kill") + + def wait(self, timeout: float) -> int: + self.waits += 1 + events.append(f"wait:{timeout}") + if self.waits == 1: + raise subprocess.TimeoutExpired("claude-tap", timeout) + return 0 + + controller = DashboardMonitorController(host="127.0.0.1", port=19527, output_dir=tmp_path) + controller._terminate_process(StubbornProcess()) # type: ignore[arg-type] + + assert events == ["terminate", "wait:5.0", "kill", "wait:5.0"] + + +def test_monitor_controller_open_dashboard_starts_and_opens_browser(tmp_path: Path) -> None: + opened: list[str] = [] + + class FakeProcess: + def poll(self) -> int | None: + return None + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + popen=lambda _cmd, **_kwargs: FakeProcess(), + is_healthy=lambda _host, _port: True, + injection_is_active=lambda: True, + open_browser=opened.append, + ) + + assert controller.can_stop() is True + controller.open_dashboard() + + assert opened == ["http://127.0.0.1:19527"] + + +def test_monitor_controller_start_fails_when_dashboard_exits_immediately(tmp_path: Path) -> None: + class FakeProcess: + returncode = 3 + + def poll(self) -> int: + return self.returncode + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + popen=lambda _cmd, **_kwargs: FakeProcess(), + is_healthy=lambda _host, _port: False, + startup_check_delay=0, + ) + + with pytest.raises(RuntimeError, match="dashboard exited with code 3"): + controller.start() + + +def test_monitor_controller_subprocess_kwargs_include_windows_flags(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr( + "claude_tap.process_utils.windows_no_console_subprocess_kwargs", + lambda: {"creationflags": 123}, + ) + + kwargs = DashboardMonitorController._subprocess_kwargs() + + assert kwargs["creationflags"] == 123 + assert "start_new_session" not in kwargs + + +def test_monitor_controller_restores_stale_injection_before_spawning_proxies(tmp_path: Path) -> None: + events: list[str] = [] + + class FakeProcess: + def poll(self) -> int | None: + return None + + def fake_popen(_cmd: list[str], **_kwargs: object) -> FakeProcess: + events.append("spawn") + return FakeProcess() + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=fake_popen, + is_healthy=lambda _host, _port: True, + proxy_is_healthy=lambda _port, _client: False, + injection_is_active=lambda: True, + disable_injection=lambda: events.append("restore"), + enable_injection=lambda **_kwargs: events.append("inject"), + ) + + controller.start() + + assert events == ["restore", "spawn", "spawn", "inject"] + + +def test_monitor_controller_start_fails_when_proxy_exits_immediately(tmp_path: Path) -> None: + injected: list[str] = [] + restored: list[str] = [] + events: list[str] = [] + + class FakeProcess: + def __init__(self, name: str, returncode: int | None) -> None: + self.name = name + self.returncode = returncode + + def poll(self) -> int | None: + return self.returncode + + def terminate(self) -> None: + events.append(f"terminate:{self.name}") + self.returncode = 0 + + def wait(self, timeout: float) -> int: + events.append(f"wait:{self.name}:{timeout}") + return 0 + + processes = iter([FakeProcess("claude", 2), FakeProcess("codex", None)]) + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=lambda _cmd, **_kwargs: next(processes), + is_healthy=lambda _host, _port: True, + proxy_is_healthy=lambda _port, _client: False, + enable_injection=lambda **_kwargs: injected.append("inject"), + disable_injection=lambda: restored.append("restore"), + startup_check_delay=0, + ) + + with pytest.raises(RuntimeError, match="claude proxy exited with code 2"): + controller.start() + + assert injected == [] + assert restored == ["restore"] + assert events == ["terminate:codex", "wait:codex:5.0"] + assert controller.is_running() is False + + +def test_monitor_controller_stop_terminates_owned_process(tmp_path: Path) -> None: + events: list[str] = [] + + class FakeProcess: + def __init__(self) -> None: + self.running = True + + def poll(self) -> int | None: + return None if self.running else 0 + + def terminate(self) -> None: + events.append("terminate") + self.running = False + + def wait(self, timeout: float) -> int: + events.append(f"wait:{timeout}") + return 0 + + process = FakeProcess() + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=lambda _cmd, **_kwargs: process, + is_healthy=lambda _host, _port: False, + proxy_is_healthy=lambda _port, _client: False, + terminate_proxies_on_ports=lambda **_kwargs: None, + ) + controller.start() + + assert controller.stop() is True + assert events == ["terminate", "wait:5.0"] + assert controller.is_running() is False + + +def test_monitor_controller_stop_restores_injection_and_stops_all_processes(tmp_path: Path) -> None: + events: list[str] = [] + restored: list[str] = [] + + class FakeProcess: + def __init__(self, name: str) -> None: + self.name = name + self.running = True + + def poll(self) -> int | None: + return None if self.running else 0 + + def terminate(self) -> None: + events.append(f"terminate:{self.name}") + self.running = False + + def wait(self, timeout: float) -> int: + events.append(f"wait:{self.name}:{timeout}") + return 0 + + processes = iter([FakeProcess("dashboard"), FakeProcess("claude"), FakeProcess("codex")]) + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + python_executable=sys.executable, + popen=lambda _cmd, **_kwargs: next(processes), + is_healthy=lambda _host, _port: False, + proxy_is_healthy=lambda _port, _client: False, + terminate_proxies_on_ports=lambda **_kwargs: None, + disable_injection=lambda: restored.append("restore"), + ) + controller.start() + + assert controller.stop() is True + assert restored == ["restore"] + assert events == [ + "terminate:dashboard", + "wait:dashboard:5.0", + "terminate:claude", + "wait:claude:5.0", + "terminate:codex", + "wait:codex:5.0", + ] + + +def test_monitor_controller_stop_reaps_reused_proxies_on_owned_ports(tmp_path: Path) -> None: + reaped: list[tuple[int | None, int | None]] = [] + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + # Injection active but no owned Popen handles -- the claude/codex proxies + # were reused from a prior session, so only the port reaper can stop them. + injection_is_active=lambda: True, + disable_injection=lambda: None, + terminate_proxies_on_ports=lambda *, claude_port, codex_port: reaped.append((claude_port, codex_port)), + ) + + assert controller.stop() is True + assert reaped == [(19528, 19529)] + + +def test_parse_macos_app_args_ignores_launch_services_process_serial_number() -> None: + args = parse_macos_app_args(["-psn_0_12345", "--tap-no-auto-start"]) + + assert args.auto_start is False + + +def test_menu_app_start_monitor_shows_error_on_failure() -> None: + class FakeController: + def start(self) -> str: + raise RuntimeError("port 19528 is already in use") + + app = object.__new__(MacOSMenuApp) + app.controller = FakeController() + calls: list[str] = [] + errors: list[tuple[str, str]] = [] + app.refresh_menu = lambda: calls.append("refresh") # type: ignore[method-assign] + app._show_error = lambda title, details: errors.append((title, details)) # type: ignore[method-assign] + app._confirm_start_monitor = lambda: True # type: ignore[method-assign] + + app.start_monitor() + + assert calls == ["refresh"] + assert errors == [("Unable to start Claude Tap monitor", "port 19528 is already in use")] + + +def test_menu_app_start_monitor_success_refreshes_menu() -> None: + class FakeController: + def __init__(self) -> None: + self.started = False + + def start(self) -> str: + self.started = True + return "http://127.0.0.1:19527" + + app = object.__new__(MacOSMenuApp) + app.controller = FakeController() + calls: list[str] = [] + app.refresh_menu = lambda: calls.append("refresh") # type: ignore[method-assign] + app._confirm_start_monitor = lambda: True # type: ignore[method-assign] + + app.start_monitor() + + assert app.controller.started is True + assert calls == ["refresh"] + + +def test_menu_app_start_monitor_cancel_does_not_start() -> None: + class FakeController: + def start(self) -> str: + pytest.fail("start should not be called") + + app = object.__new__(MacOSMenuApp) + app.controller = FakeController() + calls: list[str] = [] + app.refresh_menu = lambda: calls.append("refresh") # type: ignore[method-assign] + app._confirm_start_monitor = lambda: False # type: ignore[method-assign] + + app.start_monitor() + + assert calls == ["refresh"] + + +def test_menu_app_open_dashboard_cancel_does_not_start_when_stopped() -> None: + class FakeController: + def is_running(self) -> bool: + return False + + def open_dashboard(self) -> None: + pytest.fail("open dashboard should not start without confirmation") + + app = object.__new__(MacOSMenuApp) + app.controller = FakeController() + calls: list[str] = [] + app.refresh_menu = lambda: calls.append("refresh") # type: ignore[method-assign] + app._confirm_start_monitor = lambda: False # type: ignore[method-assign] + + app.open_dashboard() + + assert calls == ["refresh"] + + +def test_menu_app_open_dashboard_running_does_not_confirm() -> None: + events: list[str] = [] + + class FakeController: + def is_running(self) -> bool: + return True + + def open_dashboard(self) -> None: + events.append("open") + + app = object.__new__(MacOSMenuApp) + app.controller = FakeController() + app.refresh_menu = lambda: events.append("refresh") # type: ignore[method-assign] + app._confirm_start_monitor = lambda: pytest.fail("running monitor should open dashboard without confirmation") + + app.open_dashboard() + + assert events == ["open", "refresh"] + + +def test_menu_app_open_dashboard_active_monitor_after_relaunch_does_not_confirm(tmp_path: Path) -> None: + opened: list[str] = [] + + controller = DashboardMonitorController( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + popen=lambda _cmd, **_kwargs: pytest.fail("already-running monitor should not spawn"), + is_healthy=lambda _host, _port: True, + injection_is_active=lambda: True, + recorded_proxy_processes_are_running=lambda **_kwargs: True, + open_browser=opened.append, + ) + app = object.__new__(MacOSMenuApp) + app.controller = controller + app.refresh_menu = lambda: None # type: ignore[method-assign] + app._confirm_start_monitor = lambda: pytest.fail("active monitor should open dashboard without confirmation") + + app.open_dashboard() + + assert opened == ["http://127.0.0.1:19527"] + + +class FakeObjC: + def __init__(self) -> None: + self.calls: list[tuple[int, str, tuple[object, ...]]] = [] + self.strings: dict[int, str] = {} + self._next = 100 + self.objc = FakeRuntime(self) + + def _id(self) -> int: + self._next += 1 + return self._next + + def cls(self, _name: str) -> int: + return self._id() + + def sel(self, name: str | None) -> int | None: + return None if name is None else self._id() + + def nsstring(self, value: str) -> int: + ident = self._id() + self.strings[ident] = value + return ident + + def alloc_init(self, _class_name: str) -> int: + return self._id() + + def msg( + self, + receiver: int, + selector: str, + _restype: Any = None, + _argtypes: list[Any] | None = None, + *args: object, + ) -> int: + self.calls.append((receiver, selector, args)) + if selector == "runModal": + return macos_app._NS_ALERT_FIRST_BUTTON_RETURN + return self._id() + + +class FakeRuntime: + def __init__(self, objc: FakeObjC) -> None: + self.objc = objc + self.registered: list[int] = [] + self.methods: list[bytes] = [] + + def objc_getClass(self, name: bytes) -> int: + return 0 if name == b"ClaudeTapMenuTarget" else 900 + + def objc_allocateClassPair(self, _base: int, _name: bytes, _extra: int) -> int: + return 901 + + def class_addMethod(self, _cls: int, _selector: int | None, _callback: object, types: bytes) -> bool: + self.methods.append(types) + return True + + def objc_registerClassPair(self, cls: int) -> None: + self.registered.append(cls) + + +def test_menu_app_run_builds_menu_and_refreshes_without_auto_start(monkeypatch: pytest.MonkeyPatch) -> None: + fake_objc = FakeObjC() + monkeypatch.setattr(macos_app, "_ObjC", lambda: fake_objc) + monkeypatch.setattr( + macos_app, + "list_trace_sessions", + lambda: [{"agent": "codex", "record_count": 2, "first_user": "hello from trace"}], + ) + + class FakeController: + def is_running(self) -> bool: + return False + + def can_stop(self) -> bool: + return True + + def start(self) -> str: + pytest.fail("auto_start=False should not start") + + app = MacOSMenuApp(FakeController(), auto_start=False) # type: ignore[arg-type] + + assert app.run() == 0 + + titles = [ + fake_objc.strings[arg] + for _receiver, selector, args in fake_objc.calls + for arg in args + if selector == "setTitle:" + ] + assert "Monitor: Stopped" in titles + assert "Sessions: 1" in titles + assert "Latest: codex (2) - hello from trace" in titles + assert any(selector == "setDelegate:" for _receiver, selector, _args in fake_objc.calls) + + +def test_menu_app_status_item_uses_dashboard_icon_without_visible_title( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_objc = FakeObjC() + monkeypatch.setattr(macos_app, "_ObjC", lambda: fake_objc) + monkeypatch.setattr(macos_app, "list_trace_sessions", lambda: []) + + class FakeController: + def is_running(self) -> bool: + return False + + def can_stop(self) -> bool: + return False + + def start(self) -> str: + pytest.fail("auto_start=False should not start") + + app = MacOSMenuApp(FakeController(), auto_start=False) # type: ignore[arg-type] + + assert app.run() == 0 + + image_symbols = [ + (fake_objc.strings[args[0]], fake_objc.strings[args[1]]) + for _receiver, selector, args in fake_objc.calls + if selector == "imageWithSystemSymbolName:accessibilityDescription:" + ] + assert image_symbols == [("rectangle.grid.2x2", "Dashboard")] + + image_positions = [args[0] for _receiver, selector, args in fake_objc.calls if selector == "setImagePosition:"] + assert image_positions == [macos_app._NS_IMAGE_ONLY] + + status_titles = [ + fake_objc.strings[args[0]] + for _receiver, selector, args in fake_objc.calls + if selector == "setTitle:" and args and args[0] in fake_objc.strings + ] + assert "" in status_titles + assert "Claude" not in status_titles + + tooltips = [ + fake_objc.strings[args[0]] for _receiver, selector, args in fake_objc.calls if selector == "setToolTip:" + ] + assert tooltips == ["Dashboard"] + + +def test_menu_app_wrappers_refresh_and_quit() -> None: + events: list[str] = [] + + class FakeController: + def is_running(self) -> bool: + return True + + def stop(self) -> bool: + events.append("stop") + return True + + def open_dashboard(self) -> None: + events.append("open") + + app = object.__new__(MacOSMenuApp) + app.controller = FakeController() + app._app = 77 + app._objc = FakeObjC() + app.refresh_menu = lambda: events.append("refresh") # type: ignore[method-assign] + + app.stop_monitor() + app.open_dashboard() + app.quit() + + assert events == ["stop", "refresh", "open", "refresh", "stop"] + assert any(selector == "terminate:" for _receiver, selector, _args in app._objc.calls) + + +def test_menu_app_alert_helpers_use_objective_c_alerts() -> None: + app = object.__new__(MacOSMenuApp) + app._objc = FakeObjC() + + app._show_error("Title", "Details") + + assert app._confirm_start_monitor() is True + strings = set(app._objc.strings.values()) + assert {"Title", "Details", "Start Claude Tap Monitor?", "Start Monitor", "Cancel"} <= strings + + +def test_objc_rejects_non_macos(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "linux") + + with pytest.raises(RuntimeError, match="only runs on macOS"): + macos_app._ObjC() + + +def test_new_menu_target_registers_callbacks() -> None: + fake_objc = FakeObjC() + + target = macos_app._new_menu_target(fake_objc) # type: ignore[arg-type] + + assert target > 0 + assert fake_objc.objc.registered == [901] + assert fake_objc.objc.methods == [b"v@:@"] * 5 + + +def test_application_termination_callback_stops_monitor_before_quit(monkeypatch: pytest.MonkeyPatch) -> None: + events: list[str] = [] + + class FakeApp: + def prepare_to_quit(self) -> None: + events.append("stop") + + monkeypatch.setattr(macos_app, "_ACTIVE_APP", FakeApp()) + + result = macos_app._application_should_terminate_callback(0, 0, 0) + + assert events == ["stop"] + assert result == macos_app._NS_TERMINATE_NOW + + +def test_menu_callbacks_forward_to_active_app(monkeypatch: pytest.MonkeyPatch) -> None: + events: list[str] = [] + + class FakeApp: + def start_monitor(self) -> None: + events.append("start") + + def stop_monitor(self) -> None: + events.append("stop") + + def open_dashboard(self) -> None: + events.append("open") + + def refresh_menu(self) -> None: + events.append("refresh") + + def quit(self) -> None: + events.append("quit") + + monkeypatch.setattr(macos_app, "_ACTIVE_APP", FakeApp()) + + macos_app._start_monitor_callback(0, 0, 0) + macos_app._stop_monitor_callback(0, 0, 0) + macos_app._open_dashboard_callback(0, 0, 0) + macos_app._refresh_menu_callback(0, 0, 0) + macos_app._quit_callback(0, 0, 0) + + assert events == ["start", "stop", "open", "refresh", "quit"] + + +def test_main_entry_routes_macos_app_subcommand(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, object] = {} + + def fake_macos_main(argv: list[str]) -> int: + called["argv"] = argv + return 4 + + monkeypatch.setattr(sys, "argv", ["claude-tap", "macos-app", "--tap-no-auto-start"]) + monkeypatch.setattr("claude_tap.macos_app.main", fake_macos_main) + + with pytest.raises(SystemExit) as excinfo: + main_entry() + + assert excinfo.value.code == 4 + assert called["argv"] == ["--tap-no-auto-start"] diff --git a/tests/test_macos_bundle.py b/tests/test_macos_bundle.py new file mode 100644 index 0000000..9c9c1db --- /dev/null +++ b/tests/test_macos_bundle.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import os +import plistlib +import subprocess +import sys +from pathlib import Path + +import pytest + +from claude_tap import macos_bundle +from claude_tap.cli import main_entry +from claude_tap.macos_bundle import build_macos_app_bundle + + +def test_build_macos_app_bundle_writes_double_clickable_app(tmp_path: Path) -> None: + def fake_compile(_source: str, output_path: Path) -> None: + output_path.write_bytes(b"native-launcher") + + app_path = build_macos_app_bundle( + tmp_path / "Claude Tap.app", + python_executable=sys.executable, + source_root=Path("/repo/claude-tap"), + compile_launcher=fake_compile, + ) + + assert app_path == tmp_path / "Claude Tap.app" + info_path = app_path / "Contents" / "Info.plist" + launcher_path = app_path / "Contents" / "MacOS" / "claude-tap-macos" + resources_path = app_path / "Contents" / "Resources" + + assert info_path.exists() + assert launcher_path.exists() + assert resources_path.is_dir() + assert os.access(launcher_path, os.X_OK) + + info = plistlib.loads(info_path.read_bytes()) + assert info["CFBundleName"] == "Claude Tap" + assert info["CFBundleExecutable"] == "claude-tap-macos" + assert info["LSUIElement"] is True + + assert launcher_path.read_bytes() == b"native-launcher" + + +def test_build_macos_app_bundle_uses_native_launcher(tmp_path: Path) -> None: + compiled: dict[str, str | Path] = {} + + def fake_compile(source: str, output_path: Path) -> None: + compiled["source"] = source + compiled["output_path"] = output_path + output_path.write_bytes(b"native-launcher") + + app_path = build_macos_app_bundle( + tmp_path / "Claude Tap.app", + python_executable="/usr/bin/python3", + source_root=Path("/repo/claude-tap"), + compile_launcher=fake_compile, + ) + + launcher_path = app_path / "Contents" / "MacOS" / "claude-tap-macos" + source = compiled["source"] + + assert compiled["output_path"] == launcher_path + assert isinstance(source, str) + assert "/usr/bin/python3" in source + assert "claude_tap" in source + assert "macos-app" in source + assert "/repo/claude-tap" in source + assert launcher_path.read_bytes() == b"native-launcher" + + +def test_build_macos_app_bundle_can_embed_pyinstaller_executable(tmp_path: Path) -> None: + compiled: dict[str, str] = {} + + def fake_compile(source: str, output_path: Path) -> None: + compiled["source"] = source + output_path.write_bytes(b"native-launcher") + + def fake_build_frozen(resources_dir: Path) -> Path: + executable = resources_dir / "claude-tap" / "claude-tap" + executable.parent.mkdir(parents=True) + executable.write_bytes(b"frozen") + return executable + + app_path = build_macos_app_bundle( + tmp_path / "Claude Tap.app", + self_contained=True, + compile_launcher=fake_compile, + build_frozen_executable=fake_build_frozen, + ) + + source = compiled["source"] + assert (app_path / "Contents" / "Resources" / "claude-tap" / "claude-tap").read_bytes() == b"frozen" + assert "_NSGetExecutablePath" in source + assert "../Resources/claude-tap/claude-tap" in source + assert 'child_argv[1] = "macos-app";' in source + assert '"-m"' not in source + assert '"claude_tap"' not in source + + +def test_build_macos_app_main_uses_installed_mode( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + built: dict[str, object] = {} + + def fake_build(app_path: Path, **kwargs: object) -> Path: + built["app_path"] = app_path + built["kwargs"] = kwargs + return app_path + + monkeypatch.setattr(macos_bundle, "build_macos_app_bundle", fake_build) + + assert macos_bundle.main(["--output", str(tmp_path / "Tap"), "--installed"]) == 0 + + assert built["app_path"] == tmp_path / "Tap" + assert isinstance(built["kwargs"], dict) + assert built["kwargs"]["source_root"] is None + assert "Built macOS app:" in capsys.readouterr().out + + +def test_build_macos_app_bundle_self_contained_disables_source_root(tmp_path: Path) -> None: + compiled: dict[str, str] = {} + + def fake_compile(source: str, output_path: Path) -> None: + compiled["source"] = source + output_path.write_bytes(b"native-launcher") + + def fake_build_frozen(resources_dir: Path) -> Path: + executable = resources_dir / "claude-tap" / "claude-tap" + executable.parent.mkdir(parents=True) + executable.write_bytes(b"frozen") + return executable + + app_path = build_macos_app_bundle( + tmp_path / "Claude Tap", + source_root=Path("/repo/should-not-be-used"), + self_contained=True, + compile_launcher=fake_compile, + build_frozen_executable=fake_build_frozen, + ) + + assert app_path == tmp_path / "Claude Tap.app" + assert "/repo/should-not-be-used" not in compiled["source"] + + +def test_build_pyinstaller_executable_reports_subprocess_failure( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + def fake_run(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="boom") + + monkeypatch.setattr(macos_bundle.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError, match="PyInstaller: boom"): + macos_bundle._build_pyinstaller_executable(tmp_path) + + +def test_build_pyinstaller_executable_requires_expected_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr( + macos_bundle.subprocess, + "run", + lambda *_args, **_kwargs: subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr=""), + ) + + with pytest.raises(RuntimeError, match="did not create expected executable"): + macos_bundle._build_pyinstaller_executable(tmp_path) + + +def test_compile_native_launcher_reports_missing_compiler(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setattr(macos_bundle, "_native_compiler", lambda: None) + + with pytest.raises(RuntimeError, match="requires clang or cc"): + macos_bundle._compile_native_launcher("int main(void) { return 0; }", tmp_path / "launcher") + + +def test_compile_native_launcher_reports_compile_failure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source_paths: list[Path] = [] + + def fake_run(cmd: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + source_paths.append(Path(cmd[1])) + return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="compile failed") + + monkeypatch.setattr(macos_bundle, "_native_compiler", lambda: "cc") + monkeypatch.setattr(macos_bundle.subprocess, "run", fake_run) + + with pytest.raises(RuntimeError, match="compile failed"): + macos_bundle._compile_native_launcher("bad c", tmp_path / "launcher") + + assert source_paths + assert not source_paths[0].exists() + + +def test_ad_hoc_sign_app_uses_codesign_when_available(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + calls: list[list[str]] = [] + + monkeypatch.setattr(macos_bundle.shutil, "which", lambda name: f"/usr/bin/{name}" if name == "codesign" else None) + monkeypatch.setattr( + macos_bundle.subprocess, + "run", + lambda cmd, **_kwargs: calls.append(cmd), + ) + + macos_bundle._ad_hoc_sign_app(tmp_path / "Claude Tap.app") + + assert calls == [["/usr/bin/codesign", "--force", "--sign", "-", str(tmp_path / "Claude Tap.app")]] + + +def test_c_string_literal_escapes_quotes_backslashes_and_utf8() -> None: + literal = macos_bundle._c_string_literal('quote" slash\\ snowman \u2603') + + assert literal.startswith('"') + assert '\\"' in literal + assert "\\\\" in literal + assert "\\xe2\\x98\\x83" in literal + + +def test_main_entry_routes_build_macos_app_subcommand(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, object] = {} + + def fake_build_main(argv: list[str]) -> int: + called["argv"] = argv + return 5 + + monkeypatch.setattr(sys, "argv", ["claude-tap", "build-macos-app", "--output", "dist/Test.app"]) + monkeypatch.setattr("claude_tap.macos_bundle.main", fake_build_main) + + with pytest.raises(SystemExit) as excinfo: + main_entry() + + assert excinfo.value.code == 5 + assert called["argv"] == ["--output", "dist/Test.app"] diff --git a/tests/test_macos_ca_trust.py b/tests/test_macos_ca_trust.py new file mode 100644 index 0000000..228a4df --- /dev/null +++ b/tests/test_macos_ca_trust.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +import subprocess +from argparse import Namespace +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.certs import ( + build_macos_trust_ca_command, + build_macos_verify_ca_command, + is_macos_ca_trusted, + trust_macos_ca, +) +from claude_tap.cli import _ensure_ca_trust_for_forward_proxy, _trust_ca_for_current_user, async_main, trust_ca_main +from claude_tap.trace_store import get_trace_store, reset_trace_store + + +def test_parse_args_accepts_tap_trust_ca() -> None: + args = parse_args(["--tap-client", "agy", "--tap-trust-ca"]) + + assert args.client == "agy" + assert args.proxy_mode == "forward" + assert args.trust_ca is True + + +def test_parse_args_agy_does_not_require_tap_trust_ca() -> None: + args = parse_args(["--tap-client", "agy"]) + + assert args.client == "agy" + assert args.proxy_mode == "forward" + assert args.trust_ca is False + + +def test_parse_args_rejects_tap_trust_ca_with_reverse_proxy() -> None: + with pytest.raises(SystemExit): + parse_args(["--tap-client", "agy", "--tap-proxy-mode", "reverse", "--tap-trust-ca"]) + + +def test_macos_trust_ca_command_uses_user_keychain_without_sudo() -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + keychain_path = Path("/Users/test/Library/Keychains/login.keychain-db") + + cmd = build_macos_trust_ca_command(ca_path, keychain_path) + + assert cmd == [ + "security", + "add-trusted-cert", + "-r", + "trustRoot", + "-p", + "ssl", + "-k", + str(keychain_path), + str(ca_path), + ] + assert "sudo" not in cmd + + +def test_macos_verify_ca_command_is_non_mutating() -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + keychain_path = Path("/Users/test/Library/Keychains/login.keychain-db") + + cmd = build_macos_verify_ca_command(ca_path, keychain_path) + + assert cmd[0] == "security" + assert "verify-cert" in cmd + assert "add-trusted-cert" not in cmd + assert str(keychain_path) in cmd + + +def test_is_macos_ca_trusted_reads_security_verify_result(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + assert kwargs == {"capture_output": True, "text": True, "check": False} + return subprocess.CompletedProcess(cmd, 0, "", "") + + monkeypatch.setattr("claude_tap.certs.subprocess.run", fake_run) + + assert is_macos_ca_trusted(ca_path) is True + assert calls[0][1] == "verify-cert" + + +def test_is_macos_ca_trusted_returns_false_on_verify_failure(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 1, "", "not trusted") + + monkeypatch.setattr("claude_tap.certs.subprocess.run", fake_run) + + assert is_macos_ca_trusted(Path("/tmp/claude-tap-ca.pem")) is False + + +def test_trust_macos_ca_runs_add_trusted_cert(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + calls: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + assert kwargs == {"capture_output": True, "text": True, "check": False} + return subprocess.CompletedProcess(cmd, 0, "ok", "") + + monkeypatch.setattr("claude_tap.certs.subprocess.run", fake_run) + + result = trust_macos_ca(ca_path) + + assert result.returncode == 0 + assert calls[0][1] == "add-trusted-cert" + assert calls[0][-1] == str(ca_path) + + +def test_trust_ca_for_current_user_rejects_non_macos(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("claude_tap.cli.sys.platform", "linux") + + code = _trust_ca_for_current_user(Path("/tmp/claude-tap-ca.pem")) + + assert code == 1 + + +def test_trust_ca_for_current_user_skips_when_already_trusted(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + + monkeypatch.setattr("claude_tap.cli.sys.platform", "darwin") + monkeypatch.setattr("claude_tap.cli.is_macos_ca_trusted", lambda _: True) + monkeypatch.setattr( + "claude_tap.cli.trust_macos_ca", + lambda _: (_ for _ in ()).throw(AssertionError("trust command should not run")), + ) + + assert _trust_ca_for_current_user(ca_path) == 0 + + +def test_trust_ca_for_current_user_reports_install_failure(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + + monkeypatch.setattr("claude_tap.cli.sys.platform", "darwin") + monkeypatch.setattr("claude_tap.cli.is_macos_ca_trusted", lambda _: False) + monkeypatch.setattr( + "claude_tap.cli.trust_macos_ca", + lambda _: subprocess.CompletedProcess(["security"], 2, "", "keychain locked"), + ) + + assert _trust_ca_for_current_user(ca_path) == 2 + + +def test_trust_ca_for_current_user_reports_verify_failure_after_install(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + trusted_checks = iter([False, False]) + + monkeypatch.setattr("claude_tap.cli.sys.platform", "darwin") + monkeypatch.setattr("claude_tap.cli.is_macos_ca_trusted", lambda _: next(trusted_checks)) + monkeypatch.setattr( + "claude_tap.cli.trust_macos_ca", + lambda _: subprocess.CompletedProcess(["security"], 0, "", ""), + ) + + assert _trust_ca_for_current_user(ca_path) == 1 + + +def test_trust_ca_for_current_user_installs_and_rechecks(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + trusted_checks = iter([False, True]) + installed: list[Path] = [] + + def fake_is_trusted(path: Path) -> bool: + assert path == ca_path + return next(trusted_checks) + + def fake_trust(path: Path) -> subprocess.CompletedProcess[str]: + installed.append(path) + return subprocess.CompletedProcess(["security"], 0, "", "") + + monkeypatch.setattr("claude_tap.cli.sys.platform", "darwin") + monkeypatch.setattr("claude_tap.cli.is_macos_ca_trusted", fake_is_trusted) + monkeypatch.setattr("claude_tap.cli.trust_macos_ca", fake_trust) + + code = _trust_ca_for_current_user(ca_path) + + assert code == 0 + assert installed == [ca_path] + + +def test_trust_ca_main_uses_generated_ca(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + ca_path = tmp_path / "ca.pem" + key_path = tmp_path / "ca-key.pem" + trusted: list[Path] = [] + + monkeypatch.setattr("claude_tap.cli.ensure_ca", lambda: (ca_path, key_path)) + monkeypatch.setattr("claude_tap.cli._trust_ca_for_current_user", lambda path: trusted.append(path) or 0) + + assert trust_ca_main([]) == 0 + assert trusted == [ca_path] + + +def test_auto_ca_trust_skips_non_agy_clients(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + args = parse_args(["--tap-client", "gemini"]) + + monkeypatch.setattr("claude_tap.cli.sys.platform", "darwin") + monkeypatch.setattr( + "claude_tap.cli.is_macos_ca_trusted", + lambda _: (_ for _ in ()).throw(AssertionError("non-agy clients should not auto-check CA trust")), + ) + + assert _ensure_ca_trust_for_forward_proxy(args, ca_path) == 0 + + +def test_auto_ca_trust_skips_when_agy_ca_is_already_trusted(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + args = parse_args(["--tap-client", "agy"]) + + monkeypatch.setattr("claude_tap.cli.sys.platform", "darwin") + monkeypatch.setattr("claude_tap.cli.is_macos_ca_trusted", lambda _: True) + monkeypatch.setattr( + "claude_tap.cli._trust_ca_for_current_user", + lambda _: (_ for _ in ()).throw(AssertionError("already-trusted CA should not reinstall")), + ) + + assert _ensure_ca_trust_for_forward_proxy(args, ca_path) == 0 + + +def test_auto_ca_trust_installs_for_agy_on_macos(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + args = parse_args(["--tap-client", "agy"]) + trusted: list[Path] = [] + + monkeypatch.setattr("claude_tap.cli.sys.platform", "darwin") + monkeypatch.setattr("claude_tap.cli.is_macos_ca_trusted", lambda _: False) + monkeypatch.setattr("claude_tap.cli._trust_ca_for_current_user", lambda path: trusted.append(path) or 0) + + assert _ensure_ca_trust_for_forward_proxy(args, ca_path) == 0 + assert trusted == [ca_path] + + +def test_auto_ca_trust_skips_agy_on_non_macos(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + args = parse_args(["--tap-client", "agy"]) + + monkeypatch.setattr("claude_tap.cli.sys.platform", "linux") + monkeypatch.setattr( + "claude_tap.cli.is_macos_ca_trusted", + lambda _: (_ for _ in ()).throw(AssertionError("non-macOS should not auto-check CA trust")), + ) + + assert _ensure_ca_trust_for_forward_proxy(args, ca_path) == 0 + + +def test_explicit_ca_trust_still_runs_for_other_forward_clients(monkeypatch: pytest.MonkeyPatch) -> None: + ca_path = Path("/tmp/claude-tap-ca.pem") + args = parse_args(["--tap-client", "gemini", "--tap-trust-ca"]) + trusted: list[Path] = [] + + monkeypatch.setattr("claude_tap.cli._trust_ca_for_current_user", lambda path: trusted.append(path) or 0) + + assert _ensure_ca_trust_for_forward_proxy(args, ca_path) == 0 + assert trusted == [ca_path] + + +@pytest.mark.asyncio +async def test_async_main_returns_before_starting_proxy_when_trust_ca_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + ca_path = tmp_path / "ca.pem" + key_path = tmp_path / "ca-key.pem" + proxy_started = False + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "trust-failed.sqlite3")) + reset_trace_store() + + def fail_if_proxy_starts(*args, **kwargs): + nonlocal proxy_started + proxy_started = True + raise AssertionError("proxy should not start when CA trust fails") + + monkeypatch.setattr("claude_tap.cli.ensure_ca", lambda: (ca_path, key_path)) + monkeypatch.setattr("claude_tap.cli._ensure_ca_trust_for_forward_proxy", lambda _args, _path: 7) + monkeypatch.setattr("claude_tap.cli.ForwardProxyServer", fail_if_proxy_starts) + + code = await async_main( + Namespace( + output_dir=str(tmp_path / "traces"), + live_viewer=False, + live_port=0, + host="127.0.0.1", + proxy_mode="forward", + trust_ca=True, + port=0, + client="agy", + target="https://antigravity.goog", + extra_allowed_paths=[], + no_launch=True, + claude_args=[], + max_traces=0, + open_viewer=False, + ) + ) + + assert code == 7 + assert proxy_started is False + assert get_trace_store().list_session_rows() == [] diff --git a/tests/test_mimocode_launch.py b/tests/test_mimocode_launch.py new file mode 100644 index 0000000..b2ac02b --- /dev/null +++ b/tests/test_mimocode_launch.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, run_client + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_mimocode_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["mimo"] + assert cfg.cmd == "mimo" + assert cfg.label == "MiMo Code" + assert cfg.default_target == "https://api.anthropic.com" + assert cfg.base_url_env == "ANTHROPIC_BASE_URL" + # mimocode is an OpenCode fork; forward proxy is the natural default + assert cfg.default_proxy_mode == "forward" + + +def test_parse_args_mimocode_defaults_to_forward_mode() -> None: + args = parse_args(["--tap-client", "mimo"]) + assert args.client == "mimo" + assert args.proxy_mode == "forward" + + +def test_parse_args_mimocode_explicit_reverse_overrides_default() -> None: + args = parse_args(["--tap-client", "mimo", "--tap-proxy-mode", "reverse"]) + assert args.proxy_mode == "reverse" + + +@pytest.mark.asyncio +async def test_run_client_mimocode_forward_sets_node_ca_env(monkeypatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/mimo") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--print", "hello"], client="mimo", proxy_mode="forward", ca_cert_path=ca_path) + + assert code == 0 + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["MIMOCODE_MIMO_ONLY"] == "false" + assert env["NODE_EXTRA_CA_CERTS"] == str(ca_path) + assert env["SSL_CERT_FILE"] == str(ca_path) + + +@pytest.mark.asyncio +async def test_run_client_mimocode_reverse_sets_anthropic_base_url(monkeypatch) -> None: + captured: dict[str, object] = {} + for key in ("OPENAI_BASE_URL", "GOOGLE_GEMINI_BASE_URL"): + monkeypatch.delenv(key, raising=False) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/mimo") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--print", "hello"], client="mimo", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:43123" + assert "OPENAI_BASE_URL" not in env + assert "GOOGLE_GEMINI_BASE_URL" not in env + assert env["MIMOCODE_MIMO_ONLY"] == "false" + assert "localhost" in env["NO_PROXY"].split(",") + assert captured["cmd"] == ("/tmp/mimo", "--print", "hello") + + +@pytest.mark.asyncio +async def test_run_client_mimocode_reverse_extends_no_proxy_for_localhost(monkeypatch) -> None: + captured: dict[str, object] = {} + monkeypatch.setenv("HTTP_PROXY", "http://proxy.example:8080") + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") + monkeypatch.setenv("NO_PROXY", "corp.example") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/mimo") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--print", "hello"], client="mimo", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + no_proxy = env["NO_PROXY"].split(",") + assert "corp.example" in no_proxy + assert "localhost" in no_proxy + assert "127.0.0.1" in no_proxy + assert "::1" in no_proxy + assert env["no_proxy"] == env["NO_PROXY"] + + +@pytest.mark.asyncio +async def test_run_client_mimocode_capture_only_reverse_sets_multi_provider_urls(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/mimo") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--print", "hello"], client="mimo", proxy_mode="reverse", capture_only=True) + + assert code == 0 + env = captured["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:43123" + assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" + assert env["GOOGLE_GEMINI_BASE_URL"] == "http://127.0.0.1:43123" + assert env["MIMOCODE_MIMO_ONLY"] == "false" + assert "localhost" in env["NO_PROXY"].split(",") + assert captured["cmd"] == ("/tmp/mimo", "--print", "hello") diff --git a/tests/test_nav_browser.py b/tests/test_nav_browser.py new file mode 100644 index 0000000..74fbeb2 --- /dev/null +++ b/tests/test_nav_browser.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +"""Browser integration test for diff nav button fix using Playwright. + +Generates a self-contained HTML viewer with synthetic trace data, +opens it in a headless browser, and verifies that the ◀/▶ nav buttons +are correctly enabled/disabled after the .idx bug fix. +""" + +import json +import tempfile +from pathlib import Path + +import pytest + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + +pytestmark = pytest.mark.skipif(pw_missing, reason="playwright not installed") + +# ── Synthetic trace data: 4-turn conversation chain ── +# Turn 1 → Turn 2 → Turn 3 → Turn 4 +# This gives us 3 diff pairs to navigate between. + + +def _make_entry(turn: int, messages: list[dict]) -> dict: + """Build a trace entry matching the real JSONL format.""" + return { + "timestamp": f"2026-02-24T20:00:0{turn}", + "request_id": f"req_{turn}", + "turn": turn, + "duration_ms": 500, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {}, + "body": { + "model": "claude-opus-4-6", + "system": [{"type": "text", "text": "You are Claude"}], + "messages": messages, + }, + }, + "response": { + "status": 200, + "body": { + "content": [{"type": "text", "text": f"Response for turn {turn}"}], + "model": "claude-opus-4-6", + "usage": {"input_tokens": turn * 10, "output_tokens": 5}, + }, + }, + } + + +TRACE_ENTRIES = [ + _make_entry(1, [{"role": "user", "content": "hello"}]), + _make_entry( + 2, + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "how are you"}, + ], + ), + _make_entry( + 3, + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "how are you"}, + {"role": "assistant", "content": "I'm fine!"}, + {"role": "user", "content": "tell me a joke"}, + ], + ), + _make_entry( + 4, + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "how are you"}, + {"role": "assistant", "content": "I'm fine!"}, + {"role": "user", "content": "tell me a joke"}, + {"role": "assistant", "content": "Why did the chicken..."}, + {"role": "user", "content": "another one"}, + ], + ), +] + + +def _build_test_html() -> str: + """Generate self-contained viewer HTML with embedded test trace data.""" + from claude_tap.viewer import VIEWER_SCRIPT_ANCHOR, _read_viewer_template + + html = _read_viewer_template() + + records = [json.dumps(e) for e in TRACE_ENTRIES] + data_js = ( + "const EMBEDDED_TRACE_DATA = [\n" + ",\n".join(records) + "\n];\n" + 'const __TRACE_JSONL_PATH__ = "/tmp/test.jsonl";\n' + 'const __TRACE_HTML_PATH__ = "/tmp/test.html";\n' + ) + html = html.replace( + VIEWER_SCRIPT_ANCHOR, + f"\n{VIEWER_SCRIPT_ANCHOR}", + 1, + ) + return html + + +@pytest.fixture(scope="module") +def html_file(): + """Write test HTML to a temp file.""" + html = _build_test_html() + with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as f: + f.write(html) + return Path(f.name) + + +@pytest.fixture(scope="module") +def browser_page(html_file): + """Launch headless Chromium and open the test HTML.""" + from playwright.sync_api import sync_playwright + + pw = sync_playwright().start() + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{html_file}") + page.wait_for_selector(".sidebar-item", timeout=5000) + yield page + browser.close() + pw.stop() + + +class TestDiffNavInBrowser: + """Test diff navigation buttons in an actual browser.""" + + def _open_diff_for_entry(self, page, entry_index: int): + """Click the diff button on the Nth entry (0-indexed in filtered list).""" + # Close any existing diff overlay first + page.evaluate("document.querySelector('.diff-overlay')?.remove()") + # Click the sidebar item to select it + items = page.query_selector_all(".sidebar-item") + items[entry_index].click() + page.wait_for_timeout(200) + # Click the diff button (showDiff) in the action bar + page.evaluate("document.querySelector('.act-btn:nth-child(3)').click()") + page.wait_for_selector(".diff-overlay", timeout=3000) + + def _get_nav_state(self, page) -> dict: + """Get the current state of the diff nav buttons.""" + return page.evaluate("""() => { + const overlay = document.querySelector('.diff-overlay'); + if (!overlay) return { overlayExists: false }; + const prev = overlay.querySelector('.diff-nav-prev'); + const next = overlay.querySelector('.diff-nav-next'); + const title = overlay.querySelector('.diff-title')?.textContent || ''; + return { + overlayExists: true, + prevDisabled: prev?.disabled ?? null, + nextDisabled: next?.disabled ?? null, + title: title, + }; + }""") + + def test_entry_count(self, browser_page): + """Verify all 4 entries loaded.""" + count = browser_page.evaluate("document.querySelectorAll('.sidebar-item').length") + assert count == 4, f"Expected 4 entries, got {count}" + + def test_first_diff_pair_nav_state(self, browser_page): + """At diff Turn1→Turn2 (first pair): prev disabled, next enabled.""" + self._open_diff_for_entry(browser_page, 1) + state = self._get_nav_state(browser_page) + assert state["overlayExists"], "Diff overlay should be open" + assert state["prevDisabled"] is True, ( + f"BUG if False: prev should be disabled at first diff pair. State: {state}" + ) + assert state["nextDisabled"] is False, ( + f"BUG if True: next should be enabled (can go to Turn2→Turn3). State: {state}" + ) + + def test_middle_diff_pair_nav_state(self, browser_page): + """At diff Turn2→Turn3 (middle pair): both enabled.""" + self._open_diff_for_entry(browser_page, 2) + state = self._get_nav_state(browser_page) + assert state["overlayExists"], "Diff overlay should be open" + assert state["prevDisabled"] is False, f"prev should be enabled (can go back to Turn1→Turn2). State: {state}" + assert state["nextDisabled"] is False, f"next should be enabled (can go to Turn3→Turn4). State: {state}" + + def test_last_diff_pair_nav_state(self, browser_page): + """At diff Turn3→Turn4 (last pair): prev enabled, next disabled.""" + self._open_diff_for_entry(browser_page, 3) + state = self._get_nav_state(browser_page) + assert state["overlayExists"], "Diff overlay should be open" + assert state["prevDisabled"] is False, f"prev should be enabled (can go back to Turn2→Turn3). State: {state}" + assert state["nextDisabled"] is True, f"next should be disabled at last diff pair. State: {state}" + + def test_click_right_navigates(self, browser_page): + """Clicking ▶ at Turn1→Turn2 should navigate to Turn2→Turn3.""" + self._open_diff_for_entry(browser_page, 1) + state_before = self._get_nav_state(browser_page) + assert "1" in state_before["title"] and "2" in state_before["title"] + + # Click right arrow + browser_page.click(".diff-nav-next") + browser_page.wait_for_timeout(300) + + state_after = self._get_nav_state(browser_page) + assert state_after["overlayExists"], "Overlay should still exist after clicking ▶" + assert "2" in state_after["title"] and "3" in state_after["title"], ( + f"Should navigate to Turn2→Turn3, got title: {state_after['title']}" + ) + + def test_click_left_at_first_does_not_close(self, browser_page): + """Clicking ◀ at Turn1→Turn2 should NOT close the overlay (regression test).""" + self._open_diff_for_entry(browser_page, 1) + state = self._get_nav_state(browser_page) + assert state["prevDisabled"] is True, "prev should be disabled" + + # Even if we try to click the disabled button, overlay should remain + browser_page.evaluate(""" + document.querySelector('.diff-nav-prev').click(); + """) + browser_page.wait_for_timeout(300) + + state_after = self._get_nav_state(browser_page) + assert state_after["overlayExists"], "BUG: overlay disappeared after clicking disabled ◀ at first diff pair!" + + def test_click_left_navigates_back(self, browser_page): + """Clicking ◀ at Turn2→Turn3 should navigate back to Turn1→Turn2.""" + self._open_diff_for_entry(browser_page, 2) + state_before = self._get_nav_state(browser_page) + assert "2" in state_before["title"] and "3" in state_before["title"] + + # Click left arrow + browser_page.click(".diff-nav-prev") + browser_page.wait_for_timeout(300) + + state_after = self._get_nav_state(browser_page) + assert state_after["overlayExists"], "Overlay should still exist after clicking ◀" + assert "1" in state_after["title"] and "2" in state_after["title"], ( + f"Should navigate to Turn1→Turn2, got title: {state_after['title']}" + ) + + def test_keyboard_right_arrow(self, browser_page): + """Pressing → key at Turn1→Turn2 should navigate to Turn2→Turn3.""" + self._open_diff_for_entry(browser_page, 1) + + browser_page.keyboard.press("ArrowRight") + browser_page.wait_for_timeout(300) + + state = self._get_nav_state(browser_page) + assert state["overlayExists"], "Overlay should exist after pressing →" + assert "2" in state["title"] and "3" in state["title"], ( + f"→ should navigate to Turn2→Turn3, got: {state['title']}" + ) + + def test_keyboard_left_at_first_keeps_overlay(self, browser_page): + """Pressing ← at first diff should NOT close overlay (regression).""" + self._open_diff_for_entry(browser_page, 1) + + browser_page.keyboard.press("ArrowLeft") + browser_page.wait_for_timeout(300) + + state = self._get_nav_state(browser_page) + assert state["overlayExists"], "BUG: overlay disappeared after pressing ← at first diff!" + + def test_full_chain_traversal_right(self, browser_page): + """Navigate the full chain: Turn1→2, then →Turn2→3, then →Turn3→4.""" + self._open_diff_for_entry(browser_page, 1) + + # At Turn1→Turn2 + s = self._get_nav_state(browser_page) + assert "1" in s["title"] and "2" in s["title"] + + # → to Turn2→Turn3 + browser_page.click(".diff-nav-next") + browser_page.wait_for_timeout(300) + s = self._get_nav_state(browser_page) + assert "2" in s["title"] and "3" in s["title"], f"Got: {s['title']}" + + # → to Turn3→Turn4 + browser_page.click(".diff-nav-next") + browser_page.wait_for_timeout(300) + s = self._get_nav_state(browser_page) + assert "3" in s["title"] and "4" in s["title"], f"Got: {s['title']}" + + # Next should now be disabled + assert s["nextDisabled"] is True, "Should be at end of chain" + + def test_full_chain_traversal_left(self, browser_page): + """Navigate backward: start at Turn3→4, then ← all the way.""" + self._open_diff_for_entry(browser_page, 3) + + # At Turn3→Turn4 + s = self._get_nav_state(browser_page) + assert "3" in s["title"] and "4" in s["title"] + + # ← to Turn2→Turn3 + browser_page.click(".diff-nav-prev") + browser_page.wait_for_timeout(300) + s = self._get_nav_state(browser_page) + assert "2" in s["title"] and "3" in s["title"], f"Got: {s['title']}" + + # ← to Turn1→Turn2 + browser_page.click(".diff-nav-prev") + browser_page.wait_for_timeout(300) + s = self._get_nav_state(browser_page) + assert "1" in s["title"] and "2" in s["title"], f"Got: {s['title']}" + + # Prev should now be disabled + assert s["prevDisabled"] is True, "Should be at start of chain" + + def test_codex_thread_hidden_prefetch_parent(self, browser_page): + """Codex Responses diff should skip hidden prefetch parents.""" + result = browser_page.evaluate(""" + () => { + const oldEntries = entries; + const oldFiltered = filtered; + const metadata = { + "x-codex-turn-metadata": JSON.stringify({ session_id: "session-a", thread_id: "thread-a" }) + }; + try { + filtered = [ + { + turn: "1.1", + request_id: "req-1", + request: { body: { + model: "gpt-5.5", + client_metadata: metadata, + messages: [{ role: "user", content: "initial" }] + }}, + response: { body: { id: "resp_1_1", content: [{ type: "text", text: "one" }] } } + }, + { + turn: "3.2", + request_id: "req-2", + request: { body: { + model: "gpt-5.5", + client_metadata: metadata, + previous_response_id: "resp_3_1", + messages: [ + { role: "user", content: "initial" }, + { role: "assistant", content: "earlier final text" }, + { role: "user", content: "limit compare" }, + { role: "assistant", content: [{ type: "tool_use", name: "tool_search", input: { limit: 8 } }] }, + { role: "tool", content: "github tools" } + ] + }}, + response: { body: { id: "resp_3_2", content: [{ type: "text", text: "two" }] } } + }, + { + turn: "4.1", + request_id: "req-3", + request: { body: { + model: "gpt-5.5", + client_metadata: metadata, + previous_response_id: "resp_hidden_prefetch", + messages: [ + { role: "user", content: "initial" }, + { role: "assistant", content: "earlier final text" }, + { role: "user", content: "figma domain" }, + { role: "tool", content: "older tool outputs" } + ] + }}, + response: { body: { id: "resp_4_1", content: [{ type: "text", text: "three" }] } } + } + ]; + entries = filtered; + return { + parent: findPrevSameModel(2), + titleBefore: filtered[findPrevSameModel(2).idx].turn, + nextFromMiddle: findNextSameModel(1), + }; + } finally { + entries = oldEntries; + filtered = oldFiltered; + } + } + """) + + assert result == { + "parent": {"idx": 1, "isFallback": False}, + "titleBefore": "3.2", + "nextFromMiddle": 2, + } + + def test_parameter_diff_renders_field_level_line_diff(self, browser_page): + """Changed params should be rendered per field, not as opaque JSON blobs.""" + result = browser_page.evaluate(""" + () => { + const oldBody = { + previous_response_id: "resp_old", + client_metadata: { + "x-codex-turn-metadata": JSON.stringify({ turn_id: "turn-old", has_changes: true }) + } + }; + const newBody = { + previous_response_id: "resp_new", + client_metadata: { + "x-codex-turn-metadata": JSON.stringify({ turn_id: "turn-new", has_changes: false }) + } + }; + const host = document.createElement("div"); + host.innerHTML = renderStructuralDiff(structuralDiff(oldBody, newBody)); + const paramBlocks = Array.from(host.querySelectorAll(".diff-param-change")); + return { + paramCount: paramBlocks.length, + keys: paramBlocks.map(block => block.querySelector(".diff-param-key")?.textContent), + hasLineDiff: host.querySelectorAll(".diff-param-body .sbs-cell").length > 0, + text: host.innerText, + }; + } + """) + + assert result["paramCount"] == 2 + assert result["keys"] == ["previous_response_id", "client_metadata"] + assert result["hasLineDiff"] is True + assert "turn-old" in result["text"] + assert "turn-new" in result["text"] + + def test_added_tool_diff_is_expandable_with_schema(self, browser_page): + """Added namespace tools should expose their full schema in the diff.""" + result = browser_page.evaluate(""" + () => { + const oldBody = { tools: [{ type: "function", name: "tool_search" }] }; + const newBody = { tools: [ + { type: "function", name: "tool_search" }, + { + type: "namespace", + name: "mcp__codex_apps__github", + description: "Access repositories, issues, and pull requests.", + tools: [{ + type: "function", + name: "_fetch_pr", + description: "Fetch a pull request.", + parameters: { + type: "object", + properties: { + repo_full_name: { type: "string", description: "Repository in owner/name form." }, + pr_number: { type: "integer", description: "Pull request number." } + }, + required: ["repo_full_name", "pr_number"] + } + }] + } + ] }; + const host = document.createElement("div"); + host.innerHTML = renderStructuralDiff(structuralDiff(oldBody, newBody)); + const detail = host.querySelector(".diff-tool-detail"); + const beforeOpen = detail.open; + detail.open = true; + return { + beforeOpen, + summary: detail.querySelector("summary")?.innerText, + body: detail.querySelector(".diff-tool-body")?.innerText, + json: detail.querySelector(".diff-tool-json")?.textContent, + }; + } + """) + + assert result["beforeOpen"] is False + assert "mcp__codex_apps__github" in result["summary"] + assert "_fetch_pr" in result["body"] + assert "repo_full_name" in result["json"] diff --git a/tests/test_opencode_launch.py b/tests/test_opencode_launch.py new file mode 100644 index 0000000..1e63df7 --- /dev/null +++ b/tests/test_opencode_launch.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, run_client + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_opencode_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["opencode"] + assert cfg.cmd == "opencode" + assert cfg.default_target == "https://api.anthropic.com" + assert cfg.base_url_env == "ANTHROPIC_BASE_URL" + # opencode is multi-provider; forward proxy is the natural default + assert cfg.default_proxy_mode == "forward" + + +def test_parse_args_opencode_defaults_to_forward_mode() -> None: + args = parse_args(["--tap-client", "opencode"]) + assert args.client == "opencode" + assert args.proxy_mode == "forward" + + +def test_parse_args_opencode_explicit_reverse_overrides_default() -> None: + args = parse_args(["--tap-client", "opencode", "--tap-proxy-mode", "reverse"]) + assert args.proxy_mode == "reverse" + + +def test_parse_args_claude_default_proxy_mode_unchanged() -> None: + # Regression: changing the default-mode plumbing must not affect claude + args = parse_args([]) + assert args.client == "claude" + assert args.proxy_mode == "reverse" + + +def test_parse_args_codex_default_proxy_mode_unchanged() -> None: + # Regression: changing the default-mode plumbing must not affect codex + args = parse_args(["--tap-client", "codex"]) + assert args.client == "codex" + assert args.proxy_mode == "reverse" + + +@pytest.mark.asyncio +async def test_run_client_opencode_forward_sets_node_ca_env(monkeypatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/opencode") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["run", "hello"], client="opencode", proxy_mode="forward", ca_cert_path=ca_path) + + assert code == 0 + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["NODE_EXTRA_CA_CERTS"] == str(ca_path) + assert env["SSL_CERT_FILE"] == str(ca_path) + + +@pytest.mark.asyncio +async def test_run_client_opencode_reverse_sets_anthropic_base_url(monkeypatch) -> None: + captured: dict[str, object] = {} + for key in ("OPENAI_BASE_URL", "GOOGLE_GEMINI_BASE_URL"): + monkeypatch.delenv(key, raising=False) + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/opencode") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["run", "hello"], client="opencode", proxy_mode="reverse") + + assert code == 0 + env = captured["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:43123" + assert "OPENAI_BASE_URL" not in env + assert "GOOGLE_GEMINI_BASE_URL" not in env + # Reverse mode for opencode must not inject a codex-only -c flag + assert captured["cmd"] == ("/tmp/opencode", "run", "hello") + + +@pytest.mark.asyncio +async def test_run_client_opencode_capture_only_reverse_sets_multi_provider_urls(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/opencode") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["run", "hello"], client="opencode", proxy_mode="reverse", capture_only=True) + + assert code == 0 + env = captured["env"] + assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:43123" + assert env["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" + assert env["GOOGLE_GEMINI_BASE_URL"] == "http://127.0.0.1:43123" + # Reverse mode for opencode must not inject a codex-only -c flag + assert captured["cmd"] == ("/tmp/opencode", "run", "hello") diff --git a/tests/test_opencode_viewer.py b/tests/test_opencode_viewer.py new file mode 100644 index 0000000..64f9711 --- /dev/null +++ b/tests/test_opencode_viewer.py @@ -0,0 +1,69 @@ +"""Viewer regression test: opencode traces must not be mislabelled as Claude Code. + +opencode's system prompt begins with "You are opencode, ..." but may also +mention "Claude Code" later (e.g. in agent-builder examples). The sidebar +fingerprint heuristic must prefer the explicit self-identification. +""" + +from __future__ import annotations + +import json + +import pytest + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + + +@pytest.mark.skipif(pw_missing, reason="playwright not installed") +def test_opencode_prompt_is_labelled_opencode_not_claude_code(tmp_path) -> None: + from playwright.sync_api import sync_playwright + + from claude_tap.viewer import _generate_html_viewer + + # Mirror the real opencode system prompt shape: opens with the + # "You are opencode" self-id, then mentions "Claude Code" deep inside + # an example list. The pre-fix heuristic stopped at the first match + # and labelled the trace "Claude Code". + system_prompt = ( + "You are opencode, an interactive CLI tool that helps users with " + "software engineering tasks.\n" + "Use the instructions below and the tools available to you to assist the user.\n" + + ("filler ... " * 200) + + "\n(4) ask about Claude Code, Cursor, or similar agent internals\n" + + ("filler ... " * 200) + ) + + trace_path = tmp_path / "trace.jsonl" + record = { + "timestamp": "2026-05-01T23:46:43+00:00", + "request_id": "req_1", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/anthropic/v1/messages", + "headers": {}, + "body": { + "system": system_prompt, + "messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}], + }, + }, + "response": {"status": 200, "headers": {}, "body": {"content": [], "usage": {}}}, + } + trace_path.write_text(json.dumps(record) + "\n", encoding="utf-8") + + html_path = tmp_path / "trace.html" + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(html_path.resolve().as_uri(), wait_until="networkidle") + label = page.locator(".sidebar-item .si-task").first.inner_text() + browser.close() + + assert label == "OpenCode", f"opencode trace mislabelled as {label!r}" diff --git a/tests/test_path_allowlist.py b/tests/test_path_allowlist.py new file mode 100644 index 0000000..92ae38e --- /dev/null +++ b/tests/test_path_allowlist.py @@ -0,0 +1,99 @@ +"""Tests for proxy path allowlist filtering.""" + +import pytest + +from claude_tap.proxy import _is_allowed_path + + +@pytest.mark.parametrize( + "path", + [ + "/v1/messages", + "/v1/messages?stream=true", + "/v1/complete", + "/v1/responses", + "/v1/chat/completions", + "/v1/completions", + "/v1/models", + "/v1/models/claude-3", + "/v1/embeddings", + "/v1/files", + "/responses", + "/chat/completions", + "/completions", + "/models", + "/embeddings", + "/files", + "/v1beta/models/gemini-2.5-pro:streamGenerateContent", + "/v1beta/models/gemini-2.5-flash:generateContent", + "/v1alpha/models/gemini-test:generateContent", + "/v1internal:loadCodeAssist", + "/v1internal:streamGenerateContent?alt=sse", + "/search", + "/fetch", + "/usages", + "/feedback", + # AWS Bedrock paths + "/model/anthropic.claude-sonnet-4-20250514-v1:0/invoke", + "/model/anthropic.claude-sonnet-4-20250514-v1:0/invoke-with-response-stream", + "/model/us.anthropic.claude-sonnet-4-20250514-v1:0/messages", + # Claude Code through Google Vertex AI pass-through gateways + "/v1/projects/test-project/locations/us-east5/publishers/anthropic/models/claude-opus-4-7:rawPredict", + "/v1/projects/test-project/locations/us-east5/publishers/anthropic/models/claude-opus-4-7:streamRawPredict", + "/v1/projects/test-project/locations/us-east5/publishers/anthropic/models/claude-opus-4-7/count-tokens:rawPredict", + ], +) +def test_allowed_paths(path: str): + assert _is_allowed_path(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "/etc/passwd", + "/swagger/", + "/swagger-ui.html", + "/login.html", + "/metrics", + "/nacos/", + "/nexus/", + "/zabbix", + "/vnc.html", + "/", + "/admin", + "/wp-admin", + "/.env", + "/actuator/health", + "/api/v1/hack", + "/v1/projects/test-project/locations/us-east5/publishers/google/models/gemini-2.5-pro:rawPredict", + "/v1/projects/test-project/locations/us-east5/publishers/anthropic/models/claude-opus-4-7:predict", + "/v1/projects/test-project/locations/us-east5/endpoints/endpoint-id:predict", + ], +) +def test_blocked_paths(path: str): + assert _is_allowed_path(path) is False + + +@pytest.mark.parametrize( + "path,extra_prefixes", + [ + ("/custom/api/v1/messages", ("/custom/api",)), + ("/custom/api/v1/completions", ("/custom/api",)), + ("/my/api/endpoint", ("/my/api",)), + ("/api/v2/models", ("/api/v2",)), + ], +) +def test_extra_prefixes_allowed(path: str, extra_prefixes: tuple[str, ...]): + assert _is_allowed_path(path, extra_prefixes) is True + + +@pytest.mark.parametrize( + "path,extra_prefixes", + [ + ("/etc/passwd", ("/custom/api",)), + ("/swagger/", ("/custom/api",)), + ("/api/v1/hack", ("/custom/api",)), + ], +) +def test_extra_prefixes_blocked_when_not_matching(path: str, extra_prefixes: tuple[str, ...]): + assert _is_allowed_path(path, extra_prefixes) is False diff --git a/tests/test_perf_viewer.py b/tests/test_perf_viewer.py new file mode 100644 index 0000000..1c8aa33 --- /dev/null +++ b/tests/test_perf_viewer.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""Playwright tests for viewer performance with large and small traces. + +Tests lazy loading, virtual scrolling, and keyboard navigation using +both real large traces and synthetic small traces. +""" + +import json +import tempfile +from pathlib import Path + +import pytest + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + +pytestmark = pytest.mark.skipif(pw_missing, reason="playwright not installed") + +LARGE_TRACE = Path(__file__).parent.parent / ".traces" / "trace_20260228_212004.jsonl" + + +def _make_entry(turn: int, messages: list[dict]) -> dict: + """Build a trace entry matching the real JSONL format.""" + return { + "timestamp": f"2026-02-24T20:00:{turn:02d}", + "request_id": f"req_{turn}", + "turn": turn, + "duration_ms": 500 + turn * 10, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {}, + "body": { + "model": "claude-opus-4-6", + "system": [{"type": "text", "text": "You are Claude"}], + "messages": messages, + }, + }, + "response": { + "status": 200, + "body": { + "content": [{"type": "text", "text": f"Response for turn {turn}"}], + "model": "claude-opus-4-6", + "usage": {"input_tokens": turn * 100, "output_tokens": turn * 20}, + }, + }, + } + + +def _build_small_trace_html() -> str: + """Generate viewer HTML with 4 inline entries (small trace, no lazy mode).""" + from claude_tap.viewer import _generate_html_viewer + + entries = [ + _make_entry(1, [{"role": "user", "content": "hello"}]), + _make_entry( + 2, + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "how are you"}, + ], + ), + _make_entry( + 3, + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "how are you"}, + {"role": "assistant", "content": "Great!"}, + {"role": "user", "content": "tell me a joke"}, + ], + ), + _make_entry( + 4, + [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "how are you"}, + {"role": "assistant", "content": "Great!"}, + {"role": "user", "content": "tell me a joke"}, + {"role": "assistant", "content": "Why did the..."}, + {"role": "user", "content": "another"}, + ], + ), + ] + + with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False, mode="w", encoding="utf-8") as trace_f: + for e in entries: + trace_f.write(json.dumps(e) + "\n") + trace_path = Path(trace_f.name) + + html_path = Path(tempfile.mktemp(suffix=".html")) + _generate_html_viewer(trace_path, html_path) + return str(html_path) + + +# ── Shared playwright instance ── + + +@pytest.fixture(scope="module") +def pw_browser(): + """Single shared playwright + browser for the whole module.""" + from playwright.sync_api import sync_playwright + + pw = sync_playwright().start() + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() + pw.stop() + + +# ── Large trace fixtures ── + + +@pytest.fixture(scope="module") +def large_html_file(): + """Generate viewer HTML from large real trace (lazy mode).""" + if not LARGE_TRACE.exists(): + pytest.skip(f"Large trace not found: {LARGE_TRACE}") + + from claude_tap.viewer import _generate_html_viewer + + html_path = Path(tempfile.mktemp(suffix=".html")) + _generate_html_viewer(LARGE_TRACE, html_path) + yield html_path + html_path.unlink(missing_ok=True) + + +@pytest.fixture(scope="module") +def large_browser_page(pw_browser, large_html_file): + """Open large trace HTML in a browser page.""" + page = pw_browser.new_page() + page.goto(f"file://{large_html_file}", timeout=30000) + page.wait_for_selector(".sidebar-item", timeout=10000) + yield page + page.close() + + +# ── Small trace fixtures ── + + +@pytest.fixture(scope="module") +def small_html_file(): + """Generate viewer HTML from small synthetic trace (inline mode).""" + path = _build_small_trace_html() + yield Path(path) + Path(path).unlink(missing_ok=True) + + +@pytest.fixture(scope="module") +def small_browser_page(pw_browser, small_html_file): + """Open small trace HTML in a browser page.""" + page = pw_browser.new_page() + page.goto(f"file://{small_html_file}", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + yield page + page.close() + + +class TestLargeTraceSidebar: + """Verify sidebar loads quickly and virtual scrolling works.""" + + def test_sidebar_populates_within_10s(self, large_browser_page): + """Sidebar should show entries — the 10s budget is enforced by the fixture.""" + count = large_browser_page.evaluate( + """() => { + // In virtual mode, count comes from vsFilteredItems + if (typeof vsFilteredItems !== 'undefined' && vsFilteredItems.length > 0) + return vsFilteredItems.length; + return document.querySelectorAll('.sidebar-item').length; + }""" + ) + assert count > 50, f"Expected >50 sidebar entries, got {count}" + + def test_virtual_scroll_active(self, large_browser_page): + """Virtual scroll mode should be active for large traces.""" + is_virtual = large_browser_page.evaluate("typeof virtualMode !== 'undefined' && virtualMode") + assert is_virtual, "virtualMode should be true for large trace" + + def test_lazy_mode_active(self, large_browser_page): + """Lazy mode should be active for large traces.""" + is_lazy = large_browser_page.evaluate("typeof lazyMode !== 'undefined' && lazyMode") + assert is_lazy, "lazyMode should be true for large trace" + + def test_position_indicator_visible(self, large_browser_page): + """Position indicator should be visible after entry selection.""" + visible = large_browser_page.evaluate( + """() => { + const pi = document.getElementById('position-indicator'); + return pi && pi.style.display !== 'none'; + }""" + ) + assert visible, "Position indicator should be visible" + + +class TestLargeTraceDetailLoading: + """Verify clicking entries loads detail content quickly.""" + + def test_click_entry_100_shows_content(self, large_browser_page): + """Click entry ~100 and verify detail panel renders within 2s.""" + page = large_browser_page + + # Select entry at index 100 (or last if fewer) + result = page.evaluate( + """() => { + const idx = Math.min(100, filtered.length - 1); + selectEntry(idx); + return { + idx: idx, + hasDetail: document.getElementById('detail').innerHTML.length > 100, + activeIdx: activeIdx, + }; + }""" + ) + assert result["hasDetail"], f"Detail panel should have content after selecting entry {result['idx']}" + + # Verify the detail has actual sections (not just empty state) + has_sections = page.evaluate("document.querySelectorAll('.section').length > 0") + assert has_sections, "Detail panel should contain sections" + + def test_click_first_entry_shows_content(self, large_browser_page): + """First entry should also load correctly.""" + page = large_browser_page + page.evaluate("selectEntry(0)") + page.wait_for_timeout(500) + has_content = page.evaluate("document.getElementById('detail').innerHTML.length > 100") + assert has_content, "First entry detail should have content" + + +class TestKeyboardNavigation: + """Verify keyboard shortcuts work for large traces.""" + + def test_arrow_down_navigates(self, large_browser_page): + """Arrow down should move to next entry.""" + page = large_browser_page + page.evaluate("selectEntry(0)") + page.wait_for_timeout(200) + + before = page.evaluate("activeIdx") + page.keyboard.press("ArrowDown") + page.wait_for_timeout(200) + after = page.evaluate("activeIdx") + assert after == before + 1, f"ArrowDown: expected {before + 1}, got {after}" + + def test_arrow_up_navigates(self, large_browser_page): + """Arrow up should move to previous entry.""" + page = large_browser_page + page.evaluate("selectEntry(5)") + page.wait_for_timeout(200) + + before = page.evaluate("activeIdx") + page.keyboard.press("ArrowUp") + page.wait_for_timeout(200) + after = page.evaluate("activeIdx") + assert after == before - 1, f"ArrowUp: expected {before - 1}, got {after}" + + def test_home_jumps_to_first(self, large_browser_page): + """Home key should jump to first entry.""" + page = large_browser_page + page.evaluate("selectEntry(50)") + page.wait_for_timeout(200) + + page.keyboard.press("Home") + page.wait_for_timeout(200) + idx = page.evaluate("activeIdx") + assert idx == 0, f"Home: expected 0, got {idx}" + + def test_end_jumps_to_last(self, large_browser_page): + """End key should jump to last entry.""" + page = large_browser_page + page.evaluate("selectEntry(0)") + page.wait_for_timeout(200) + + page.keyboard.press("End") + page.wait_for_timeout(200) + idx = page.evaluate("activeIdx") + total = page.evaluate("filtered.length") + assert idx == total - 1, f"End: expected {total - 1}, got {idx}" + + def test_page_down_jumps_10(self, large_browser_page): + """PageDown should advance by 10 entries.""" + page = large_browser_page + page.evaluate("selectEntry(0)") + page.wait_for_timeout(200) + + page.keyboard.press("PageDown") + page.wait_for_timeout(200) + idx = page.evaluate("activeIdx") + assert idx == 10, f"PageDown: expected 10, got {idx}" + + +class TestSmallTraceRegression: + """Verify small traces still work with inline (non-lazy) approach.""" + + def test_inline_mode_active(self, small_browser_page): + """Small traces should NOT use lazy mode.""" + is_lazy = small_browser_page.evaluate("typeof lazyMode !== 'undefined' && lazyMode") + assert not is_lazy, "lazyMode should be false for small trace" + + def test_sidebar_has_4_entries(self, small_browser_page): + """All 4 entries should appear in sidebar.""" + count = small_browser_page.evaluate("document.querySelectorAll('.sidebar-item').length") + assert count == 4, f"Expected 4, got {count}" + + def test_detail_loads(self, small_browser_page): + """Clicking an entry should show detail content.""" + page = small_browser_page + page.evaluate("selectEntry(0)") + page.wait_for_timeout(300) + has_content = page.evaluate("document.getElementById('detail').innerHTML.length > 100") + assert has_content, "Detail panel should have content" + + def test_no_virtual_scroll(self, small_browser_page): + """Small traces should not use virtual scroll.""" + is_virtual = small_browser_page.evaluate("typeof virtualMode !== 'undefined' && virtualMode") + assert not is_virtual, "virtualMode should be false for small trace" + + def test_keyboard_nav_works(self, small_browser_page): + """Arrow keys should work in non-virtual mode.""" + page = small_browser_page + page.evaluate("selectEntry(0)") + page.wait_for_timeout(200) + + page.keyboard.press("ArrowDown") + page.wait_for_timeout(200) + idx = page.evaluate("activeIdx") + assert idx == 1, f"ArrowDown should move to 1, got {idx}" + + page.keyboard.press("End") + page.wait_for_timeout(200) + idx = page.evaluate("activeIdx") + assert idx == 3, f"End should move to 3, got {idx}" diff --git a/tests/test_pi_launch.py b/tests/test_pi_launch.py new file mode 100644 index 0000000..8fda572 --- /dev/null +++ b/tests/test_pi_launch.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, run_client + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_pi_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["pi"] + + assert cfg.cmd == "pi" + assert cfg.label == "Pi" + assert cfg.default_target == "https://api.openai.com" + assert cfg.base_url_env == "OPENAI_BASE_URL" + assert cfg.base_url_suffix == "/v1" + assert cfg.default_proxy_mode == "forward" + + +def test_parse_args_pi_defaults_to_forward_mode() -> None: + args = parse_args(["--tap-client", "pi"]) + + assert args.client == "pi" + assert args.target == "https://api.openai.com" + assert args.proxy_mode == "forward" + + +def test_parse_args_pi_explicit_reverse_overrides_default() -> None: + args = parse_args(["--tap-client", "pi", "--tap-proxy-mode", "reverse"]) + + assert args.client == "pi" + assert args.proxy_mode == "reverse" + + +@pytest.mark.asyncio +async def test_run_client_pi_forward_sets_proxy_ca_and_preserves_args(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setenv("NO_PROXY", "example.com") + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/pi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["--model", "openai-codex/gpt-5.3-codex-spark", "-p", "hello"], + client="pi", + proxy_mode="forward", + ca_cert_path=ca_path, + ) + + assert code == 0 + assert captured["cmd"] == ( + "/tmp/pi", + "--model", + "openai-codex/gpt-5.3-codex-spark", + "-p", + "hello", + ) + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["http_proxy"] == "http://127.0.0.1:43123" + assert env["NODE_EXTRA_CA_CERTS"] == str(ca_path) + assert env["SSL_CERT_FILE"] == str(ca_path) + assert "example.com" in env["NO_PROXY"] + assert "localhost" in env["NO_PROXY"] + assert "127.0.0.1" in env["NO_PROXY"] + assert env["no_proxy"] == env["NO_PROXY"] + assert "OPENAI_BASE_URL" not in env + + +@pytest.mark.asyncio +async def test_run_client_pi_reverse_sets_openai_base_url_without_codex_config(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/pi") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--provider", "openai", "-p", "hello"], client="pi", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == ("/tmp/pi", "--provider", "openai", "-p", "hello") + assert captured["env"]["OPENAI_BASE_URL"] == "http://127.0.0.1:43123/v1" diff --git a/tests/test_prompt_snapshot.py b/tests/test_prompt_snapshot.py new file mode 100644 index 0000000..bf35c16 --- /dev/null +++ b/tests/test_prompt_snapshot.py @@ -0,0 +1,460 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from claude_tap.prompt_snapshot import infer_provider, render_prompt_markdown, snapshot_from_records + + +def _record(path: str, body: dict, *, turn: int = 1) -> dict: + return { + "timestamp": "2026-05-21T10:00:00+00:00", + "request_id": f"req_{turn}", + "turn": turn, + "duration_ms": 1, + "request": {"method": "POST", "path": path, "headers": {}, "body": body}, + "response": {"status": 200, "headers": {}, "body": {}}, + "upstream_base_url": "https://upstream.example.com", + } + + +def test_anthropic_snapshot_selects_tool_bearing_request(): + light = _record( + "/v1/messages?beta=true", + { + "model": "claude-haiku", + "system": [{"type": "text", "text": "probe system"}], + "messages": [{"role": "user", "content": "probe"}], + }, + turn=1, + ) + full = _record( + "/v1/messages?beta=true", + { + "model": "claude-opus", + "system": [{"type": "text", "text": "main system"}, {"type": "text", "text": "second block"}], + "messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}], + "tools": [ + { + "name": "Bash", + "description": "Run shell commands", + "input_schema": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + } + ], + }, + turn=2, + ) + + snapshot = snapshot_from_records([light, full]) + + assert snapshot.provider == "anthropic" + assert snapshot.model == "claude-opus" + assert snapshot.turn == 2 + assert snapshot.system_prompt == "main system\n\nsecond block" + assert snapshot.user_message == "hello" + assert len(snapshot.tools) == 1 + assert snapshot.tools[0].name == "Bash" + assert snapshot.tools[0].schema["properties"]["cmd"]["type"] == "string" + + +def test_openai_responses_snapshot_extracts_instructions_roles_and_tools(): + record = _record( + "/v1/responses", + { + "model": "gpt-5.4", + "instructions": "top instructions", + "input": [ + {"role": "developer", "content": [{"type": "input_text", "text": "developer rules"}]}, + {"role": "system", "content": "system from input"}, + {"role": "user", "content": [{"type": "input_text", "text": "do the thing"}]}, + ], + "tools": [ + { + "type": "function", + "name": "update_plan", + "description": "Update a plan", + "parameters": {"type": "object", "properties": {"plan": {"type": "array"}}}, + } + ], + }, + ) + + snapshot = snapshot_from_records([record]) + + assert snapshot.provider == "openai" + assert snapshot.system_prompt == "top instructions\n\nsystem from input" + assert snapshot.developer_prompt == "developer rules" + assert snapshot.user_message == "do the thing" + assert snapshot.tools[0].name == "update_plan" + assert snapshot.tools[0].schema["properties"]["plan"]["type"] == "array" + + +def test_openai_chat_completions_snapshot_extracts_messages_and_function_tool(): + record = _record( + "/v1/chat/completions", + { + "model": "gpt-4o-mini", + "messages": [ + {"role": "system", "content": "chat system"}, + {"role": "developer", "content": [{"type": "text", "text": "chat developer"}]}, + {"role": "user", "content": "chat user"}, + ], + "tools": [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Lookup data", + "parameters": {"type": "object", "properties": {"id": {"type": "string"}}}, + }, + } + ], + }, + ) + + snapshot = snapshot_from_records([record]) + + assert snapshot.provider == "openai" + assert snapshot.system_prompt == "chat system" + assert snapshot.developer_prompt == "chat developer" + assert snapshot.user_message == "chat user" + assert snapshot.tools[0].name == "lookup" + assert snapshot.tools[0].schema["properties"]["id"]["type"] == "string" + + +def test_gemini_snapshot_extracts_system_contents_and_function_declarations(): + record = _record( + "/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse", + { + "system_instruction": {"parts": [{"text": "gemini system"}]}, + "contents": [ + {"role": "user", "parts": [{"text": "hello "}, {"text": "gemini"}]}, + {"role": "model", "parts": [{"text": "ignored"}]}, + ], + "tools": [ + { + "function_declarations": [ + { + "name": "search", + "description": "Search things", + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, + } + ] + } + ], + }, + ) + + snapshot = snapshot_from_records([record]) + + assert snapshot.provider == "gemini" + assert snapshot.model == "gemini-2.5-pro" + assert snapshot.system_prompt == "gemini system" + assert snapshot.user_message == "hello\n\ngemini" + assert snapshot.tools[0].name == "search" + + +def test_gemini_snapshot_treats_roleless_contents_as_user_text(): + record = _record( + "/v1beta/models/gemini-2.5-pro:generateContent", + {"contents": [{"parts": [{"text": "roleless prompt"}]}]}, + ) + + snapshot = snapshot_from_records([record]) + + assert snapshot.provider == "gemini" + assert snapshot.user_message == "roleless prompt" + + +def test_gemini_snapshot_accepts_cli_camel_case_fields(): + record = _record( + "/v1beta/models/gemini-3.1-pro-preview:streamGenerateContent?alt=sse", + { + "systemInstruction": {"parts": [{"text": "gemini cli system"}]}, + "contents": [{"role": "user", "parts": [{"text": "cli prompt"}]}], + "tools": [ + { + "functionDeclarations": [ + { + "name": "read_file", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}, + } + ] + } + ], + }, + ) + + snapshot = snapshot_from_records([record]) + + assert snapshot.system_prompt == "gemini cli system" + assert snapshot.user_message == "cli prompt" + assert snapshot.tools[0].name == "read_file" + + +def test_code_assist_nested_request_snapshot_extracts_gemini_prompt(): + record = _record( + "/v1internal:streamGenerateContent?alt=sse", + { + "request": { + "systemInstruction": {"parts": [{"text": "code assist system"}]}, + "contents": [{"role": "user", "parts": [{"text": "code assist prompt"}]}], + "tools": [ + { + "functionDeclarations": [ + { + "name": "read_file", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}, + } + ] + } + ], + } + }, + ) + + snapshot = snapshot_from_records([record]) + + assert infer_provider(record) == "gemini" + assert snapshot.provider == "gemini" + assert snapshot.system_prompt == "code assist system" + assert snapshot.user_message == "code assist prompt" + assert snapshot.tools[0].name == "read_file" + assert snapshot.tools[0].schema["properties"]["path"]["type"] == "string" + + +def test_legacy_completion_snapshot_extracts_prompt_field(): + anthropic = snapshot_from_records( + [_record("/v1/complete", {"model": "claude-legacy", "prompt": "legacy anthropic prompt"})] + ) + openai = snapshot_from_records( + [_record("/v1/completions", {"model": "gpt-legacy", "prompt": "legacy openai prompt"})] + ) + + assert anthropic.provider == "anthropic" + assert anthropic.user_message == "legacy anthropic prompt" + assert openai.provider == "openai" + assert openai.user_message == "legacy openai prompt" + + +def test_bedrock_snapshot_infers_anthropic_without_system_prompt(): + record = _record( + "/model/us.anthropic.claude-sonnet-4-6-v1:0/invoke", + { + "anthropic_version": "bedrock-2023-05-31", + "messages": [{"role": "user", "content": [{"text": "bedrock prompt"}]}], + }, + ) + + snapshot = snapshot_from_records([record]) + + assert infer_provider(record) == "anthropic" + assert snapshot.provider == "anthropic" + assert snapshot.user_message == "bedrock prompt" + + +def test_bedrock_converse_snapshot_extracts_tool_config_tools(): + record = _record( + "/model/us.anthropic.claude-sonnet-4-6-v1:0/converse", + { + "anthropic_version": "bedrock-2023-05-31", + "messages": [{"role": "user", "content": [{"text": "bedrock prompt"}]}], + "toolConfig": { + "tools": [ + { + "toolSpec": { + "name": "read_file", + "description": "Read a file", + "inputSchema": {"json": {"type": "object", "properties": {"path": {"type": "string"}}}}, + } + } + ] + }, + }, + ) + + snapshot = snapshot_from_records([record]) + + assert snapshot.tools[0].name == "read_file" + assert snapshot.tools[0].schema["properties"]["path"]["type"] == "string" + + +def test_websocket_snapshot_prefers_prompt_bearing_request_event(): + record = _record( + "/v1/responses", + { + "type": "response.create", + "model": "gpt-5", + "input": [{"type": "function_call_output", "call_id": "call_1", "output": "{}"}], + }, + ) + record["transport"] = "websocket" + record["request"]["ws_events"] = [ + {"type": "response.create", "model": "gpt-5", "input": [], "tools": [], "generate": False}, + { + "type": "response.create", + "model": "gpt-5", + "instructions": "ws instructions", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "ws prompt"}]}], + "tools": [ + { + "type": "function", + "name": "exec_command", + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + } + ], + }, + record["request"]["body"], + ] + + snapshot = snapshot_from_records([record]) + + assert snapshot.provider == "openai" + assert snapshot.system_prompt == "ws instructions" + assert snapshot.user_message == "ws prompt" + assert snapshot.tools[0].name == "exec_command" + assert snapshot.raw_request_body["instructions"] == "ws instructions" + + +def test_provider_inference_can_fall_back_to_body_shape(): + assert infer_provider(_record("/custom", {"system": "s", "messages": []})) == "anthropic" + assert infer_provider(_record("/custom", {"instructions": "i", "input": []})) == "openai" + assert infer_provider(_record("/custom", {"system_instruction": {}, "contents": []})) == "gemini" + assert infer_provider(_record("/custom", {"systemInstruction": {}, "contents": []})) == "gemini" + assert infer_provider(_record("/custom", {"metadata": {}})) == "unknown" + + +def test_snapshot_rejects_traces_without_prompt_bearing_requests(): + records = [ + _record("/health", {"metadata": {}}), + {"request": {"method": "GET", "path": "/v1/messages", "body": "not-json"}}, + ] + + try: + snapshot_from_records(records) + except ValueError as exc: + assert str(exc) == "no prompt-bearing request found in trace" + else: + raise AssertionError("expected prompt-less trace to be rejected") + + +def test_snapshot_selection_penalizes_lightweight_model_probe(): + probe = _record("/v1/models", {"model": "probe", "instructions": "probe"}, turn=1) + full = _record( + "/v1/responses", + { + "model": "gpt-5", + "instructions": "real instructions", + "input": [{"role": "user", "content": "real user"}], + }, + turn=2, + ) + + snapshot = snapshot_from_records([probe, full]) + + assert snapshot.turn == 2 + assert snapshot.system_prompt == "real instructions" + + +def test_prompt_markdown_is_comparison_oriented_and_includes_raw_schema(): + snapshot = snapshot_from_records( + [ + _record( + "/v1/messages", + { + "model": "claude", + "system": "# sys\ncontent", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + { + "name": "Read", + "description": "# Read files", + "input_schema": {"type": "object"}, + } + ], + }, + ) + ] + ) + + out = render_prompt_markdown(snapshot) + + assert "# Prompt Snapshot" not in out + assert "Request ID" not in out + assert "Captured" not in out + assert "# System Prompt" in out + assert "## sys" in out + assert "## Read" in out + assert "### Read files" in out + assert '"type": "object"' in out + + +def test_snapshot_extracts_nested_text_blocks_and_schema_fallbacks(): + record = _record( + "/v1/responses", + { + "model": "gpt-5", + "input": [ + {"role": "system", "content": {"input_text": "system dict text"}}, + {"role": "user", "content": ["plain", {"content": {"text": "nested"}}]}, + ], + "tools": [ + "ignore", + {"type": "function", "name": "schema_from_input", "input_schema": {"type": "object"}}, + ], + }, + ) + + snapshot = snapshot_from_records([record]) + + assert snapshot.system_prompt == "system dict text" + assert snapshot.user_message == "plain\n\nnested" + assert snapshot.tools[0].name == "schema_from_input" + assert snapshot.tools[0].schema == {"type": "object"} + + +def test_gemini_tools_accept_raw_tool_without_function_declarations(): + snapshot = snapshot_from_records( + [ + _record( + "/v1beta/models/gemini-pro:generateContent", + { + "system_instruction": {"parts": [{"text": "system"}]}, + "contents": [{"role": "user", "parts": [{"text": "hello"}]}], + "tools": ["ignore", {"type": "googleSearch"}], + }, + ) + ] + ) + + assert snapshot.tools[0].name == "googleSearch" + assert snapshot.tools[0].raw == {"type": "googleSearch"} + + +def test_prompt_md_export_format(tmp_path: Path): + from claude_tap.export import export_main + + trace = tmp_path / "trace.jsonl" + trace.write_text( + json.dumps( + _record( + "/v1/messages", + { + "model": "claude", + "system": "system text", + "messages": [{"role": "user", "content": "hello"}], + }, + ) + ) + + "\n", + encoding="utf-8", + ) + out = tmp_path / "snapshot.prompt.md" + + rc = export_main([str(trace), "-o", str(out)]) + + assert rc == 0 + text = out.read_text(encoding="utf-8") + assert "# Prompt Snapshot" not in text + assert "# System Prompt" in text + assert "system text" in text diff --git a/tests/test_qoder_launch.py b/tests/test_qoder_launch.py new file mode 100644 index 0000000..854c645 --- /dev/null +++ b/tests/test_qoder_launch.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from claude_tap import parse_args +from claude_tap.cli import CLIENT_CONFIGS, run_client +from claude_tap.proxy import _build_record + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def test_qoder_registered_in_client_configs() -> None: + cfg = CLIENT_CONFIGS["qoder"] + + assert cfg.cmd == "qodercli" + assert cfg.label == "Qoder CLI" + assert cfg.default_target == "https://api2.qoder.sh" + assert cfg.base_url_env == "QODER_BASE_URL" + assert cfg.base_url_suffix == "" + assert cfg.default_proxy_mode == "forward" + + +def test_parse_args_qoder_defaults_to_forward_mode() -> None: + args = parse_args(["--tap-client", "qoder"]) + + assert args.client == "qoder" + assert args.target == "https://api2.qoder.sh" + assert args.proxy_mode == "forward" + + +def test_parse_args_qoder_explicit_reverse_overrides_default() -> None: + args = parse_args(["--tap-client", "qoder", "--tap-proxy-mode", "reverse"]) + + assert args.client == "qoder" + assert args.proxy_mode == "reverse" + + +def test_qoder_trace_headers_redact_request_identity_and_response_cookie() -> None: + record = _build_record( + req_id="req_qoder", + turn=1, + duration_ms=42, + method="POST", + path_qs="/api/agent/query", + req_headers={ + "Cosy-Key": "cosy-request-signature-secret", + "Cosy-MachineToken": "qoder-machine-token-secret", + "Cosy-MachineId": "qoder-machine-id-secret", + "Cosy-User": "qoder-user-secret", + "Content-Type": "application/json", + }, + req_body={"prompt": "hello"}, + status=200, + resp_headers={ + "Set-Cookie": "acw_tc=qoder-response-cookie-secret; Path=/", + "Content-Type": "application/json", + }, + resp_body={"ok": True}, + ) + + req_headers = record["request"]["headers"] + assert req_headers["Cosy-Key"] == "***" + assert "signature-secret" not in req_headers["Cosy-Key"] + assert req_headers["Cosy-MachineToken"] == "***" + assert "machine-token-secret" not in req_headers["Cosy-MachineToken"] + assert req_headers["Cosy-MachineId"] == "***" + assert "machine-id-secret" not in req_headers["Cosy-MachineId"] + assert req_headers["Cosy-User"] == "***" + assert "user-secret" not in req_headers["Cosy-User"] + assert req_headers["Content-Type"] == "application/json" + + resp_headers = record["response"]["headers"] + assert resp_headers["Set-Cookie"] == "***" + assert "response-cookie-secret" not in resp_headers["Set-Cookie"] + assert resp_headers["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_run_client_qoder_forward_sets_proxy_ca_and_preserves_args(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + ca_path = Path("/tmp/test-ca.pem") + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.delenv("QODER_BASE_URL", raising=False) + monkeypatch.setenv("NO_PROXY", "example.com") + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/qodercli") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["-p", "hello", "--permission-mode", "dont_ask"], + client="qoder", + proxy_mode="forward", + ca_cert_path=ca_path, + ) + + assert code == 0 + assert captured["cmd"] == ("/tmp/qodercli", "-p", "hello", "--permission-mode", "dont_ask") + env = captured["env"] + assert env["HTTPS_PROXY"] == "http://127.0.0.1:43123" + assert env["http_proxy"] == "http://127.0.0.1:43123" + assert env["NODE_EXTRA_CA_CERTS"] == str(ca_path) + assert env["SSL_CERT_FILE"] == str(ca_path) + assert "example.com" in env["NO_PROXY"] + assert "localhost" in env["NO_PROXY"] + assert "127.0.0.1" in env["NO_PROXY"] + assert env["no_proxy"] == env["NO_PROXY"] + assert "QODER_BASE_URL" not in env + + +@pytest.mark.asyncio +async def test_run_client_qoder_reverse_sets_structural_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs["env"] + return _DummyProc() + + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: "/tmp/qodercli") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["-p", "hello"], client="qoder", proxy_mode="reverse") + + assert code == 0 + assert captured["cmd"] == ("/tmp/qodercli", "-p", "hello") + assert captured["env"]["QODER_BASE_URL"] == "http://127.0.0.1:43123" diff --git a/tests/test_request_body_parsing.py b/tests/test_request_body_parsing.py new file mode 100644 index 0000000..0a44590 --- /dev/null +++ b/tests/test_request_body_parsing.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import json + +from claude_tap.proxy import _parse_request_body_for_trace + + +def test_parse_request_body_for_trace_unwraps_double_serialized_object() -> None: + inner = {"model": "claude-test", "messages": [{"role": "user", "content": "hi"}]} + body = json.dumps(json.dumps(inner)).encode() + + assert _parse_request_body_for_trace(body) == inner + + +def test_parse_request_body_for_trace_keeps_double_serialized_non_object() -> None: + inner = ["not", "a", "request", "object"] + body = json.dumps(json.dumps(inner)).encode() + + assert _parse_request_body_for_trace(body) == json.dumps(inner) + + +def test_parse_request_body_for_trace_keeps_plain_json_string() -> None: + body = json.dumps("plain prompt").encode() + + assert _parse_request_body_for_trace(body) == "plain prompt" + + +def test_parse_request_body_for_trace_decodes_invalid_json_as_text() -> None: + assert _parse_request_body_for_trace(b"{not-json") == "{not-json" + + +def test_parse_request_body_for_trace_empty_body_is_none() -> None: + assert _parse_request_body_for_trace(b"") is None diff --git a/tests/test_responses_browser.py b/tests/test_responses_browser.py new file mode 100644 index 0000000..1b20c37 --- /dev/null +++ b/tests/test_responses_browser.py @@ -0,0 +1,1748 @@ +"""Browser coverage for OpenAI Responses traces in viewer.html.""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from claude_tap.viewer import _generate_html_viewer + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + +pytestmark = pytest.mark.skipif(pw_missing, reason="playwright not installed") + + +@pytest.fixture(scope="module") +def responses_html_file() -> Path: + trace_path = Path(__file__).parent / "fixtures" / "openai_responses_trace.jsonl" + html_path = Path(tempfile.mktemp(suffix=".html")) + _generate_html_viewer(trace_path, html_path) + yield html_path + html_path.unlink(missing_ok=True) + + +@pytest.fixture() +def responses_page(responses_html_file: Path): + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{responses_html_file}", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + yield page + browser.close() + + +@pytest.fixture(scope="module") +def codex_ws_multi_html_file() -> Path: + trace_path = Path(__file__).parent / "fixtures" / "codex_ws_multi_response_trace.jsonl" + html_path = Path(tempfile.mktemp(suffix=".html")) + _generate_html_viewer(trace_path, html_path) + yield html_path + html_path.unlink(missing_ok=True) + + +@pytest.fixture() +def codex_ws_multi_page(codex_ws_multi_html_file: Path): + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{codex_ws_multi_html_file}", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + yield page + browser.close() + + +@pytest.fixture(scope="module") +def chat_completions_history_html_file() -> Path: + trace_path = Path(tempfile.mktemp(suffix=".jsonl")) + html_path = Path(tempfile.mktemp(suffix=".html")) + record = { + "request_id": "req_kimi_history", + "turn": 1, + "request": { + "method": "POST", + "path": "/chat/completions", + "body": { + "model": "kimi-k2-turbo-preview", + "messages": [ + {"role": "system", "content": "Kimi CLI regression system prompt."}, + {"role": "user", "content": "Use a tool."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_read", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path":"pyproject.toml"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_read", "content": "project metadata"}, + {"role": "user", "content": "Continue in the same chat."}, + ], + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a file.", + "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}, + }, + } + ], + }, + }, + "response": {"status": 200, "body": {"content": [{"type": "text", "text": "Done."}]}}, + } + trace_path.write_text(json.dumps(record) + "\n") + _generate_html_viewer(trace_path, html_path) + yield html_path + trace_path.unlink(missing_ok=True) + html_path.unlink(missing_ok=True) + + +@pytest.fixture() +def chat_completions_history_page(chat_completions_history_html_file: Path): + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + page = browser.new_page() + page.goto(f"file://{chat_completions_history_html_file}", timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + yield page + browser.close() + + +def test_viewer_renders_codex_responses_messages_usage_and_response(responses_page) -> None: + responses_page.locator(".sidebar-item").first.click() + responses_page.wait_for_selector("#detail .section", timeout=5000) + + detail_text = responses_page.locator("#detail").inner_text() + + assert "Input" in detail_text + assert "USER" in detail_text + assert "Hello" in detail_text + assert "Response" in detail_text + assert "Hello! How can I help?" in detail_text + assert "500" in detail_text + assert "10" in detail_text + responses_page.locator(".section-header", has_text="Tools").click() + tools_text = responses_page.locator(".section", has_text="Tools").first.inner_text() + assert "exec_command" in tools_text + assert "web_search" in tools_text + assert "unknown" not in tools_text.lower() + + +def test_viewer_json_tree_toggle_collapses_and_expands(responses_page) -> None: + responses_page.locator(".sidebar-item").first.click() + responses_page.wait_for_selector("#detail .section", timeout=5000) + + result = responses_page.evaluate( + """() => { + const section = Array.from(document.querySelectorAll('#detail .section')) + .find(el => el.querySelector('.title')?.textContent === 'Full JSON'); + if (!section) return { found: false }; + + const toggle = section.querySelector('.jt-toggle'); + const children = section.querySelector('.jt-children'); + const summary = section.querySelector('.jt-summary'); + const closeLine = children?.nextElementSibling; + const initial = { + toggle: toggle?.textContent, + childrenOpen: children?.classList.contains('jt-open'), + summaryShown: summary?.classList.contains('jt-show'), + closeHidden: closeLine?.classList.contains('jt-hidden'), + }; + + toggle.click(); + const collapsed = { + toggle: toggle.textContent, + childrenOpen: children.classList.contains('jt-open'), + summaryShown: summary.classList.contains('jt-show'), + closeHidden: closeLine.classList.contains('jt-hidden'), + }; + + toggle.click(); + const expanded = { + toggle: toggle.textContent, + childrenOpen: children.classList.contains('jt-open'), + summaryShown: summary.classList.contains('jt-show'), + closeHidden: closeLine.classList.contains('jt-hidden'), + }; + + return { found: true, initial, collapsed, expanded }; + }""" + ) + + assert result == { + "found": True, + "initial": {"toggle": "▼", "childrenOpen": True, "summaryShown": False, "closeHidden": False}, + "collapsed": {"toggle": "▶", "childrenOpen": False, "summaryShown": True, "closeHidden": True}, + "expanded": {"toggle": "▼", "childrenOpen": True, "summaryShown": False, "closeHidden": False}, + } + + +def test_viewer_maps_responses_cached_tokens_to_cache_read(responses_page) -> None: + result = responses_page.evaluate( + """() => getUsage({ + response: { + body: { + usage: { + input_tokens: 11767, + input_tokens_details: { cached_tokens: 11648 }, + output_tokens: 6, + total_tokens: 11773 + } + } + } + })""" + ) + + assert result["input_tokens"] == 11767 + assert result["output_tokens"] == 6 + assert result["cache_read_input_tokens"] == 11648 + + +def test_viewer_prefers_openai_tokens_over_zero_aliases(responses_page) -> None: + result = responses_page.evaluate( + """() => getUsage({ + response: { + body: { + usage: { + prompt_tokens: 743, + completion_tokens: 95, + total_tokens: 838, + input_tokens: 0, + output_tokens: 0 + } + } + } + })""" + ) + + assert result["input_tokens"] == 743 + assert result["output_tokens"] == 95 + assert result["total_tokens"] == 838 + + +def test_viewer_renders_chat_completions_system_and_history_tool_calls(chat_completions_history_page) -> None: + chat_completions_history_page.locator(".sidebar-item").first.click() + chat_completions_history_page.wait_for_selector("#detail .section", timeout=5000) + + result = chat_completions_history_page.evaluate( + """() => { + const body = entries[0].request.body; + const messages = getMessages(body); + const assistantBlocks = Array.from(document.querySelectorAll('#detail .msg.assistant')) + .map(el => el.innerText.trim()); + return { + system: extractSystem(body), + roles: messages.map(m => m.role), + assistantContent: messages.find(m => m.role === 'assistant')?.content || [], + titles: Array.from(document.querySelectorAll('#detail .section .title')).map(el => el.textContent), + detailText: document.querySelector('#detail').innerText, + assistantBlocks, + }; + }""" + ) + + assert result["system"] == "Kimi CLI regression system prompt." + assert result["roles"] == ["user", "assistant", "tool", "user"] + assert result["assistantContent"] == [ + {"type": "tool_use", "id": "call_read", "name": "read_file", "input": {"path": "pyproject.toml"}} + ] + assert "System Prompt" in result["titles"] + assert "Kimi CLI regression system prompt." in result["detailText"] + assert "read_file" in result["detailText"] + assert all(block != "ASSISTANT" for block in result["assistantBlocks"]) + + +def test_viewer_treats_codex_forward_websocket_path_as_primary(responses_page) -> None: + result = responses_page.evaluate( + """() => ({ + tier: pathTier('/backend-api/codex/responses'), + primary: isPathPrimary('/backend-api/codex/responses') + })""" + ) + + assert result == {"tier": 0, "primary": True} + + +def test_viewer_sorts_dotted_websocket_turns_by_numeric_segments(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const makeEntry = (turn) => ({ + timestamp: '2026-05-07T10:00:00Z', + request_id: 'req_' + turn, + turn, + duration_ms: 1, + request: { + method: 'POST', + path: '/backend-api/codex/responses', + body: { model: 'gpt-test' } + }, + response: { + status: 200, + body: { usage: { input_tokens: 1, output_tokens: 1 }, output: [] } + } + }); + entries = [makeEntry('1.12'), makeEntry('1.2'), makeEntry(2)]; + activePaths = new Set(['/backend-api/codex/responses']); + searchQuery = ''; + activeTools = null; + applyFilter(false); + return { + filteredTurns: filtered.map(e => e.turn), + sidebarTurns: [...document.querySelectorAll('.sidebar-item .si-turn')].map(el => el.textContent) + }; + }""" + ) + + assert result == { + "filteredTurns": ["1.2", "1.12", 2], + "sidebarTurns": ["Turn 1.2", "Turn 1.12", "Turn 2"], + } + + +def test_viewer_expands_codex_websocket_session_into_response_entries(codex_ws_multi_page) -> None: + result = codex_ws_multi_page.evaluate( + """() => ({ + entries: entries.length, + derived: entries.filter(e => e.derived_from_websocket).length, + sidebar: document.querySelectorAll('.sidebar-item').length, + banners: document.querySelectorAll('.continuation-banner').length, + turns: entries.map(e => e.turn), + previousIds: entries.map(e => e.request.body.previous_response_id || ''), + responseIds: entries.map(e => e.response.body.id || ''), + hasPrompt: entries.map(e => JSON.stringify(e.request.body).includes('你好,调用一个工具,然后结束')), + usage: entries.map(e => getUsage(e)?.total_tokens || 0), + messages: entries.map(e => getMessages(e.request.body).map(m => m.role)), + responseTypes: entries.map(e => (getResponseOutput(e)?.content || []).map(c => c.type)) + })""" + ) + + assert result == { + "entries": 2, + "derived": 2, + "sidebar": 2, + "banners": 0, + "turns": ["14.2", "14.3"], + "previousIds": ["resp_prefetch", "resp_tool"], + "responseIds": ["resp_tool", "resp_final"], + "hasPrompt": [True, True], + "usage": [24, 35], + "messages": [["developer", "user", "user"], ["developer", "user", "user", "assistant", "tool"]], + "responseTypes": [["tool_use"], ["text"]], + } + + codex_ws_multi_page.locator(".sidebar-item").nth(0).click() + tool_call_detail = codex_ws_multi_page.locator("#detail").inner_text() + assert "Sanitized project rules." in tool_call_detail + assert "你好,调用一个工具,然后结束" in tool_call_detail + assert "exec_command" in tool_call_detail + assert "FINAL_OK" not in tool_call_detail + + codex_ws_multi_page.locator(".sidebar-item").nth(1).click() + final_detail = codex_ws_multi_page.locator("#detail").inner_text() + assert "Sanitized project rules." in final_detail + assert "你好,调用一个工具,然后结束" in final_detail + assert "/workspace/project" in final_detail + assert "exec_command" in final_detail + assert "FINAL_OK" in final_detail + assert "Responses continuation" not in final_detail + assert "有状态 Responses 续接" not in final_detail + + +def test_viewer_interleaves_codex_ws_tool_results_with_prior_outputs(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const record = { + request_id: 'req_interleave', + turn: 21, + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/backend-api/codex/responses', + headers: {}, + body: { + type: 'response.create', + model: 'gpt-5.5', + input: [], + stream: true + }, + ws_events: [ + { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_prefetch', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Run two tools.' }] + } + ], + tools: [{ type: 'function', name: 'exec_command' }], + stream: true + }, + { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_first', + input: [ + { type: 'function_call_output', call_id: 'call_first', output: 'first result' } + ], + tools: [{ type: 'function', name: 'exec_command' }], + stream: true + }, + { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_second', + input: [ + { type: 'custom_tool_call_output', call_id: 'call_second', output: 'second result' } + ], + tools: [{ type: 'function', name: 'exec_command' }], + stream: true + } + ] + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { type: 'response.created', response: { id: 'resp_first', status: 'in_progress', model: 'gpt-5.5' } }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_first', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'First decision' }] + } + }, + { + type: 'response.output_item.done', + output_index: 1, + item: { + id: 'fc_first', + type: 'function_call', + name: 'exec_command', + call_id: 'call_first', + arguments: '{\"cmd\":\"pwd\"}' + } + }, + { + type: 'response.completed', + response: { + id: 'resp_first', + status: 'completed', + model: 'gpt-5.5', + output: [], + usage: { input_tokens: 10, output_tokens: 4, total_tokens: 14 } + } + }, + { + type: 'response.created', + response: { + id: 'resp_second', + status: 'in_progress', + model: 'gpt-5.5', + previous_response_id: 'resp_first' + } + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_second', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Second decision' }] + } + }, + { + type: 'response.output_item.done', + output_index: 1, + item: { + id: 'fc_second', + type: 'function_call', + name: 'exec_command', + call_id: 'call_second', + arguments: '{\"cmd\":\"ls\"}' + } + }, + { + type: 'response.completed', + response: { + id: 'resp_second', + status: 'completed', + model: 'gpt-5.5', + previous_response_id: 'resp_first', + output: [], + usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 } + } + }, + { + type: 'response.created', + response: { + id: 'resp_final', + status: 'in_progress', + model: 'gpt-5.5', + previous_response_id: 'resp_second' + } + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_final', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Done' }] + } + }, + { + type: 'response.completed', + response: { + id: 'resp_final', + status: 'completed', + model: 'gpt-5.5', + previous_response_id: 'resp_second', + output: [], + usage: { input_tokens: 30, output_tokens: 2, total_tokens: 32 } + } + } + ] + } + }; + const expanded = expandWebSocketResponseEntries([record]); + const messages = getMessages(expanded[2].request.body); + return messages.map(message => { + const content = Array.isArray(message.content) ? message.content : []; + const toolUse = content.find(block => block.type === 'tool_use'); + if (toolUse) return `assistant:tool_use:${toolUse.id}:${toolUse.name}`; + const toolResult = content.find(block => block.type === 'tool_result'); + if (toolResult) return `tool:${toolResult.tool_use_id}:${toolResult.content}`; + const text = content.map(block => block.text || '').filter(Boolean).join(' '); + return `${message.role}:text:${text}`; + }); + }""" + ) + + assert result == [ + "user:text:Run two tools.", + "assistant:text:First decision", + "assistant:tool_use:call_first:exec_command", + "tool:call_first:first result", + "assistant:text:Second decision", + "assistant:tool_use:call_second:exec_command", + "tool:call_second:second result", + ] + + +def test_viewer_reconstructs_split_codex_ws_records_across_previous_response_ids(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const baseRequest = { + method: 'WEBSOCKET', + path: '/backend-api/codex/responses', + headers: {} + }; + const prefetch = { + request_id: 'req_split', + turn: 1, + transport: 'websocket', + request: { + ...baseRequest, + body: { type: 'response.create', model: 'gpt-5.5', input: [], generate: false }, + ws_events: [{ type: 'response.create', model: 'gpt-5.5', input: [], generate: false }] + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { type: 'response.created', response: { id: 'resp_prefetch', status: 'in_progress', model: 'gpt-5.5', generate: false } }, + { type: 'response.completed', response: { id: 'resp_prefetch', status: 'completed', model: 'gpt-5.5', generate: false, output: [], usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 } } } + ] + } + }; + const toolTurn = { + request_id: 'req_split_2', + turn: '1.2', + transport: 'websocket', + request: { + ...baseRequest, + body: { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_prefetch', + input: [ + { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'Rules.' }] }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Run pwd.' }] } + ], + tools: [{ type: 'function', name: 'exec_command' }] + }, + ws_events: [] + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { type: 'response.created', response: { id: 'resp_tool', status: 'in_progress', model: 'gpt-5.5', previous_response_id: 'resp_prefetch' } }, + { type: 'response.output_item.done', output_index: 0, item: { type: 'function_call', name: 'exec_command', call_id: 'call_pwd', arguments: '{\"cmd\":\"pwd\"}' } }, + { type: 'response.completed', response: { id: 'resp_tool', status: 'completed', model: 'gpt-5.5', previous_response_id: 'resp_prefetch', output: [], usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } } + ] + } + }; + const finalTurn = { + request_id: 'req_split_3', + turn: '1.3', + transport: 'websocket', + request: { + ...baseRequest, + body: { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_tool', + input: [{ type: 'function_call_output', call_id: 'call_pwd', output: '/workspace/project' }], + tools: [{ type: 'function', name: 'exec_command' }] + }, + ws_events: [] + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { type: 'response.created', response: { id: 'resp_final', status: 'in_progress', model: 'gpt-5.5', previous_response_id: 'resp_tool' } }, + { type: 'response.output_item.done', output_index: 0, item: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'FINAL_SPLIT_OK' }] } }, + { type: 'response.completed', response: { id: 'resp_final', status: 'completed', model: 'gpt-5.5', previous_response_id: 'resp_tool', output: [], usage: { input_tokens: 20, output_tokens: 3, total_tokens: 23 } } } + ] + } + }; + const expanded = expandWebSocketResponseEntries([prefetch, toolTurn, finalTurn]); + return { + turns: expanded.map(entry => entry.turn), + responseIds: expanded.map(entry => entry.response.body.id), + messages: expanded.map(entry => getMessages(entry.request.body).map(message => { + const content = Array.isArray(message.content) ? message.content : []; + const toolUse = content.find(block => block.type === 'tool_use'); + if (toolUse) return `${message.role}:tool_use:${toolUse.id}:${toolUse.name}`; + const toolResult = content.find(block => block.type === 'tool_result'); + if (toolResult) return `${message.role}:tool_result:${toolResult.tool_use_id}:${toolResult.content}`; + return `${message.role}:text:${content.map(block => block.text || '').filter(Boolean).join(' ')}`; + })), + output: expanded.map(entry => (getResponseOutput(entry)?.content || []).map(block => block.text || block.name || block.type)) + }; + }""" + ) + + assert result == { + "turns": ["1.2", "1.3"], + "responseIds": ["resp_tool", "resp_final"], + "messages": [ + ["developer:text:Rules.", "user:text:Run pwd."], + [ + "developer:text:Rules.", + "user:text:Run pwd.", + "assistant:tool_use:call_pwd:exec_command", + "tool:tool_result:call_pwd:/workspace/project", + ], + ], + "output": [["exec_command"], ["FINAL_SPLIT_OK"]], + } + + +def test_viewer_skips_codex_prefetch_when_generate_false_only_on_created(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const record = { + request_id: 'req_prefetch_created_flag', + turn: 1, + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/backend-api/codex/responses', + body: { + type: 'response.create', + model: 'gpt-5.5', + instructions: 'You are Codex.', + input: [] + }, + ws_events: [ + { + type: 'response.create', + model: 'gpt-5.5', + instructions: 'You are Codex.', + input: [], + generate: false + }, + { + type: 'response.create', + model: 'gpt-5.5', + instructions: 'You are Codex.', + previous_response_id: 'resp_prefetch', + input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Real prompt.' }] } + ] + } + ] + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { + type: 'response.created', + response: { + id: 'resp_prefetch', + status: 'in_progress', + model: 'gpt-5.5', + instructions: 'You are Codex.', + generate: false + } + }, + { + type: 'response.completed', + response: { + id: 'resp_prefetch', + status: 'completed', + model: 'gpt-5.5', + instructions: 'You are Codex.', + output: [], + usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 } + } + }, + { + type: 'response.created', + response: { + id: 'resp_real', + status: 'in_progress', + model: 'gpt-5.5', + instructions: 'You are Codex.', + previous_response_id: 'resp_prefetch' + } + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_real', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Real response.' }] + } + }, + { + type: 'response.completed', + response: { + id: 'resp_real', + status: 'completed', + model: 'gpt-5.5', + instructions: 'You are Codex.', + previous_response_id: 'resp_prefetch', + output: [], + usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 } + } + } + ] + } + }; + const expanded = expandWebSocketResponseEntries([record]); + renderDetail(expanded[0]); + return { + entryCount: expanded.length, + responseIds: expanded.map(entry => entry.response.body.id), + roles: expanded.map(entry => getMessages(entry.request.body).map(message => message.role)), + detailText: document.querySelector('#detail')?.innerText || '' + }; + }""" + ) + + assert result["entryCount"] == 1 + assert result["responseIds"] == ["resp_real"] + assert result["roles"] == [["developer", "user"]] + assert "Real prompt." in result["detailText"] + assert "Real response." in result["detailText"] + + +def test_viewer_filters_direct_codex_generate_false_prefetch(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const prefetch = { + request_id: 'req_direct_prefetch', + turn: 1, + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/v1/responses', + body: { type: 'response.create', model: 'gpt-5.5', input: [], generate: false }, + ws_events: [] + }, + response: { + status: 101, + headers: {}, + body: { + id: 'resp_direct_prefetch', + status: 'completed', + model: 'gpt-5.5', + generate: false, + output: [], + usage: { input_tokens: 10, output_tokens: 0, total_tokens: 10 } + }, + ws_events: [] + } + }; + const realTurn = { + request_id: 'req_direct_real', + turn: 2, + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/v1/responses', + body: { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_direct_prefetch', + input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Real prompt.' }] } + ] + }, + ws_events: [] + }, + response: { + status: 101, + headers: {}, + body: { + id: 'resp_direct_real', + status: 'completed', + model: 'gpt-5.5', + previous_response_id: 'resp_direct_prefetch', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Real answer.' }] } + ], + usage: { input_tokens: 12, output_tokens: 2, total_tokens: 14 } + }, + ws_events: [] + } + }; + const expanded = expandWebSocketResponseEntries([prefetch, realTurn]); + return { + turns: expanded.map(entry => entry.turn), + roles: getMessages(expanded[0].request.body).map(message => message.role) + }; + }""" + ) + + assert result == {"turns": [2], "roles": ["user"]} + + +def test_viewer_stitches_direct_codex_response_records_across_previous_response_ids( + responses_page, +) -> None: + result = responses_page.evaluate( + """() => { + const baseRequest = { + method: 'WEBSOCKET', + path: '/v1/responses', + headers: {} + }; + const toolTurn = { + request_id: 'req_direct_tool', + turn: 2, + transport: 'websocket', + request: { + ...baseRequest, + body: { + type: 'response.create', + model: 'gpt-5.5', + input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Run pwd.' }] } + ], + tools: [{ type: 'function', name: 'exec_command' }] + }, + ws_events: [] + }, + response: { + status: 101, + headers: {}, + body: { + id: 'resp_direct_tool', + status: 'completed', + model: 'gpt-5.5', + output: [ + { + type: 'function_call', + name: 'exec_command', + call_id: 'call_direct_pwd', + arguments: '{\"cmd\":\"pwd\"}' + } + ], + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } + }, + ws_events: [] + } + }; + const finalTurn = { + request_id: 'req_direct_final', + turn: '2.2', + transport: 'websocket', + request: { + ...baseRequest, + body: { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_direct_tool', + input: [ + { type: 'function_call_output', call_id: 'call_direct_pwd', output: '/workspace/project' } + ], + tools: [{ type: 'function', name: 'exec_command' }] + }, + ws_events: [] + }, + response: { + status: 101, + headers: {}, + body: { + id: 'resp_direct_final', + status: 'completed', + model: 'gpt-5.5', + previous_response_id: 'resp_direct_tool', + output: [ + { + type: 'function_call', + name: 'exec_command', + call_id: 'call_direct_ls', + arguments: '{\"cmd\":\"ls -la\"}' + } + ], + usage: { input_tokens: 20, output_tokens: 4, total_tokens: 24 } + }, + ws_events: [] + } + }; + const expanded = expandWebSocketResponseEntries([toolTurn, finalTurn]); + return { + turns: expanded.map(entry => entry.turn), + messages: getMessages(expanded[1].request.body).map(message => { + const content = Array.isArray(message.content) ? message.content : []; + const toolUse = content.find(block => block.type === 'tool_use'); + if (toolUse) return `${message.role}:tool_use:${toolUse.id}:${toolUse.name}`; + const toolResult = content.find(block => block.type === 'tool_result'); + if (toolResult) return `${message.role}:tool_result:${toolResult.tool_use_id}:${toolResult.content}`; + return `${message.role}:text:${content.map(block => block.text || '').filter(Boolean).join(' ')}`; + }), + output: (getResponseOutput(expanded[1])?.content || []).map(block => { + if (block.type === 'tool_use') return `${block.type}:${block.id}:${block.name}`; + return block.text || block.type; + }) + }; + }""" + ) + + assert result == { + "turns": [2, "2.2"], + "messages": [ + "user:text:Run pwd.", + "assistant:tool_use:call_direct_pwd:exec_command", + "tool:tool_result:call_direct_pwd:/workspace/project", + ], + "output": ["tool_use:call_direct_ls:exec_command"], + } + + +def test_viewer_does_not_synthesize_instructions_without_user_input(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const body = { + type: 'response.create', + model: 'gpt-5.5', + instructions: 'You are Codex.', + input: [ + { type: 'function_call_output', call_id: 'call_pwd', output: '/workspace/project' } + ] + }; + return getMessages(body).map(message => message.role); + }""" + ) + + assert result == ["tool"] + + +def test_viewer_preserves_codex_ws_history_across_incremental_expansion(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const baseRequest = { + method: 'WEBSOCKET', + path: '/backend-api/codex/responses', + headers: {} + }; + const toolTurn = { + request_id: 'req_incremental_1', + turn: 1, + transport: 'websocket', + request: { + ...baseRequest, + body: { + type: 'response.create', + model: 'gpt-5.5', + input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Run pwd.' }] }], + tools: [{ type: 'function', name: 'exec_command' }] + } + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { type: 'response.created', response: { id: 'resp_tool', status: 'in_progress', model: 'gpt-5.5' } }, + { type: 'response.output_item.done', output_index: 0, item: { type: 'function_call', name: 'exec_command', call_id: 'call_pwd', arguments: '{\"cmd\":\"pwd\"}' } }, + { type: 'response.completed', response: { id: 'resp_tool', status: 'completed', model: 'gpt-5.5', output: [], usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } } + ] + } + }; + const finalTurn = { + request_id: 'req_incremental_2', + turn: 2, + transport: 'websocket', + request: { + ...baseRequest, + body: { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_tool', + input: [{ type: 'function_call_output', call_id: 'call_pwd', output: '/workspace/project' }], + tools: [{ type: 'function', name: 'exec_command' }] + } + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { type: 'response.created', response: { id: 'resp_final', status: 'in_progress', model: 'gpt-5.5', previous_response_id: 'resp_tool' } }, + { type: 'response.output_item.done', output_index: 0, item: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'FINAL_INCREMENTAL_OK' }] } }, + { type: 'response.completed', response: { id: 'resp_final', status: 'completed', model: 'gpt-5.5', previous_response_id: 'resp_tool', output: [], usage: { input_tokens: 20, output_tokens: 3, total_tokens: 23 } } } + ] + } + }; + const history = createWebSocketResponseHistoryStore(); + expandWebSocketResponseEntries([toolTurn], history); + const expanded = expandWebSocketResponseEntries([finalTurn], history); + const messages = getMessages(expanded[0].request.body).map(message => { + const content = Array.isArray(message.content) ? message.content : []; + const toolUse = content.find(block => block.type === 'tool_use'); + if (toolUse) return `${message.role}:tool_use:${toolUse.id}:${toolUse.name}`; + const toolResult = content.find(block => block.type === 'tool_result'); + if (toolResult) return `${message.role}:tool_result:${toolResult.tool_use_id}:${toolResult.content}`; + return `${message.role}:text:${content.map(block => block.text || '').filter(Boolean).join(' ')}`; + }); + return { + messages, + historySizes: [...history.values()].map(node => ( + (node.requestInput || []).length + (node.outputMessages || []).length + )) + }; + }""" + ) + + assert result == { + "messages": [ + "user:text:Run pwd.", + "assistant:tool_use:call_pwd:exec_command", + "tool:tool_result:call_pwd:/workspace/project", + ], + "historySizes": [2, 2], + } + + +def test_viewer_omits_empty_reasoning_blocks_for_zero_reasoning_tokens(responses_page) -> None: + responses_page.evaluate( + """() => { + entries[0].response.body = { + output: [ + { type: 'reasoning', summary: [{ type: 'summary_text', text: '' }] }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Visible answer' }] } + ], + usage: { input_tokens: 1, output_tokens: 1, reasoning_tokens: 0 } + }; + renderDetail(entries[0]); + }""" + ) + + detail_text = responses_page.locator("#detail").inner_text() + + assert "Visible answer" in detail_text + assert "thinking" not in detail_text.lower() + + +def test_viewer_reconstructs_ws_output_from_output_item_done_when_completed_output_is_empty( + responses_page, +) -> None: + responses_page.evaluate( + """() => { + entries[0].response.body = { status: 'completed', output: [], usage: { input_tokens: 1, output_tokens: 1 } }; + entries[0].response.ws_events = [ + { type: 'response.created', response: { id: 'resp_1', status: 'in_progress' } }, + { type: 'response.output_item.done', output_index: 0, item: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Recovered from ws_events' }] } }, + { type: 'response.completed', response: { id: 'resp_1', status: 'completed', output: [], usage: { input_tokens: 1, output_tokens: 1 } } } + ]; + renderDetail(entries[0]); + }""" + ) + + detail_text = responses_page.locator("#detail").inner_text() + + assert "Recovered from ws_events" in detail_text + + +def test_viewer_normalizes_generic_responses_tool_call_items(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const body = { + input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Search the docs.' }] }, + { type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'Responses items' } }, + { + type: 'computer_call_output', + call_id: 'call_screen', + output: { type: 'computer_screenshot', image_url: 'https://example.test/screen.png' } + } + ] + }; + const output = normalizeResponseOutput([ + { type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'Responses items' } }, + { type: 'file_search_call', status: 'completed', queries: ['viewer parser'] }, + { type: 'custom_tool_call', status: 'completed', name: 'deploy_preview' } + ]); + const messages = getMessages(body); + return { + responseNames: output.content.filter(block => block.type === 'tool_use').map(block => block.name), + responseInputs: output.content.filter(block => block.type === 'tool_use').map(block => block.input), + roles: messages.map(message => message.role), + renderedMessages: renderMessages(messages) + }; + }""" + ) + + assert result["responseNames"] == ["web_search", "file_search", "deploy_preview"] + assert result["responseInputs"][0] == {"action": {"type": "search", "query": "Responses items"}} + assert result["roles"] == ["user", "assistant", "tool"] + assert "web_search" in result["renderedMessages"] + assert "computer_screenshot" in result["renderedMessages"] + + +def test_viewer_marks_codex_message_content_blocks(responses_page) -> None: + responses_page.evaluate( + """() => { + const record = { + request_id: 'req_codex_multiblock', + turn: 12, + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/backend-api/codex/responses', + headers: {}, + body: { + type: 'response.create', + model: 'gpt-5.5', + input: [ + { + type: 'message', + role: 'developer', + content: [ + { type: 'input_text', text: 'Developer policy block.' }, + { type: 'input_text', text: 'Repository instruction block.' }, + { type: 'input_text', text: 'Runtime permission block.' } + ] + }, + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'User task block.' }, + { type: 'input_text', text: 'Attached context block.' } + ] + } + ], + stream: true + } + }, + response: { + status: 200, + headers: {}, + body: { + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Done.' }] }], + usage: { input_tokens: 10, output_tokens: 2 } + } + } + }; + renderDetail(record); + }""" + ) + + detail_text = responses_page.locator("#detail").inner_text() + assert responses_page.locator(".content-block-meta").count() == 0 + assert responses_page.locator(".msg.system .content-block.block-framed").count() == 3 + assert responses_page.locator(".msg.user .content-block.block-framed").count() == 2 + assert ( + responses_page.locator(".msg.system .content-block.block-framed").first.evaluate( + "el => getComputedStyle(el).borderLeftWidth" + ) + == "1px" + ) + assert "#1/3" not in detail_text + assert "input_text" not in detail_text + assert "Repository instruction block." in detail_text + + +def test_viewer_marks_claude_system_and_message_content_blocks(responses_page) -> None: + responses_page.evaluate( + """() => { + const record = { + request_id: 'req_claude_multiblock', + turn: 3, + request: { + method: 'POST', + path: '/v1/messages', + headers: {}, + body: { + model: 'claude-opus-4-6', + system: [ + { type: 'text', text: 'Claude Code system block one.' }, + { type: 'text', text: 'Claude Code system block two.' } + ], + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'Read the first file.' }, + { type: 'text', text: 'Then summarize the diff.' } + ] + } + ] + } + }, + response: { + status: 200, + headers: {}, + body: { + content: [{ type: 'text', text: 'Claude response.' }], + usage: { input_tokens: 12, output_tokens: 3 } + } + } + }; + renderDetail(record); + }""" + ) + + detail_text = responses_page.locator("#detail").inner_text() + assert responses_page.locator(".content-block-meta").count() == 0 + assert responses_page.locator(".system-prompt-blocks .content-block.block-framed").count() == 2 + assert responses_page.locator(".msg.user .content-block.block-framed").count() == 2 + assert ( + responses_page.locator(".system-prompt-blocks .content-block.block-framed").first.evaluate( + "el => getComputedStyle(el).borderLeftWidth" + ) + == "1px" + ) + assert "#1/2" not in detail_text + assert "system · text" not in detail_text + system_copy_text = responses_page.evaluate( + """() => { + const section = Array.from(document.querySelectorAll('#detail .section')) + .find(el => el.querySelector('.title')?.textContent === t('section_system')); + const encoded = section?.querySelector('.copy-btn')?.dataset.copy || ''; + return decodeURIComponent(escape(atob(encoded))); + }""" + ) + assert system_copy_text == "Claude Code system block one.\n\nClaude Code system block two." + assert "Claude Code system block two." in responses_page.locator("#detail").inner_text() + + +def test_viewer_renders_image_content_blocks(responses_page) -> None: + responses_page.evaluate( + """() => { + const record = { + request_id: 'req_image_blocks', + turn: 14, + request: { + method: 'POST', + path: '/v1/responses', + headers: {}, + body: { + model: 'gpt-5.5', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Please inspect this tiny image.' }, + { + type: 'input_image', + image_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAEklEQVR4nGPQb3j7Hx9mGBkKAKVjpsHoKJzJAAAAAElFTkSuQmCC', + detail: 'high' + } + ] + } + ] + } + }, + response: { + status: 200, + headers: {}, + body: { + content: [{ type: 'text', text: 'Image block response OK.' }], + usage: { input_tokens: 15, output_tokens: 4 } + } + } + }; + renderDetail(record); + }""" + ) + + assert responses_page.locator(".msg.user .content-block.block-framed").count() == 2 + image = responses_page.locator(".msg.user .content-block.block-framed img") + assert image.count() == 1 + assert "content-image" in image.first.get_attribute("class") + assert image.first.get_attribute("src").startswith("data:image/png;base64,") + assert image.first.get_attribute("alt") == "image" + image_state = image.first.evaluate( + """img => { + const rect = img.getBoundingClientRect(); + return { + complete: img.complete, + naturalWidth: img.naturalWidth, + width: rect.width, + height: rect.height, + minWidth: getComputedStyle(img).minWidth, + }; + }""" + ) + assert image_state["complete"] is True + assert image_state["naturalWidth"] == 8 + assert image_state["width"] >= 100 + assert image_state["height"] >= 100 + assert image_state["minWidth"] == "min(160px, 100%)" + + +def test_viewer_preserves_file_id_image_content_blocks(responses_page) -> None: + responses_page.evaluate( + """() => { + const record = { + request_id: 'req_file_id_image_blocks', + turn: 15, + request: { + method: 'POST', + path: '/v1/responses', + headers: {}, + body: { + model: 'gpt-5.5', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Please inspect this uploaded image.' }, + { + type: 'input_image', + file_id: 'file-test-image-123', + detail: 'high' + } + ] + } + ] + } + }, + response: { + status: 200, + headers: {}, + body: { + content: [{ type: 'text', text: 'Uploaded image block response OK.' }], + usage: { input_tokens: 15, output_tokens: 4 } + } + } + }; + renderDetail(record); + }""" + ) + + assert responses_page.locator(".msg.user .content-block.block-framed").count() == 2 + placeholder = responses_page.locator(".msg.user .content-image-placeholder") + assert placeholder.count() == 1 + assert placeholder.inner_text() == "image: file_id file-test-image-123" + assert responses_page.locator(".msg.user img.content-image").count() == 0 + assert "Please inspect this uploaded image." in responses_page.locator("#detail").inner_text() + + +def test_viewer_does_not_auto_load_remote_image_urls(responses_page) -> None: + responses_page.evaluate( + """() => { + const record = { + request_id: 'req_remote_image_blocks', + turn: 16, + request: { + method: 'POST', + path: '/v1/responses', + headers: {}, + body: { + model: 'gpt-5.5', + input: [ + { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Please inspect this remote image reference.' }, + { + type: 'input_image', + image_url: 'https://internal.example.test/private.png', + detail: 'high' + } + ] + } + ] + } + }, + response: { + status: 200, + headers: {}, + body: { + content: [{ type: 'text', text: 'Remote image reference response OK.' }], + usage: { input_tokens: 15, output_tokens: 4 } + } + } + }; + renderDetail(record); + }""" + ) + + assert responses_page.locator(".msg.user .content-block.block-framed").count() == 2 + placeholder = responses_page.locator(".msg.user .content-image-placeholder") + assert placeholder.count() == 1 + assert placeholder.inner_text() == "image: url" + assert responses_page.locator(".msg.user img.content-image").count() == 0 + assert responses_page.locator('.msg.user img[src*="internal.example.test"]').count() == 0 + + +def test_viewer_renders_codex_tool_search_call_and_output(responses_page) -> None: + result = responses_page.evaluate( + """() => { + const record = { + request_id: 'req_tool_search', + turn: 7, + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/backend-api/codex/responses', + headers: {}, + body: { + type: 'response.create', + model: 'gpt-5.5', + input: [], + stream: true + }, + ws_events: [ + { + type: 'response.create', + model: 'gpt-5.5', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Find browser automation tools.' }] + } + ], + tools: [{ type: 'tool_search', description: '# Tool discovery' }], + stream: true + }, + { + type: 'response.create', + model: 'gpt-5.5', + previous_response_id: 'resp_search', + input: [ + { + type: 'tool_search_output', + call_id: 'call_search', + status: 'completed', + execution: 'client', + tools: [ + { + type: 'namespace', + name: 'mcp__codex_apps__figma', + tools: [ + { type: 'function', name: '_use_figma' }, + { type: 'function', name: '_generate_figma_design' } + ] + } + ] + } + ], + tools: [ + { type: 'tool_search', description: '# Tool discovery' }, + { type: 'namespace', name: 'mcp__codex_apps__figma' } + ], + stream: true + } + ] + }, + response: { + status: 101, + headers: {}, + body: {}, + ws_events: [ + { type: 'response.created', response: { id: 'resp_search', status: 'in_progress', model: 'gpt-5.5' } }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'tsc_1', + type: 'tool_search_call', + status: 'completed', + arguments: { query: 'browser automation screenshot localhost plugin tool', limit: 5 }, + call_id: 'call_search', + execution: 'client' + } + }, + { + type: 'response.completed', + response: { + id: 'resp_search', + status: 'completed', + model: 'gpt-5.5', + output: [], + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } + } + }, + { + type: 'response.created', + response: { + id: 'resp_final', + status: 'in_progress', + model: 'gpt-5.5', + previous_response_id: 'resp_search' + } + }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'msg_final', + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Use the Figma namespace.' }] + } + }, + { + type: 'response.completed', + response: { + id: 'resp_final', + status: 'completed', + model: 'gpt-5.5', + previous_response_id: 'resp_search', + output: [], + usage: { input_tokens: 12, output_tokens: 4, total_tokens: 16 } + } + } + ] + } + }; + const expanded = expandWebSocketResponseEntries([record]); + renderDetail(expanded[0]); + const firstDetail = document.querySelector('#detail').innerText; + renderDetail(expanded[1]); + const secondDetail = document.querySelector('#detail').innerText; + const responseToolNames = expanded.flatMap(e => + (getResponseOutput(e)?.content || []) + .filter(block => block.type === 'tool_use') + .map(block => block.name) + ); + return { + entryCount: expanded.length, + firstDetail, + secondDetail, + secondRoles: getMessages(expanded[1].request.body).map(message => message.role), + responseToolNames + }; + }""" + ) + + assert result["entryCount"] == 2 + assert "tool_search" in result["firstDetail"] + assert "browser automation screenshot localhost plugin tool" in result["firstDetail"] + assert "limit" in result["firstDetail"] + assert "tool_search_output" in result["secondDetail"] + assert "mcp__codex_apps__figma" in result["secondDetail"] + assert "mcp__codex_apps__figma._use_figma" in result["secondDetail"] + assert result["secondRoles"] == ["user", "assistant", "tool"] + assert result["responseToolNames"] == ["tool_search"] + + +def test_viewer_labels_codex_request_input_as_context_when_response_output_missing( + responses_page, +) -> None: + responses_page.evaluate( + """() => { + entries[0].request.path = '/backend-api/codex/responses'; + entries[0].request.body = { + model: 'gpt-5.4', + instructions: 'You are Codex, a coding agent.', + input: [ + { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'developer policy' }] }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'first user question' }] }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'prior assistant answer' }] }, + { type: 'reasoning', summary: [{ type: 'summary_text', text: 'hidden reasoning' }] }, + { type: 'function_call', name: 'exec_command', arguments: '{\"cmd\":\"pwd\"}' }, + { type: 'function_call_output', call_id: 'call_1', output: 'ok' }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'latest user question' }] }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'second prior assistant answer' }] } + ] + }; + entries[0].response.body = { + status: 'completed', + output: [], + usage: { input_tokens: 12, output_tokens: 0 } + }; + entries[0].response.ws_events = []; + renderDetail(entries[0]); + }""" + ) + + section_titles = responses_page.locator("#detail .section .title").all_inner_texts() + detail_text = responses_page.locator("#detail").inner_text() + response_text = responses_page.locator("#detail .section").nth(2).inner_text() + + assert "Messages" not in section_titles + assert "Request Context" in section_titles + assert "No response output captured; showing request context only." in detail_text + assert "prior assistant answer" in detail_text + assert "second prior assistant answer" in detail_text + assert "No response output captured; showing request context only." in response_text + + +def test_viewer_warns_for_empty_input_responses_continuation(responses_page) -> None: + responses_page.evaluate( + """() => { + entries[0].request.headers = { + session_id: 'session_abc', + version: '0.122.0-alpha.1' + }; + entries[0].request.body = { + type: 'response.create', + model: 'gpt-5.5', + instructions: 'You are Codex.', + input: [], + prompt_cache_key: 'cache_abc' + }; + entries[0].response.body = { + id: 'resp_current', + previous_response_id: 'resp_previous', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Continuation answer' }] } + ], + usage: { input_tokens: 2, output_tokens: 3 } + }; + renderDetail(entries[0]); + }""" + ) + + detail_text = responses_page.locator("#detail").inner_text() + + assert "Stateful Responses continuation" in detail_text + assert "previous_response_id but no captured user message history" in detail_text + assert "resp_previous" in detail_text + assert "cache_abc" in detail_text + assert "0.122.0-alpha.1" in detail_text + assert "Continuation answer" in detail_text + + +def test_viewer_warns_for_top_level_responses_continuation_payload(responses_page) -> None: + responses_page.evaluate( + """() => { + entries[0] = { + turn: 1, + request_id: 'req_top_level', + request: { + method: 'WEBSOCKET', + path: '/v1/responses', + headers: {}, + body: { + type: 'response.create', + model: 'gpt-5.5', + input: [], + prompt_cache_key: 'cache_top_level' + } + }, + response: { + id: 'resp_top_current', + previous_response_id: 'resp_top_previous', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Top-level answer' }] } + ] + } + }; + renderDetail(entries[0]); + }""" + ) + + detail_text = responses_page.locator("#detail").inner_text() + + assert "Stateful Responses continuation" in detail_text + assert "resp_top_previous" in detail_text + assert "cache_top_level" in detail_text + assert "Top-level answer" in detail_text + + +def test_viewer_warns_for_tool_result_only_responses_continuation(responses_page) -> None: + responses_page.evaluate( + """() => { + entries[0].request.body = { + type: 'response.create', + model: 'gpt-5.5', + instructions: 'You are Codex.', + input: [ + { + type: 'function_call_output', + call_id: 'call_123', + output: 'name = "claude-tap"' + } + ], + prompt_cache_key: 'cache_tool_result' + }; + entries[0].response.body = { + id: 'resp_tool_current', + previous_response_id: 'resp_tool_previous', + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'claude-tap' }] } + ], + usage: { input_tokens: 2, output_tokens: 3 } + }; + renderDetail(entries[0]); + }""" + ) + + detail_text = responses_page.locator("#detail").inner_text() + + assert "Stateful Responses continuation" in detail_text + assert "previous_response_id but no captured user message history" in detail_text + assert "resp_tool_previous" in detail_text + assert "cache_tool_result" in detail_text + assert "claude-tap" in detail_text diff --git a/tests/test_responses_support.py b/tests/test_responses_support.py new file mode 100644 index 0000000..be7db45 --- /dev/null +++ b/tests/test_responses_support.py @@ -0,0 +1,557 @@ +"""Focused tests for OpenAI Responses support.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from claude_tap.sse import SSEReassembler +from claude_tap.viewer import _extract_metadata, _extract_request_messages + + +def test_sse_reassembler_reconstructs_openai_responses_completed_event() -> None: + reassembler = SSEReassembler() + reassembler.feed_bytes( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_1","status":"in_progress","model":"gpt-5.4"}}\n\n' + ) + reassembler.feed_bytes( + b'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.4","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello!"}]},{"type":"reasoning","summary":[{"type":"summary_text","text":""}]}],"usage":{"input_tokens":12,"output_tokens":3,"reasoning_tokens":0}}}\n\n' + ) + + body = reassembler.reconstruct() + + assert body is not None + assert body["status"] == "completed" + assert body["output"][0]["content"][0]["text"] == "Hello!" + assert body["usage"] == {"input_tokens": 12, "output_tokens": 3, "reasoning_tokens": 0} + + +def test_sse_reassembler_recovers_output_from_item_events_when_completed_is_empty() -> None: + # The Codex/ChatGPT backend over HTTP/SSE (model_provider = "openai_http") + # streams output via response.output_item.* events and sends a terminal + # response.completed with output: []. The reassembler must keep the items. + reassembler = SSEReassembler() + for frame in ( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_1","status":"in_progress","model":"gpt-5.5","output":[]}}\n\n', + b'event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":[]}}\n\n', + b'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"delta":"Hello"}\n\n', + b'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"delta":" world"}\n\n', + b'event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hello world"}]}}\n\n', + b'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_1","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":13159,"output_tokens":18,"total_tokens":13177}}}\n\n', + ): + reassembler.feed_bytes(frame) + + body = reassembler.reconstruct() + + assert body is not None + assert body["status"] == "completed" + assert body["output"][0]["content"][0]["text"] == "Hello world" + assert body["usage"] == {"input_tokens": 13159, "output_tokens": 18, "total_tokens": 13177} + + +def test_sse_reassembler_keeps_streamed_text_when_completed_done_is_missing() -> None: + # A truncated capture: output_item.done / response.completed never arrive, + # so the accumulated output_text.delta content must survive on its own. + reassembler = SSEReassembler() + for frame in ( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"resp_2","status":"in_progress","output":[]}}\n\n', + b'event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":[]}}\n\n', + b'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"delta":"partial"}\n\n', + ): + reassembler.feed_bytes(frame) + + body = reassembler.reconstruct() + + assert body is not None + assert body["output"][0]["content"][0]["text"] == "partial" + + +def test_sse_reassembler_merges_response_incomplete_terminal_event() -> None: + # A response truncated by max_output_tokens ends with response.incomplete, + # not response.completed. The final status/usage/details must be captured + # while the streamed output items are preserved. + reassembler = SSEReassembler() + for frame in ( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"r","status":"in_progress","output":[]}}\n\n', + b'event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"cut off"}]}}\n\n', + b'event: response.incomplete\ndata: {"type":"response.incomplete","response":{"id":"r","status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":5,"output_tokens":7}}}\n\n', + ): + reassembler.feed_bytes(frame) + + body = reassembler.reconstruct() + + assert body is not None + assert body["status"] == "incomplete" + assert body["incomplete_details"] == {"reason": "max_output_tokens"} + assert body["usage"] == {"input_tokens": 5, "output_tokens": 7} + assert body["output"][0]["content"][0]["text"] == "cut off" + + +def test_sse_reassembler_merges_response_failed_terminal_event() -> None: + reassembler = SSEReassembler() + for frame in ( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"r","status":"in_progress","output":[]}}\n\n', + b'event: response.failed\ndata: {"type":"response.failed","response":{"id":"r","status":"failed","output":[],"error":{"code":"server_error","message":"boom"},"usage":{"input_tokens":3,"output_tokens":0}}}\n\n', + ): + reassembler.feed_bytes(frame) + + body = reassembler.reconstruct() + + assert body is not None + assert body["status"] == "failed" + assert body["error"] == {"code": "server_error", "message": "boom"} + + +def test_sse_reassembler_records_stream_level_response_error_event() -> None: + # response.error has no "response" object and can arrive mid-stream; the + # error must be attached without discarding accumulated output. + reassembler = SSEReassembler() + for frame in ( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"r","status":"in_progress","output":[]}}\n\n', + b'event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}}\n\n', + b'event: response.error\ndata: {"type":"error","code":"rate_limit_exceeded","message":"slow down","param":null}\n\n', + ): + reassembler.feed_bytes(frame) + + body = reassembler.reconstruct() + + assert body is not None + assert body["status"] == "failed" + assert body["error"]["code"] == "rate_limit_exceeded" + assert body["error"]["message"] == "slow down" + assert body["output"][0]["content"][0]["text"] == "hi" + + +def test_sse_reassembler_accepts_output_item_before_response_created() -> None: + reassembler = SSEReassembler() + reassembler.feed_bytes( + b'event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":"bad","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"early"}]}}\n\n' + ) + + body = reassembler.reconstruct() + + assert body is not None + assert body["output"][0]["content"][0]["text"] == "early" + + +def test_sse_reassembler_repairs_non_list_responses_output_and_content() -> None: + reassembler = SSEReassembler() + for frame in ( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"r","status":"in_progress","output":{"unexpected":true}}}\n\n', + b'event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":{"unexpected":true}}}\n\n', + b'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"repaired"}\n\n', + ): + reassembler.feed_bytes(frame) + + body = reassembler.reconstruct() + + assert body is not None + assert body["output"][0]["content"] == [{"type": "output_text", "text": "repaired"}] + + +def test_sse_reassembler_ignores_malformed_responses_item_and_text_events() -> None: + item_reassembler = SSEReassembler() + item_reassembler.feed_bytes( + b'event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":0,"item":null}\n\n' + ) + assert item_reassembler.reconstruct() is None + + empty_delta_reassembler = SSEReassembler() + empty_delta_reassembler.feed_bytes( + b'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"delta":""}\n\n' + ) + assert empty_delta_reassembler.reconstruct() is None + + out_of_range_reassembler = SSEReassembler() + out_of_range_reassembler.feed_bytes( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"r","output":[]}}\n\n' + ) + out_of_range_reassembler.feed_bytes( + b'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"delta":"ignored"}\n\n' + ) + assert out_of_range_reassembler.reconstruct() == {"id": "r", "output": []} + + non_dict_item_reassembler = SSEReassembler() + non_dict_item_reassembler.feed_bytes( + b'event: response.created\ndata: {"type":"response.created","response":{"id":"r","output":["raw"]}}\n\n' + ) + non_dict_item_reassembler.feed_bytes( + b'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","output_index":0,"delta":"ignored"}\n\n' + ) + assert non_dict_item_reassembler.reconstruct() == {"id": "r", "output": ["raw"]} + + +def test_sse_reassembler_handles_responses_terminal_without_response_object() -> None: + reassembler = SSEReassembler() + reassembler.feed_bytes(b'event: response.completed\ndata: {"type":"response.completed","id":"r"}\n\n') + + body = reassembler.reconstruct() + + assert body == {"type": "response.completed", "id": "r"} + + +def test_sse_reassembler_records_response_error_before_any_snapshot() -> None: + reassembler = SSEReassembler() + reassembler.feed_bytes(b'event: response.error\ndata: {"type":"error","code":"server_error","message":"boom"}\n\n') + + body = reassembler.reconstruct() + + assert body is not None + assert body["status"] == "failed" + assert body["error"] == {"code": "server_error", "message": "boom"} + + +def test_extract_metadata_supports_responses_input_roles_and_ws_usage() -> None: + fixture_path = Path(__file__).parent / "fixtures" / "openai_responses_trace.jsonl" + record_json = fixture_path.read_text(encoding="utf-8").splitlines()[0] + + meta = _extract_metadata(record_json) + + assert meta is not None + assert meta["message_count"] == 1 + assert meta["input_tokens"] == 500 + assert meta["output_tokens"] == 10 + assert meta["model"] == "gpt-5.4" + assert meta["has_system"] is True + assert "exec_command" in meta["tool_names"] + assert "web_search" in meta["tool_names"] + + +def test_extract_metadata_maps_responses_cached_tokens_to_cache_read() -> None: + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/responses", + "body": {"model": "gpt-5.4", "input": [{"role": "user", "content": "hi"}]}, + }, + "response": { + "status": 200, + "body": { + "usage": { + "input_tokens": 11767, + "input_tokens_details": {"cached_tokens": 11648}, + "output_tokens": 6, + "total_tokens": 11773, + } + }, + }, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert meta["input_tokens"] == 11767 + assert meta["output_tokens"] == 6 + assert meta["cache_read_input_tokens"] == 11648 + + +def test_extract_metadata_falls_back_to_tool_type_and_nested_function_name() -> None: + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": { + "model": "gpt-5.4", + "tools": [ + {"type": "tool_search", "description": "# Tool discovery"}, + { + "type": "function", + "function": { + "name": "nested_function", + "description": "Chat Completions style function tool.", + }, + }, + ], + }, + }, + "response": {"status": 200, "body": {"usage": {"input_tokens": 1, "output_tokens": 1}}}, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert "tool_search" in meta["tool_names"] + assert "nested_function" in meta["tool_names"] + + +def test_extract_metadata_counts_responses_tool_search_call_from_body_output() -> None: + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": {"model": "gpt-5.5", "input": [{"role": "user", "content": "Find tools."}]}, + }, + "response": { + "status": 200, + "body": { + "output": [ + { + "type": "tool_search_call", + "status": "completed", + "arguments": {"query": "browser automation", "limit": 5}, + "call_id": "call_search", + "execution": "client", + } + ], + "usage": {"input_tokens": 4, "output_tokens": 2}, + }, + }, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert meta["response_tool_names"] == ["tool_search"] + + +def test_extract_metadata_counts_generic_responses_tool_call_items() -> None: + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": {"model": "gpt-5.5", "input": [{"role": "user", "content": "Search."}]}, + }, + "response": { + "status": 200, + "body": { + "output": [ + {"type": "web_search_call", "status": "completed", "action": {"type": "search", "query": "docs"}}, + {"type": "file_search_call", "status": "completed", "queries": ["parser"]}, + {"type": "custom_tool_call", "status": "completed", "name": "deploy_preview"}, + ], + "usage": {"input_tokens": 4, "output_tokens": 2}, + }, + }, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert meta["response_tool_names"] == ["web_search", "file_search", "deploy_preview"] + + +def test_extract_metadata_counts_ws_tool_search_call_output_item_when_completed_output_is_empty() -> None: + record = { + "turn": 1, + "request": { + "method": "WEBSOCKET", + "path": "/backend-api/codex/responses", + "body": {"model": "gpt-5.5", "input": [{"role": "user", "content": "Find tools."}]}, + }, + "response": { + "status": 101, + "body": {"output": [], "usage": {"input_tokens": 4, "output_tokens": 2}}, + "ws_events": [ + {"type": "response.created", "response": {"id": "resp_search", "status": "in_progress"}}, + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "tool_search_call", + "status": "completed", + "arguments": {"query": "browser automation", "limit": 5}, + "call_id": "call_search", + "execution": "client", + }, + }, + { + "type": "response.completed", + "response": { + "id": "resp_search", + "status": "completed", + "output": [], + "usage": {"input_tokens": 4, "output_tokens": 2}, + }, + }, + ], + }, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert meta["response_tool_names"] == ["tool_search"] + + +def test_extract_metadata_supports_interleaved_responses_roles_without_type() -> None: + record = { + "turn": 1, + "request_id": "req_1", + "timestamp": "2026-03-17T00:00:00Z", + "duration_ms": 10, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": { + "model": "gpt-5.4", + "input": [ + {"role": "developer", "content": [{"type": "input_text", "text": "Follow the repo rules."}]}, + {"role": "user", "content": [{"type": "input_text", "text": "Fix the bug."}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": "I will inspect the parser."}]}, + ], + }, + }, + "response": {"status": 200, "body": {"usage": {"input_tokens": 1, "output_tokens": 1}}}, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert meta["message_count"] == 3 + assert meta["input_tokens"] == 1 + assert meta["output_tokens"] == 1 + + +def test_extract_metadata_counts_responses_function_call_input_items() -> None: + record = { + "turn": 1, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": { + "model": "gpt-5.4", + "instructions": "Use tools when needed.", + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "Read pyproject."}]}, + { + "type": "function_call", + "name": "read_file", + "arguments": '{"path":"pyproject.toml"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": '[project]\nname = "claude-tap"', + }, + ], + "tools": [{"type": "function", "name": "read_file"}], + }, + }, + "response": {"status": 200, "body": {"usage": {"input_tokens": 4, "output_tokens": 2}}}, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert meta["message_count"] == 3 + assert meta["has_system"] is True + assert meta["input_tokens"] == 4 + assert meta["output_tokens"] == 2 + assert "read_file" in meta["tool_names"] + + +def test_extract_request_messages_normalizes_responses_function_call_input_items() -> None: + messages = _extract_request_messages( + { + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "Read pyproject."}]}, + { + "type": "function_call", + "name": "read_file", + "arguments": '{"path":"pyproject.toml"}', + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": '[project]\nname = "claude-tap"', + }, + { + "type": "function_call", + "name": "shell", + "arguments": "not json", + }, + { + "type": "function_call", + "name": "missing_args", + }, + { + "type": "web_search_call", + "action": {"type": "search", "query": "Responses items"}, + }, + { + "type": "computer_call_output", + "call_id": "call_screen", + "output": {"type": "computer_screenshot", "image_url": "https://example.test/screen.png"}, + }, + ] + } + ) + + assert messages[0]["role"] == "user" + assert messages[1] == { + "role": "assistant", + "content": [{"type": "tool_use", "name": "read_file", "input": {"path": "pyproject.toml"}}], + } + assert messages[2] == {"role": "tool", "content": '[project]\nname = "claude-tap"'} + assert messages[3]["content"][0]["input"] == "not json" + assert messages[4]["content"][0]["input"] == {} + assert messages[5] == { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "name": "web_search", + "input": {"action": {"type": "search", "query": "Responses items"}}, + } + ], + } + assert messages[6]["role"] == "tool" + assert "computer_screenshot" in messages[6]["content"] + + +def test_extract_request_messages_normalizes_responses_tool_search_output_input_items() -> None: + messages = _extract_request_messages( + { + "input": [ + { + "type": "tool_search_output", + "call_id": "call_search", + "status": "completed", + "execution": "client", + "tools": [ + { + "type": "namespace", + "name": "mcp__codex_apps__figma", + "tools": [{"type": "function", "name": "_use_figma"}], + } + ], + } + ] + } + ) + + assert len(messages) == 1 + assert messages[0]["role"] == "tool" + assert "tool_search_output" in messages[0]["content"] + assert "mcp__codex_apps__figma" in messages[0]["content"] + assert "mcp__codex_apps__figma._use_figma" in messages[0]["content"] + + +def test_extract_metadata_ignores_list_response_body() -> None: + record = { + "turn": 1, + "request_id": "req_1", + "timestamp": "2026-03-17T00:00:00Z", + "duration_ms": 10, + "request": { + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-opus-4-6", + "messages": [{"role": "user", "content": "hello"}], + }, + }, + "response": {"status": 200, "body": [{"type": "text", "text": "hello"}]}, + } + + meta = _extract_metadata(json.dumps(record)) + + assert meta is not None + assert meta["message_count"] == 1 + assert meta["input_tokens"] == 0 + assert meta["output_tokens"] == 0 + assert meta["error_message"] == "" diff --git a/tests/test_search_browser.py b/tests/test_search_browser.py new file mode 100644 index 0000000..7204f4f --- /dev/null +++ b/tests/test_search_browser.py @@ -0,0 +1,678 @@ +#!/usr/bin/env python3 +"""Playwright browser tests for global trace search in viewer.html using real trace data.""" + +from __future__ import annotations + +import json +import re +import tempfile +from pathlib import Path + +import pytest + +from claude_tap.viewer import _generate_html_viewer + +pw_missing = False +try: + from playwright.sync_api import sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + +pytestmark = pytest.mark.skipif(pw_missing, reason="playwright not installed") + +_WORD_RE = re.compile(r"[A-Za-z]{4,}") +_STOPWORDS = { + "this", + "that", + "with", + "from", + "have", + "will", + "into", + "your", + "http", + "https", + "json", + "true", + "false", + "null", +} + + +def _load_entries(trace_file: Path) -> list[dict]: + lines = trace_file.read_text(encoding="utf-8").splitlines() + return [json.loads(line) for line in lines if line.strip()] + + +def _pick_real_trace_file() -> Path: + traces_dir = Path(__file__).parent.parent / ".traces" + trace_files = sorted(traces_dir.glob("trace_*.jsonl"), key=lambda p: p.stat().st_size) + candidates = [] + for path in trace_files: + if path.stat().st_size == 0: + continue + line_count = sum(1 for _ in path.open("r", encoding="utf-8")) + if line_count >= 4: + candidates.append(path) + if not candidates: + pytest.skip("No real trace file with >=4 entries found in .traces/") + return candidates[0] + + +def _normalize_messages_for_diff(body: dict | None) -> list[dict]: + if not body: + return [] + if isinstance(body.get("messages"), list) and body["messages"]: + return [msg for msg in body["messages"] if isinstance(msg, dict)] + if isinstance(body.get("input"), list): + normalized = [] + for item in body["input"]: + if not isinstance(item, dict) or item.get("type") != "message": + continue + normalized.append( + { + "role": item.get("role", "user"), + "content": item.get("content"), + } + ) + return normalized + return [] + + +def _normalize_content_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + normalized = [] + for item in content: + if isinstance(item, dict): + normalized.append({k: v for k, v in item.items() if k != "cache_control"}) + else: + normalized.append(item) + return json.dumps(normalized, ensure_ascii=False) + return json.dumps(content, ensure_ascii=False) + + +def _msg_hash(msg: dict) -> str: + role = msg.get("role", "") + text = _normalize_content_text(msg.get("content")) + return f"{role}:{text[:500]}" + + +def _is_prefix_of(shorter: list[str], longer: list[str]) -> bool: + if not shorter or len(longer) < len(shorter): + return False + return all(shorter[i] == longer[i] for i in range(len(shorter))) + + +def _find_prev_same_model(entries: list[dict], idx: int) -> int: + target = entries[idx] + target_body = target.get("request", {}).get("body") or {} + target_hashes = [_msg_hash(msg) for msg in _normalize_messages_for_diff(target_body)] + + best_idx = -1 + best_len = 0 + for i in range(idx - 1, -1, -1): + candidate_body = entries[i].get("request", {}).get("body") or {} + candidate_hashes = [_msg_hash(msg) for msg in _normalize_messages_for_diff(candidate_body)] + if candidate_hashes and _is_prefix_of(candidate_hashes, target_hashes): + if len(candidate_hashes) > best_len: + best_len = len(candidate_hashes) + best_idx = i + if best_idx >= 0: + return best_idx + + target_model = target_body.get("model") + for i in range(idx - 1, -1, -1): + candidate_body = entries[i].get("request", {}).get("body") or {} + candidate_model = candidate_body.get("model") + if candidate_model == target_model: + return i + return -1 + + +def _score_diff_messages(old_msgs: list[dict], new_msgs: list[dict]) -> int: + prefix = 0 + while prefix < len(old_msgs) and prefix < len(new_msgs): + if _msg_hash(old_msgs[prefix]) != _msg_hash(new_msgs[prefix]): + break + prefix += 1 + + old_tail = old_msgs[prefix:] + new_tail = new_msgs[prefix:] + if not old_tail and not new_tail: + return 0 + + max_len = 0 + for msg in old_tail + new_tail: + max_len = max(max_len, len(_normalize_content_text(msg.get("content")))) + return max_len + + +def _pick_real_trace_file_for_diff() -> tuple[Path, int]: + traces_dir = Path(__file__).parent.parent / ".traces" + trace_files = sorted(traces_dir.glob("trace_*.jsonl")) + best_path = None + best_idx = -1 + best_score = -1 + for path in trace_files: + file_size = path.stat().st_size + if file_size == 0 or file_size > 5_000_000: + continue + entries = _load_entries(path) + if len(entries) < 4: + continue + for idx in range(1, len(entries)): + prev_idx = _find_prev_same_model(entries, idx) + if prev_idx < 0: + continue + old_msgs = _normalize_messages_for_diff(entries[prev_idx].get("request", {}).get("body") or {}) + new_msgs = _normalize_messages_for_diff(entries[idx].get("request", {}).get("body") or {}) + if len(new_msgs) < 2: + continue + score = _score_diff_messages(old_msgs, new_msgs) + if score > best_score: + best_score = score + best_path = path + best_idx = idx + if best_path and best_idx >= 0: + return best_path, best_idx + pytest.skip("No real multi-turn trace file with a message diff target found in .traces/") + + +def _extract_messages(body: dict | None) -> list[str]: + if not body: + return [] + texts: list[str] = [] + if isinstance(body.get("messages"), list): + for msg in body["messages"]: + content = msg.get("content") + if isinstance(content, str): + texts.append(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and isinstance(block.get("text"), str): + texts.append(block["text"]) + if isinstance(body.get("input"), list): + for item in body["input"]: + if item.get("type") != "message": + continue + content = item.get("content") + if isinstance(content, str): + texts.append(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and isinstance(block.get("text"), str): + texts.append(block["text"]) + return texts + + +def _pick_message_term(entries: list[dict]) -> tuple[str, int]: + for idx, entry in enumerate(entries): + texts = _extract_messages(entry.get("request", {}).get("body", {})) + for text in texts: + for word in _WORD_RE.findall(text): + lw = word.lower() + if lw not in _STOPWORDS: + return lw, idx + return "model", 0 + + +def _pick_cross_entry_term(entries: list[dict]) -> str: + entry_texts = [json.dumps(entry, ensure_ascii=False).lower() for entry in entries] + by_entry_count: dict[str, int] = {} + total_counts: dict[str, int] = {} + + for text in entry_texts: + seen = set() + for word in _WORD_RE.findall(text): + lw = word.lower() + if lw in _STOPWORDS: + continue + total_counts[lw] = total_counts.get(lw, 0) + text.count(lw) + seen.add(lw) + for word in seen: + by_entry_count[word] = by_entry_count.get(word, 0) + 1 + + scored: list[tuple[int, int, str]] = [] + total_entries = len(entries) + for word, entry_hits in by_entry_count.items(): + total_hits = total_counts.get(word, 0) + if entry_hits >= 2 and total_hits <= 40 and entry_hits < total_entries: + scored.append((entry_hits, total_hits, word)) + if scored: + scored.sort() + return scored[0][2] + + # Fallback: broad but always present in responses traces. + return "response" + + +@pytest.fixture(scope="module") +def trace_entries() -> tuple[Path, list[dict], str, tuple[str, int]]: + trace_file = _pick_real_trace_file() + entries = _load_entries(trace_file) + cross_term = _pick_cross_entry_term(entries) + message_term = _pick_message_term(entries) + return trace_file, entries, cross_term, message_term + + +@pytest.fixture(scope="module") +def html_file(trace_entries) -> Path: + trace_file, _, _, _ = trace_entries + with tempfile.TemporaryDirectory() as tmpdir: + html_path = Path(tmpdir) / "search_test_viewer.html" + _generate_html_viewer(trace_file, html_path) + html = html_path.read_text(encoding="utf-8") + # Persist file after tempdir exits for module-scoped browser fixture. + with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as f: + f.write(html) + return Path(f.name) + + +@pytest.fixture(scope="module") +def browser_page(html_file): + from playwright.sync_api import sync_playwright + + pw = sync_playwright().start() + browser = pw.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 900}) + page.goto(f"file://{html_file}") + page.wait_for_selector(".sidebar-item", timeout=10000) + yield page + browser.close() + pw.stop() + + +def _dispatch_find_shortcut(page, *, meta: bool, ctrl: bool) -> None: + page.evaluate( + """([metaKey, ctrlKey]) => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'f', + metaKey, + ctrlKey, + bubbles: true, + cancelable: true, + }) + ); + }""", + [meta, ctrl], + ) + + +def _write_search_trace(path: Path, count: int = 1) -> None: + records = [] + for idx in range(count): + records.append( + { + "timestamp": "2026-05-29T08:00:00+00:00", + "request_id": f"req_search_quote_{idx}", + "turn": idx + 1, + "duration_ms": 1200, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {"Host": "api.anthropic.com"}, + "body": { + "model": "claude-sonnet-4-6", + "messages": [ + { + "role": "user", + "content": "Investigate agent routing metadata.", + } + ], + "metadata": { + "subagent_type": "code-review", + "subagent_hint": "subagent_type appears in JSON tree rendering.", + }, + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "model": "claude-sonnet-4-6", + "content": [ + { + "type": "text", + "text": "The search target is present in metadata.", + } + ], + }, + }, + } + ) + path.write_text( + "\n".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) for record in records) + "\n", + encoding="utf-8", + ) + + +def _write_duplicate_request_id_tool_trace(path: Path) -> str: + call_id = "call_kL48GiOmxdX2R6uxH6DqTz0o" + records = [ + { + "timestamp": "2026-05-29T08:00:00+00:00", + "request_id": "req_shared_search", + "turn": 1, + "duration_ms": 800, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {"Host": "api.anthropic.com"}, + "body": { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Check status."}], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "I will inspect the repo."}], + }, + }, + }, + { + "timestamp": "2026-05-29T08:00:01+00:00", + "request_id": "req_shared_search", + "turn": 1, + "duration_ms": 900, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {"Host": "api.anthropic.com"}, + "body": { + "model": "claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Run git status."}], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "model": "claude-sonnet-4-6", + "content": [ + { + "type": "tool_use", + "id": call_id, + "name": "exec_command", + "input": {"cmd": "git status --short --branch"}, + } + ], + }, + }, + }, + ] + path.write_text( + "\n".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) for record in records) + "\n", + encoding="utf-8", + ) + return call_id + + +def test_json_key_query_without_key_quotes_matches(tmp_path): + trace_path = tmp_path / "quote_trace.jsonl" + html_path = tmp_path / "quote_viewer.html" + _write_search_trace(trace_path) + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={"width": 1280, "height": 760}) + page.goto(f"file://{html_path}") + page.wait_for_selector(".sidebar-item", timeout=5000) + + _dispatch_find_shortcut(page, meta=False, ctrl=True) + page.fill("#global-search-input", 'subagent_type: "') + page.wait_for_function("() => document.querySelector('#global-search-count')?.textContent !== '0 of 0'") + page.wait_for_function("() => document.querySelectorAll('mark.global-search-hit').length > 0") + + count_text = page.inner_text("#global-search-count") + marked_text = page.locator("mark.global-search-hit.current").first.inner_text() + assert " of " in count_text + assert "0 of 0" not in count_text + assert "subagent_type" in marked_text + finally: + browser.close() + + +def test_global_search_distinguishes_duplicate_request_ids(tmp_path): + trace_path = tmp_path / "duplicate_request_id_trace.jsonl" + html_path = tmp_path / "duplicate_request_id_viewer.html" + call_id = _write_duplicate_request_id_tool_trace(trace_path) + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={"width": 1280, "height": 760}) + page.goto(f"file://{html_path}") + page.wait_for_selector(".sidebar-item", timeout=5000) + page.locator(".sidebar-item[data-idx='1']").click() + page.wait_for_function( + "callId => document.querySelector('#detail')?.innerText.includes(callId)", + arg=call_id, + ) + + _dispatch_find_shortcut(page, meta=False, ctrl=True) + page.fill("#global-search-input", call_id) + page.wait_for_function("() => document.querySelector('#global-search-count')?.textContent !== '0 of 0'") + page.wait_for_function( + "callId => [...document.querySelectorAll('mark.global-search-hit')].some(mark => mark.textContent === callId)", + arg=call_id, + ) + + count_text = page.inner_text("#global-search-count") + assert "0 of 0" not in count_text + finally: + browser.close() + + +def test_same_entry_search_navigation_does_not_rerender_detail(tmp_path): + trace_path = tmp_path / "same_entry_trace.jsonl" + html_path = tmp_path / "same_entry_viewer.html" + _write_search_trace(trace_path) + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={"width": 1280, "height": 760}) + page.goto(f"file://{html_path}") + page.wait_for_selector(".sidebar-item", timeout=5000) + page.evaluate( + """() => { + window.__renderDetailCount = 0; + const original = window.renderDetail; + window.renderDetail = function(...args) { + window.__renderDetailCount += 1; + return original.apply(this, args); + }; + }""" + ) + + _dispatch_find_shortcut(page, meta=False, ctrl=True) + page.fill("#global-search-input", "subagent") + page.wait_for_function("() => document.querySelectorAll('mark.global-search-hit').length > 1") + before = page.evaluate("window.__renderDetailCount") + page.keyboard.press("Enter") + page.wait_for_timeout(100) + after = page.evaluate("window.__renderDetailCount") + + assert after == before + finally: + browser.close() + + +def test_lazy_global_search_does_not_parse_every_entry(tmp_path): + trace_path = tmp_path / "lazy_trace.jsonl" + html_path = tmp_path / "lazy_viewer.html" + _write_search_trace(trace_path, count=60) + _generate_html_viewer(trace_path, html_path) + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + try: + page = browser.new_page(viewport={"width": 1280, "height": 760}) + page.goto(f"file://{html_path}") + page.wait_for_selector(".sidebar-item", timeout=5000) + initial_cache_size = page.evaluate("entryCache.size") + + _dispatch_find_shortcut(page, meta=False, ctrl=True) + page.fill("#global-search-input", 'subagent_type: "') + page.wait_for_function("() => document.querySelector('#global-search-count')?.textContent !== '0 of 0'") + + parsed_entries = page.evaluate("entryCache.size") + assert parsed_entries < 10 + assert parsed_entries <= initial_cache_size + 1 + finally: + browser.close() + + +class TestViewerGlobalSearch: + def test_cmd_or_ctrl_f_opens_custom_search(self, browser_page): + _dispatch_find_shortcut(browser_page, meta=True, ctrl=False) + browser_page.wait_for_selector("#global-search-overlay.open", timeout=3000) + assert browser_page.evaluate("document.activeElement?.id") == "global-search-input" + + browser_page.keyboard.press("Escape") + browser_page.wait_for_function( + "() => !document.querySelector('#global-search-overlay')?.classList.contains('open')" + ) + + _dispatch_find_shortcut(browser_page, meta=False, ctrl=True) + browser_page.wait_for_selector("#global-search-overlay.open", timeout=3000) + assert browser_page.evaluate("document.activeElement?.id") == "global-search-input" + + def test_typing_highlights_and_match_counter(self, browser_page, trace_entries): + _, _, cross_term, _ = trace_entries + browser_page.fill("#global-search-input", cross_term) + browser_page.wait_for_function("() => document.querySelectorAll('mark.global-search-hit').length > 0") + count_text = browser_page.inner_text("#global-search-count") + assert " of " in count_text + assert "matches" in count_text + + def test_enter_navigates_matches(self, browser_page): + before = browser_page.inner_text("#global-search-count") + browser_page.keyboard.press("Enter") + browser_page.wait_for_timeout(150) + after = browser_page.inner_text("#global-search-count") + assert before != after, f"Expected current match index to advance, got: {after}" + + def test_cross_entry_navigation_switches_sidebar(self, browser_page): + start_turn = browser_page.inner_text(".sidebar-item.active .si-turn") + switched = False + for _ in range(80): + browser_page.keyboard.press("Enter") + browser_page.wait_for_timeout(80) + now_turn = browser_page.inner_text(".sidebar-item.active .si-turn") + if now_turn != start_turn: + switched = True + break + assert switched, "Expected search navigation to jump to a different sidebar entry" + + def test_escape_closes_and_clears_highlights(self, browser_page): + browser_page.keyboard.press("Escape") + browser_page.wait_for_function( + "() => !document.querySelector('#global-search-overlay')?.classList.contains('open')" + ) + mark_count = browser_page.evaluate("document.querySelectorAll('mark.global-search-hit').length") + assert mark_count == 0, f"Expected highlights to clear on Escape, got {mark_count}" + + def test_collapsed_section_auto_expands_on_match(self, browser_page, trace_entries): + _, entries, _, message_term_info = trace_entries + message_term, _ = message_term_info + + # Sidebar order can differ from raw trace order. Find an entry that renders messages. + sidebar_items = browser_page.locator(".sidebar-item") + max_items = min(sidebar_items.count(), len(entries)) + found_msg_section = False + for i in range(max_items): + sidebar_items.nth(i).click() + browser_page.wait_for_timeout(120) + if browser_page.locator(".section .msg").count() > 0: + found_msg_section = True + break + + assert found_msg_section, "Expected at least one sidebar entry to render message blocks" + + # Collapse the messages section (identified by message blocks). + msg_section = browser_page.locator(".section", has=browser_page.locator(".msg")).first + msg_body = msg_section.locator(".section-body") + msg_section.locator(".section-header").click() + browser_page.wait_for_timeout(120) + assert "open" not in msg_body.get_attribute("class") + + _dispatch_find_shortcut(browser_page, meta=False, ctrl=True) + browser_page.fill("#global-search-input", message_term) + + browser_page.wait_for_function( + """() => { + const section = [...document.querySelectorAll('.section')].find(s => s.querySelector('.msg')); + if (!section) return false; + const body = section.querySelector('.section-body'); + return body && body.classList.contains('open'); + }""" + ) + + def test_diff_overlay_content_is_scrollable(self, browser_page): + trace_file, target_idx = _pick_real_trace_file_for_diff() + with tempfile.TemporaryDirectory() as tmpdir: + html_path = Path(tmpdir) / "diff_scroll_test_viewer.html" + _generate_html_viewer(trace_file, html_path) + browser_page.set_viewport_size({"width": 1180, "height": 360}) + browser_page.goto(f"file://{html_path}") + browser_page.wait_for_selector(".sidebar-item", timeout=10000) + browser_page.locator(f".sidebar-item[data-idx='{target_idx}']").click() + browser_page.wait_for_timeout(120) + browser_page.evaluate("document.querySelector('.act-btn:nth-child(3)')?.click()") + browser_page.wait_for_selector(".diff-overlay", timeout=3000) + + state = browser_page.evaluate("""() => { + const overlay = document.querySelector('.diff-overlay'); + const body = overlay?.querySelector('.diff-body'); + const block = overlay?.querySelector('.diff-new-msg, .diff-removed-msg, .diff-modified-msg'); + if (!body || !block) { + return { + hasBody: Boolean(body), + hasBlock: Boolean(block), + overlayScrollable: false, + blockScrollable: false, + overlayCanScroll: false, + blockCanScroll: false, + }; + } + + const bodyFiller = document.createElement('div'); + bodyFiller.style.height = '900px'; + body.appendChild(bodyFiller); + const blockFiller = document.createElement('div'); + blockFiller.style.height = '420px'; + block.appendChild(blockFiller); + + const overlayScrollable = body.scrollHeight > body.clientHeight; + const blockScrollable = block.scrollHeight > block.clientHeight; + + body.scrollTop = body.scrollHeight; + block.scrollTop = block.scrollHeight; + + return { + hasBody: true, + hasBlock: true, + overlayScrollable, + blockScrollable, + overlayCanScroll: body.scrollTop > 0, + blockCanScroll: block.scrollTop > 0, + }; + }""") + + assert state["hasBody"], "Expected diff overlay body container to exist" + assert state["hasBlock"], "Expected at least one diff message block to exist" + assert state["overlayScrollable"], "Expected diff overlay body to overflow after long content is present" + assert state["overlayCanScroll"], "Expected diff overlay body to scroll when content is long" + assert state["blockScrollable"], "Expected diff message block to overflow after long content is present" + assert state["blockCanScroll"], "Expected diff message block to be scrollable" diff --git a/tests/test_shared_dashboard.py b/tests/test_shared_dashboard.py new file mode 100644 index 0000000..0508a35 --- /dev/null +++ b/tests/test_shared_dashboard.py @@ -0,0 +1,782 @@ +from __future__ import annotations + +import signal +import subprocess +from pathlib import Path + +import pytest +from aiohttp import web + +from claude_tap.shared_dashboard import ( + CLAUDE_TAP_VERSION, + DEFAULT_DASHBOARD_PORT, + _dashboard_listening_pids_for_port, + _dashboard_lock_path, + _dashboard_process_command, + _dashboard_spawn_lock, + _looks_like_legacy_dashboard_command, + _spawn_dashboard_subprocess, + _sync_dashboard_healthy_for_current_db, + _terminate_legacy_dashboard_pids, + dashboard_connect_host, + dashboard_url, + ensure_shared_dashboard, + is_dashboard_healthy, + is_legacy_dashboard_healthy, + resolve_dashboard_port, + stop_dashboard_service, + stop_legacy_dashboard_process, + stop_shared_dashboard, +) +from claude_tap.trace_store import resolve_db_path + + +async def _start_test_app(app: web.Application) -> tuple[web.AppRunner, int]: + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, port + + +def test_resolve_dashboard_port_defaults_to_shared_port() -> None: + assert resolve_dashboard_port(0) == DEFAULT_DASHBOARD_PORT + assert resolve_dashboard_port(None) == DEFAULT_DASHBOARD_PORT + + +def test_resolve_dashboard_port_honors_explicit_port() -> None: + assert resolve_dashboard_port(3000) == 3000 + + +def test_resolve_dashboard_port_honors_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("CLOUDTAP_DASHBOARD_PORT", "8765") + assert resolve_dashboard_port(0) == 8765 + + +@pytest.mark.parametrize("value", ["0", "-1", "not-a-port"]) +def test_resolve_dashboard_port_ignores_invalid_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv("CLOUDTAP_DASHBOARD_PORT", value) + assert resolve_dashboard_port(0) == DEFAULT_DASHBOARD_PORT + + +def test_dashboard_url() -> None: + assert dashboard_connect_host("localhost") == "localhost" + assert dashboard_connect_host(" ") == "127.0.0.1" + assert dashboard_connect_host("0.0.0.0") == "127.0.0.1" + assert dashboard_connect_host("::") == "::1" + assert dashboard_connect_host("[::]") == "::1" + assert dashboard_url("127.0.0.1", 1234) == "http://127.0.0.1:1234" + assert dashboard_url("0.0.0.0", 1234) == "http://127.0.0.1:1234" + assert dashboard_url("::", 1234) == "http://[::1]:1234" + assert dashboard_url("::1", 1234) == "http://[::1]:1234" + assert dashboard_url("[::1]", 1234) == "http://[::1]:1234" + + +def test_sync_dashboard_health_uses_proxyless_opener(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class FakeResponse: + status = 200 + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return json_bytes + + class FakeOpener: + def open(self, url: str, *, timeout: float) -> FakeResponse: + calls.append((url, timeout)) + return FakeResponse() + + db_path = tmp_path / "health.sqlite3" + json_bytes = f'{{"ok":true,"db_path":"{db_path}","version":"{CLAUDE_TAP_VERSION}"}}'.encode() + calls: list[tuple[str, float]] = [] + monkeypatch.setenv("CLOUDTAP_DB", str(db_path)) + monkeypatch.setattr("claude_tap.shared_dashboard._LOCAL_DASHBOARD_OPENER", FakeOpener()) + + assert _sync_dashboard_healthy_for_current_db("127.0.0.1", 19527) is True + assert calls and calls[0][0] == "http://127.0.0.1:19527/dashboard/health" + + +def test_sync_dashboard_health_rejects_stale_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + class FakeResponse: + status = 200 + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return json_bytes + + class FakeOpener: + def open(self, _url: str, *, timeout: float) -> FakeResponse: + assert timeout > 0 + return FakeResponse() + + db_path = tmp_path / "health.sqlite3" + json_bytes = f'{{"ok":true,"db_path":"{db_path}","version":"0.1.106"}}'.encode() + monkeypatch.setenv("CLOUDTAP_DB", str(db_path)) + monkeypatch.setattr("claude_tap.shared_dashboard._LOCAL_DASHBOARD_OPENER", FakeOpener()) + + assert _sync_dashboard_healthy_for_current_db("127.0.0.1", 19527) is False + + +def test_dashboard_lock_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3")) + assert _dashboard_lock_path() == tmp_path / "dashboard.lock" + + +def test_dashboard_spawn_lock(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3")) + with _dashboard_spawn_lock(): + pass + with _dashboard_spawn_lock(): + pass + + +def test_spawn_dashboard_subprocess(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + spawned_args: list[tuple[list[str], dict[str, object]]] = [] + + class FakePopen: + def __init__(self, cmd: list[str], **kwargs: object) -> None: + spawned_args.append((cmd, kwargs)) + self.pid = 99999 + + monkeypatch.setattr(subprocess, "Popen", FakePopen) + + _spawn_dashboard_subprocess("127.0.0.1", 19527, tmp_path) + + assert len(spawned_args) == 1 + cmd, kwargs = spawned_args[0] + assert "dashboard" in cmd + assert "--tap-live-port" in cmd + assert "19527" in cmd + assert str(tmp_path) in cmd + assert kwargs["stdin"] == subprocess.DEVNULL + assert kwargs["stdout"] == subprocess.DEVNULL + assert kwargs["stderr"] == subprocess.DEVNULL + assert kwargs["start_new_session"] is True + + +def test_spawn_dashboard_subprocess_hides_windows_console( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + captured: dict[str, object] = {} + + class FakeStartupInfo: + def __init__(self) -> None: + self.dwFlags = 0 + self.wShowWindow: int | None = None + + class FakePopen: + def __init__(self, cmd: list[str], **kwargs: object) -> None: + captured["cmd"] = cmd + captured["kwargs"] = kwargs + self.pid = 99999 + + scripts_dir = tmp_path / "uv" / "tools" / "claude-tap" / "Scripts" + scripts_dir.mkdir(parents=True) + python_exe = scripts_dir / "python.exe" + pythonw_exe = scripts_dir / "pythonw.exe" + python_exe.touch() + pythonw_exe.touch() + + monkeypatch.setattr("claude_tap.shared_dashboard.sys.platform", "win32") + monkeypatch.setattr("claude_tap.shared_dashboard.sys.executable", str(python_exe)) + monkeypatch.setattr(subprocess, "Popen", FakePopen) + monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", 0x1000, raising=False) + monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x2000, raising=False) + monkeypatch.setattr(subprocess, "STARTF_USESHOWWINDOW", 0x4000, raising=False) + monkeypatch.setattr(subprocess, "SW_HIDE", 0, raising=False) + monkeypatch.setattr(subprocess, "STARTUPINFO", FakeStartupInfo, raising=False) + + _spawn_dashboard_subprocess("0.0.0.0", 19527, tmp_path) + + cmd = captured["cmd"] + kwargs = captured["kwargs"] + assert isinstance(cmd, list) + assert isinstance(kwargs, dict) + assert cmd[0] == str(pythonw_exe) + assert cmd[-2:] == ["--tap-host", "0.0.0.0"] + assert kwargs["stdin"] == subprocess.DEVNULL + assert kwargs["stdout"] == subprocess.DEVNULL + assert kwargs["stderr"] == subprocess.DEVNULL + assert kwargs["creationflags"] == 0x1000 | 0x2000 + assert "start_new_session" not in kwargs + startupinfo = kwargs["startupinfo"] + assert isinstance(startupinfo, FakeStartupInfo) + assert startupinfo.dwFlags == 0x4000 + assert startupinfo.wShowWindow == 0 + + +@pytest.mark.asyncio +async def test_is_dashboard_healthy_real_server(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + import aiohttp + + from claude_tap.live import LiveViewerServer + from claude_tap.shared_dashboard import wait_for_dashboard_healthy + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + + # Before starting, it should be unhealthy + assert await is_dashboard_healthy("127.0.0.1", 54321) is False + assert await wait_for_dashboard_healthy("127.0.0.1", 54321, timeout=0.2, interval=0.05) is False + + # Start real server + server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + async with aiohttp.ClientSession() as session: + async with session.get(f"http://127.0.0.1:{port}/dashboard/health") as resp: + assert resp.status == 200 + payload = await resp.json() + assert payload["ok"] is True + assert payload["db_path"] == str(resolve_db_path()) + assert payload["dashboard_mode"] is True + assert payload["version"] == CLAUDE_TAP_VERSION + assert isinstance(payload["quit_token"], str) + assert payload["quit_token"] + assert await is_dashboard_healthy("127.0.0.1", port) is True + assert await wait_for_dashboard_healthy("127.0.0.1", port, timeout=1.0) is True + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_stop_shared_dashboard_stops_real_server(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + from claude_tap.live import LiveViewerServer + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + + server = LiveViewerServer(port=0, migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + assert await stop_shared_dashboard("127.0.0.1", port) is True + assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is False + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_bind_all_dashboard_uses_loopback_for_local_controls( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from claude_tap.live import LiveViewerServer + + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "dashboard.sqlite3")) + + server = LiveViewerServer(port=0, host="0.0.0.0", migrate_from=tmp_path, dashboard_mode=True) + port = await server.start() + try: + assert server.url == f"http://127.0.0.1:{port}" + assert await is_dashboard_healthy("0.0.0.0", port) is True + assert await stop_shared_dashboard("0.0.0.0", port) is True + assert await is_dashboard_healthy("0.0.0.0", port, require_current_db=False) is False + finally: + await server.stop() + + +@pytest.mark.asyncio +async def test_stop_shared_dashboard_requires_health_token() -> None: + quit_called = False + + async def health(request: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + async def quit_dashboard(request: web.Request) -> web.Response: + nonlocal quit_called + quit_called = True + return web.json_response({"ok": True}) + + app = web.Application() + app.router.add_get("/dashboard/health", health) + app.router.add_post("/dashboard/quit", quit_dashboard) + runner, port = await _start_test_app(app) + try: + assert await stop_shared_dashboard("127.0.0.1", port) is False + assert quit_called is False + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_stop_shared_dashboard_rejects_unhealthy_or_forbidden_server() -> None: + async def unhealthy(request: web.Request) -> web.Response: + return web.json_response({"ok": False}, status=500) + + unhealthy_app = web.Application() + unhealthy_app.router.add_get("/dashboard/health", unhealthy) + unhealthy_runner, unhealthy_port = await _start_test_app(unhealthy_app) + try: + assert await stop_shared_dashboard("127.0.0.1", unhealthy_port) is False + finally: + await unhealthy_runner.cleanup() + + async def health(request: web.Request) -> web.Response: + return web.json_response({"ok": True, "quit_token": "test-token"}) + + async def forbidden_quit(request: web.Request) -> web.Response: + assert request.headers["X-Claude-Tap-Dashboard-Token"] == "test-token" + return web.json_response({"ok": False}, status=403) + + forbidden_app = web.Application() + forbidden_app.router.add_get("/dashboard/health", health) + forbidden_app.router.add_post("/dashboard/quit", forbidden_quit) + forbidden_runner, forbidden_port = await _start_test_app(forbidden_app) + try: + assert await stop_shared_dashboard("127.0.0.1", forbidden_port) is False + finally: + await forbidden_runner.cleanup() + + +@pytest.mark.asyncio +async def test_stop_shared_dashboard_handles_post_client_error(monkeypatch: pytest.MonkeyPatch) -> None: + import aiohttp + + async def healthy(*_args: object, **_kwargs: object) -> tuple[int, dict[str, str]]: + return 200, {"quit_token": "test-token"} + + class FailingSession: + def __init__(self, *_args: object, **_kwargs: object) -> None: + pass + + async def __aenter__(self) -> "FailingSession": + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + def post(self, *_args: object, **_kwargs: object) -> object: + raise aiohttp.ClientError("post failed") + + monkeypatch.setattr("claude_tap.shared_dashboard._dashboard_get_status_and_payload", healthy) + monkeypatch.setattr("claude_tap.shared_dashboard.aiohttp.ClientSession", FailingSession) + + assert await stop_shared_dashboard("127.0.0.1", 19527) is False + + +@pytest.mark.asyncio +async def test_stop_dashboard_service_falls_back_to_legacy_process(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + async def fake_stop_shared_dashboard(_host: str, _port: int) -> bool: + calls.append("shared") + return False + + async def fake_is_legacy_dashboard_healthy(_host: str, _port: int) -> bool: + calls.append("legacy") + return True + + async def fake_stop_legacy_dashboard_process(_host: str, _port: int) -> bool: + calls.append("process") + return True + + monkeypatch.setattr("claude_tap.shared_dashboard.stop_shared_dashboard", fake_stop_shared_dashboard) + monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", fake_is_legacy_dashboard_healthy) + monkeypatch.setattr("claude_tap.shared_dashboard.stop_legacy_dashboard_process", fake_stop_legacy_dashboard_process) + + assert await stop_dashboard_service("127.0.0.1", 19527) is True + assert calls == ["shared", "legacy", "process"] + + +def test_looks_like_legacy_dashboard_command_is_strict() -> None: + assert _looks_like_legacy_dashboard_command( + "/usr/bin/python -m claude_tap dashboard --tap-live-port 19527", + 19527, + ) + assert not _looks_like_legacy_dashboard_command( + "/usr/bin/python -m claude_tap dashboard --tap-live-port 3000", + 19527, + ) + assert not _looks_like_legacy_dashboard_command( + "/usr/bin/python -m claude_tap proxy --tap-live-port 19527", + 19527, + ) + assert not _looks_like_legacy_dashboard_command( + "/usr/bin/python -m other_app dashboard --tap-live-port 19527", + 19527, + ) + + +def test_dashboard_listening_pids_uses_lsof(monkeypatch: pytest.MonkeyPatch) -> None: + class Result: + returncode = 0 + stdout = "111\nnot-a-pid\n222\n" + + calls: list[list[str]] = [] + + def fake_which(name: str) -> str | None: + return f"/usr/bin/{name}" if name == "lsof" else None + + def fake_run(cmd: list[str], **_kwargs: object) -> Result: + calls.append(cmd) + return Result() + + monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", fake_which) + monkeypatch.setattr("claude_tap.shared_dashboard.subprocess.run", fake_run) + + assert _dashboard_listening_pids_for_port(19527) == [111, 222] + assert calls == [["/usr/bin/lsof", "-nP", "-iTCP:19527", "-sTCP:LISTEN", "-t"]] + + +def test_dashboard_listening_pids_falls_back_to_ss(monkeypatch: pytest.MonkeyPatch) -> None: + class Result: + returncode = 0 + stdout = 'LISTEN 0 128 127.0.0.1:19527 *:* users:(("python",pid=333,fd=6),("python",pid=444,fd=7))' + + def fake_which(name: str) -> str | None: + return "/usr/bin/ss" if name == "ss" else None + + monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", fake_which) + monkeypatch.setattr("claude_tap.shared_dashboard.subprocess.run", lambda *_args, **_kwargs: Result()) + + assert _dashboard_listening_pids_for_port(19527) == [333, 444] + + +def test_dashboard_listening_pids_handles_missing_tools(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", lambda _name: None) + + assert _dashboard_listening_pids_for_port(0) == [] + assert _dashboard_listening_pids_for_port(19527) == [] + + +def test_dashboard_process_command_reads_linux_proc(monkeypatch: pytest.MonkeyPatch) -> None: + def fake_read_bytes(path: Path) -> bytes: + assert str(path) == "/proc/123/cmdline" + return b"python\0-m\0claude_tap\0dashboard\0" + + monkeypatch.setattr("claude_tap.shared_dashboard.sys.platform", "linux") + monkeypatch.setattr("pathlib.Path.read_bytes", fake_read_bytes) + + assert _dashboard_process_command(123) == "python -m claude_tap dashboard" + + +def test_dashboard_process_command_falls_back_to_ps(monkeypatch: pytest.MonkeyPatch) -> None: + class Result: + returncode = 0 + stdout = "claude-tap dashboard --tap-live-port 19527\n" + + def fake_which(name: str) -> str | None: + return "/bin/ps" if name == "ps" else None + + monkeypatch.setattr("claude_tap.shared_dashboard.sys.platform", "darwin") + monkeypatch.setattr("claude_tap.shared_dashboard.shutil.which", fake_which) + monkeypatch.setattr("claude_tap.shared_dashboard.subprocess.run", lambda *_args, **_kwargs: Result()) + + assert _dashboard_process_command(123) == "claude-tap dashboard --tap-live-port 19527" + + +def test_terminate_legacy_dashboard_pids_filters_commands(monkeypatch: pytest.MonkeyPatch) -> None: + commands = { + 111: "/usr/bin/python -m claude_tap dashboard --tap-live-port 19527", + 222: "/usr/bin/python -m claude_tap proxy --tap-live-port 19527", + } + killed: list[tuple[int, signal.Signals]] = [] + + monkeypatch.setattr("claude_tap.shared_dashboard._dashboard_process_command", commands.__getitem__) + monkeypatch.setattr("claude_tap.shared_dashboard.os.kill", lambda pid, sig: killed.append((pid, sig))) + + assert _terminate_legacy_dashboard_pids([111, 222], 19527) is True + assert killed == [(111, signal.SIGTERM)] + + +@pytest.mark.asyncio +async def test_stop_legacy_dashboard_process_terminates_and_waits(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[str, object]] = [] + + def fake_listening_pids(port: int) -> list[int]: + calls.append(("pids", port)) + return [111] + + def fake_terminate(pids: list[int], port: int) -> bool: + calls.append(("terminate", (pids, port))) + return True + + async def fake_wait_stopped(host: str, port: int) -> bool: + calls.append(("wait", (host, port))) + return True + + monkeypatch.setattr("claude_tap.shared_dashboard._dashboard_listening_pids_for_port", fake_listening_pids) + monkeypatch.setattr("claude_tap.shared_dashboard._terminate_legacy_dashboard_pids", fake_terminate) + monkeypatch.setattr("claude_tap.shared_dashboard.wait_for_dashboard_stopped", fake_wait_stopped) + + assert await stop_legacy_dashboard_process("127.0.0.1", 19527) is True + assert calls == [ + ("pids", 19527), + ("terminate", ([111], 19527)), + ("wait", ("127.0.0.1", 19527)), + ] + + +@pytest.mark.asyncio +async def test_is_dashboard_healthy_prefers_lightweight_health_route( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "health.sqlite3")) + sessions_seen = False + app = web.Application() + + async def health(request: web.Request) -> web.Response: + return web.json_response({"ok": True, "db_path": str(resolve_db_path()), "version": CLAUDE_TAP_VERSION}) + + async def sessions(request: web.Request) -> web.Response: + nonlocal sessions_seen + sessions_seen = True + return web.json_response({"sessions": []}) + + app.router.add_get("/dashboard/health", health) + app.router.add_get("/api/sessions", sessions) + runner, port = await _start_test_app(app) + try: + assert await is_dashboard_healthy("127.0.0.1", port) is True + assert sessions_seen is False + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_is_dashboard_healthy_rejects_stale_version(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "current.sqlite3")) + app = web.Application() + + async def health(request: web.Request) -> web.Response: + return web.json_response({"ok": True, "db_path": str(resolve_db_path()), "version": "0.1.106"}) + + app.router.add_get("/dashboard/health", health) + runner, port = await _start_test_app(app) + try: + assert await is_dashboard_healthy("127.0.0.1", port) is False + assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is True + assert await is_legacy_dashboard_healthy("127.0.0.1", port) is False + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_is_dashboard_healthy_rejects_different_database(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "current.sqlite3")) + app = web.Application() + + async def health(request: web.Request) -> web.Response: + return web.json_response( + {"ok": True, "db_path": str(tmp_path / "other.sqlite3"), "version": CLAUDE_TAP_VERSION} + ) + + app.router.add_get("/dashboard/health", health) + runner, port = await _start_test_app(app) + try: + assert await is_dashboard_healthy("127.0.0.1", port) is False + assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is True + assert await is_legacy_dashboard_healthy("127.0.0.1", port) is False + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_is_dashboard_healthy_falls_back_for_legacy_dashboard() -> None: + app = web.Application() + + async def sessions(request: web.Request) -> web.Response: + return web.json_response({"sessions": []}) + + app.router.add_get("/api/sessions", sessions) + runner, port = await _start_test_app(app) + try: + assert await is_dashboard_healthy("127.0.0.1", port) is False + assert await is_dashboard_healthy("127.0.0.1", port, require_current_db=False) is True + assert await is_legacy_dashboard_healthy("127.0.0.1", port) is True + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +async def test_ensure_shared_dashboard_already_healthy_does_not_reopen_browser( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + async def mock_true(h: str, p: int) -> bool: + return True + + migrated: list[Path] = [] + monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_true) + monkeypatch.setattr("claude_tap.history.migrate_legacy_traces", migrated.append) + + opened = [] + + def fake_open(url: str) -> None: + opened.append(url) + + url, spawned = await ensure_shared_dashboard( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + open_browser=True, + open_browser_fn=fake_open, + ) + + assert url == "http://127.0.0.1:19527" + assert spawned is False + assert opened == [] + assert migrated == [tmp_path] + + +@pytest.mark.asyncio +async def test_ensure_shared_dashboard_stops_stale_dashboard_before_spawn( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + calls: list[tuple[str, object]] = [] + + async def fake_is_dashboard_healthy(_host: str, _port: int, *, require_current_db: bool = True) -> bool: + calls.append(("health", require_current_db)) + return not require_current_db + + async def fake_stop_dashboard_service(_host: str, _port: int) -> bool: + calls.append(("stop", None)) + return True + + def fake_spawn_if_needed(_host: str, _port: int, _output_dir: Path) -> bool: + calls.append(("spawn", None)) + return True + + async def fake_wait_for_dashboard_healthy(_host: str, _port: int) -> bool: + calls.append(("wait", None)) + return True + + monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", fake_is_dashboard_healthy) + monkeypatch.setattr("claude_tap.shared_dashboard.stop_dashboard_service", fake_stop_dashboard_service) + monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess_if_needed", fake_spawn_if_needed) + monkeypatch.setattr("claude_tap.shared_dashboard.wait_for_dashboard_healthy", fake_wait_for_dashboard_healthy) + + url, spawned = await ensure_shared_dashboard( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + open_browser=False, + open_browser_fn=lambda _url: None, + ) + + assert url == "http://127.0.0.1:19527" + assert spawned is True + assert calls == [("health", True), ("health", False), ("stop", None), ("spawn", None), ("wait", None)] + + +@pytest.mark.asyncio +async def test_ensure_shared_dashboard_reports_unstoppable_stale_dashboard( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + async def fake_is_dashboard_healthy(_host: str, _port: int, *, require_current_db: bool = True) -> bool: + return not require_current_db + + async def fake_stop_dashboard_service(_host: str, _port: int) -> bool: + return False + + monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", fake_is_dashboard_healthy) + monkeypatch.setattr("claude_tap.shared_dashboard.stop_dashboard_service", fake_stop_dashboard_service) + + with pytest.raises(RuntimeError, match="outdated claude-tap dashboard"): + await ensure_shared_dashboard( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + open_browser=False, + open_browser_fn=lambda _url: None, + ) + + +@pytest.mark.asyncio +async def test_ensure_shared_dashboard_migrates_after_lock_time_reuse( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + async def mock_false(h: str, p: int, *, require_current_db: bool = True) -> bool: + return False + + migrated: list[Path] = [] + monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_false) + monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", mock_false) + monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess_if_needed", lambda h, p, d: False) + monkeypatch.setattr("claude_tap.shared_dashboard._migrate_legacy_traces", migrated.append) + + opened: list[str] = [] + + url, spawned = await ensure_shared_dashboard( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + open_browser=True, + open_browser_fn=opened.append, + ) + + assert url == "http://127.0.0.1:19527" + assert spawned is False + assert opened == [] + assert migrated == [tmp_path] + + +@pytest.mark.asyncio +async def test_ensure_shared_dashboard_spawns(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3")) + + health_calls: list[int] = [] + + async def mock_health(h: str, p: int, *, require_current_db: bool = True) -> bool: + if len(health_calls) < 2: + health_calls.append(1) + return False + return True + + async def mock_legacy_false(h: str, p: int) -> bool: + return False + + monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_health) + monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", mock_legacy_false) + monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess", lambda h, p, d: None) + + opened = [] + + def fake_open(url: str) -> None: + opened.append(url) + + url, spawned = await ensure_shared_dashboard( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + open_browser=True, + open_browser_fn=fake_open, + ) + + assert url == "http://127.0.0.1:19527" + assert spawned is True + assert opened == ["http://127.0.0.1:19527"] + + +@pytest.mark.asyncio +async def test_ensure_shared_dashboard_timeout_raises_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("CLOUDTAP_DB", str(tmp_path / "test.sqlite3")) + + async def mock_false(h: str, p: int, *, require_current_db: bool = True) -> bool: + return False + + async def mock_wait_false(h: str, p: int, **kw: object) -> bool: + return False + + monkeypatch.setattr("claude_tap.shared_dashboard.is_dashboard_healthy", mock_false) + monkeypatch.setattr("claude_tap.shared_dashboard.is_legacy_dashboard_healthy", mock_false) + monkeypatch.setattr("claude_tap.shared_dashboard.wait_for_dashboard_healthy", mock_wait_false) + monkeypatch.setattr("claude_tap.shared_dashboard._spawn_dashboard_subprocess", lambda h, p, d: None) + + with pytest.raises(RuntimeError, match="Failed to start shared dashboard"): + await ensure_shared_dashboard( + host="127.0.0.1", + port=19527, + output_dir=tmp_path, + open_browser=False, + open_browser_fn=lambda u: None, + ) diff --git a/tests/test_trace_store_compaction.py b/tests/test_trace_store_compaction.py new file mode 100644 index 0000000..20ffd95 --- /dev/null +++ b/tests/test_trace_store_compaction.py @@ -0,0 +1,559 @@ +"""Tests for compact SQLite trace record storage.""" + +from __future__ import annotations + +import json +import sqlite3 +from copy import deepcopy + +from claude_tap.compact_trace import ( + BLOB_KIND_JSON, + BLOB_REF_MARKER, + json_blob_payload, + load_compact_trace, + make_blob_ref, +) +from claude_tap.trace_store import ( + COMPACT_RECORD_MARKER, + TraceStore, + get_trace_store, +) + + +def _large_codex_record(index: int, *, instructions: str, tools: list[dict]) -> dict: + return { + "timestamp": f"2026-05-30T04:00:{index:02d}+00:00", + "turn": index, + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "body": { + "model": "gpt-5.5", + "instructions": instructions, + "tools": tools, + "input": [ + { + "role": "user", + "content": [{"type": "input_text", "text": f"round {index}"}], + } + ], + "previous_response_id": f"resp_{index - 1}" if index > 1 else None, + }, + }, + "response": { + "status": 101, + "body": { + "id": f"resp_{index}", + "model": "gpt-5.5", + "instructions": instructions, + "tools": tools, + "output": [ + { + "type": "function_call", + "call_id": f"call_{index}", + "name": "shell", + "arguments": json.dumps({"cmd": f"printf round-{index}"}), + }, + { + "type": "message", + "content": [{"type": "output_text", "text": f"done {index}"}], + }, + ], + "usage": {"input_tokens": 100 + index, "output_tokens": 10}, + }, + }, + } + + +def _large_message_item(index: int) -> dict: + return { + "role": "user" if index % 2 else "assistant", + "content": [ + { + "type": "input_text", + "text": f"shared history item {index} " + ("long repeated agent context payload " * 120), + } + ], + } + + +def _messages_record(index: int, messages: list[dict]) -> dict: + return { + "timestamp": f"2026-06-11T08:00:{index:02d}+00:00", + "turn": index, + "request": { + "method": "POST", + "path": "/v1/messages", + "body": { + "model": "claude-sonnet-4-6", + "messages": deepcopy(messages), + }, + }, + "response": { + "status": 200, + "body": { + "content": [{"type": "text", "text": f"ok {index}"}], + "usage": {"input_tokens": 1000 + index, "output_tokens": 12}, + }, + }, + } + + +def _responses_input_record(index: int, input_items: list[dict]) -> dict: + return { + "timestamp": f"2026-06-11T08:01:{index:02d}+00:00", + "turn": index, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": { + "model": "gpt-5.5", + "input": deepcopy(input_items), + }, + }, + "response": { + "status": 200, + "body": { + "output": [{"type": "message", "content": [{"type": "output_text", "text": f"done {index}"}]}], + "usage": {"input_tokens": 900 + index, "output_tokens": 9}, + }, + }, + } + + +def _raw_record_payloads(db_path) -> list[dict]: + conn = sqlite3.connect(db_path) + rows = conn.execute("SELECT payload_json FROM records ORDER BY record_index").fetchall() + return [json.loads(row[0]) for row in rows] + + +def test_trace_store_compacts_repeated_instructions_and_tools(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + instructions = "shared system instructions\n" * 800 + tools = [ + { + "type": "function", + "name": "shell", + "description": "shared shell tool description " * 300, + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + } + ] + records = [_large_codex_record(index, instructions=instructions, tools=tools) for index in range(1, 4)] + + for record in records: + store.append_record(session_id, deepcopy(record)) + + assert store.load_records(session_id) == records + exported = [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] + assert exported == records + + raw_payloads = _raw_record_payloads(trace_db) + assert all(COMPACT_RECORD_MARKER in payload for payload in raw_payloads) + assert all(BLOB_REF_MARKER in json.dumps(payload) for payload in raw_payloads) + assert instructions not in json.dumps(raw_payloads, ensure_ascii=False) + + conn = sqlite3.connect(trace_db) + blob_count = conn.execute("SELECT COUNT(*) FROM record_blobs").fetchone()[0] + # instructions and tools are shared across request/response and all records. + assert blob_count == 2 + + +def test_compact_blobs_follow_session_lifecycle(trace_db) -> None: + store = get_trace_store() + first_session = store.create_session(client="codex", proxy_mode="reverse") + second_session = store.create_session(client="codex", proxy_mode="reverse") + instructions = "shared but session-scoped instructions\n" * 800 + tools = [ + { + "type": "function", + "name": "shell", + "description": "shared shell tool description " * 300, + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + } + ] + record = _large_codex_record(1, instructions=instructions, tools=tools) + + store.append_record(first_session, deepcopy(record)) + store.append_record(second_session, deepcopy(record)) + + conn = store._connect() + assert conn.execute("SELECT COUNT(*) FROM record_blobs WHERE session_id = ?", (first_session,)).fetchone()[0] == 2 + assert conn.execute("SELECT COUNT(*) FROM record_blobs WHERE session_id = ?", (second_session,)).fetchone()[0] == 2 + + conn.execute("DELETE FROM sessions WHERE id = ?", (first_session,)) + conn.commit() + + assert conn.execute("SELECT COUNT(*) FROM record_blobs WHERE session_id = ?", (first_session,)).fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM record_blobs WHERE session_id = ?", (second_session,)).fetchone()[0] == 2 + assert store.load_records(second_session) == [record] + + +def test_trace_store_reads_legacy_full_payload_rows(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + legacy_record = { + "timestamp": "2026-05-30T04:01:00+00:00", + "turn": 1, + "request": {"body": {"model": "gpt-5.5", "input": "legacy full row"}}, + "response": {"body": {"output": [{"type": "message", "content": "ok"}]}}, + } + conn = sqlite3.connect(trace_db) + conn.execute( + """ + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + VALUES (?, 1, 1, ?, ?) + """, + ( + session_id, + legacy_record["timestamp"], + json.dumps(legacy_record, ensure_ascii=False, separators=(",", ":")), + ), + ) + conn.commit() + + assert store.load_records(session_id) == [legacy_record] + assert json.loads(store.export_jsonl(session_id)) == legacy_record + + +def test_trace_store_reads_legacy_compact_rows_without_refs(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + instructions = "legacy shared instructions " * 200 + tools = [ + { + "type": "function", + "name": "legacy_shell", + "description": "legacy shared tool schema " * 120, + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + } + ] + fake_user_blob_ref = { + BLOB_REF_MARKER: { + "version": 1, + "kind": "json", + "hash": "sha256:user-controlled-marker-shape", + "bytes": 42, + } + } + legacy_record = _large_codex_record(1, instructions=instructions, tools=tools) + legacy_record["response"]["body"]["output"].append( + { + "type": "message", + "content": [ + { + "type": "output_text", + "text": "preserve marker-shaped user payload", + "metadata": fake_user_blob_ref, + } + ], + } + ) + compact_payload = deepcopy(legacy_record) + conn = sqlite3.connect(trace_db) + for value in (instructions, tools): + payload_json, size_bytes, hash_value = json_blob_payload(value) + conn.execute( + """ + INSERT OR IGNORE INTO record_blobs (session_id, hash, kind, payload_json, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?, '2026-06-11T08:02:00+00:00') + """, + (session_id, hash_value, BLOB_KIND_JSON, payload_json, size_bytes), + ) + ref = make_blob_ref(hash_value, size_bytes) + for section in ("request", "response"): + target = compact_payload[section]["body"] + if target.get("instructions") == value: + target["instructions"] = ref + if target.get("tools") == value: + target["tools"] = ref + legacy_compact_payload = { + COMPACT_RECORD_MARKER: { + "version": 1, + "encoding": "json-blob-ref", + }, + "record": compact_payload, + } + conn.execute( + """ + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + VALUES (?, 1, 1, ?, ?) + """, + ( + session_id, + legacy_record["timestamp"], + json.dumps(legacy_compact_payload, ensure_ascii=False, separators=(",", ":")), + ), + ) + conn.commit() + + assert store.load_records(session_id) == [legacy_record] + assert [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] == [legacy_record] + assert load_compact_trace(store.export_compact(session_id)) == [legacy_record] + + +def test_trace_store_migrates_v3_database_and_keeps_full_rows_readable(tmp_path) -> None: + db_path = tmp_path / "v3.sqlite3" + legacy_record = { + "timestamp": "2026-05-30T04:02:00+00:00", + "turn": 1, + "request": {"body": {"model": "gpt-5.5", "input": "v3 row"}}, + "response": {"body": {"output": [{"type": "message", "content": "ok"}]}}, + } + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + date_key TEXT NOT NULL, + client TEXT NOT NULL DEFAULT '', + proxy_mode TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + record_count INTEGER NOT NULL DEFAULT 0, + summary_json TEXT, + legacy_source_key TEXT NOT NULL DEFAULT '', + legacy_rel_path TEXT + ); + CREATE TABLE records ( + session_id TEXT NOT NULL, + record_index INTEGER NOT NULL, + turn INTEGER, + timestamp TEXT, + payload_json TEXT NOT NULL, + PRIMARY KEY (session_id, record_index) + ); + CREATE TABLE proxy_logs ( + session_id TEXT NOT NULL, + line_no INTEGER NOT NULL, + logged_at TEXT, + level TEXT, + message TEXT NOT NULL, + PRIMARY KEY (session_id, line_no) + ); + CREATE TABLE migration_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + PRAGMA user_version = 3; + """ + ) + conn.execute( + """ + INSERT INTO sessions (id, started_at, updated_at, date_key, client, proxy_mode, status, record_count) + VALUES ('legacy-session', '2026-05-30T04:02:00+00:00', '2026-05-30T04:02:00+00:00', '2026-05-30', 'codex', 'reverse', 'complete', 1) + """ + ) + conn.execute( + """ + INSERT INTO records (session_id, record_index, turn, timestamp, payload_json) + VALUES ('legacy-session', 1, 1, '2026-05-30T04:02:00+00:00', ?) + """, + (json.dumps(legacy_record, ensure_ascii=False, separators=(",", ":")),), + ) + conn.commit() + conn.close() + + store = TraceStore(db_path) + assert store.load_records("legacy-session") == [legacy_record] + with sqlite3.connect(db_path) as migrated: + assert migrated.execute("PRAGMA user_version").fetchone()[0] == 4 + assert migrated.execute("SELECT COUNT(*) FROM record_blobs").fetchone()[0] == 0 + + +def test_compact_storage_reduces_large_trace_payload_and_preserves_roundtrip(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + instructions = "repeatable instructions block " * 2000 + tools = [ + { + "type": "function", + "name": f"tool_{tool_index}", + "description": "repeatable tool schema " * 500, + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + } + for tool_index in range(4) + ] + records = [_large_codex_record(index, instructions=instructions, tools=tools) for index in range(1, 81)] + raw_jsonl = "".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" for record in records) + + for record in records: + store.append_record(session_id, deepcopy(record)) + + conn = sqlite3.connect(trace_db) + stored_payload_bytes = conn.execute("SELECT SUM(LENGTH(payload_json)) FROM records").fetchone()[0] + blob_payload_bytes = conn.execute("SELECT SUM(size_bytes) FROM record_blobs").fetchone()[0] + compact_total = stored_payload_bytes + blob_payload_bytes + + assert store.load_records(session_id) == records + assert [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] == records + assert compact_total < len(raw_jsonl.encode("utf-8")) * 0.15 + assert conn.execute("SELECT COUNT(*) FROM record_blobs").fetchone()[0] == 2 + + +def test_trace_store_compacts_repeated_messages_without_prefix_dependency(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + first = _large_message_item(1) + second = _large_message_item(2) + third = _large_message_item(3) + fourth = _large_message_item(4) + fifth = _large_message_item(5) + records = [ + _messages_record(1, [first, second]), + _messages_record(2, [third, first, second]), + # This is intentionally not an append-only prefix of the previous request. + _messages_record(3, [fourth, second, first, third]), + _messages_record(4, [first, fourth, second, third]), + _messages_record(5, [fifth, first, fourth, second, third]), + ] + raw_jsonl = "".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" for record in records) + + for record in records: + store.append_record(session_id, deepcopy(record)) + + assert store.load_records(session_id) == records + assert store.load_records(session_id, limit=1, offset=1) == [records[1]] + assert [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] == records + + raw_payloads = _raw_record_payloads(trace_db) + encoded_payloads = json.dumps(raw_payloads, ensure_ascii=False) + assert all(COMPACT_RECORD_MARKER in payload for payload in raw_payloads) + assert BLOB_REF_MARKER in encoded_payloads + assert "shared history item 1" not in encoded_payloads + + conn = sqlite3.connect(trace_db) + stored_payload_bytes = conn.execute("SELECT SUM(LENGTH(payload_json)) FROM records").fetchone()[0] + blob_payload_bytes = conn.execute("SELECT SUM(size_bytes) FROM record_blobs").fetchone()[0] + compact_total = stored_payload_bytes + blob_payload_bytes + + assert compact_total < len(raw_jsonl.encode("utf-8")) * 0.45 + assert conn.execute("SELECT COUNT(*) FROM record_blobs").fetchone()[0] == 5 + + +def test_trace_store_compacts_repeated_responses_input_items(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + shared_context = _large_message_item(1) + tool_result = { + "type": "function_call_output", + "call_id": "call_shared", + "output": "large tool result " * 400, + } + records = [ + _responses_input_record(1, [shared_context, tool_result]), + _responses_input_record(2, [shared_context, tool_result, _large_message_item(2)]), + ] + + for record in records: + store.append_record(session_id, deepcopy(record)) + + assert store.load_records(session_id) == records + raw_payloads = _raw_record_payloads(trace_db) + encoded_payloads = json.dumps(raw_payloads, ensure_ascii=False) + assert BLOB_REF_MARKER in encoded_payloads + assert "large tool result" not in encoded_payloads + + conn = sqlite3.connect(trace_db) + assert conn.execute("SELECT COUNT(*) FROM record_blobs").fetchone()[0] == 3 + + +def test_compact_records_preserve_user_blob_ref_shaped_payloads(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + fake_user_blob_ref = { + BLOB_REF_MARKER: { + "version": 1, + "kind": "json", + "hash": "sha256:user-controlled-marker-shape", + "bytes": 42, + } + } + shared_context = _large_message_item(1) + records = [ + _responses_input_record( + 1, + [ + shared_context, + { + "type": "function_call_output", + "call_id": "call_marker", + "output": fake_user_blob_ref, + }, + ], + ), + _responses_input_record( + 2, + [ + shared_context, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "second turn keeps a user supplied marker-shaped object", + "metadata": fake_user_blob_ref, + } + ], + }, + ], + ), + ] + + for record in records: + store.append_record(session_id, deepcopy(record)) + + raw_payloads = _raw_record_payloads(trace_db) + assert any(COMPACT_RECORD_MARKER in payload for payload in raw_payloads) + assert store.load_records(session_id) == records + assert [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] == records + assert load_compact_trace(store.export_compact(session_id)) == records + + +def test_trace_store_keeps_small_messages_inline(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="claude", proxy_mode="reverse") + record = _messages_record(1, [{"role": "user", "content": "hi"}]) + + store.append_record(session_id, deepcopy(record)) + + assert store.load_records(session_id) == [record] + raw_payloads = _raw_record_payloads(trace_db) + assert COMPACT_RECORD_MARKER not in raw_payloads[0] + assert BLOB_REF_MARKER not in json.dumps(raw_payloads, ensure_ascii=False) + + conn = sqlite3.connect(trace_db) + assert conn.execute("SELECT COUNT(*) FROM record_blobs").fetchone()[0] == 0 + + +def test_trace_store_message_item_compaction_scales_to_long_reordered_histories(trace_db) -> None: + store = get_trace_store() + session_id = store.create_session(client="codex", proxy_mode="reverse") + shared_items = [_large_message_item(index) for index in range(1, 21)] + records = [] + for turn in range(1, 61): + history = [shared_items[(turn + step * 7) % len(shared_items)] for step in range(14)] + if turn % 3 == 0: + history = list(reversed(history)) + records.append(_responses_input_record(turn, history)) + raw_jsonl = "".join(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" for record in records) + + for record in records: + store.append_record(session_id, deepcopy(record)) + + conn = sqlite3.connect(trace_db) + stored_payload_bytes = conn.execute("SELECT SUM(LENGTH(payload_json)) FROM records").fetchone()[0] + blob_payload_bytes = conn.execute("SELECT SUM(size_bytes) FROM record_blobs").fetchone()[0] + compact_total = stored_payload_bytes + blob_payload_bytes + + assert store.load_records(session_id) == records + assert store.load_records(session_id, limit=3, offset=41) == records[41:44] + assert [json.loads(line) for line in store.export_jsonl(session_id).splitlines()] == records + assert load_compact_trace(store.export_compact(session_id)) == records + assert compact_total < len(raw_jsonl.encode("utf-8")) * 0.2 + assert conn.execute("SELECT COUNT(*) FROM record_blobs").fetchone()[0] == len(shared_items) diff --git a/tests/test_trace_store_locking.py b/tests/test_trace_store_locking.py new file mode 100644 index 0000000..a5a96f7 --- /dev/null +++ b/tests/test_trace_store_locking.py @@ -0,0 +1,334 @@ +"""Regression tests for SQLite trace-store lock contention.""" + +from __future__ import annotations + +import json +import multiprocessing +import os +import sqlite3 +import stat +import subprocess +import sys +import time +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +import pytest + +from claude_tap.cli import _create_trace_writer +from claude_tap.codex_app_transcript import CodexAppTranscriptSessionRegistry +from claude_tap.trace import TraceWriter +from claude_tap.trace_store import TraceStore +from tests.conftest import e2e_env, trace_db_path +from tests.test_e2e import PROJECT_ROOT, run_fake_upstream_in_thread + +FAKE_LOCKING_CLIENT_SCRIPT = r"""#!/usr/bin/env python3 +import json +import os +import sqlite3 +import urllib.request + +locker = None +if os.environ.get("LOCK_SQLITE_DURING_REQUEST") == "1": + locker = sqlite3.connect(os.environ["CLOUDTAP_DB"], timeout=0.1) + locker.execute("BEGIN IMMEDIATE") + locker.execute("UPDATE sessions SET status = status") + +request = urllib.request.Request( + os.environ["ANTHROPIC_BASE_URL"] + "/v1/messages", + data=json.dumps({ + "model": "claude-test-model", + "max_tokens": 32, + "messages": [{"role": "user", "content": "lock smoke"}], + }).encode(), + headers={ + "Content-Type": "application/json", + "x-api-key": "sk-ant-test-key", + "anthropic-version": "2023-06-01", + }, +) +try: + with urllib.request.urlopen(request, timeout=10) as response: + assert response.status == 200 +finally: + if locker is not None: + locker.rollback() + locker.close() +""" + + +def _record(index: int) -> dict: + return { + "timestamp": f"2026-07-12T08:00:{index:02d}+00:00", + "turn": index, + "request": { + "method": "POST", + "path": "/v1/responses", + "body": {"model": "gpt-5", "input": f"lock test {index}"}, + }, + "response": { + "status": 200, + "body": {"output": [], "usage": {"input_tokens": 1, "output_tokens": 1}}, + }, + } + + +def _append_records_in_process(db_path: str, session_id: str, start: int, count: int) -> int: + store = TraceStore(Path(db_path)) + try: + for index in range(start, start + count): + store.append_record(session_id, _record(index)) + finally: + store.close() + return count + + +def _hold_sqlite_write_lock(db_path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(db_path, timeout=0.1) + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("BEGIN IMMEDIATE") + conn.execute("UPDATE sessions SET status = status") + return conn + + +def _run_locked_proxy_smoke( + tmp_path: Path, + upstream_port: int, + *, + lock_during_request: bool, +) -> tuple[subprocess.CompletedProcess[str], Path]: + trace_dir = tmp_path / "traces" + fake_bin_dir = tmp_path / "bin" + trace_dir.mkdir(exist_ok=True) + fake_bin_dir.mkdir(exist_ok=True) + fake_client = fake_bin_dir / "claude" + fake_client.write_text(FAKE_LOCKING_CLIENT_SCRIPT, encoding="utf-8") + fake_client.chmod(fake_client.stat().st_mode | stat.S_IEXEC) + + env = e2e_env(os.environ.copy(), trace_dir) + db_path = trace_db_path(trace_dir) + env["PATH"] = f"{fake_bin_dir}{os.pathsep}{env.get('PATH', '')}" + if lock_during_request: + env["LOCK_SQLITE_DURING_REQUEST"] = "1" + process = subprocess.run( + [ + sys.executable, + "-m", + "claude_tap", + "--tap-output-dir", + str(trace_dir), + "--tap-no-live", + "--tap-no-open", + "--tap-target", + f"http://127.0.0.1:{upstream_port}", + ], + cwd=PROJECT_ROOT, + env=env, + capture_output=True, + text=True, + timeout=20, + ) + return process, db_path + + +def test_read_paths_use_short_lived_connections(tmp_path: Path) -> None: + db_path = tmp_path / "short-reads.sqlite3" + writer_store = TraceStore(db_path) + session_id = writer_store.create_session(client="codex", proxy_mode="reverse") + writer_store.append_record(session_id, _record(1)) + writer_store.close() + + reader_store = TraceStore(db_path) + assert [row["id"] for row in reader_store.list_session_rows()] == [session_id] + assert reader_store.load_records(session_id) == [_record(1)] + assert reader_store.dashboard_snapshot()[session_id][1] == 1 + assert getattr(reader_store._tls, "conn", None) is None + + +def test_failed_write_rolls_back_quickly(tmp_path: Path) -> None: + db_path = tmp_path / "rollback.sqlite3" + store = TraceStore(db_path) + session_id = store.create_session(client="codex", proxy_mode="reverse") + writer_conn = store._connect() + writer_conn.execute("PRAGMA busy_timeout = 50") + locker = _hold_sqlite_write_lock(db_path) + + started = time.monotonic() + try: + with pytest.raises(sqlite3.OperationalError, match="locked"): + store.append_record(session_id, _record(1)) + finally: + locker.rollback() + locker.close() + + assert time.monotonic() - started < 0.5 + assert writer_conn.in_transaction is False + store.append_record(session_id, _record(2)) + assert store.load_records(session_id) == [_record(2)] + + +@pytest.mark.asyncio +async def test_trace_writer_drops_locked_record_without_interrupting_capture(tmp_path: Path, capsys) -> None: + db_path = tmp_path / "writer.sqlite3" + store = TraceStore(db_path) + session_id = store.create_session(client="codex", proxy_mode="reverse") + writer = TraceWriter(session_id, store=store) + await writer.write(_record(1)) + + store._connect().execute("PRAGMA busy_timeout = 50") + locker = _hold_sqlite_write_lock(db_path) + try: + await writer.write(_record(2)) + finally: + locker.rollback() + locker.close() + writer.close() + + summary = writer.get_summary() + stored_summary = json.loads(store.load_session_row(session_id)["summary_json"]) + assert summary["api_calls"] == 2 + assert summary["trace_storage_errors"] == 1 + assert summary["dropped_trace_records"] == 1 + assert stored_summary["trace_storage_errors"] == 1 + assert stored_summary["dropped_trace_records"] == 1 + assert store.load_records(session_id) == [_record(1)] + assert capsys.readouterr().err.count("continuing without blocking proxy") == 1 + + +def test_cross_process_writes_share_one_serialized_record_sequence(tmp_path: Path) -> None: + db_path = tmp_path / "multiprocess.sqlite3" + store = TraceStore(db_path) + session_id = store.create_session(client="codex", proxy_mode="reverse") + store.close() + + context = multiprocessing.get_context("spawn") + with ProcessPoolExecutor(max_workers=4, mp_context=context) as executor: + futures = [ + executor.submit(_append_records_in_process, str(db_path), session_id, worker * 10, 10) + for worker in range(4) + ] + assert [future.result(timeout=30) for future in futures] == [10, 10, 10, 10] + + reader = TraceStore(db_path) + records = reader.load_records(session_id) + assert len(records) == 40 + assert {record["request"]["body"]["input"] for record in records} == {f"lock test {index}" for index in range(40)} + + +def test_file_lock_failure_becomes_bounded_sqlite_error(tmp_path: Path, monkeypatch) -> None: + from claude_tap import trace_store + + store = TraceStore(tmp_path / "file-lock.sqlite3") + session_id = store.create_session(client="codex", proxy_mode="reverse") + monkeypatch.setattr(trace_store, "WRITE_LOCK_TIMEOUT_SECONDS", 0.02) + monkeypatch.setattr(trace_store, "WRITE_LOCK_RETRY_SECONDS", 0.001) + monkeypatch.setattr(trace_store, "_try_lock_file_exclusive", lambda _file: (_ for _ in ()).throw(OSError("busy"))) + + started = time.monotonic() + with pytest.raises(sqlite3.OperationalError, match="trace write lock unavailable"): + store.append_record(session_id, _record(1)) + assert time.monotonic() - started < 0.2 + + +@pytest.mark.asyncio +async def test_startup_lock_creates_non_blocking_writer(tmp_path: Path, capsys) -> None: + db_path = tmp_path / "startup.sqlite3" + setup = TraceStore(db_path) + setup.create_session(client="setup") + setup.close() + store = TraceStore(db_path) + locker = _hold_sqlite_write_lock(db_path) + started = time.monotonic() + try: + writer = _create_trace_writer( + store=store, + client="codex", + proxy_mode="reverse", + metadata={"client": "codex", "proxy_mode": "reverse"}, + ) + finally: + locker.rollback() + locker.close() + + assert time.monotonic() - started < 1.5 + await writer.write(_record(1)) + writer.close() + summary = writer.get_summary() + assert summary["api_calls"] == 1 + assert summary["trace_storage_errors"] == 2 + assert summary["dropped_trace_records"] == 1 + assert capsys.readouterr().err.count("continuing without blocking proxy") == 1 + + +@pytest.mark.asyncio +async def test_codex_app_registry_does_not_stop_on_startup_lock(tmp_path: Path, capsys) -> None: + db_path = tmp_path / "codex-app-startup.sqlite3" + setup = TraceStore(db_path) + setup.create_session(client="setup") + setup.close() + store = TraceStore(db_path) + registry = CodexAppTranscriptSessionRegistry( + store=store, + metadata={"client": "codexapp", "proxy_mode": "transcript"}, + ) + locker = _hold_sqlite_write_lock(db_path) + try: + await registry.write_next_turn(tmp_path / "rollout.jsonl", _record(1)) + finally: + locker.rollback() + locker.close() + registry.close() + + summary = registry.get_summary() + assert summary["api_calls"] == 1 + assert summary["trace_storage_errors"] == 2 + assert summary["dropped_trace_records"] == 1 + assert capsys.readouterr().err.count("continuing without blocking proxy") == 1 + + +def test_real_proxy_continues_when_database_is_locked_at_startup(tmp_path: Path) -> None: + stop_upstream, upstream_port = run_fake_upstream_in_thread() + trace_dir = tmp_path / "traces" + trace_dir.mkdir() + (trace_dir / "trace_legacy.jsonl").write_text(json.dumps(_record(0)) + "\n", encoding="utf-8") + db_path = trace_db_path(trace_dir) + setup = TraceStore(db_path) + setup.create_session(client="setup") + setup.close() + locker = _hold_sqlite_write_lock(db_path) + try: + process, _ = _run_locked_proxy_smoke(tmp_path, upstream_port, lock_during_request=False) + finally: + locker.rollback() + locker.close() + stop_upstream() + + assert process.returncode == 0, process.stderr + assert "API calls: 1" in process.stdout + assert "legacy trace migration skipped" in process.stderr + assert "continuing without blocking proxy" in process.stderr + + +def test_real_proxy_continues_when_database_locks_during_request(tmp_path: Path) -> None: + stop_upstream, upstream_port = run_fake_upstream_in_thread() + try: + process, db_path = _run_locked_proxy_smoke(tmp_path, upstream_port, lock_during_request=True) + finally: + stop_upstream() + + assert process.returncode == 0, process.stderr + assert "API calls: 1" in process.stdout + assert "Trace storage errors: 1" in process.stdout + assert "Dropped trace records: 1" in process.stdout + assert "continuing without blocking proxy" in process.stderr + + conn = sqlite3.connect(db_path) + try: + row = conn.execute("SELECT status, summary_json FROM sessions WHERE client = 'claude'").fetchone() + finally: + conn.close() + assert row is not None + summary = json.loads(row[1]) + assert row[0] == "complete" + assert summary["trace_storage_errors"] == 1 + assert summary["dropped_trace_records"] == 1 diff --git a/tests/test_translate_i18n.py b/tests/test_translate_i18n.py new file mode 100644 index 0000000..c7d9623 --- /dev/null +++ b/tests/test_translate_i18n.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +SCRIPT_PATH = Path(__file__).parent.parent / "scripts" / "translate_i18n.py" +SPEC = importlib.util.spec_from_file_location("translate_i18n", SCRIPT_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +SAMPLE_SOURCE = """ +const I18N = { + en: { + title: "Trace Viewer", + copy: "Copy", + refresh: "Refresh", + }, + "zh-CN": { + title: "追踪查看器", + copy: "复制", + refresh: "刷新", + }, + ja: { + title: "トレースビューア", + copy: "コピー", + }, + ko: { + title: "트레이스 뷰어", + copy: "복사", + }, + fr: { + title: "Visionneuse de traces", + copy: "Copier", + }, + ar: { + title: "عارض التتبع", + copy: "نسخ", + }, + de: { + title: "Trace-Viewer", + copy: "Kopieren", + }, + ru: { + title: "Просмотр трассировки", + copy: "Копировать", + }, +}; +""" + + +def test_find_missing_keys_from_en_and_zh_cn_intersection() -> None: + _, _, entries = MODULE.collect_i18n_data(SAMPLE_SOURCE, "I18N") + + missing = MODULE.find_missing_keys(entries, MODULE.LANG_ORDER) + + assert missing == { + "ja": ["refresh"], + "ko": ["refresh"], + "fr": ["refresh"], + "ar": ["refresh"], + "de": ["refresh"], + "ru": ["refresh"], + } + + +def test_apply_translations_to_source_inserts_without_reformatting() -> None: + updates = { + "ja": {"refresh": "更新"}, + "de": {"refresh": "Aktualisieren"}, + } + + updated = MODULE.apply_translations_to_source(SAMPLE_SOURCE, "I18N", updates) + + assert ' refresh: "更新",' in updated + assert ' refresh: "Aktualisieren",' in updated + assert 'title: "Trace Viewer"' in updated + assert 'copy: "Copy"' in updated + + +def test_find_missing_keys_keeps_en_block_order() -> None: + source = """ +const I18N = { + en: { + a: "A", + z: "Z", + b: "B", + }, + "zh-CN": { + a: "甲", + z: "乙", + b: "丙", + }, + ja: { + a: "エー", + }, +}; +""" + _, _, entries = MODULE.collect_i18n_data(source, "I18N") + missing = MODULE.find_missing_keys(entries, ["ja"]) + assert missing["ja"] == ["z", "b"] + + +def test_apply_translations_preserves_packed_line_style() -> None: + source = """ +const I18N = { + ja: { + title: "トレースビューア", copy: "コピー", + }, +}; +""" + updates = {"ja": {"diff_select_target": "比較対象:", "diff_select_auto": "自動"}} + updated = MODULE.apply_translations_to_source(source, "I18N", updates) + assert ' title: "トレースビューア", copy: "コピー",' in updated + assert ' diff_select_target: "比較対象:", diff_select_auto: "自動",' in updated + + +def test_request_openrouter_translation_adds_fullwidth_instruction_for_ja(monkeypatch) -> None: + captured_payload: dict[str, object] = {} + + class FakeResponse: + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + def read(self) -> bytes: + body = {"choices": [{"message": {"content": '{"diff_select_target":"比較対象:"}'}}]} + return json.dumps(body).encode("utf-8") + + def fake_urlopen(request, timeout=90): # type: ignore[no-untyped-def] + del timeout + captured_payload.update(json.loads(request.data.decode("utf-8"))) + return FakeResponse() + + monkeypatch.setattr(MODULE, "urlopen", fake_urlopen) + result = MODULE.request_openrouter_translation( + api_key="test-key", + model="test-model", + lang="ja", + keys=["diff_select_target"], + en_map={"diff_select_target": "Compare against:"}, + zh_map={"diff_select_target": "对比对象:"}, + existing_lang_map={"diff_select_target": "比較対象:", "copied": "コピー済み!"}, + ) + + assert result["diff_select_target"] == "比較対象:" + user_prompt = json.loads(captured_payload["messages"][1]["content"]) + assert any("fullwidth punctuation style" in req for req in user_prompt["requirements"]) + assert user_prompt["fullwidth_punctuation_examples"]["target_existing"]["diff_select_target"] == "比較対象:" + assert user_prompt["fullwidth_punctuation_examples"]["zh-CN_reference"]["diff_select_target"] == "对比对象:" + + +def test_normalize_punctuation_style_for_ko_uses_zh_cn_reference() -> None: + normalized = MODULE.normalize_punctuation_style( + lang="ko", + translations={"diff_select_target": "비교 대상:", "copied": "복사됨!"}, + zh_map={"diff_select_target": "对比对象:", "copied": "已复制!"}, + ) + assert normalized["diff_select_target"] == "비교 대상:" + assert normalized["copied"] == "복사됨!" + + +def test_main_dry_run_exits_without_api_key(tmp_path: Path, capsys) -> None: + test_file = tmp_path / "viewer.html" + test_file.write_text(SAMPLE_SOURCE, encoding="utf-8") + + code = MODULE.main(["--dry-run", "--file", str(test_file), "--object-name", "I18N"]) + out = capsys.readouterr().out + + assert code == 0 + assert "Dry run: missing keys that would be translated" in out + assert "- ja: planned 1 key(s)" in out + assert test_file.read_text(encoding="utf-8") == SAMPLE_SOURCE + + +def test_main_dry_run_supports_json_i18n_source(tmp_path: Path, capsys) -> None: + entries = { + "en": {"title": "Trace Viewer", "copy": "Copy", "refresh": "Refresh"}, + "zh-CN": {"title": "追踪查看器", "copy": "复制", "refresh": "刷新"}, + "ja": {"title": "トレースビューア", "copy": "コピー"}, + } + test_file = tmp_path / "viewer_i18n.json" + original = json.dumps(entries, ensure_ascii=False, indent=2) + "\n" + test_file.write_text(original, encoding="utf-8") + + code = MODULE.main(["--dry-run", "--file", str(test_file)]) + out = capsys.readouterr().out + + assert code == 0 + assert "Dry run: missing keys that would be translated" in out + assert "- ja: planned 1 key(s)" in out + assert " - refresh" in out + assert test_file.read_text(encoding="utf-8") == original + + +def test_apply_translations_to_json_entries_updates_existing_languages_only() -> None: + entries = { + "en": {"title": "Trace Viewer"}, + "zh-CN": {"title": "追踪查看器"}, + "ja": {"title": "トレースビューア"}, + } + + updated = MODULE.apply_translations_to_json_entries( + entries, + { + "ja": {"refresh": "更新"}, + "missing-lang": {"refresh": "ignored"}, + }, + ) + + assert updated["ja"] == {"title": "トレースビューア", "refresh": "更新"} + assert "missing-lang" not in updated + assert entries["ja"] == {"title": "トレースビューア"} + + +def test_load_i18n_json_rejects_non_string_values(tmp_path: Path) -> None: + test_file = tmp_path / "bad_i18n.json" + test_file.write_text(json.dumps({"en": {"title": 123}, "zh-CN": {"title": "追踪查看器"}}), encoding="utf-8") + + try: + MODULE.load_i18n_json(test_file) + except ValueError as exc: + assert "string keys and values" in str(exc) + else: + raise AssertionError("Expected invalid i18n JSON to be rejected") diff --git a/tests/test_update_changelog.py b/tests/test_update_changelog.py new file mode 100644 index 0000000..edefadf --- /dev/null +++ b/tests/test_update_changelog.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/update_changelog.py.""" + +from __future__ import annotations + +import datetime as dt +import importlib.util +import sys +from pathlib import Path + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "update_changelog.py" +MODULE_NAME = "update_changelog" + + +def _load_module(): + spec = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +def test_render_release_section_uses_commit_subjects() -> None: + module = _load_module() + + section = module.render_release_section( + "0.1.40", + dt.date(2026, 5, 3), + ["fix: derive release versions from git tags", "docs: update changelog"], + ) + + assert "## [0.1.40] - 2026-05-03" in section + assert "- fix: derive release versions from git tags" in section + assert "- docs: update changelog" in section + + +def test_render_release_section_handles_empty_subjects() -> None: + module = _load_module() + + section = module.render_release_section("0.1.40", dt.date(2026, 5, 3), []) + + assert "- Maintenance release." in section + + +def test_insert_release_section_after_unreleased() -> None: + module = _load_module() + changelog = "# Changelog\n\n## [Unreleased]\n\n## [0.1.39] - 2026-05-02\n" + + updated = module.insert_release_section(changelog, "## [0.1.40] - 2026-05-03\n\n### Changed\n- test\n") + + assert updated.index("## [Unreleased]") < updated.index("## [0.1.40]") + assert updated.index("## [0.1.40]") < updated.index("## [0.1.39]") diff --git a/tests/test_update_command.py b/tests/test_update_command.py new file mode 100644 index 0000000..c735b8c --- /dev/null +++ b/tests/test_update_command.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import subprocess +import sys + +import pytest + +from claude_tap.cli import ( + _build_update_command, + _detect_installer, + main_entry, + parse_update_args, + update_main, +) + + +def test_detect_installer_uses_uv_tool_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "claude_tap.cli_update.sys.executable", + r"C:\Users\alice\AppData\Roaming\uv\data\tools\claude-tap\Scripts\python.exe", + ) + monkeypatch.setattr("claude_tap.cli_update.sys.platform", "win32") + monkeypatch.setattr("claude_tap.cli_update.shutil.which", lambda _name: r"C:\tools\uv.exe") + + assert _detect_installer() == "uv" + + +def test_detect_installer_honors_custom_uv_tool_dir(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "claude_tap.cli_update.sys.executable", + r"D:\managed-tools\claude-tap\Scripts\python.exe", + ) + monkeypatch.setenv("UV_TOOL_DIR", r"D:\managed-tools") + monkeypatch.setattr("claude_tap.cli_update.sys.platform", "win32") + monkeypatch.setattr("claude_tap.cli_update.shutil.which", lambda _name: None) + + assert _detect_installer() == "uv" + + +def test_detect_installer_does_not_treat_uv_on_path_as_windows_uv_install( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("claude_tap.cli_update.sys.executable", r"C:\Python312\python.exe") + monkeypatch.setattr("claude_tap.cli_update.sys.platform", "win32") + monkeypatch.setattr("claude_tap.cli_update.shutil.which", lambda _name: r"C:\tools\uv.exe") + + assert _detect_installer() == "pip" + + +def test_detect_installer_keeps_non_windows_uv_path_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("claude_tap.cli_update.sys.executable", "/usr/bin/python3") + monkeypatch.setattr("claude_tap.cli_update.sys.platform", "linux") + monkeypatch.setattr("claude_tap.cli_update.shutil.which", lambda _name: "/usr/bin/uv") + + assert _detect_installer() == "uv" + + +def test_build_update_command_uses_uv_shim(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda name: "/tmp/uv" if name == "uv" else None) + + assert _build_update_command("uv") == ["/tmp/uv", "tool", "upgrade", "claude-tap"] + + +def test_build_update_command_returns_none_when_uv_missing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _name: None) + + assert _build_update_command("uv") is None + + +def test_build_update_command_uses_current_python_for_pip() -> None: + assert _build_update_command("pip") == [sys.executable, "-m", "pip", "install", "--upgrade", "claude-tap"] + + +def test_parse_update_args_defaults_to_auto() -> None: + args = parse_update_args([]) + + assert args.installer == "auto" + assert args.dry_run is False + + +def test_update_main_dry_run_prints_command( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda name: "/tmp/uv" if name == "uv" else None) + + assert update_main(["--installer", "uv", "--dry-run"]) == 0 + + out = capsys.readouterr().out + assert "Upgrading claude-tap with uv" in out + assert "/tmp/uv tool upgrade claude-tap" in out + + +def test_update_main_runs_selected_command(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return subprocess.CompletedProcess(cmd, 7) + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert update_main(["--installer", "pip"]) == 7 + assert captured["cmd"] == [sys.executable, "-m", "pip", "install", "--upgrade", "claude-tap"] + assert captured["kwargs"] == {"check": False} + + +def test_update_main_hides_windows_console(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + class FakeStartupInfo: + def __init__(self) -> None: + self.dwFlags = 0 + self.wShowWindow: int | None = None + + def fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return subprocess.CompletedProcess(cmd, 0) + + monkeypatch.setattr("claude_tap.cli_update.sys.platform", "win32") + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(subprocess, "CREATE_NO_WINDOW", 0x1000, raising=False) + monkeypatch.setattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x2000, raising=False) + monkeypatch.setattr(subprocess, "STARTF_USESHOWWINDOW", 0x4000, raising=False) + monkeypatch.setattr(subprocess, "SW_HIDE", 0, raising=False) + monkeypatch.setattr(subprocess, "STARTUPINFO", FakeStartupInfo, raising=False) + + assert update_main(["--installer", "pip"]) == 0 + + assert captured["cmd"] == [sys.executable, "-m", "pip", "install", "--upgrade", "claude-tap"] + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["check"] is False + assert kwargs["stdin"] == subprocess.DEVNULL + assert kwargs["creationflags"] == 0x1000 | 0x2000 + startupinfo = kwargs["startupinfo"] + assert isinstance(startupinfo, FakeStartupInfo) + assert startupinfo.dwFlags == 0x4000 + assert startupinfo.wShowWindow == 0 + + +def test_update_main_reports_missing_uv(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _name: None) + + assert update_main(["--installer", "uv"]) == 1 + + err = capsys.readouterr().err + assert "uv" in err + assert "--installer pip" in err + + +def test_main_entry_routes_update_subcommand(monkeypatch: pytest.MonkeyPatch) -> None: + called: dict[str, object] = {} + + def fake_update_main(argv): + called["argv"] = argv + return 3 + + monkeypatch.setattr(sys, "argv", ["claude-tap", "update", "--installer", "pip"]) + monkeypatch.setattr("claude_tap.cli.update_main", fake_update_main) + + with pytest.raises(SystemExit) as excinfo: + main_entry() + + assert excinfo.value.code == 3 + assert called["argv"] == ["--installer", "pip"] diff --git a/tests/test_upstream.py b/tests/test_upstream.py new file mode 100644 index 0000000..4b00510 --- /dev/null +++ b/tests/test_upstream.py @@ -0,0 +1,92 @@ +"""Tests for upstream URL construction and connection diagnostics.""" + +from __future__ import annotations + +import ssl + +import pytest + +from claude_tap.upstream import ( + KNOWN_UPSTREAM_ENDPOINT_PATHS, + build_upstream_url, + format_upstream_error, + is_ssl_certificate_error, +) + + +@pytest.mark.parametrize("endpoint", KNOWN_UPSTREAM_ENDPOINT_PATHS) +def test_build_upstream_url_avoids_duplicate_known_endpoint(endpoint: str) -> None: + url = build_upstream_url(f"https://gateway.example/proxy{endpoint}", endpoint) + + assert url == f"https://gateway.example/proxy{endpoint}" + + +@pytest.mark.parametrize( + ("versioned_endpoint", "short_endpoint"), + [ + ("/v1/chat/completions", "/chat/completions"), + ("/v1/messages", "/messages"), + ("/v1/responses", "/responses"), + ("/v1/completions", "/completions"), + ], +) +def test_build_upstream_url_allows_short_request_path_against_versioned_endpoint( + versioned_endpoint: str, short_endpoint: str +) -> None: + url = build_upstream_url(f"https://gateway.example/proxy{versioned_endpoint}", f"{short_endpoint}?stream=true") + + assert url == f"https://gateway.example/proxy{versioned_endpoint}?stream=true" + + +def test_build_upstream_url_avoids_duplicate_messages_endpoint() -> None: + url = build_upstream_url("http://example.com/proxy/v1/messages", "/v1/messages") + + assert url == "http://example.com/proxy/v1/messages" + + +def test_build_upstream_url_avoids_duplicate_messages_subpath_and_query() -> None: + url = build_upstream_url("http://example.com/proxy/v1/messages", "/v1/messages/count_tokens?beta=true") + + assert url == "http://example.com/proxy/v1/messages/count_tokens?beta=true" + + +def test_build_upstream_url_allows_short_endpoint_path_against_versioned_target() -> None: + url = build_upstream_url("http://example.com/proxy/v1/messages", "/messages?stream=true") + + assert url == "http://example.com/proxy/v1/messages?stream=true" + + +def test_build_upstream_url_keeps_normal_base_url_join() -> None: + url = build_upstream_url("https://api.moonshot.ai/v1", "/chat/completions") + + assert url == "https://api.moonshot.ai/v1/chat/completions" + + +def test_build_upstream_url_preserves_endpoint_base_for_openai_style_target() -> None: + url = build_upstream_url("https://gateway.example/v1/chat/completions", "/chat/completions") + + assert url == "https://gateway.example/v1/chat/completions" + + +def test_format_upstream_error_adds_ssl_certificate_diagnostics() -> None: + exc = ssl.SSLCertVerificationError("certificate verify failed: unable to get local issuer certificate") + + text = format_upstream_error( + exc, + target_url="https://user:secret@gateway.example/v1/messages", + upstream_url="https://user:secret@gateway.example/v1/messages", + ) + + assert is_ssl_certificate_error(exc) + assert "SSL_CERT_FILE" in text + assert "provider base URL" in text + assert "user:secret" not in text + assert "***@gateway.example" in text + + +def test_format_upstream_error_keeps_non_ssl_error_short() -> None: + exc = ConnectionError("connection refused") + + assert format_upstream_error(exc, target_url="http://example.test", upstream_url="http://example.test") == ( + "connection refused" + ) diff --git a/tests/test_usage.py b/tests/test_usage.py new file mode 100644 index 0000000..765fc0b --- /dev/null +++ b/tests/test_usage.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import pytest + +from claude_tap.trace import TraceWriter +from claude_tap.usage import normalize_usage + + +def test_normalize_usage_maps_responses_cached_tokens() -> None: + usage = normalize_usage( + { + "input_tokens": 11767, + "input_tokens_details": {"cached_tokens": 11648}, + "output_tokens": 6, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 11773, + } + ) + + assert usage["input_tokens"] == 11767 + assert usage["output_tokens"] == 6 + assert usage["cache_read_input_tokens"] == 11648 + + +def test_normalize_usage_maps_chat_completion_cached_tokens() -> None: + usage = normalize_usage( + { + "prompt_tokens": 8, + "completion_tokens": 5, + "prompt_tokens_details": {"cached_tokens": 3}, + "total_tokens": 13, + } + ) + + assert usage["input_tokens"] == 8 + assert usage["output_tokens"] == 5 + assert usage["cache_read_input_tokens"] == 3 + + +def test_normalize_usage_prefers_openai_tokens_over_zero_aliases() -> None: + usage = normalize_usage( + { + "prompt_tokens": 743, + "completion_tokens": 95, + "total_tokens": 838, + "prompt_tokens_details": {"cached_tokens": 0}, + "input_tokens": 0, + "output_tokens": 0, + } + ) + + assert usage["input_tokens"] == 743 + assert usage["output_tokens"] == 95 + assert usage["total_tokens"] == 838 + assert usage["cache_read_input_tokens"] == 0 + + +def test_normalize_usage_maps_bedrock_converse_tokens() -> None: + usage = normalize_usage( + { + "inputTokens": 12, + "outputTokens": 3, + "totalTokens": 15, + "cacheReadInputTokens": 7, + "cacheWriteInputTokens": 5, + } + ) + + assert usage["input_tokens"] == 12 + assert usage["output_tokens"] == 3 + assert usage["total_tokens"] == 15 + assert usage["cache_read_input_tokens"] == 7 + assert usage["cache_creation_input_tokens"] == 5 + + +def test_normalize_usage_preserves_explicit_anthropic_cache_read() -> None: + usage = normalize_usage( + { + "input_tokens": 10, + "output_tokens": 2, + "cache_read_input_tokens": 4, + "input_tokens_details": {"cached_tokens": 9}, + } + ) + + assert usage["cache_read_input_tokens"] == 4 + + +def test_normalize_usage_drops_null_token_fields_before_alias_mapping() -> None: + usage = normalize_usage( + { + "input_tokens": None, + "output_tokens": None, + "prompt_tokens": 8, + "completion_tokens": 5, + "cache_creation_input_tokens": None, + } + ) + + assert usage["input_tokens"] == 8 + assert usage["output_tokens"] == 5 + assert "cache_creation_input_tokens" not in usage + + +@pytest.mark.asyncio +async def test_trace_writer_counts_responses_cached_tokens(trace_db) -> None: + from claude_tap.trace_store import get_trace_store + + session_id = get_trace_store().create_session() + writer = TraceWriter(session_id) + try: + await writer.write( + { + "request": {"body": {"model": "gpt-5.4"}}, + "response": { + "body": { + "usage": { + "input_tokens": 11767, + "input_tokens_details": {"cached_tokens": 11648}, + "output_tokens": 6, + } + } + }, + } + ) + + summary = writer.get_summary() + assert summary["input_tokens"] == 11767 + assert summary["output_tokens"] == 6 + assert summary["cache_read_tokens"] == 11648 + finally: + writer.close() diff --git a/tests/test_verify_screenshots.py b/tests/test_verify_screenshots.py new file mode 100644 index 0000000..866bf6c --- /dev/null +++ b/tests/test_verify_screenshots.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/verify_screenshots.py.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +from claude_tap.viewer import _generate_html_viewer + +pytest.importorskip("playwright.sync_api") + +SCRIPT_PATH = Path(__file__).resolve().parent.parent / "scripts" / "verify_screenshots.py" +MODULE_NAME = "verify_screenshots" + + +def _load_module(): + spec = importlib.util.spec_from_file_location(MODULE_NAME, SCRIPT_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +def test_verify_viewer_html_accepts_explicit_empty_trace_state(tmp_path: Path) -> None: + module = _load_module() + trace_path = tmp_path / "empty.jsonl" + html_path = tmp_path / "empty.html" + trace_path.write_text("", encoding="utf-8") + _generate_html_viewer(trace_path, html_path) + + issues = module.verify_viewer_html(str(html_path)) + + assert issues == [] + + +def test_verify_viewer_html_still_rejects_unloaded_file_picker(tmp_path: Path) -> None: + module = _load_module() + html_path = tmp_path / "viewer.html" + html_path.write_text((Path(__file__).resolve().parent.parent / "claude_tap" / "viewer.html").read_text()) + + issues = module.verify_viewer_html(str(html_path)) + + assert "No sidebar entries — viewer empty" in issues diff --git a/tests/test_viewer_contracts.py b/tests/test_viewer_contracts.py new file mode 100644 index 0000000..b8a1233 --- /dev/null +++ b/tests/test_viewer_contracts.py @@ -0,0 +1,3401 @@ +"""Cross-client contract tests for the self-contained HTML viewer. + +These tests intentionally exercise viewer.html through a real browser instead +of only checking generated markup. The goal is to keep core semantic sections +stable across supported trace shapes whenever the large inline JS file changes. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + +from claude_tap.compact_trace import build_compact_trace_bundle +from claude_tap.viewer import _generate_html_viewer, _generate_html_viewer_from_compact_bundle, _read_viewer_template + +pw_missing = False +try: + from playwright.sync_api import Page, sync_playwright # noqa: F401 +except ImportError: + pw_missing = True + Page = Any # type: ignore[assignment,misc] + +pytestmark = pytest.mark.skipif(pw_missing, reason="playwright not installed") + + +@dataclass(frozen=True) +class ViewerContractCase: + name: str + records: tuple[dict[str, Any], ...] + expected_sections: tuple[str, ...] + expected_system: str | None + expected_roles: tuple[str, ...] + expected_tools: tuple[str, ...] + expected_output_types: tuple[str, ...] + expected_usage: dict[str, int] + required_detail_text: tuple[str, ...] + min_stream_events: int = 0 + entry_index: int = 0 + expected_sidebar_label: str | None = None + + +def _sse_frame(payload: dict[str, Any]) -> str: + return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + + +def _anthropic_messages_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T13:20:00+00:00", + "request_id": "req_anthropic_contract", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {}, + "body": { + "model": "claude-opus-4-6", + "system": "Claude Code contract system prompt.", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "Read pyproject.toml."}]}, + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_read", + "name": "Read", + "input": {"file_path": "pyproject.toml"}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_read", + "content": "project metadata", + } + ], + }, + ], + "tools": [ + { + "name": "Read", + "description": "Read a file.", + "input_schema": { + "type": "object", + "properties": {"file_path": {"type": "string"}}, + "required": ["file_path"], + }, + } + ], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "content": [{"type": "text", "text": "Anthropic response OK."}], + "usage": {"input_tokens": 120, "output_tokens": 9, "cache_read_input_tokens": 40}, + }, + }, + } + + +def _responses_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T13:21:00+00:00", + "request_id": "req_responses_contract", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": {}, + "body": { + "model": "gpt-5.4", + "instructions": "You are Codex contract system prompt.", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Run pwd."}], + } + ], + "tools": [{"type": "function", "name": "exec_command", "description": "Runs a command."}], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "output": [ + { + "type": "function_call", + "name": "exec_command", + "arguments": '{"cmd":"pwd"}', + "call_id": "call_pwd", + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Responses final OK."}], + }, + ], + "usage": { + "input_tokens": 130, + "output_tokens": 14, + "input_tokens_details": {"cached_tokens": 50}, + }, + }, + }, + } + + +def _responses_empty_input_record() -> dict[str, Any]: + record = _responses_record() + record["request_id"] = "req_responses_empty_input" + record["request"]["body"]["input"] = [] + record["response"]["body"]["output"] = [] + return record + + +def _codex_websocket_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T13:22:00+00:00", + "request_id": "req_codex_ws_contract", + "turn": "1.2", + "duration_ms": 100, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/backend-api/codex/responses", + "headers": {}, + "body": { + "type": "response.create", + "model": "gpt-5.5", + "instructions": "You are Codex WebSocket contract system prompt.", + "input": [ + { + "type": "message", + "role": "developer", + "content": [{"type": "input_text", "text": "Follow repository rules."}], + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Continue after a tool call."}], + }, + ], + "tools": [{"type": "function", "name": "exec_command", "description": "Runs a command."}], + "stream": True, + }, + }, + "response": { + "status": 101, + "headers": {}, + "body": None, + "ws_events": [ + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "name": "exec_command", + "arguments": '{"cmd":"pwd"}', + "call_id": "call_pwd", + }, + }, + { + "type": "response.output_item.done", + "output_index": 1, + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "WebSocket final OK."}], + }, + }, + { + "type": "response.completed", + "response": { + "usage": {"input_tokens": 140, "output_tokens": 8, "total_tokens": 148}, + "output": [], + }, + }, + ], + }, + } + + +def _content_block_boundary_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-24T07:20:00+00:00", + "request_id": "req_content_block_boundary_contract", + "turn": 99, + "duration_ms": 120, + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": {}, + "body": { + "model": "zz-content-block-boundary", + "system": [ + {"type": "text", "text": "System block one."}, + {"type": "text", "text": "System block two."}, + ], + "input": [ + { + "type": "message", + "role": "developer", + "content": [ + {"type": "input_text", "text": "Developer block one."}, + {"type": "input_text", "text": "Developer block two."}, + ], + }, + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "User block one."}, + {"type": "input_text", "text": "User block two."}, + { + "type": "input_image", + "image_url": ( + "data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAEklEQVR4nGPQb3j7Hx9mGBkKAKVjpsHoKJzJAAAAAElFTkSuQmCC" + ), + "detail": "high", + }, + ], + }, + { + "type": "message", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "Assistant text block."}, + {"type": "tool_use", "name": "read_file", "input": {"path": "README.md"}}, + ], + }, + { + "type": "message", + "role": "tool", + "content": [ + {"type": "tool_result", "tool_use_id": "call_one", "content": "Tool result one."}, + {"type": "tool_result", "tool_use_id": "call_two", "content": "Tool result two."}, + ], + }, + ], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "content": [{"type": "text", "text": "Content block response OK."}], + "usage": {"input_tokens": 42, "output_tokens": 5}, + }, + }, + } + + +def _codex_reverse_websocket_record() -> dict[str, Any]: + record = _codex_websocket_record() + record["request_id"] = "req_codex_reverse_ws_contract" + record["request"]["path"] = "/v1/responses" + return record + + +def _chat_completions_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T13:23:00+00:00", + "request_id": "req_chat_contract", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/chat/completions", + "headers": {}, + "body": { + "model": "kimi-k2-turbo-preview", + "messages": [ + {"role": "system", "content": "Kimi contract system prompt."}, + {"role": "user", "content": "Read the project metadata."}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_read", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path":"pyproject.toml"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_read", "content": "project metadata"}, + ], + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read a file.", + "parameters": { + "type": "object", + "properties": {"path": {"type": "string"}}, + }, + }, + } + ], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "id": "chatcmpl-contract", + "object": "chat.completion", + "model": "kimi-k2-turbo-preview", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "reasoning_content": "Need to inspect metadata before answering.", + "tool_calls": [ + { + "id": "call_response_read", + "type": "function", + "function": { + "name": "inspect_metadata", + "arguments": '{"path":"pyproject.toml"}', + }, + } + ], + }, + "finish_reason": "tool_calls", + }, + { + "index": 1, + "message": {"role": "assistant", "content": "Chat final OK."}, + "finish_reason": "stop", + }, + ], + "usage": {"prompt_tokens": 150, "completion_tokens": 10, "cached_tokens": 70}, + }, + }, + } + + +def _opencode_chat_completions_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T16:11:10+00:00", + "request_id": "req_opencode_real_shape_contract", + "turn": 2, + "duration_ms": 1800, + "request": { + "method": "POST", + "path": "/zen/v1/chat/completions", + "headers": {"Host": "opencode.ai"}, + "body": { + "model": "deepseek-v4-flash-free", + "messages": [ + { + "role": "system", + "content": ( + "You are opencode, an interactive CLI tool that helps users " + "with software engineering tasks." + ), + }, + { + "role": "user", + "content": "Run printf OPENCODE_TOOL_ONE and pwd.", + }, + { + "role": "assistant", + "content": "", + "reasoning_content": "The user wants me to run a specific command.", + "tool_calls": [ + { + "id": "call_one", + "type": "function", + "function": { + "name": "bash", + "arguments": ( + '{"command":"printf \'OPENCODE_TOOL_ONE\\\\n\'; pwd",' + '"description":"Run printf and pwd"}' + ), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_one", + "content": "OPENCODE_TOOL_ONE\n/home/liaohch3/src/github.com/liaohch3/claude-tap-3\n", + }, + { + "role": "assistant", + "content": "OPENCODE_TOOL_ONE\n/home/liaohch3/src/github.com/liaohch3/claude-tap-3", + }, + { + "role": "user", + "content": "Second turn: run printf OPENCODE_TOOL_TWO and ls pyproject.toml.", + }, + { + "role": "assistant", + "content": "", + "reasoning_content": "The user wants a second command and the previous path.", + "tool_calls": [ + { + "id": "call_two", + "type": "function", + "function": { + "name": "bash", + "arguments": ( + '{"command":"printf \'OPENCODE_TOOL_TWO\\\\n\'; ls pyproject.toml",' + '"description":"Run printf and ls"}' + ), + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_two", + "content": "OPENCODE_TOOL_TWO\npyproject.toml\n", + }, + ], + "tools": [ + { + "type": "function", + "function": { + "name": "bash", + "description": "Executes a given bash command.", + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string"}, + "description": {"type": "string"}, + }, + "required": ["command"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "edit", + "description": "Edit a file.", + "parameters": { + "type": "object", + "properties": {"filePath": {"type": "string"}}, + }, + }, + }, + ], + "stream": True, + "stream_options": {"include_usage": True}, + "tool_choice": "auto", + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "object": "chat.completion", + "model": "deepseek-v4-flash", + "content": [ + { + "type": "thinking", + "thinking": "The user wants both the path from the previous output and the new output.", + }, + { + "type": "text", + "text": ( + "Path from OPENCODE_TOOL_ONE: " + "`/home/liaohch3/src/github.com/liaohch3/claude-tap-3`\n\n" + "New command output:\n```\nOPENCODE_TOOL_TWO\npyproject.toml\n```" + ), + }, + ], + "usage": { + "prompt_tokens": 14070, + "completion_tokens": 109, + "total_tokens": 14179, + "prompt_tokens_details": {"cached_tokens": 13952}, + "completion_tokens_details": {"reasoning_tokens": 57}, + "input_tokens": 14070, + "output_tokens": 109, + "cache_read_input_tokens": 13952, + }, + }, + "sse_events": [ + { + "event": "message", + "data": { + "choices": [ + { + "delta": { + "reasoning_content": ( + "The user wants both the path from the previous output and the new output." + ) + } + } + ] + }, + }, + { + "event": "message", + "data": { + "choices": [ + { + "delta": { + "content": ( + "Path from OPENCODE_TOOL_ONE: " + "`/home/liaohch3/src/github.com/liaohch3/claude-tap-3`" + ) + } + } + ] + }, + }, + ], + }, + } + + +def _opencode_openai_oauth_responses_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T16:45:43+00:00", + "request_id": "req_opencode_openai_oauth_responses_contract", + "turn": 3, + "duration_ms": 6106, + "request": { + "method": "POST", + "path": "/backend-api/codex/responses", + "headers": {"Host": "chatgpt.com", "User-Agent": "opencode/1.14.48"}, + "body": { + "model": "gpt-5.4-mini", + "instructions": ( + "You are OpenCode, You and the user share the same workspace " + "and collaborate to achieve the user's goals." + ), + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": ( + "Use the bash tool to run exactly: printf " + "'OPENCODE_OPENAI_OAUTH_TOOL_ONE\\n'; pwd ." + ), + } + ], + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "Running the requested shell command."}], + }, + { + "type": "function_call", + "call_id": "call_oauth_bash", + "name": "bash", + "arguments": ( + '{"command":"printf \'OPENCODE_OPENAI_OAUTH_TOOL_ONE\\\\n\'; pwd .",' + '"description":"Runs the requested command"}' + ), + }, + { + "type": "function_call_output", + "call_id": "call_oauth_bash", + "output": ( + "OPENCODE_OPENAI_OAUTH_TOOL_ONE\n/home/liaohch3/src/github.com/liaohch3/claude-tap-3\n" + ), + }, + ], + "tools": [ + {"type": "function", "name": "bash", "description": "Run a shell command."}, + {"type": "function", "name": "read", "description": "Read a file."}, + ], + "stream": True, + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": ( + "OPENCODE_OPENAI_OAUTH_TOOL_ONE\n" + "/home/liaohch3/src/github.com/liaohch3/claude-tap-3" + ), + } + ], + } + ], + "usage": { + "input_tokens": 12424, + "output_tokens": 115, + "input_tokens_details": {"cached_tokens": 11776}, + }, + }, + "sse_events": [ + { + "event": "response.output_item.done", + "data": { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": ( + "OPENCODE_OPENAI_OAUTH_TOOL_ONE\n" + "/home/liaohch3/src/github.com/liaohch3/claude-tap-3" + ), + } + ], + }, + }, + } + ], + }, + } + + +def _pi_openai_oauth_websocket_record() -> dict[str, Any]: + system_prompt = ( + "You are an expert coding assistant operating inside pi, a coding agent harness. " + "You help users by reading files, executing commands, editing code, and writing new files.\n\n" + "Available tools:\n- bash: Execute bash commands (ls, grep, find, etc.)" + ) + initial_request = { + "type": "response.create", + "model": "gpt-5.3-codex-spark", + "store": False, + "stream": True, + "instructions": system_prompt, + "input": [ + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Use the bash tool to run exactly: printf 'PI_TOOL_ONE\\n'; pwd.", + } + ], + } + ], + "tools": [{"type": "function", "name": "bash", "description": "Execute a bash command."}], + "reasoning": {"effort": "low", "summary": "auto"}, + "prompt_cache_key": "session-pi-contract", + "tool_choice": "auto", + "parallel_tool_calls": True, + } + continuation_request = { + **initial_request, + "previous_response_id": "resp_pi_tool_call", + "input": [ + { + "type": "function_call_output", + "call_id": "call_pi_bash", + "output": "PI_TOOL_ONE\n/home/liaohch3/src/github.com/liaohch3/claude-tap-3\n", + } + ], + } + return { + "timestamp": "2026-05-14T07:47:21+00:00", + "request_id": "req_pi_openai_oauth_ws_contract", + "turn": 1, + "duration_ms": 2400, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/backend-api/codex/responses", + "headers": {"Host": "chatgpt.com", "User-Agent": "pi (browser)", "originator": "pi"}, + "body": continuation_request, + "ws_events": [initial_request, continuation_request], + }, + "response": { + "status": 101, + "headers": {}, + "body": { + "id": "resp_pi_final", + "status": "completed", + "instructions": system_prompt, + "model": "gpt-5.3-codex-spark", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "PI_TOOL_ONE\n/home/liaohch3/src/github.com/liaohch3/claude-tap-3", + } + ], + } + ], + "usage": {"input_tokens": 655, "output_tokens": 29, "total_tokens": 684}, + }, + "ws_events": [ + { + "type": "response.created", + "response": { + "id": "resp_pi_tool_call", + "status": "in_progress", + "instructions": system_prompt, + "model": "gpt-5.3-codex-spark", + "previous_response_id": None, + }, + }, + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "name": "bash", + "call_id": "call_pi_bash", + "arguments": '{"command":"printf \'PI_TOOL_ONE\\n\'; pwd"}', + }, + }, + { + "type": "response.completed", + "response": { + "id": "resp_pi_tool_call", + "status": "completed", + "instructions": system_prompt, + "model": "gpt-5.3-codex-spark", + "previous_response_id": None, + "output": [ + { + "type": "function_call", + "name": "bash", + "call_id": "call_pi_bash", + "arguments": '{"command":"printf \'PI_TOOL_ONE\\n\'; pwd"}', + } + ], + "usage": {"input_tokens": 530, "output_tokens": 88, "total_tokens": 618}, + }, + }, + { + "type": "response.created", + "response": { + "id": "resp_pi_final", + "status": "in_progress", + "instructions": system_prompt, + "model": "gpt-5.3-codex-spark", + "previous_response_id": "resp_pi_tool_call", + }, + }, + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "PI_TOOL_ONE\n/home/liaohch3/src/github.com/liaohch3/claude-tap-3", + } + ], + }, + }, + { + "type": "response.completed", + "response": { + "id": "resp_pi_final", + "status": "completed", + "instructions": system_prompt, + "model": "gpt-5.3-codex-spark", + "previous_response_id": "resp_pi_tool_call", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "PI_TOOL_ONE\n/home/liaohch3/src/github.com/liaohch3/claude-tap-3", + } + ], + } + ], + "usage": {"input_tokens": 655, "output_tokens": 29, "total_tokens": 684}, + }, + }, + ], + }, + } + + +def _gemini_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T13:24:00+00:00", + "request_id": "req_gemini_contract", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/v1internal:streamGenerateContent?alt=sse", + "headers": {"Host": "cloudcode-pa.googleapis.com"}, + "body": { + "model": "gemini-3-flash-preview", + "request": { + "systemInstruction": { + "role": "user", + "parts": [{"text": "You are Gemini CLI contract system prompt."}], + }, + "contents": [ + {"role": "user", "parts": [{"text": "Use shell to inspect the workspace."}]}, + { + "role": "model", + "parts": [{"functionCall": {"name": "run_shell_command", "args": {"command": "pwd"}}}], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "id": "run_shell_command_1", + "name": "run_shell_command", + "response": {"output": "Output: /repo\nProcess Group PGID: 123"}, + } + } + ], + }, + ], + "tools": [ + { + "functionDeclarations": [ + { + "name": "run_shell_command", + "description": "Runs a shell command.", + "parametersJsonSchema": { + "type": "object", + "properties": {"command": {"type": "string"}}, + }, + } + ] + } + ], + }, + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": ( + _sse_frame( + { + "response": { + "candidates": [ + { + "content": { + "role": "model", + "parts": [ + {"thought": True, "text": "I should run the command."}, + {"functionCall": {"name": "run_shell_command", "args": {"command": "pwd"}}}, + ], + } + } + ], + "usageMetadata": { + "promptTokenCount": 160, + "candidatesTokenCount": 5, + "cachedContentTokenCount": 80, + }, + } + } + ) + + _sse_frame( + { + "response": { + "candidates": [{"content": {"role": "model", "parts": [{"text": "Gemini final OK."}]}}], + "usageMetadata": { + "promptTokenCount": 170, + "candidatesTokenCount": 13, + "cachedContentTokenCount": 80, + }, + } + } + ) + ), + }, + } + + +def _bedrock_converse_record() -> dict[str, Any]: + return { + "timestamp": "2026-05-13T13:30:00+00:00", + "request_id": "req_bedrock_converse_contract", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/model/anthropic.claude-sonnet-4-20250514-v1:0/converse", + "headers": {}, + "body": { + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "Use Bedrock Converse to answer."}], + } + ], + "tools": [ + { + "name": "lookup", + "description": "Look up a value.", + "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}, + } + ], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "output": { + "message": { + "role": "assistant", + "content": [ + {"text": "Bedrock Converse final OK."}, + { + "reasoningContent": { + "reasoningText": { + "text": "Bedrock reasoning OK.", + "signature": "sig-contract", + } + } + }, + {"toolUse": {"toolUseId": "tool-1", "name": "lookup", "input": {"query": "bedrock"}}}, + ], + } + }, + "usage": { + "inputTokens": 180, + "outputTokens": 18, + "totalTokens": 198, + "cacheReadInputTokens": 60, + "cacheWriteInputTokens": 12, + }, + }, + }, + } + + +def _contract_cases() -> tuple[ViewerContractCase, ...]: + return ( + ViewerContractCase( + name="anthropic_messages", + records=(_anthropic_messages_record(),), + expected_sections=("Tools", "System Prompt", "Messages", "Response"), + expected_system="Claude Code contract system prompt.", + expected_roles=("user", "assistant", "user"), + expected_tools=("Read",), + expected_output_types=("text",), + expected_usage={"input_tokens": 120, "output_tokens": 9, "cache_read_input_tokens": 40}, + required_detail_text=("Read pyproject.toml.", "project metadata", "Anthropic response OK."), + ), + ViewerContractCase( + name="openai_responses", + records=(_responses_record(),), + expected_sections=("Tools", "System Prompt", "Messages", "Response"), + expected_system="You are Codex contract system prompt.", + expected_roles=("developer", "user"), + expected_tools=("exec_command",), + expected_output_types=("tool_use", "text"), + expected_usage={"input_tokens": 130, "output_tokens": 14, "cache_read_input_tokens": 50}, + required_detail_text=("Run pwd.", "exec_command", "Responses final OK."), + ), + ViewerContractCase( + name="codex_websocket", + records=(_codex_websocket_record(),), + expected_sections=("Tools", "System Prompt", "Request Context", "Response", "SSE Events"), + expected_system="You are Codex WebSocket contract system prompt.", + expected_roles=("developer", "user"), + expected_tools=("exec_command",), + expected_output_types=("tool_use", "text"), + expected_usage={"input_tokens": 140, "output_tokens": 8}, + required_detail_text=("Continue after a tool call.", "exec_command", "WebSocket final OK."), + min_stream_events=3, + ), + ViewerContractCase( + name="codex_reverse_websocket", + records=(_codex_reverse_websocket_record(),), + expected_sections=("Tools", "System Prompt", "Request Context", "Response", "SSE Events"), + expected_system="You are Codex WebSocket contract system prompt.", + expected_roles=("developer", "user"), + expected_tools=("exec_command",), + expected_output_types=("tool_use", "text"), + expected_usage={"input_tokens": 140, "output_tokens": 8}, + required_detail_text=("Continue after a tool call.", "exec_command", "WebSocket final OK."), + min_stream_events=3, + ), + ViewerContractCase( + name="chat_completions", + records=(_chat_completions_record(),), + expected_sections=("Tools", "System Prompt", "Messages", "Response"), + expected_system="Kimi contract system prompt.", + expected_roles=("user", "assistant", "tool"), + expected_tools=("read_file",), + expected_output_types=("thinking", "tool_use", "text"), + expected_usage={"input_tokens": 150, "output_tokens": 10, "cache_read_input_tokens": 70}, + required_detail_text=( + "Read the project metadata.", + "read_file", + "Need to inspect metadata before answering.", + "inspect_metadata", + "Chat final OK.", + ), + ), + ViewerContractCase( + name="opencode_chat_completions", + records=(_opencode_chat_completions_record(),), + expected_sections=("Tools", "System Prompt", "Messages", "Response", "SSE Events"), + expected_system="You are opencode, an interactive CLI tool that helps users with software engineering tasks.", + expected_roles=("user", "assistant", "tool", "assistant", "user", "assistant", "tool"), + expected_tools=("bash", "edit"), + expected_output_types=("thinking", "text"), + expected_usage={"input_tokens": 14070, "output_tokens": 109, "cache_read_input_tokens": 13952}, + required_detail_text=( + "OPENCODE_TOOL_ONE", + "/home/liaohch3/src/github.com/liaohch3/claude-tap-3", + "OPENCODE_TOOL_TWO", + "pyproject.toml", + ), + min_stream_events=2, + ), + ViewerContractCase( + name="opencode_openai_oauth_responses", + records=(_opencode_openai_oauth_responses_record(),), + expected_sections=("Tools", "System Prompt", "Messages", "Response", "SSE Events"), + expected_system=( + "You are OpenCode, You and the user share the same workspace " + "and collaborate to achieve the user's goals." + ), + expected_roles=("developer", "user", "assistant", "assistant", "tool"), + expected_tools=("bash", "read"), + expected_output_types=("text",), + expected_usage={"input_tokens": 12424, "output_tokens": 115, "cache_read_input_tokens": 11776}, + required_detail_text=( + "OPENCODE_OPENAI_OAUTH_TOOL_ONE", + "/home/liaohch3/src/github.com/liaohch3/claude-tap-3", + "bash", + ), + min_stream_events=1, + ), + ViewerContractCase( + name="pi_openai_oauth_websocket_final_response", + records=(_pi_openai_oauth_websocket_record(),), + expected_sections=("Tools", "System Prompt", "Request Context", "Response", "SSE Events"), + expected_system=( + "You are an expert coding assistant operating inside pi, a coding agent harness. " + "You help users by reading files, executing commands, editing code, and writing new files.\n\n" + "Available tools:\n- bash: Execute bash commands (ls, grep, find, etc.)" + ), + expected_roles=("developer", "user", "assistant", "tool"), + expected_tools=("bash",), + expected_output_types=("text",), + expected_usage={"input_tokens": 655, "output_tokens": 29}, + required_detail_text=( + "PI_TOOL_ONE", + "/home/liaohch3/src/github.com/liaohch3/claude-tap-3", + "printf 'PI_TOOL_ONE", + ), + min_stream_events=2, + entry_index=1, + expected_sidebar_label="Pi", + ), + ViewerContractCase( + name="gemini", + records=(_gemini_record(),), + expected_sections=("Tools", "System Prompt", "Messages", "Response", "SSE Events"), + expected_system="You are Gemini CLI contract system prompt.", + expected_roles=("user", "assistant", "tool"), + expected_tools=("run_shell_command",), + expected_output_types=("thinking", "tool_use", "text"), + expected_usage={"input_tokens": 170, "output_tokens": 13, "cache_read_input_tokens": 80}, + required_detail_text=("Use shell to inspect the workspace.", "Output: /repo", "Gemini final OK."), + min_stream_events=2, + ), + ViewerContractCase( + name="bedrock_converse", + records=(_bedrock_converse_record(),), + expected_sections=("Tools", "Messages", "Response"), + expected_system=None, + expected_roles=("user",), + expected_tools=("lookup",), + expected_output_types=("text", "thinking", "tool_use"), + expected_usage={ + "input_tokens": 180, + "output_tokens": 18, + "cache_read_input_tokens": 60, + "cache_creation_input_tokens": 12, + }, + required_detail_text=( + "Use Bedrock Converse to answer.", + "Bedrock Converse final OK.", + "Bedrock reasoning OK.", + "lookup", + ), + ), + ViewerContractCase( + name="content_block_boundaries", + records=(_content_block_boundary_record(),), + expected_sections=("System Prompt", "Messages", "Response"), + expected_system="System block one.\n\nSystem block two.", + expected_roles=("developer", "user", "assistant", "tool"), + expected_tools=(), + expected_output_types=("text",), + expected_usage={"input_tokens": 42, "output_tokens": 5}, + required_detail_text=( + "System block one.", + "Developer block one.", + "User block two.", + "Assistant text block.", + "Tool result two.", + "Content block response OK.", + ), + ), + ) + + +def _runtime_smoke_records() -> tuple[dict[str, Any], ...]: + return ( + { + "request_id": "req_empty_body", + "turn": 1, + "request": {"method": "POST", "path": "/v1/messages", "headers": {}, "body": None}, + "response": {"status": 200, "headers": {}, "body": None}, + }, + { + "request_id": "req_string_bodies", + "turn": 2, + "request": {"method": "POST", "path": "/v1/messages", "headers": {}, "body": "not json"}, + "response": {"status": 200, "headers": {}, "body": "plain text response"}, + }, + ) + + +def _sidebar_order_records() -> tuple[dict[str, Any], ...]: + base = { + "duration_ms": 100, + "request": {"method": "POST", "path": "/v1/messages", "headers": {}, "body": {}}, + "response": {"status": 200, "headers": {}, "body": {"content": [{"type": "text", "text": "OK"}]}}, + } + rows = [ + ("req_order_unknown", 1, "other-model", "First sidebar task"), + ("req_order_sonnet", 2, "aws.claude-sonnet-4.6", "Second sidebar task"), + ("req_order_opus", 3, "aws.claude-opus-4.6", "Second sidebar task"), + ] + records = [] + for request_id, turn, model, prompt in rows: + record = json.loads(json.dumps(base)) + record["request_id"] = request_id + record["turn"] = turn + record["timestamp"] = f"2026-05-13T13:2{turn}:00+00:00" + record["request"]["body"]["model"] = model + record["request"]["body"]["messages"] = [{"role": "user", "content": prompt}] + records.append(record) + return tuple(records) + + +def _claude_code_session_round_records() -> tuple[dict[str, Any], ...]: + model = "aws.claude-opus-4.6" + + def make_record( + request_id: str, + turn: int, + messages: list[dict[str, Any]], + response_content: list[dict[str, Any]], + *, + system: str | None = None, + stop_reason: str = "end_turn", + ) -> dict[str, Any]: + body: dict[str, Any] = {"model": model, "messages": messages} + if system is not None: + body["system"] = system + return { + "request_id": request_id, + "turn": turn, + "timestamp": f"2026-05-13T13:{20 + turn:02d}:00+00:00", + "duration_ms": 100, + "request": {"method": "POST", "path": "/v1/messages", "headers": {}, "body": body}, + "response": { + "status": 200, + "headers": {}, + "body": {"stop_reason": stop_reason, "content": response_content}, + }, + } + + first_prompt = "Check configured MCP settings" + second_prompt = "Diagnose portfolio holdings" + third_prompt = "Add portfolio positions" + title_system = 'Generate a concise, sentence-case title for the session. Return JSON with a single "title" field.' + first_tool = {"type": "tool_use", "id": "tool_1", "name": "ListMcpResourcesTool", "input": {}} + second_tool = {"type": "tool_use", "id": "tool_2", "name": "portfolio", "input": {}} + third_tool = {"type": "tool_use", "id": "tool_3", "name": "search_stock_by_name", "input": {"query": "ACME"}} + + return ( + make_record( + "req_title", + 1, + [{"role": "user", "content": [{"type": "text", "text": f"\n{first_prompt}\n"}]}], + [{"type": "text", "text": '{"title": "Check configured MCP settings"}'}], + system=title_system, + ), + make_record( + "req_first_tool", + 2, + [{"role": "user", "content": first_prompt}], + [first_tool], + stop_reason="tool_use", + ), + make_record( + "req_first_final", + 3, + [ + {"role": "user", "content": first_prompt}, + {"role": "assistant", "content": [first_tool]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool_1", "content": "mcp list"}]}, + ], + [{"type": "text", "text": "Configured MCP server: wyckoff."}], + ), + make_record( + "req_second_tool", + 4, + [ + {"role": "user", "content": first_prompt}, + {"role": "assistant", "content": "Configured MCP server: wyckoff."}, + {"role": "user", "content": second_prompt}, + ], + [second_tool], + stop_reason="tool_use", + ), + make_record( + "req_second_final", + 5, + [ + {"role": "user", "content": first_prompt}, + {"role": "assistant", "content": "Configured MCP server: wyckoff."}, + {"role": "user", "content": second_prompt}, + {"role": "assistant", "content": [second_tool]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool_2", "content": "empty"}]}, + ], + [{"type": "text", "text": "No holdings found."}], + ), + make_record( + "req_third_tool", + 6, + [ + {"role": "user", "content": first_prompt}, + {"role": "assistant", "content": "Configured MCP server: wyckoff."}, + {"role": "user", "content": second_prompt}, + {"role": "assistant", "content": "No holdings found."}, + {"role": "user", "content": third_prompt}, + ], + [third_tool], + stop_reason="tool_use", + ), + make_record( + "req_third_suggestion", + 7, + [ + {"role": "user", "content": first_prompt}, + {"role": "assistant", "content": "Configured MCP server: wyckoff."}, + {"role": "user", "content": second_prompt}, + {"role": "assistant", "content": "No holdings found."}, + {"role": "user", "content": third_prompt}, + {"role": "assistant", "content": "Portfolio diagnosis complete."}, + {"role": "user", "content": "[SUGGESTION MODE: Suggest what the user might naturally type next.]"}, + ], + [{"type": "text", "text": "Give me an action plan."}], + ), + ) + + +def _codex_app_large_session_records() -> tuple[dict[str, Any], ...]: + records: list[dict[str, Any]] = [] + session_id = "codex-session-alpha" + prompts = [ + "Write Codex App runtime wiki", + "Review live dashboard capture", + "Fix duplicate Codex App trace rows", + ] + for turn in range(1, 61): + prompt_index = (turn - 1) // 20 + prompt = prompts[prompt_index] + step = (turn - 1) % 20 + hour = 10 + (turn - 1) // 60 + minute = (turn - 1) % 60 + injected_user_messages = [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "# AGENTS.md instructions\nSkip maintainer automation notes."} + ], + }, + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "\nskip cwd\n"}], + }, + ] + prior_messages: list[dict[str, Any]] = [] + for prior_prompt in prompts[:prompt_index]: + prior_messages.extend( + [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": prior_prompt}], + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": f"Finished {prior_prompt}."}], + }, + ] + ) + user_message = { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": prompt}], + } + continuation_messages: list[dict[str, Any]] = [] + if step: + continuation_messages = [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": f"Working on {prompt} step {step}."}], + }, + { + "type": "function_call_output", + "call_id": f"call-{turn}", + "output": f"step {step} output", + }, + ] + records.append( + { + "timestamp": f"2026-06-13T{hour:02d}:{minute:02d}:00+00:00", + "request_id": f"req_codexapp_{turn}", + "turn": turn, + "duration_ms": 0, + "transport": "codex-app-transcript", + "request": { + "method": "CODEX_APP_TRANSCRIPT", + "path": "/v1/responses", + "headers": {"x-codex-app-session-id": session_id}, + "body": { + "model": "gpt-5.5", + "metadata": {"codex_app_session_id": session_id}, + "input": [ + *injected_user_messages, + *prior_messages, + user_message, + *continuation_messages, + ], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "id": f"resp_codexapp_{turn}", + "status": "completed", + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": f"Answer {turn}."}], + } + ], + "usage": {"input_tokens": turn, "output_tokens": 1, "total_tokens": turn + 1}, + }, + }, + } + ) + return tuple(records) + + +def _codex_display_turn_records() -> tuple[dict[str, Any], ...]: + return ( + { + "timestamp": "2026-06-01T00:57:34.069390+00:00", + "request_id": "req_models", + "turn": 1, + "duration_ms": 4031, + "request": { + "method": "GET", + "path": "/v1/models?client_version=0.134.0", + "headers": {}, + "body": None, + }, + "response": {"status": 200, "headers": {}, "body": {"data": []}}, + }, + { + "timestamp": "2026-06-01T00:57:40.306340+00:00", + "request_id": "req_prefetch", + "turn": 2, + "duration_ms": 1643, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": {"type": "response.create", "model": "gpt-5.5", "generate": False, "input": []}, + }, + "response": { + "status": 101, + "headers": {}, + "body": { + "id": "resp_prefetch", + "model": "gpt-5.5", + "generate": False, + "output": [], + "usage": {"input_tokens": 10088, "output_tokens": 0}, + }, + }, + }, + { + "timestamp": "2026-06-01T00:58:53.101027+00:00", + "request_id": "req_first_visible", + "turn": "2.2", + "duration_ms": 74200, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": { + "type": "response.create", + "model": "gpt-5.5", + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Clean local branches."}], + } + ], + }, + }, + "response": { + "status": 101, + "headers": {}, + "body": { + "id": "resp_first_visible", + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "I will inspect the worktree."}], + } + ], + "usage": {"input_tokens": 23768, "output_tokens": 407}, + }, + }, + }, + { + "timestamp": "2026-06-01T00:59:02.416139+00:00", + "request_id": "req_second_visible", + "turn": "2.3", + "duration_ms": 83000, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": { + "type": "response.create", + "model": "gpt-5.5", + "previous_response_id": "resp_first_visible", + "input": [{"type": "function_call_output", "call_id": "call_status", "output": "{}"}], + }, + }, + "response": { + "status": 101, + "headers": {}, + "body": { + "id": "resp_second_visible", + "model": "gpt-5.5", + "previous_response_id": "resp_first_visible", + "output": [ + { + "type": "function_call", + "name": "exec_command", + "call_id": "call_branch", + "arguments": '{"cmd":"git branch"}', + } + ], + "usage": {"input_tokens": 25076, "output_tokens": 303}, + }, + }, + }, + { + "timestamp": "2026-06-01T00:59:12.416139+00:00", + "request_id": "req_zero_output_visible", + "turn": "2.4", + "duration_ms": 5000, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": { + "type": "response.create", + "model": "simple", + "previous_response_id": "resp_second_visible", + "input": [{"type": "function_call_output", "call_id": "call_branch", "output": "{}"}], + }, + }, + "response": { + "status": 500, + "headers": {}, + "body": { + "error": {"message": "upstream timeout"}, + "usage": {"input_tokens": 0, "output_tokens": 0}, + }, + }, + }, + ) + + +def _codex_lazy_display_turn_records() -> tuple[dict[str, Any], ...]: + records = list(_codex_display_turn_records()) + for idx in range(60): + records.append( + { + "timestamp": f"2026-06-01T01:{idx % 60:02d}:00.000000+00:00", + "request_id": f"req_model_{idx}", + "turn": 100 + idx, + "duration_ms": 10, + "request": { + "method": "GET", + "path": "/v1/models", + "headers": {}, + "body": None, + }, + "response": {"status": 200, "headers": {}, "body": {"data": []}}, + } + ) + return tuple(records) + + +def _codex_direct_generate_false_records() -> tuple[dict[str, Any], ...]: + visible = json.loads(json.dumps(_responses_record())) + visible["request_id"] = "req_direct_visible" + visible["turn"] = 2 + visible["request"]["body"]["model"] = "gpt-5.5" + visible["response"]["body"]["id"] = "resp_direct_visible" + visible["response"]["body"]["usage"] = {"input_tokens": 12, "output_tokens": 1} + + return ( + { + "timestamp": "2026-06-01T02:00:00.000000+00:00", + "request_id": "req_direct_prefetch", + "turn": 1, + "duration_ms": 20, + "request": { + "method": "POST", + "path": "/v1/responses", + "headers": {}, + "body": {"model": "gpt-5.5", "generate": False, "input": []}, + }, + "response": { + "status": 200, + "headers": {}, + "body": { + "id": "resp_direct_prefetch", + "model": "gpt-5.5", + "generate": False, + "output": [], + "usage": {"input_tokens": 9, "output_tokens": 0}, + }, + }, + }, + visible, + ) + + +def _codex_lazy_event_generate_false_records() -> tuple[dict[str, Any], ...]: + records: list[dict[str, Any]] = [ + { + "timestamp": "2026-06-01T02:10:00.000000+00:00", + "request_id": "req_event_prefetch", + "turn": 1, + "duration_ms": 20, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": {"type": "response.create", "model": "gpt-5.5", "input": []}, + "ws_events": [ + { + "type": "response.create", + "data": {"model": "gpt-5.5", "generate": False, "input": []}, + } + ], + }, + "response": { + "status": 101, + "headers": {}, + "body": None, + "ws_events": [ + { + "type": "response.created", + "data": { + "response": { + "id": "resp_event_prefetch", + "model": "gpt-5.5", + "generate": False, + } + }, + }, + { + "type": "response.completed", + "data": { + "response": { + "id": "resp_event_prefetch", + "model": "gpt-5.5", + "generate": False, + "output": [], + "usage": {"input_tokens": 11, "output_tokens": 0}, + } + }, + }, + ], + }, + }, + { + "timestamp": "2026-06-01T02:10:01.000000+00:00", + "request_id": "req_event_visible", + "turn": 2, + "duration_ms": 50, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": {"type": "response.create", "model": "gpt-5.5", "input": []}, + }, + "response": { + "status": 101, + "headers": {}, + "body": None, + "ws_events": [ + { + "type": "response.created", + "data": {"response": {"id": "resp_event_visible", "model": "gpt-5.5"}}, + }, + { + "type": "response.output_item.done", + "data": { + "output_index": 0, + "item": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "event visible"}], + }, + }, + }, + { + "type": "response.completed", + "data": { + "response": { + "id": "resp_event_visible", + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "event visible"}], + } + ], + "usage": {"input_tokens": 12, "output_tokens": 1}, + } + }, + }, + ], + }, + }, + ] + for idx in range(60): + records.append( + { + "timestamp": f"2026-06-01T02:11:{idx % 60:02d}.000000+00:00", + "request_id": f"req_event_model_{idx}", + "turn": 100 + idx, + "duration_ms": 10, + "request": { + "method": "GET", + "path": "/v1/models", + "headers": {}, + "body": None, + }, + "response": {"status": 200, "headers": {}, "body": {"data": []}}, + } + ) + return tuple(records) + + +def _write_trace(trace_path: Path, records: tuple[dict[str, Any], ...]) -> None: + trace_path.write_text( + "".join(json.dumps(record, ensure_ascii=False) + "\n" for record in records), + encoding="utf-8", + ) + + +def _generate_case_html(tmp_path: Path, name: str, records: tuple[dict[str, Any], ...]) -> Path: + trace_path = tmp_path / f"{name}.jsonl" + html_path = tmp_path / f"{name}.html" + _write_trace(trace_path, records) + _generate_html_viewer(trace_path, html_path) + return html_path + + +def _compact_contract_records() -> tuple[dict[str, Any], ...]: + records = [] + for turn in (1, 2): + record = json.loads(json.dumps(_responses_record())) + record["request_id"] = f"req_compact_contract_{turn}" + record["turn"] = turn + record["request"]["body"]["instructions"] = "Shared compact system prompt. " * 120 + record["request"]["body"]["tools"] = [ + { + "type": "function", + "name": "exec_command", + "description": "Large repeated tool schema. " * 120, + "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}, + } + ] + record["request"]["body"]["input"] = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": f"Compact bundle turn {turn}."}], + } + ] + record["response"]["body"]["output"] = [ + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": f"Compact response {turn}."}], + } + ] + records.append(record) + return tuple(records) + + +def _open_viewer_with_error_capture(page: Page, html_path: Path) -> list[str]: + errors: list[str] = [] + page.on("pageerror", lambda exc: errors.append(f"pageerror: {exc}")) + page.on("console", lambda msg: errors.append(f"console.error: {msg.text}") if msg.type == "error" else None) + page.goto(html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".sidebar-item", timeout=5000) + return errors + + +@pytest.fixture(scope="module") +def chromium_browser(): + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() + + +@pytest.mark.parametrize("case", _contract_cases(), ids=lambda case: case.name) +def test_viewer_semantic_contracts_across_supported_trace_shapes( + tmp_path: Path, chromium_browser, case: ViewerContractCase +) -> None: + html_path = _generate_case_html(tmp_path, case.name, case.records) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + page.locator(".sidebar-item").nth(case.entry_index).click() + page.wait_for_selector("#detail .section", timeout=5000) + + result = page.evaluate( + """(entryIndex) => { + const entry = entries[entryIndex]; + const body = entry.request.body; + const output = getResponseOutput(entry); + const usage = getUsage(entry); + return { + sectionTitles: Array.from(document.querySelectorAll('#detail .section .title')).map(el => el.textContent), + sidebarLabel: document.querySelectorAll('.sidebar-item .si-task')[entryIndex]?.textContent || '', + system: extractSystem(body) || '', + roles: getMessages(body).map(message => message.role), + tools: getRequestTools(body).map(toolDisplayName), + outputTypes: (output?.content || []).map(block => block.type), + usage, + eventCount: getResponseEvents(entry).length, + detailText: document.querySelector('#detail').innerText, + }; + }""", + case.entry_index, + ) + finally: + page.close() + + assert errors == [] + assert "Full JSON" in result["sectionTitles"] + assert any(title != "Full JSON" for title in result["sectionTitles"]) + for section in case.expected_sections: + assert section in result["sectionTitles"] + if case.expected_system is not None: + assert result["system"] == case.expected_system + if case.expected_sidebar_label is not None: + assert result["sidebarLabel"] == case.expected_sidebar_label + assert result["roles"] == list(case.expected_roles) + assert result["tools"] == list(case.expected_tools) + assert result["outputTypes"] == list(case.expected_output_types) + for key, value in case.expected_usage.items(): + assert result["usage"][key] == value + assert result["eventCount"] >= case.min_stream_events + for text in case.required_detail_text: + assert text in result["detailText"] + + +def test_viewer_detail_tabs_keep_default_view_and_expose_trace_mode(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "detail_tabs", (_responses_record(),)) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + page.locator(".sidebar-item").first.click() + page.wait_for_selector('#detail .detail-tab[data-tab="default"].active', timeout=5000) + + default_state = page.evaluate( + """() => ({ + tabs: Array.from(document.querySelectorAll('#detail .detail-tab')).map(el => ({ + mode: el.dataset.tab, + label: el.querySelector('span:not(.tab-count)')?.textContent || '', + active: el.classList.contains('active'), + })), + sectionTitles: Array.from(document.querySelectorAll('#detail .section .title')).map(el => el.textContent), + text: document.querySelector('#detail')?.innerText || '', + })""" + ) + + page.locator('#detail .detail-tab[data-tab="trace"]').click() + page.wait_for_selector('#detail .detail-tab[data-tab="trace"].active', timeout=5000) + trace_state = page.evaluate( + """() => ({ + sectionCount: document.querySelectorAll('#detail .section').length, + blockTitles: Array.from(document.querySelectorAll('#detail .trace-block-title .trace-title')).map(el => el.textContent), + copyButtons: Array.from(document.querySelectorAll('#detail .trace-copy-btn')).map(el => el.textContent), + formats: Array.from(document.querySelectorAll('#detail .trace-format-btn')).map(el => ({ + format: el.dataset.format, + label: el.textContent, + active: el.classList.contains('active'), + })), + text: document.querySelector('#detail')?.innerText || '', + })""" + ) + page.evaluate( + """() => { + window.__copiedTraceText = ''; + window.copyToClipboard = (text, btn) => { + window.__copiedTraceText = text; + if (btn) btn.textContent = t('copied'); + return Promise.resolve(); + }; + }""" + ) + page.locator("#detail .trace-copy-btn").first.click() + page.wait_for_function("window.__copiedTraceText.includes('Run pwd.')") + copied_trace_text = page.evaluate("window.__copiedTraceText") + + page.locator('#detail .trace-format-btn[data-format="yaml"]').click() + page.wait_for_selector('#detail .trace-format-btn[data-format="yaml"].active', timeout=5000) + yaml_state = page.evaluate( + """() => ({ + text: document.querySelector('#detail')?.innerText || '', + codeFormat: document.querySelector('#detail .trace-code')?.dataset.format || '', + prettyCount: document.querySelectorAll('#detail .trace-pretty').length, + })""" + ) + + page.locator('#detail .trace-format-btn[data-format="pretty"]').click() + page.wait_for_selector('#detail .trace-format-btn[data-format="pretty"].active', timeout=5000) + pretty_state = page.evaluate( + """() => ({ + text: document.querySelector('#detail')?.innerText || '', + codeCount: document.querySelectorAll('#detail .trace-code').length, + prettyCount: document.querySelectorAll('#detail .trace-pretty').length, + })""" + ) + + remaining_tabs = page.evaluate( + "() => Array.from(document.querySelectorAll('#detail .detail-tab')).map(el => el.dataset.tab)" + ) + finally: + page.close() + + assert errors == [] + assert default_state["tabs"] == [ + {"mode": "default", "label": "Default", "active": True}, + {"mode": "trace", "label": "Trace", "active": False}, + ] + assert default_state["sectionTitles"] == ["Tools", "System Prompt", "Messages", "Response", "Full JSON"] + assert "Responses final OK." in default_state["text"] + assert "Diff with Prev" in default_state["text"] + assert trace_state["sectionCount"] == 0 + assert trace_state["blockTitles"] == ["Input", "Output", "Metadata"] + assert trace_state["copyButtons"] == ["Copy", "Copy", "Copy"] + assert '"messages"' in copied_trace_text + assert "Run pwd." in copied_trace_text + assert trace_state["formats"] == [ + {"format": "json", "label": "JSON", "active": True}, + {"format": "yaml", "label": "YAML", "active": False}, + {"format": "pretty", "label": "Pretty", "active": False}, + ] + assert '"messages"' in trace_state["text"] + assert "req_responses_contract" in trace_state["text"] + assert "Responses final OK." in trace_state["text"] + assert yaml_state["codeFormat"] == "yaml" + assert yaml_state["prettyCount"] == 0 + assert "messages:" in yaml_state["text"] + assert "req_responses_contract" in yaml_state["text"] + assert pretty_state["codeCount"] == 0 + assert pretty_state["prettyCount"] == 3 + assert "messages" in pretty_state["text"] + assert "req_responses_contract" in pretty_state["text"] + assert remaining_tabs == ["default", "trace"] + + +def test_viewer_tool_call_params_can_expand_escaped_string_newlines(tmp_path: Path, chromium_browser) -> None: + record = json.loads(json.dumps(_responses_record())) + decoded_command = "python - <<'PY'\nprint(\"hello\")\nPY" + record["response"]["body"]["output"][0]["arguments"] = json.dumps( + {"cmd": decoded_command, "yield_time_ms": 1000}, + ensure_ascii=False, + ) + html_path = _generate_case_html(tmp_path, "tool_call_param_escapes", (record,)) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + page.locator(".sidebar-item").first.click() + page.wait_for_selector("#detail .section", timeout=5000) + page.wait_for_selector("#detail .tool-input-toggle", timeout=5000) + page.evaluate( + """() => { + window.__copiedToolInput = ''; + window.copyToClipboard = (text) => { + window.__copiedToolInput = text; + return Promise.resolve(); + }; + }""" + ) + + input_view = page.locator("#detail .tool-input-view").first + raw_text = input_view.inner_text() + input_view_count = page.locator("#detail .tool-input-view").count() + toggle_text_before = page.locator("#detail .tool-input-toggle").first.inner_text() + copy_button_count = page.locator("#detail .tool-input-copy").count() + page.locator("#detail .tool-input-copy").first.click() + copied_raw_text = page.evaluate("window.__copiedToolInput") + page.locator("#detail .tool-input-toggle").first.click() + page.wait_for_selector("#detail .tool-input-view.expanded", timeout=5000) + decoded_text = input_view.inner_text() + toggle_text_expanded = page.locator("#detail .tool-input-toggle").first.inner_text() + page.locator("#detail .tool-input-copy").first.click() + copied_decoded_text = page.evaluate("window.__copiedToolInput") + expanded_view_count = page.locator("#detail .tool-input-view").count() + decoded_box_count = page.locator("#detail .tool-input-decoded").count() + decoded_copy_button_count = page.locator("#detail .tool-input-copy-decoded").count() + page.locator("#detail .tool-input-toggle").first.click() + restored_text = input_view.inner_text() + full_json_buttons = page.locator("#detail .json-view .tool-input-toggle").count() + full_json_copy_buttons = page.locator("#detail .json-view .tool-input-copy").count() + finally: + page.close() + + assert errors == [] + assert "\\nprint" in raw_text + assert "python - <<'PY'\nprint(\"hello\")\nPY" in decoded_text + assert copied_raw_text == raw_text + assert copied_decoded_text == decoded_text + assert toggle_text_before == "\u21b5" + assert toggle_text_expanded == "Raw" + assert restored_text == raw_text + assert input_view_count == expanded_view_count == 1 + assert decoded_box_count == 0 + assert copy_button_count == 1 + assert decoded_copy_button_count == 0 + assert full_json_buttons == 0 + assert full_json_copy_buttons == 0 + + +def test_viewer_renders_embedded_and_dropped_compact_trace_bundle(tmp_path: Path, chromium_browser) -> None: + records = _compact_contract_records() + bundle = build_compact_trace_bundle(list(records)) + html_path = tmp_path / "compact.html" + bundle_path = tmp_path / "compact.ctap.json" + blank_html_path = tmp_path / "blank.html" + + _generate_html_viewer_from_compact_bundle( + bundle, + html_path, + display_trace_path=bundle_path, + display_html_path=html_path, + ) + bundle_path.write_text(json.dumps(bundle, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + blank_html_path.write_text(_read_viewer_template(), encoding="utf-8") + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + page.locator(".sidebar-item").nth(1).click() + page.wait_for_selector("#detail .section", timeout=5000) + embedded_state = page.evaluate( + """() => ({ + entryCount: entries.length, + hasBlobRef: JSON.stringify(EMBEDDED_TRACE_COMPACT_DATA).includes('__claude_tap_blob_ref__'), + detailText: document.querySelector('#detail')?.innerText || '', + })""" + ) + finally: + page.close() + + assert errors == [] + assert embedded_state["entryCount"] == 2 + assert embedded_state["hasBlobRef"] is True + assert "Compact bundle turn 2." in embedded_state["detailText"] + assert "Compact response 2." in embedded_state["detailText"] + + drop_page = chromium_browser.new_page() + drop_errors: list[str] = [] + drop_page.on("pageerror", lambda exc: drop_errors.append(f"pageerror: {exc}")) + drop_page.on( + "console", lambda msg: drop_errors.append(f"console.error: {msg.text}") if msg.type == "error" else None + ) + try: + drop_page.goto(blank_html_path.resolve().as_uri(), timeout=10000) + drop_page.set_input_files("#file-input", str(bundle_path)) + drop_page.wait_for_selector(".sidebar-item", timeout=5000) + dropped_state = drop_page.evaluate( + """() => ({ + entryCount: entries.length, + sidebarText: document.querySelector('#sidebar')?.innerText || '', + detailText: document.querySelector('#detail')?.innerText || '', + })""" + ) + finally: + drop_page.close() + + assert drop_errors == [] + assert dropped_state["entryCount"] == 2 + assert "Compact bundle turn 1." in dropped_state["detailText"] + assert "gpt-5.4" in dropped_state["sidebarText"] + + +def test_viewer_does_not_synthesize_messages_for_empty_responses_input(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "responses_empty_input", (_responses_empty_input_record(),)) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + page.locator(".sidebar-item").first.click() + page.wait_for_selector("#detail .section", timeout=5000) + state = page.evaluate( + """() => ({ + roles: getMessages(entries[0].request.body).map(message => message.role), + sectionTitles: Array.from(document.querySelectorAll('#detail .section .title')).map(el => el.textContent), + detailText: document.querySelector('#detail')?.innerText || '', + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["roles"] == [] + assert "System Prompt" in state["sectionTitles"] + assert "Tools" in state["sectionTitles"] + assert "Messages" not in state["sectionTitles"] + assert "You are Codex contract system prompt." in state["detailText"] + + +def test_viewer_sidebar_order_can_switch_between_model_turn_and_session_sequence( + tmp_path: Path, chromium_browser +) -> None: + html_path = _generate_case_html(tmp_path, "sidebar_order", _sidebar_order_records()) + + page = chromium_browser.new_page() + page.add_init_script("localStorage.setItem('claude-tap-sidebar-order', 'model')") + try: + errors = _open_viewer_with_error_capture(page, html_path) + model_state = page.evaluate( + """() => ({ + label: document.querySelector('#sidebar-sort-label')?.textContent || '', + buttons: Array.from(document.querySelectorAll('.sidebar-sort-btn')).map(el => ({ + mode: el.dataset.sortMode, + label: el.textContent, + active: el.classList.contains('active'), + })), + groups: Array.from(document.querySelectorAll('.sidebar-group-header .group-name')).map(el => el.textContent), + turns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + })""" + ) + + page.locator('.sidebar-sort-btn[data-sort-mode="turn"]').click() + page.wait_for_selector('.sidebar-sort-btn[data-sort-mode="turn"].active', timeout=5000) + turn_state = page.evaluate( + """() => ({ + buttons: Array.from(document.querySelectorAll('.sidebar-sort-btn')).map(el => ({ + mode: el.dataset.sortMode, + label: el.textContent, + active: el.classList.contains('active'), + })), + groupCount: document.querySelectorAll('.sidebar-group-header').length, + turns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + })""" + ) + + page.locator('.sidebar-sort-btn[data-sort-mode="session"]').click() + page.wait_for_selector('.sidebar-sort-btn[data-sort-mode="session"].active', timeout=5000) + session_state = page.evaluate( + """() => ({ + buttons: Array.from(document.querySelectorAll('.sidebar-sort-btn')).map(el => ({ + mode: el.dataset.sortMode, + label: el.textContent, + active: el.classList.contains('active'), + })), + groups: Array.from(document.querySelectorAll('.sidebar-group-header .group-name')).map(el => el.textContent), + counts: Array.from(document.querySelectorAll('.sidebar-group-header .group-count')).map(el => el.textContent), + turns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + })""" + ) + finally: + page.close() + + assert errors == [] + assert model_state["label"] == "Order" + assert model_state["buttons"] == [ + {"mode": "model", "label": "Model", "active": True}, + {"mode": "turn", "label": "Turn", "active": False}, + {"mode": "session", "label": "Query", "active": False}, + ] + assert model_state["groups"] == ["aws.claude-opus-4.6", "aws.claude-sonnet-4.6", "other-model"] + assert model_state["turns"] == ["Turn 3", "Turn 2", "Turn 1"] + assert turn_state["buttons"] == [ + {"mode": "model", "label": "Model", "active": False}, + {"mode": "turn", "label": "Turn", "active": True}, + {"mode": "session", "label": "Query", "active": False}, + ] + assert turn_state["groupCount"] == 0 + assert turn_state["turns"] == ["Turn 1", "Turn 2", "Turn 3"] + assert session_state["buttons"] == [ + {"mode": "model", "label": "Model", "active": False}, + {"mode": "turn", "label": "Turn", "active": False}, + {"mode": "session", "label": "Query", "active": True}, + ] + assert session_state["groups"] == [ + "Query 1 - First sidebar task", + "Query 2 - Second sidebar task", + "Query 3 - Second sidebar task", + ] + assert session_state["counts"] == ["1", "1", "1"] + assert session_state["turns"] == ["Turn 1", "Turn 2", "Turn 3"] + + +def test_viewer_session_order_groups_claude_code_tool_loop_rounds(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "claude_code_session_rounds", _claude_code_session_round_records()) + + page = chromium_browser.new_page() + page.add_init_script("localStorage.setItem('claude-tap-sidebar-order', 'session')") + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => ({ + groups: Array.from(document.querySelectorAll('.sidebar-group-header .group-name')).map(el => el.textContent), + counts: Array.from(document.querySelectorAll('.sidebar-group-header .group-count')).map(el => el.textContent), + turns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + headerText: document.querySelector('#sidebar')?.innerText || '', + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["groups"] == [ + "Query 1 - Check configured MCP settings", + "Query 2 - Diagnose portfolio holdings", + "Query 3 - Add portfolio positions", + ] + assert state["counts"] == ["3", "2", "2"] + assert state["turns"] == ["Turn 1", "Turn 2", "Turn 3", "Turn 4", "Turn 5", "Turn 6", "Turn 7"] + assert "SUGGESTION MODE" not in state["headerText"] + + +def test_viewer_session_order_groups_large_codex_app_sessions_in_virtual_mode(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "codex_app_large_sessions", _codex_app_large_session_records()) + + page = chromium_browser.new_page() + page.add_init_script("localStorage.setItem('claude-tap-sidebar-order', 'session')") + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => ({ + virtualMode, + groups: Array.from(document.querySelectorAll('.sidebar-group-header .group-name')).map(el => el.textContent), + counts: Array.from(document.querySelectorAll('.sidebar-group-header .group-count')).map(el => el.textContent), + virtualGroups: vsFilteredItems + .filter(row => row.type === 'group') + .map(row => row.group.userText), + virtualCounts: vsFilteredItems + .filter(row => row.type === 'group') + .map(row => String(row.group.items.length)), + rowCount: vsFilteredItems.length, + visualCount: visualOrder.length, + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["virtualMode"] is True + assert state["groups"] == ["Query 1 - Write Codex App runtime wiki"] + assert state["counts"] == ["20"] + assert state["virtualGroups"] == [ + "Write Codex App runtime wiki", + "Review live dashboard capture", + "Fix duplicate Codex App trace rows", + ] + assert state["virtualCounts"] == ["20", "20", "20"] + assert state["rowCount"] == 63 + assert state["visualCount"] == 60 + + +def test_viewer_codex_display_turns_skip_capture_control_records(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "codex_display_turns", _codex_display_turn_records()) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => ({ + sidebarTurns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + codexEntries: entries + .filter(entry => entry.request?.path === '/v1/responses') + .map(entry => ({ + turn: entry.turn, + displayTurn: entry.display_turn, + captureTurn: entry.capture_turn, + requestId: entry.request_id, + })), + resetTurns: (() => { + const extra = JSON.parse(JSON.stringify(entries.find(entry => entry.request?.path === '/v1/responses' && entry.display_turn === 3))); + delete extra.display_turn; + extra.turn = '2.5'; + extra.capture_turn = '2.5'; + extra.request_id = 'req_after_reset'; + return normalizeDisplayTurns([...entries, extra], true) + .filter(entry => entry.request?.path === '/v1/responses' && entry.display_turn !== undefined) + .map(entry => entry.display_turn); + })(), + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["sidebarTurns"] == ["Turn 1", "Turn 2", "Turn 3"] + assert state["codexEntries"] == [ + { + "turn": "2.2", + "displayTurn": 1, + "captureTurn": "2.2", + "requestId": "req_first_visible", + }, + { + "turn": "2.3", + "displayTurn": 2, + "captureTurn": "2.3", + "requestId": "req_second_visible", + }, + { + "turn": "2.4", + "displayTurn": 3, + "captureTurn": "2.4", + "requestId": "req_zero_output_visible", + }, + ] + assert state["resetTurns"] == [1, 2, 3, 4] + + +def test_viewer_direct_responses_generate_false_is_not_navigable(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html( + tmp_path, + "codex_direct_generate_false", + _codex_direct_generate_false_records(), + ) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => ({ + sidebarTurns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + filteredRequestIds: filtered.map(entry => entry.request_id), + entriesById: Object.fromEntries(entries.map(entry => [entry.request_id, { + displayTurn: entry.display_turn ?? null, + captureTurn: entry.capture_turn ?? null, + navigable: isNavigableTraceEntry(entry), + }])), + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["sidebarTurns"] == ["Turn 1"] + assert state["filteredRequestIds"] == ["req_direct_visible"] + assert state["entriesById"]["req_direct_prefetch"] == { + "displayTurn": None, + "captureTurn": 1, + "navigable": False, + } + assert state["entriesById"]["req_direct_visible"] == { + "displayTurn": 1, + "captureTurn": 2, + "navigable": True, + } + + +def test_viewer_codex_lazy_display_turns_skip_capture_control_records(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "codex_lazy_display_turns", _codex_lazy_display_turn_records()) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => ({ + usesCompactBundle: typeof EMBEDDED_TRACE_COMPACT_DATA !== 'undefined', + usesMetadataMode: typeof EMBEDDED_TRACE_META !== 'undefined', + sidebarTurns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + sidebarPaths: Array.from(document.querySelectorAll('.sidebar-item .si-path')).map(el => el.textContent), + filteredRequestIds: filtered.map(entry => entry.request_id), + codexEntries: entries + .filter(entry => entry.request?.path === '/v1/responses') + .map(entry => ({ + requestId: entry.request_id, + turn: entry.turn, + displayTurn: entry.display_turn ?? null, + captureTurn: entry.capture_turn ?? null, + responseOutputCount: entry.response?.body?.output?.length ?? 0, + outputTokens: entry.response?.body?.usage?.output_tokens ?? 0, + navigable: isNavigableTraceEntry(entry), + })), + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["usesCompactBundle"] is True + assert state["usesMetadataMode"] is False + assert state["sidebarTurns"] == ["Turn 1", "Turn 2", "Turn 3"] + assert state["sidebarPaths"] == ["WEBSOCKET /v1/responses", "WEBSOCKET /v1/responses", "WEBSOCKET /v1/responses"] + assert state["filteredRequestIds"] == ["req_first_visible", "req_second_visible", "req_zero_output_visible"] + assert state["codexEntries"] == [ + { + "requestId": "req_first_visible", + "turn": "2.2", + "displayTurn": 1, + "captureTurn": "2.2", + "responseOutputCount": 1, + "outputTokens": 407, + "navigable": True, + }, + { + "requestId": "req_second_visible", + "turn": "2.3", + "displayTurn": 2, + "captureTurn": "2.3", + "responseOutputCount": 1, + "outputTokens": 303, + "navigable": True, + }, + { + "requestId": "req_zero_output_visible", + "turn": "2.4", + "displayTurn": 3, + "captureTurn": "2.4", + "responseOutputCount": 0, + "outputTokens": 0, + "navigable": True, + }, + ] + + +def test_viewer_codex_lazy_stubs_read_generate_from_websocket_events(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html( + tmp_path, + "codex_lazy_event_generate_false", + _codex_lazy_event_generate_false_records(), + ) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => ({ + usesCompactBundle: typeof EMBEDDED_TRACE_COMPACT_DATA !== 'undefined', + usesMetadataMode: typeof EMBEDDED_TRACE_META !== 'undefined', + sidebarTurns: Array.from(document.querySelectorAll('.sidebar-item .si-turn')).map(el => el.textContent), + filteredRequestIds: filtered.map(entry => entry.request_id), + eventEntries: entries + .filter(entry => entry.request_id.startsWith('req_event_') && entry.request?.path === '/v1/responses') + .map(entry => ({ + requestId: entry.request_id, + displayTurn: entry.display_turn ?? null, + captureTurn: entry.capture_turn ?? null, + requestGenerate: entry.request?.body?.generate ?? null, + responseGenerate: entry.response?.body?.generate ?? null, + responseOutputCount: entry.response?.body?.output?.length ?? 0, + outputTokens: entry.response?.body?.usage?.output_tokens ?? 0, + navigable: isNavigableTraceEntry(entry), + })), + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["usesCompactBundle"] is True + assert state["usesMetadataMode"] is False + assert state["sidebarTurns"] == ["Turn 1"] + assert state["filteredRequestIds"] == ["req_event_visible"] + assert state["eventEntries"] == [ + { + "requestId": "req_event_visible", + "displayTurn": 1, + "captureTurn": 2, + "requestGenerate": None, + "responseGenerate": None, + "responseOutputCount": 1, + "outputTokens": 1, + "navigable": True, + }, + ] + + +def test_viewer_codex_lazy_trace_tab_preserves_display_turn_metadata(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html( + tmp_path, + "codex_lazy_trace_display_turns", + _codex_lazy_display_turn_records(), + ) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => { + function metadataText() { + const blocks = Array.from(document.querySelectorAll('#detail .trace-block')); + const block = blocks.find(el => el.querySelector('.trace-title')?.textContent === 'Metadata'); + return block?.innerText || ''; + } + const idx = filtered.findIndex(entry => entry.request?.path === '/v1/responses' && entry.display_turn === 1); + selectEntry(idx); + document.querySelector('#detail .detail-tab[data-tab="trace"]').click(); + const jsonMetadata = metadataText(); + document.querySelector('#detail .trace-format-btn[data-format="yaml"]').click(); + const yamlMetadata = metadataText(); + document.querySelector('#detail .trace-format-btn[data-format="pretty"]').click(); + const prettyMetadata = metadataText(); + return { + selectedIdx: idx, + activeDisplayTurn: filtered[activeIdx]?.display_turn ?? null, + jsonMetadata, + yamlMetadata, + prettyMetadata, + }; + }""" + ) + finally: + page.close() + + assert errors == [] + assert state["selectedIdx"] >= 0 + assert state["activeDisplayTurn"] == 1 + assert '"display_turn": 1' in state["jsonMetadata"] + assert '"capture_turn": "2.2"' in state["jsonMetadata"] + assert "display_turn: 1" in state["yamlMetadata"] + assert "capture_turn: 2.2" in state["yamlMetadata"] + assert "display_turn" in state["prettyMetadata"] + assert "capture_turn" in state["prettyMetadata"] + + +def test_viewer_session_group_hover_shows_full_truncated_user_input(tmp_path: Path, chromium_browser) -> None: + long_prompt = ( + "Investigate why the dashboard session group title is truncated, then preserve this full original " + "user input in a hover tooltip so maintainers can read the complete request without opening the turn." + ) + record = { + "request_id": "req_long_session_prompt", + "turn": 1, + "timestamp": "2026-05-13T13:21:00+00:00", + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {}, + "body": { + "model": "aws.claude-sonnet-4.6", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "\nskip injected context\n"}, + { + "type": "text", + "text": "\nskip local command context\n", + }, + {"type": "text", "text": long_prompt}, + {"type": "text", "text": "[Image: source: /tmp/screenshot.png]"}, + ], + } + ], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": {"content": [{"type": "text", "text": "OK"}]}, + }, + } + injected_continuation = json.loads(json.dumps(record)) + injected_continuation["request_id"] = "req_injected_context_only" + injected_continuation["turn"] = 2 + injected_continuation["request"]["body"]["messages"] = [ + {"role": "user", "content": "\nThe following skills are available...\n"} + ] + json_title_record = json.loads(json.dumps(record)) + json_title_record["request_id"] = "req_json_title" + json_title_record["turn"] = 3 + json_title_record["request"]["body"]["messages"] = [ + { + "role": "user", + "content": [ + {"type": "text", "text": '{"title":"Fix login button on mobile"}'}, + {"type": "text", "text": "\nInjected context\n"}, + ], + } + ] + html_path = _generate_case_html( + tmp_path, + "session_hover_tooltip", + (record, injected_continuation, json_title_record), + ) + + page = chromium_browser.new_page() + page.add_init_script("localStorage.setItem('claude-tap-sidebar-order', 'session')") + try: + errors = _open_viewer_with_error_capture(page, html_path) + header = page.locator(".sidebar-group-header").nth(0) + assert header.locator(".group-name").inner_text().endswith("...") + assert "" not in header.locator(".group-name").inner_text() + assert header.locator(".group-count").inner_text() == "2" + json_title_header = page.locator(".sidebar-group-header").nth(1) + json_title_name = json_title_header.locator(".group-name").inner_text() + assert "fix login button on mobile" in json_title_name.lower() + assert "{" not in json_title_name + + header.hover() + page.wait_for_selector(".session-hover-tooltip.visible", timeout=5000) + tooltip_text = page.locator(".session-hover-tooltip.visible").inner_text() + finally: + page.close() + + assert errors == [] + assert tooltip_text == long_prompt + + +def test_viewer_recovers_session_title_image_placeholders(tmp_path: Path, chromium_browser) -> None: + prompt = "My local TNTCloud nodes all time out, but the same account works on another computer." + image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" + title_record = { + "timestamp": "2026-05-13T13:21:00+00:00", + "request_id": "req_title_image_placeholder", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/v1/messages", + "headers": {}, + "body": { + "model": "aws.claude-sonnet-4.6", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": f"\n[Image #1] {prompt}\n"}], + } + ], + }, + }, + "response": { + "status": 200, + "headers": {}, + "body": {"content": [{"type": "text", "text": '{"title":"TNTCloud timeout"}'}]}, + }, + } + actual_record = json.loads(json.dumps(title_record)) + actual_record["request_id"] = "req_actual_image" + actual_record["turn"] = 2 + actual_record["request"]["body"]["messages"] = [ + { + "role": "user", + "content": [ + {"type": "text", "text": f"[Image #1] {prompt}"}, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_data}}, + ], + } + ] + + html_path = _generate_case_html(tmp_path, "image-placeholder-recovery", (title_record, actual_record)) + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + page.locator(".sidebar-item").nth(0).click() + page.wait_for_selector("#detail .msg.user img.message-image", timeout=5000) + result = page.evaluate( + """() => ({ + imageCount: document.querySelectorAll('#detail .msg.user img.message-image').length, + imageSrc: document.querySelector('#detail .msg.user img.message-image')?.getAttribute('src') || '', + text: document.querySelector('#detail .msg.user')?.textContent || '' + })""" + ) + finally: + page.close() + + assert errors == [] + assert result["imageCount"] == 1 + assert result["imageSrc"].startswith("data:image/png;base64,") + assert "[Image #1]" in result["text"] + + +def test_viewer_runtime_smoke_handles_degenerate_records_without_js_errors(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "runtime_smoke", _runtime_smoke_records()) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + sidebar_count = page.locator(".sidebar-item").count() + for index in range(sidebar_count): + page.locator(".sidebar-item").nth(index).click() + page.wait_for_selector("#detail .section", timeout=5000) + assert "Full JSON" in page.locator("#detail").inner_text() + finally: + page.close() + + assert errors == [] + assert sidebar_count == len(_runtime_smoke_records()) + + +def test_viewer_empty_embedded_trace_renders_explicit_no_api_calls_state(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "empty_trace", ()) + + page = chromium_browser.new_page() + errors: list[str] = [] + page.on("pageerror", lambda exc: errors.append(f"pageerror: {exc}")) + page.on("console", lambda msg: errors.append(f"console.error: {msg.text}") if msg.type == "error" else None) + try: + page.goto(html_path.resolve().as_uri(), timeout=10000) + page.wait_for_selector(".empty-trace-state", timeout=5000) + state = page.evaluate( + """() => ({ + title: document.querySelector('#empty-trace-title')?.textContent || '', + description: document.querySelector('#empty-trace-desc')?.textContent || '', + count: document.querySelector('#empty-trace-count')?.textContent || '', + hint: document.querySelector('#empty-trace-hint')?.textContent || '', + sidebarItemCount: document.querySelectorAll('.sidebar-item').length, + sidebarDisplay: getComputedStyle(document.querySelector('#sidebar-wrap')).display, + detailDisplay: getComputedStyle(document.querySelector('#detail')).display, + pathText: document.querySelector('#trace-path-bar')?.innerText || '', + oldDropTitlePresent: Boolean(document.querySelector('#drop-title')), + fileInputPresent: Boolean(document.querySelector('#file-input')), + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["title"] == "No API calls captured" + assert "generated" in state["description"] + assert state["count"] == "Captured API calls: 0" + assert "real empty run" in state["hint"] + assert state["sidebarItemCount"] == 0 + assert state["sidebarDisplay"] == "none" + assert state["detailDisplay"] == "none" + assert "empty_trace.jsonl" in state["pathText"] + assert "empty_trace.html" in state["pathText"] + assert state["oldDropTitlePresent"] is False + assert state["fileInputPresent"] is True + + +def test_viewer_trace_path_copy_handles_apostrophes(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "user's_trace", (_anthropic_messages_record(),)) + + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + copy_button = page.locator("#trace-path-bar .tp-copy").first + assert copy_button.get_attribute("onclick") is None + assert "user's_trace.jsonl" in (copy_button.get_attribute("data-copy-path") or "") + finally: + page.close() + + assert errors == [] + + +def test_viewer_iframe_embed_query_hides_chrome_and_keeps_trace_loaded(tmp_path: Path, chromium_browser) -> None: + html_path = _generate_case_html(tmp_path, "embed_trace", (_anthropic_messages_record(),)) + query = "embed=1&hideHeader=1&hidePath=1&hideHistory=1&hideControls=1&density=compact&theme=light" + + page = chromium_browser.new_page() + errors: list[str] = [] + page.on("pageerror", lambda exc: errors.append(f"pageerror: {exc}")) + page.on("console", lambda msg: errors.append(f"console.error: {msg.text}") if msg.type == "error" else None) + try: + page.goto( + f"{html_path.resolve().as_uri()}?{query}", + timeout=10000, + ) + page.wait_for_selector(".sidebar-item", timeout=5000) + state = page.evaluate( + """() => ({ + embedMode: document.documentElement.dataset.embedMode || '', + bodyClasses: Array.from(document.body.classList), + headerDisplay: getComputedStyle(document.querySelector('.header')).display, + pathDisplay: getComputedStyle(document.querySelector('#trace-path-bar')).display, + themeToggleDisplay: getComputedStyle(document.querySelector('#theme-toggle')).display, + langSelectDisplay: getComputedStyle(document.querySelector('#lang-select')).display, + searchBarDisplay: getComputedStyle(document.querySelector('#search-bar')).display, + sidebarSortDisplay: getComputedStyle(document.querySelector('#sidebar-sort')).display, + detailTabsDisplay: getComputedStyle(document.querySelector('.detail-inspector-bar')).display, + actionBarDisplay: getComputedStyle(document.querySelector('.action-bar')).display, + sidebarWidth: Math.round(document.querySelector('#sidebar-wrap').getBoundingClientRect().width), + theme: document.documentElement.dataset.theme || 'light', + sidebarItemCount: document.querySelectorAll('.sidebar-item').length, + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["embedMode"] == "true" + assert "embed-mode" in state["bodyClasses"] + assert "embed-compact" in state["bodyClasses"] + assert state["headerDisplay"] == "none" + assert state["pathDisplay"] == "none" + assert state["themeToggleDisplay"] == "none" + assert state["langSelectDisplay"] == "none" + assert state["searchBarDisplay"] == "none" + assert state["sidebarSortDisplay"] == "none" + assert state["detailTabsDisplay"] == "none" + assert state["actionBarDisplay"] == "none" + assert state["sidebarWidth"] <= 280 + assert state["theme"] == "light" + assert state["sidebarItemCount"] == 1 + + sandbox_page = chromium_browser.new_page() + sandbox_errors: list[str] = [] + sandbox_page.on("pageerror", lambda exc: sandbox_errors.append(f"pageerror: {exc}")) + sandbox_page.on( + "console", + lambda msg: sandbox_errors.append(f"console.error: {msg.text}") if msg.type == "error" else None, + ) + harness_path = tmp_path / "embed_harness.html" + harness_path.write_text( + f'', + encoding="utf-8", + ) + try: + sandbox_page.goto(harness_path.resolve().as_uri(), timeout=10000) + frame = sandbox_page.frame_locator("iframe") + frame.locator(".sidebar-item").first.wait_for(state="attached", timeout=5000) + sandbox_state = frame.locator("body").evaluate( + """() => ({ + embedMode: document.documentElement.dataset.embedMode || '', + headerDisplay: getComputedStyle(document.querySelector('.header')).display, + pathDisplay: getComputedStyle(document.querySelector('#trace-path-bar')).display, + sidebarItemCount: document.querySelectorAll('.sidebar-item').length, + dropZoneDisplay: getComputedStyle(document.querySelector('#drop-zone')).display, + })""" + ) + finally: + sandbox_page.close() + + assert sandbox_errors == [] + assert sandbox_state["embedMode"] == "true" + assert sandbox_state["headerDisplay"] == "none" + assert sandbox_state["pathDisplay"] == "none" + assert sandbox_state["sidebarItemCount"] == 1 + assert sandbox_state["dropZoneDisplay"] == "none" + + +def test_viewer_v8_coverage_exercises_core_inline_js_functions(tmp_path: Path, chromium_browser) -> None: + records = tuple(record for case in _contract_cases() for record in case.records) + html_path = _generate_case_html(tmp_path, "v8_coverage", records) + required_functions = { + "renderDetail", + "extractSystem", + "getMessages", + "getRequestTools", + "getUsage", + "getResponseEvents", + "getResponseOutput", + "geminiMessages", + "geminiResponseOutput", + "renderTools", + "renderImageBlock", + "sessionTurnDiscriminator", + "showSessionTooltip", + } + + page = chromium_browser.new_page() + page.add_init_script( + "window.__TRACE_SESSION_EXPORTS__ = {compact: 'coverage.json', log: 'coverage.log', html: 'coverage.html'};" + ) + try: + session = page.context.new_cdp_session(page) + session.send("Profiler.enable") + session.send("Profiler.startPreciseCoverage", {"callCount": True, "detailed": True}) + errors = _open_viewer_with_error_capture(page, html_path) + + export_items = page.locator("#viewer-actions .export-menu-item") + assert export_items.count() == 2 + assert export_items.all_text_contents() == ["Export JSON", "Export HTML"] + assert page.locator('#viewer-actions a[href="coverage.log"]').count() == 0 + + entry_count = page.evaluate("entries.length") + for index in range(entry_count): + page.evaluate("entryIndex => renderDetail(entries[entryIndex])", index) + page.wait_for_selector("#detail .section", timeout=5000) + page.evaluate( + """(entryIndex) => { + const entry = entries[entryIndex]; + const body = entry.request.body; + getMessages(body); + getRequestTools(body); + extractSystem(body); + getUsage(entry); + getResponseEvents(entry); + getResponseOutput(entry); + const jsonSection = Array.from(document.querySelectorAll('#detail .section')) + .find(el => el.querySelector('.title')?.textContent === t('section_json')); + const jsonToggle = jsonSection?.querySelector('.jt-toggle'); + if (jsonToggle) { + jsonToggle.click(); + jsonToggle.click(); + } + }""", + index, + ) + + page.evaluate( + """() => { + const imageBlock = { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + } + }; + imageLookupKey('[Image #1] coverage prompt'); + isInlineImageUrl('data:image/png;base64,abc'); + imageSourceFromBlock(imageBlock); + imageBlocksForContent([{ type: 'text', text: '[Image #1] coverage prompt' }, imageBlock]); + imageSourceKey(imageBlock); + buildSessionImageRegistry(); + naturalTextFromPromptPayload({ prompt: 'coverage prompt' }); + if (entries.length) { + sessionTurnDiscriminator(entries[0]); + sessionKeyForEntry(entries[0], null); + matchSearch(entries[0], '1'); + const originalPrompt = window.prompt; + window.prompt = () => '1'; + promptJumpToTurn(); + window.prompt = originalPrompt; + _buildDiffTargetOptions(Math.min(1, filtered.length - 1)); + if (filtered.length > 1) showDiffForIdx(1, null, 0); + } + const stubEntry = buildStubEntry({ + turn: '2.2', + transport: 'websocket', + method: 'WEBSOCKET', + path: '/v1/responses', + model: 'gpt-5.5', + request_generate: true, + response_output_count: 1, + output_tokens: 1, + }, 0); + normalizeDisplayTurns([stubEntry], true); + renderImageElement('data:image/png;base64,abc', 'coverage image'); + renderImageElementForBlock(imageBlock); + document.body.insertAdjacentHTML('beforeend', renderImageBlock(imageBlock, 0, 1, { frameBlocks: true })); + renderViewerActions(); + valueHasReadableEscapes({ cmd: 'printf "coverage\\\\n"' }); + decodeEscapedTextForView('line1\\\\nline2\\\\t\\\\u4e00'); + document.body.insertAdjacentHTML( + 'beforeend', + renderToolInput({ cmd: 'printf "coverage\\n"', yield_time_ms: 1000 }) + ); + document.querySelector('.tool-input-toggle')?.click(); + const tooltipTrigger = document.querySelector('.sidebar-group-header') || document.createElement('div'); + if (!tooltipTrigger.isConnected) document.body.appendChild(tooltipTrigger); + tooltipTrigger.dataset.fullUserInput = 'coverage tooltip prompt'; + sessionTooltip(); + showSessionTooltip(tooltipTrigger); + hideSessionTooltip(tooltipTrigger); + }""" + ) + + coverage = session.send("Profiler.takePreciseCoverage") + session.send("Profiler.stopPreciseCoverage") + session.send("Profiler.disable") + finally: + page.close() + + covered_names: set[str] = set() + used_main_script_bytes = 0 + for script in coverage["result"]: + if not script.get("url", "").endswith("v8_coverage.html"): + continue + functions = script.get("functions", []) + if len(functions) < 50: + continue + for function in functions: + ranges = function.get("ranges", []) + if any(item.get("count", 0) > 0 for item in ranges): + name = function.get("functionName") + if name: + covered_names.add(name) + used_main_script_bytes += sum( + item.get("endOffset", 0) - item.get("startOffset", 0) for item in ranges if item.get("count", 0) > 0 + ) + + assert errors == [] + assert required_functions <= covered_names + assert used_main_script_bytes > 50_000 + + +def test_viewer_visual_layout_contracts_cover_css_modes(tmp_path: Path, chromium_browser) -> None: + records = tuple(record for case in _contract_cases() for record in case.records) + html_path = _generate_case_html(tmp_path, "visual_contract", records) + + page = chromium_browser.new_page(viewport={"width": 1440, "height": 1000}) + try: + errors = _open_viewer_with_error_capture(page, html_path) + page.evaluate( + "requestId => renderDetail(entries.find(entry => entry.request_id === requestId))", "req_gemini_contract" + ) + page.evaluate( + """() => { + const toolsSection = Array.from(document.querySelectorAll('#detail .section')) + .find(section => section.querySelector('.title')?.textContent === 'Tools'); + if (toolsSection && !toolsSection.querySelector('.section-body')?.classList.contains('open')) { + toolsSection.querySelector('.section-header').click(); + } + }""" + ) + + def snapshot(width: int, height: int, theme: str, mobile: bool = False) -> dict: + page.set_viewport_size({"width": width, "height": height}) + page.evaluate("theme => document.documentElement.setAttribute('data-theme', theme)", theme) + if mobile: + page.evaluate("mobileShowDetail()") + return page.evaluate( + """() => { + const rect = selector => { + const el = document.querySelector(selector); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { width: r.width, height: r.height, left: r.left, right: r.right, top: r.top, bottom: r.bottom }; + }; + const color = selector => getComputedStyle(document.querySelector(selector)).backgroundColor; + return { + overflowX: document.documentElement.scrollWidth - window.innerWidth, + bodyBg: getComputedStyle(document.body).backgroundColor, + userMsgBg: color('.msg.user'), + sidebar: rect('#sidebar-wrap'), + detail: rect('#detail'), + sectionHeader: rect('.section-header'), + tokenBar: rect('.token-bar'), + message: rect('.msg'), + toolBlock: rect('.tool-block'), + response: rect('.section-body.open .content-block'), + sectionCount: document.querySelectorAll('#detail .section').length, + }; + }""" + ) + + desktop_light = snapshot(1440, 1000, "light") + desktop_dark = snapshot(1440, 1000, "dark") + page.evaluate( + "requestId => renderDetail(entries.find(entry => entry.request_id === requestId))", + "req_content_block_boundary_contract", + ) + dark_content_block = page.evaluate( + """() => { + document.documentElement.setAttribute('data-theme', 'dark'); + const block = document.querySelector('.content-block.block-framed'); + if (!block) return null; + const style = getComputedStyle(block); + return { + backgroundColor: style.backgroundColor, + borderLeftWidth: style.borderLeftWidth, + }; + }""" + ) + page.evaluate( + "requestId => renderDetail(entries.find(entry => entry.request_id === requestId))", "req_gemini_contract" + ) + mobile_dark = snapshot(390, 900, "dark", mobile=True) + finally: + page.close() + + assert errors == [] + for result in (desktop_light, desktop_dark, mobile_dark): + assert result["overflowX"] <= 2 + assert result["sectionCount"] >= 5 + assert result["sectionHeader"]["height"] >= 28 + assert result["tokenBar"]["width"] > 250 + assert result["message"]["height"] > 40 + assert result["toolBlock"]["width"] > 250 + assert result["response"]["height"] > 20 + + assert desktop_light["sidebar"]["width"] >= 280 + assert desktop_light["detail"]["width"] >= 1000 + assert desktop_dark["sidebar"]["width"] >= 280 + assert desktop_dark["detail"]["width"] >= 1000 + assert desktop_light["bodyBg"] != desktop_dark["bodyBg"] + assert desktop_light["userMsgBg"] != desktop_dark["userMsgBg"] + assert dark_content_block is not None + assert dark_content_block["backgroundColor"] != "rgba(0, 0, 0, 0)" + assert dark_content_block["borderLeftWidth"] == "1px" + + assert mobile_dark["sidebar"]["width"] == 0 + assert mobile_dark["detail"]["width"] == 390 + assert mobile_dark["detail"]["left"] == 0 + + +def test_viewer_session_identical_prompts_image_tags_and_early_title_generation( + tmp_path: Path, chromium_browser +) -> None: + model = "aws.claude-opus-4.6" + title_system = 'Generate a concise, sentence-case title for the session. Return JSON with a single "title" field.' + + def make_record( + request_id: str, + turn: int, + messages: list[dict[str, Any]], + response_content: list[dict[str, Any]], + *, + system: str | None = None, + ) -> dict[str, Any]: + body: dict[str, Any] = {"model": model, "messages": messages} + if system is not None: + body["system"] = system + return { + "request_id": request_id, + "turn": turn, + "timestamp": f"2026-05-13T13:{20 + turn:02d}:00+00:00", + "duration_ms": 100, + "request": {"method": "POST", "path": "/v1/messages", "headers": {}, "body": body}, + "response": { + "status": 200, + "headers": {}, + "body": {"stop_reason": "end_turn", "content": response_content}, + }, + } + + records = ( + # Turn 1 + make_record( + "req_t1", + 1, + [{"role": "user", "content": "继续"}], + [{"type": "text", "text": "Turn 1 done"}], + ), + # Turn 1 title-gen + make_record( + "req_t1_title", + 2, + [{"role": "user", "content": "继续"}], + [{"type": "text", "text": '{"title": "Group1"}'}], + system=title_system, + ), + # Turn 2 title-gen arrives before the real request with the same prompt. + make_record( + "req_t2_title", + 3, + [{"role": "user", "content": "继续"}], + [{"type": "text", "text": '{"title": "Group2"}'}], + system=title_system, + ), + # Turn 2 (identical prompt) + make_record( + "req_t2", + 4, + [{"role": "user", "content": "继续"}], + [{"type": "text", "text": "Turn 2 done"}], + ), + # Turn 3 (image wrappers) + make_record( + "req_t3", + 5, + [ + { + "role": "user", + "content": [ + {"type": "text", "text": ""}, + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", + }, + }, + {"type": "text", "text": ""}, + {"type": "text", "text": "Analyze the flowchart"}, + ], + } + ], + [{"type": "text", "text": "Turn 3 done"}], + ), + ) + + html_path = _generate_case_html(tmp_path, "identical_prompts_and_image_tags", records) + page = chromium_browser.new_page() + page.add_init_script("localStorage.setItem('claude-tap-sidebar-order', 'session')") + try: + errors = _open_viewer_with_error_capture(page, html_path) + state = page.evaluate( + """() => ({ + groups: Array.from(document.querySelectorAll('.sidebar-group-header .group-name')).map(el => el.textContent), + counts: Array.from(document.querySelectorAll('.sidebar-group-header .group-count')).map(el => el.textContent), + })""" + ) + finally: + page.close() + + assert errors == [] + assert state["groups"] == [ + "Query 1 - 继续", + "Query 2 - 继续", + "Query 3 - Analyze the flowchart", + ] + assert state["counts"] == ["2", "2", "1"] + + +def test_viewer_codex_global_search_skips_non_navigable_and_orders_by_capture_turn( + tmp_path: Path, chromium_browser +) -> None: + records = ( + { + "timestamp": "2026-06-01T00:57:34.000Z", + "request_id": "req_models", + "turn": 1, + "duration_ms": 100, + "request": { + "method": "GET", + "path": "/v1/models", + "headers": {}, + "body": None, + }, + "response": {"status": 200, "headers": {}, "body": {"data": []}}, + }, + { + "timestamp": "2026-06-01T00:57:40.000Z", + "request_id": "req_prefetch", + "turn": 2, + "duration_ms": 100, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": { + "type": "response.create", + "model": "gpt-hidden-model", + "generate": False, + "input": [], + }, + }, + "response": { + "status": 101, + "headers": {}, + "body": { + "id": "resp_prefetch", + "model": "gpt-hidden-model", + "generate": False, + "output": [], + "usage": {"input_tokens": 10, "output_tokens": 0}, + }, + }, + }, + { + "timestamp": "2026-06-01T00:58:53.000Z", + "request_id": "req_response_2", + "turn": 2, + "duration_ms": 100, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": { + "type": "response.create", + "model": "gpt-5.5", + "input": [ + { + "type": "message", + "role": "user", + "content": "hello", + } + ], + }, + }, + "response": { + "status": 101, + "headers": {}, + "body": { + "id": "resp_response_2", + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "role": "assistant", + "content": "hi", + } + ], + "usage": {"input_tokens": 10, "output_tokens": 10}, + }, + }, + }, + { + "timestamp": "2026-06-01T00:59:00.000Z", + "request_id": "req_mcp_between", + "turn": 3, + "duration_ms": 100, + "request": { + "method": "POST", + "path": "/v1/mcp/list", + "headers": {}, + "body": {"query": "mcp-query"}, + }, + "response": { + "status": 200, + "headers": {}, + "body": {"tools": []}, + }, + }, + { + "timestamp": "2026-06-01T00:59:02.000Z", + "request_id": "req_response_4", + "turn": 4, + "duration_ms": 100, + "transport": "websocket", + "request": { + "method": "WEBSOCKET", + "path": "/v1/responses", + "headers": {}, + "body": { + "type": "response.create", + "model": "gpt-5.5", + "previous_response_id": "resp_response_2", + "input": [ + { + "type": "message", + "role": "user", + "content": "then what", + } + ], + }, + }, + "response": { + "status": 101, + "headers": {}, + "body": { + "id": "resp_response_4", + "model": "gpt-5.5", + "output": [ + { + "type": "message", + "role": "assistant", + "content": "that is it", + } + ], + "usage": {"input_tokens": 10, "output_tokens": 10}, + }, + }, + }, + ) + + html_path = _generate_case_html(tmp_path, "codex_search_sort", records) + page = chromium_browser.new_page() + try: + errors = _open_viewer_with_error_capture(page, html_path) + # Verify global search skips the hidden model + page.evaluate("() => openGlobalSearch()") + page.evaluate("() => { $('#global-search-input').value = 'gpt-hidden-model'; recalcGlobalSearchMatches(); }") + search_state = page.evaluate("() => ({ totalMatches: globalSearchState.totalMatches })") + + # Expand paths and check sorting (should be chronological: turn 2 -> 3 -> 4) + page.evaluate("() => { closeGlobalSearch(); activePaths.add('/v1/mcp/list'); applyFilter(); }") + debug_state = page.evaluate( + """() => ({ + entries: entries.map(e => ({ + request_id: e.request_id, + turn: e.turn, + capture_turn: e.capture_turn, + display_turn: e.display_turn, + captureTurnValue: captureTurnValue(e), + displayTurnValue: displayTurnValue(e), + isNavigable: isNavigableTraceEntry(e), + })), + filtered: filtered.map(e => e.request_id), + })""" + ) + sorted_ids = [rid for rid in debug_state["filtered"] if rid != "req_models"] + finally: + page.close() + + assert errors == [] + assert search_state["totalMatches"] == 0 + assert sorted_ids == ["req_response_2", "req_mcp_between", "req_response_4"] diff --git a/tests/test_viewer_i18n_source.py b/tests/test_viewer_i18n_source.py new file mode 100644 index 0000000..e892026 --- /dev/null +++ b/tests/test_viewer_i18n_source.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from pathlib import Path + +from claude_tap.viewer import VIEWER_JS_PATHS, _generate_html_viewer, _load_viewer_i18n, _read_viewer_template + +EXPECTED_LANGUAGES = ["en", "zh-CN", "ja", "ko", "fr", "ar", "de", "ru"] +CRITICAL_KEYS = [ + "title", + "section_system", + "section_messages", + "section_tools", + "section_response", + "section_sse", + "section_json", + "empty_trace_title", + "diff_select_target", +] + + +def test_viewer_i18n_json_has_complete_language_key_sets() -> None: + entries = _load_viewer_i18n() + + assert list(entries) == EXPECTED_LANGUAGES + source_keys = set(entries["en"]) + assert len(source_keys) >= 40 + for lang in EXPECTED_LANGUAGES: + assert set(entries[lang]) == source_keys + for key in CRITICAL_KEYS: + assert entries[lang][key] + + +def test_viewer_session_sort_label_uses_query_language() -> None: + entries = _load_viewer_i18n() + + assert entries["en"]["sort_session"] == "Query" + assert entries["zh-CN"]["sort_session"] == "用户输入" + + +def test_read_viewer_template_embeds_i18n_before_main_script() -> None: + html = _read_viewer_template() + + assert "const __CLAUDE_TAP_I18N__ =" in html + assert "const I18N = typeof __CLAUDE_TAP_I18N__" in html + assert '"section_system":"System Prompt"' in html + assert '"section_tools":"工具"' in html + assert html.index("const __CLAUDE_TAP_I18N__ =") < html.index("const $ = s =>") + assert "CLAUDE_TAP_VIEWER_STYLE" not in html + assert "CLAUDE_TAP_VIEWER_SCRIPT" not in html + assert "viewer_assets" not in html + + +def test_read_viewer_template_embeds_split_js_assets_in_order() -> None: + html = _read_viewer_template() + + markers = [ + "const $ = s =>", + "function expandWebSocketResponseEntries", + "function renderApp", + "function renderSidebar", + "function renderDetail", + "function renderContent", + "function renderJSONTree", + "function lineDiff", + "function mobileNext", + ] + + positions = [html.index(marker) for marker in markers] + assert positions == sorted(positions) + + +def test_split_viewer_js_assets_use_semantic_filenames() -> None: + names = [path.name for path in VIEWER_JS_PATHS] + + assert names == [ + "state.js", + "responses.js", + "lazy_loading.js", + "i18n_ui.js", + "live_bootstrap.js", + "filters_search.js", + "sidebar.js", + "detail_trace.js", + "renderers.js", + "sections_json.js", + "diff.js", + "utilities_mobile.js", + ] + assert all(path.exists() for path in VIEWER_JS_PATHS) + assert all(not name[0].isdigit() for name in names) + + +def test_generate_html_viewer_remains_self_contained_after_i18n_split(tmp_path: Path) -> None: + trace_path = tmp_path / "empty.jsonl" + html_path = tmp_path / "empty.html" + trace_path.write_text("", encoding="utf-8") + + _generate_html_viewer(trace_path, html_path) + + html = html_path.read_text(encoding="utf-8") + assert "const __CLAUDE_TAP_I18N__ =" in html + assert "viewer_i18n.json" not in html + assert "No API calls captured" in html + assert "EMBEDDED_TRACE_COMPACT_DATA" in html diff --git a/tests/test_viewer_js_units.py b/tests/test_viewer_js_units.py new file mode 100644 index 0000000..b8030ff --- /dev/null +++ b/tests/test_viewer_js_units.py @@ -0,0 +1,568 @@ +from __future__ import annotations + +import shutil +import subprocess +import textwrap +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js is required for viewer JS unit tests") +def test_viewer_split_js_core_units_run_without_playwright() -> None: + script = textwrap.dedent( + r""" + const assert = require('assert/strict'); + const fs = require('fs'); + const path = require('path'); + const vm = require('vm'); + + const repoRoot = process.argv.at(-1); + const assetDir = path.join(repoRoot, 'claude_tap', 'viewer_assets'); + + function classList() { + return { add() {}, remove() {}, toggle() {}, contains() { return false; } }; + } + + function element() { + return { + style: {}, + dataset: {}, + classList: classList(), + children: [], + innerHTML: '', + textContent: '', + value: '', + setAttribute() {}, + appendChild(child) { this.children.push(child); return child; }, + removeChild(child) { this.children = this.children.filter(item => item !== child); }, + addEventListener() {}, + querySelector() { return null; }, + querySelectorAll() { return []; }, + focus() {}, + select() {}, + setSelectionRange() {}, + remove() {}, + }; + } + + const context = { + console, + URLSearchParams, + setTimeout() {}, + clearTimeout() {}, + requestAnimationFrame(callback) { if (typeof callback === 'function') callback(); return 1; }, + cancelAnimationFrame() {}, + window: { + location: { search: '?embed=1&hideHeader=1&density=compact&theme=dark' }, + localStorage: { getItem() { return null; }, setItem() {} }, + matchMedia() { return { matches: false }; }, + }, + navigator: { language: 'en', clipboard: null }, + document: { + documentElement: { dataset: {}, classList: classList() }, + body: element(), + querySelector() { return element(); }, + querySelectorAll() { return []; }, + getElementById() { return element(); }, + createElement() { return element(); }, + addEventListener() {}, + removeEventListener() {}, + execCommand() { return false; }, + }, + }; + vm.createContext(context); + + for (const assetName of [ + 'state.js', + 'responses.js', + 'lazy_loading.js', + 'i18n_ui.js', + 'live_bootstrap.js', + 'filters_search.js', + 'renderers.js', + 'diff.js', + 'utilities_mobile.js', + ]) { + const source = fs.readFileSync(path.join(assetDir, assetName), 'utf8'); + vm.runInContext(source, context, { filename: assetName }); + } + + const plain = value => JSON.parse(JSON.stringify(value)); + + assert.deepEqual(plain(context.parseEmbedQueryOptions()), { + enabled: true, + hideHeader: true, + hidePath: false, + hideHistory: false, + hideControls: false, + compact: true, + theme: 'dark', + }); + + assert.deepEqual(plain(context.turnSortSegments('1.02.beta')), [1, 2, 0]); + assert.equal(context.compareTurns('1.10', '1.2') > 0, true); + assert.equal(context.compareTurns('2', '10') < 0, true); + + assert.deepEqual( + plain(context.lineDiff('alpha\nold\nsame', 'alpha\nnew\nsame')), + [ + { type: 'ctx', text: 'alpha' }, + { type: 'change', oldText: 'old', newText: 'new' }, + { type: 'ctx', text: 'same' }, + ], + ); + + const events = [ + { event: 'response.created', data: { response: { id: 'resp_first' } } }, + { + event: 'response.output_item.done', + data: { + output_index: 0, + item: { + id: 'item_first_tool', + type: 'function_call', + call_id: 'call_1', + name: 'shell', + arguments: '{"cmd":"pwd"}', + }, + }, + }, + { + event: 'response.completed', + data: { response: { id: 'resp_first', output: [], usage: { output_tokens: 1 } } }, + }, + { event: 'response.created', data: { response: { id: 'resp_prefetch', generate: false } } }, + { + event: 'response.completed', + data: { response: { id: 'resp_prefetch', generate: false, usage: { output_tokens: 0 } } }, + }, + ]; + const groups = context.splitWebSocketResponseEvents(events); + assert.equal(groups.length, 2); + assert.equal(context.completedResponseFromEvents(groups[0].events).id, 'resp_first'); + assert.deepEqual( + plain(groups.filter(group => context.isDisplayableWebSocketResponseGroup(group)).map(group => group.responseId)), + ['resp_first'], + ); + assert.deepEqual(plain(context.webSocketOutputMessages(groups[0].events)), [ + { + type: 'message', + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'call_1', + name: 'shell', + input: { cmd: 'pwd' }, + }], + }, + ]); + + assert.deepEqual(plain(context.normalizeDisplayContentBlocks([ + { type: 'input_text', text: 'hello' }, + { type: 'input_image', source: { media_type: 'image/png', data: 'base64-data' } }, + { type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }, + ])), [ + { type: 'input_text', text: 'hello' }, + { type: 'input_image', source: { media_type: 'image/png', data: 'base64-data' } }, + { type: 'tool_result', tool_use_id: 'call_1', content: 'ok' }, + ]); + + assert.deepEqual(plain(context.getMessages({ + instructions: 'Be concise', + input: [{ role: 'user', content: [{ type: 'input_text', text: 'Hi' }] }], + })), [ + { role: 'developer', content: [{ type: 'text', text: 'Be concise' }] }, + { role: 'user', content: [{ type: 'input_text', text: 'Hi' }] }, + ]); + + assert.deepEqual( + plain(context.getRequestTools({ + model: 'gpt-5.6-sol', + input: [{ + type: 'additional_tools', + role: 'developer', + tools: [ + { name: 'exec', description: 'Run a command' }, + { name: 'wait' }, + { name: 'request_user_input' }, + ], + }], + }).map(tool => context.toolDisplayName(tool))), + ['exec', 'wait', 'request_user_input'], + ); + + assert.deepEqual( + plain(context.getRequestTools({ + tools: [{ name: 'exec' }], + input: [{ + type: 'additional_tools', + tools: [{ name: 'exec' }, { name: 'collaboration' }], + }], + }).map(tool => context.toolDisplayName(tool))), + ['exec', 'collaboration'], + ); + + const codexPrefetchId = 'resp_prefetch_tools'; + const codexVisibleId = 'resp_visible'; + const codexExpanded = context.expandWebSocketResponseEntries([ + { + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/v1/responses', + body: { + model: 'gpt-5.6-sol', + generate: false, + input: [{ + type: 'additional_tools', + role: 'developer', + tools: [ + { name: 'exec' }, + { name: 'wait' }, + { name: 'request_user_input' }, + { name: 'collaboration' }, + ], + }], + }, + }, + response: { + body: { + id: codexPrefetchId, + generate: false, + output: [], + usage: { input_tokens: 10, output_tokens: 0 }, + }, + }, + }, + { + transport: 'websocket', + request: { + method: 'WEBSOCKET', + path: '/v1/responses', + body: { + model: 'gpt-5.6-sol', + previous_response_id: codexPrefetchId, + input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Run pwd' }] }], + }, + }, + response: { + body: { + id: codexVisibleId, + previous_response_id: codexPrefetchId, + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], + usage: { input_tokens: 20, output_tokens: 2 }, + }, + }, + }, + ]); + assert.equal(codexExpanded.length, 1); + assert.deepEqual( + plain(context.getRequestTools(codexExpanded[0].request.body).map(tool => context.toolDisplayName(tool))), + ['exec', 'wait', 'request_user_input', 'collaboration'], + ); + assert.deepEqual( + plain(context.getMessages(codexExpanded[0].request.body).map(message => message.role)), + ['user'], + ); + + const compactBundle = { + __claude_tap_compact_trace__: { version: 1 }, + blobs: { + hash_1: { + kind: 'json', + payload: { + method: 'POST', + path: '/v1/responses', + body: { input: [{ role: 'user', content: 'compact prompt' }] }, + }, + }, + }, + records: [{ + __claude_tap_compact_record__: { + version: 1, + refs: [{ path: '/request', hash: 'hash_1', bytes: 100 }], + }, + record: { + turn: 1, + request: { + __claude_tap_blob_ref__: { version: 1, kind: 'json', hash: 'hash_1' }, + }, + response: { + status: 200, + body: { + output: [{ + type: 'message', + content: [{ + type: 'output_text', + text: 'marker-shaped user payload', + metadata: { + __claude_tap_blob_ref__: { + version: 1, + kind: 'json', + hash: 'user-controlled-marker-shape', + }, + }, + }], + }], + }, + }, + }, + }], + }; + const fakeUserMarker = { + __claude_tap_blob_ref__: { + version: 1, + kind: 'json', + hash: 'user-controlled-marker-shape', + }, + }; + assert.deepEqual(plain(context.materializeCompactTraceBundle(compactBundle)), [{ + turn: 1, + request: { + method: 'POST', + path: '/v1/responses', + body: { input: [{ role: 'user', content: 'compact prompt' }] }, + }, + response: { + status: 200, + body: { + output: [{ + type: 'message', + content: [{ + type: 'output_text', + text: 'marker-shaped user payload', + metadata: fakeUserMarker, + }], + }], + }, + }, + }]); + assert.deepEqual( + plain(context.parseTraceText(JSON.stringify(compactBundle))), + plain(context.materializeCompactTraceBundle(compactBundle)), + ); + + const legacyCompactBundle = { + __claude_tap_compact_trace__: { version: 1 }, + blobs: { + hash_legacy_instructions: { + kind: 'json', + payload: 'legacy compact instructions', + }, + hash_legacy_input: { + kind: 'json', + payload: { + role: 'user', + content: [{ type: 'input_text', text: 'legacy compact input item' }], + }, + }, + }, + records: [{ + __claude_tap_compact_record__: { + version: 1, + encoding: 'json-blob-ref', + }, + record: { + turn: 2, + request: { + body: { + instructions: { + __claude_tap_blob_ref__: { version: 1, kind: 'json', hash: 'hash_legacy_instructions' }, + }, + input: [ + { + __claude_tap_blob_ref__: { version: 1, kind: 'json', hash: 'hash_legacy_input' }, + }, + { + role: 'user', + content: [{ type: 'input_text', text: 'keep marker shape' }], + metadata: fakeUserMarker, + }, + ], + }, + }, + response: { body: { output: [] } }, + }, + }], + }; + assert.deepEqual(plain(context.materializeCompactTraceBundle(legacyCompactBundle)), [{ + turn: 2, + request: { + body: { + instructions: 'legacy compact instructions', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'legacy compact input item' }], + }, + { + role: 'user', + content: [{ type: 'input_text', text: 'keep marker shape' }], + metadata: fakeUserMarker, + }, + ], + }, + }, + response: { body: { output: [] } }, + }]); + + /* ── normalizeUsage: provider-aware cache flag ── */ + + // OpenAI-style: cached_tokens embedded in prompt_tokens via details + const openaiUsage = context.normalizeUsage({ + prompt_tokens: 100, + completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 60 }, + }); + assert.equal(openaiUsage.input_tokens, 100); + assert.equal(openaiUsage.cache_read_input_tokens, 60); + assert.equal(openaiUsage._cache_read_in_input, true); + + // Claude/Anthropic-style: cache_read_input_tokens separate from input_tokens + const claudeUsage = context.normalizeUsage({ + input_tokens: 40, + output_tokens: 20, + cache_read_input_tokens: 60, + cache_creation_input_tokens: 10, + }); + assert.equal(claudeUsage.input_tokens, 40); + assert.equal(claudeUsage.cache_read_input_tokens, 60); + assert.equal(claudeUsage._cache_read_in_input, false); + + // Bedrock Converse-style camelCase: cacheReadInputTokens is a separate bucket + const bedrockUsage = context.normalizeUsage({ + inputTokens: 9, + outputTokens: 1, + cacheReadInputTokens: 12, + cacheWriteInputTokens: 2, + }); + assert.equal(bedrockUsage.input_tokens, 9); + assert.equal(bedrockUsage.cache_read_input_tokens, 12); + assert.equal(bedrockUsage.cache_creation_input_tokens, 2); + assert.equal(bedrockUsage._cache_read_in_input, false); + + // No cache data at all: flag should be absent + const noCacheUsage = context.normalizeUsage({ input_tokens: 100, output_tokens: 50 }); + assert.equal(noCacheUsage.cache_read_input_tokens, undefined); + assert.equal(noCacheUsage._cache_read_in_input, undefined); + + /* ── Cache hit rate denominator correctness ── */ + + // Simulate OpenAI-style: cache embedded in input → rate = 60/100 = 60% + // denominator = input_tokens = 100 + const openaiRate = Math.round(60 / 100 * 100); + assert.equal(openaiRate, 60); + + // Simulate Claude-style: cache separate → total input-side = 40+60+10 = 110 + // rate = 60/110 = 55% (NOT 60/40 = 150% which is the old buggy result) + const claudeTotalInput = 40 + 60 + 10; + const claudeRate = Math.round(60 / claudeTotalInput * 100); + assert.equal(claudeRate, 55); + assert.ok(claudeRate <= 100, 'Claude-style rate must not exceed 100%'); + + /* ── Direct DOM test: #stat-cache-hit-rate via applyFilter() ── */ + + context.assert = assert; + context.element = element; + + vm.runInContext(` + // Persistent stat elements so applyFilter can set textContent + const _statEls = {}; + document.querySelector = function (sel) { + if (typeof sel === 'string' && sel.startsWith('#')) { + const id = sel.slice(1); + if (!_statEls[id]) _statEls[id] = element(); + return _statEls[id]; + } + return element(); + }; + // Stub heavy rendering helpers irrelevant to stat computation + renderSidebar = function () {}; + updatePositionIndicator = function () {}; + renderToolFilter = function () {}; + renderPathFilter = function () {}; + renderTracePathBar = function () {}; + + function makeUsageEntry(usage, path) { + return { + request: { path: path || '/v1/messages', method: 'POST', body: {} }, + response: { body: { usage } }, + turn: '1', + duration_ms: 100, + }; + } + + // Claude-style: cache_read separate from input → 60/(40+60+10)=55% + entries = [makeUsageEntry({ + input_tokens: 40, output_tokens: 20, + cache_read_input_tokens: 60, cache_creation_input_tokens: 10, + })]; + activePaths = new Set(['/v1/messages']); + searchQuery = ''; + activeTools = null; + applyFilter(); + assert.equal(_statEls['stat-cache-hit-rate'].textContent, '55%', + 'Claude-style direct DOM: expected 55%'); + assert.equal(_statEls['stat-cache-hit-rate-group'].style.display, 'flex', + 'Claude-style direct DOM: group should be visible'); + + // OpenAI-style: cache embedded in input → 60/100=60% + entries = [makeUsageEntry({ + prompt_tokens: 100, completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 60 }, + })]; + applyFilter(); + assert.equal(_statEls['stat-cache-hit-rate'].textContent, '60%', + 'OpenAI-style direct DOM: expected 60%'); + + // Bedrock camelCase: cache_read separate from input → 12/(9+12+2)=52% + entries = [makeUsageEntry({ + inputTokens: 9, outputTokens: 1, + cacheReadInputTokens: 12, cacheWriteInputTokens: 2, + })]; + applyFilter(); + assert.equal(_statEls['stat-cache-hit-rate'].textContent, '52%', + 'Bedrock camelCase direct DOM: expected 52%'); + + // No cache data: group should be hidden + entries = [makeUsageEntry({ input_tokens: 100, output_tokens: 50 })]; + applyFilter(); + assert.equal(_statEls['stat-cache-hit-rate-group'].style.display, 'none', + 'No-cache direct DOM: group should be hidden'); + + // Mixed providers: OpenAI(100,cache=60) + Claude(40,cache_read=60,create=10) + // denom = 100 + 110 = 210, cache_read = 120, rate = 57% + entries = [ + makeUsageEntry({ + prompt_tokens: 100, completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 60 }, + }), + makeUsageEntry({ + input_tokens: 40, output_tokens: 20, + cache_read_input_tokens: 60, cache_creation_input_tokens: 10, + }), + ]; + applyFilter(); + assert.equal(_statEls['stat-cache-hit-rate'].textContent, '57%', + 'Mixed-provider direct DOM: expected 57%'); + + // Mixed cached and uncached entries: uncached input still belongs in denominator + // denom = OpenAI input 100 + uncached input 100, cache_read = 60, rate = 30% + entries = [ + makeUsageEntry({ + prompt_tokens: 100, completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 60 }, + }), + makeUsageEntry({ input_tokens: 100, output_tokens: 10 }), + ]; + applyFilter(); + assert.equal(_statEls['stat-cache-hit-rate'].textContent, '30%', + 'Mixed cached/uncached direct DOM: expected 30%'); + `, context); + """ + ) + + subprocess.run(["node", "-e", script, str(REPO_ROOT)], check=True, capture_output=True, text=True) diff --git a/tests/test_viewer_non_dict_body.py b/tests/test_viewer_non_dict_body.py new file mode 100644 index 0000000..47cab70 --- /dev/null +++ b/tests/test_viewer_non_dict_body.py @@ -0,0 +1,271 @@ +"""Regression: viewer must tolerate non-dict request/response bodies. + +opencode in forward-proxy mode (the new default in this PR) captures every +HTTPS upstream the client talks to — not just LLM endpoints. First-run +bootstraps proxy npm registry traffic (security advisory POSTs, .tgz +downloads, HTML error pages) whose `request.body` / `response.body` end up +as strings instead of JSON objects. The pre-fix viewer extractor assumed +dict and crashed with `AttributeError: 'str' object has no attribute 'get'`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from claude_tap.viewer import LAZY_THRESHOLD, _extract_metadata, _generate_html_viewer + + +def _record(req_body, resp_body, *, request_id: str = "req_1", turn: int = 1) -> dict: + return { + "timestamp": "2026-05-04T03:00:00+00:00", + "request_id": request_id, + "turn": turn, + "duration_ms": 50, + "request": { + "method": "POST", + "path": "/-/npm/v1/security/advisories/bulk", + "headers": {}, + "body": req_body, + }, + "response": { + "status": 200, + "headers": {}, + "body": resp_body, + }, + } + + +def test_extract_metadata_handles_string_request_body() -> None: + rec = _record("raw-string-payload-not-json", {"usage": {"input_tokens": 0}}) + meta = _extract_metadata(json.dumps(rec)) + assert meta is not None + assert meta["request_id"] == "req_1" + # Non-dict body should degrade gracefully — no model, no system hint. + assert meta["model"] == "" + assert meta["has_system"] is False + assert meta["message_count"] == 0 + assert meta["tool_names"] == [] + + +def test_extract_metadata_handles_string_response_body() -> None: + rec = _record({"model": "claude-x", "messages": []}, "...") + meta = _extract_metadata(json.dumps(rec)) + assert meta is not None + assert meta["model"] == "claude-x" + # Non-dict response body must not block extraction of request-side fields. + assert meta["input_tokens"] == 0 + assert meta["output_tokens"] == 0 + assert meta["response_tool_names"] == [] + assert meta["error_message"] == "" + + +def test_extract_metadata_handles_both_bodies_as_strings() -> None: + rec = _record("npm-tgz-binary-blob", "gateway timeout html") + meta = _extract_metadata(json.dumps(rec)) + assert meta is not None + assert meta["status"] == 200 + assert meta["path"] == "/-/npm/v1/security/advisories/bulk" + + +def test_extract_metadata_handles_non_dict_request_response_containers() -> None: + rec = { + "timestamp": "2026-05-07T15:25:00+00:00", + "request_id": "req_bad_container", + "request": "opaque request container", + "response": "opaque response container", + } + meta = _extract_metadata(json.dumps(rec)) + assert meta is not None + assert meta["request_id"] == "req_bad_container" + assert meta["method"] == "" + assert meta["path"] == "" + assert meta["status"] == 0 + + +def test_extract_metadata_handles_talon_codex_trace_with_string_request_body() -> None: + """Regression for Talon-launched Codex traces captured by claude-tap 0.1.38. + + The crashed stack was in _generate_html_viewer -> _extract_metadata when a + Codex WebSocket trace had request.body as a string. Metadata extraction must + still degrade gracefully and keep usage from the terminal stream event. + """ + rec = { + "timestamp": "2026-05-07T15:26:00+00:00", + "request_id": "req_talon_codex_string_body", + "turn": 1, + "duration_ms": 220000, + "request": { + "method": "WEBSOCKET", + "path": "/backend-api/codex/responses", + "headers": {"user-agent": "codex/0.128.0"}, + "body": "opaque websocket create payload", + }, + "response": { + "status": 101, + "headers": {}, + "body": "", + "ws_events": [ + { + "type": "response.completed", + "response": { + "id": "resp_talon_codex", + "usage": { + "input_tokens": 12, + "output_tokens": 5, + "cache_read_input_tokens": 3, + "cache_creation_input_tokens": 2, + }, + }, + } + ], + }, + } + + meta = _extract_metadata(json.dumps(rec)) + + assert meta is not None + assert meta["request_id"] == "req_talon_codex_string_body" + assert meta["path"] == "/backend-api/codex/responses" + assert meta["model"] == "" + assert meta["has_system"] is False + assert meta["message_count"] == 0 + assert meta["input_tokens"] == 12 + assert meta["output_tokens"] == 5 + assert meta["cache_read_input_tokens"] == 3 + assert meta["cache_creation_input_tokens"] == 2 + + +def test_generate_html_viewer_does_not_crash_on_mixed_bodies(tmp_path: Path) -> None: + """End-to-end: lazy path (>LAZY_THRESHOLD records) calls _extract_metadata + on every line. A single string-body record must not abort generation.""" + trace_path = tmp_path / "trace.jsonl" + lines: list[str] = [] + # Fill above the lazy threshold so _extract_metadata is exercised. + for i in range(LAZY_THRESHOLD + 5): + if i % 7 == 0: + # Non-LLM npm/tgz traffic — string bodies. + rec = _record("opaque-binary", "", request_id=f"req_{i}", turn=i) + else: + rec = _record( + {"model": "claude-x", "messages": [{"role": "user", "content": "hi"}]}, + {"usage": {"input_tokens": 1, "output_tokens": 1}, "content": []}, + request_id=f"req_{i}", + turn=i, + ) + lines.append(json.dumps(rec)) + trace_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + html_path = tmp_path / "trace.html" + _generate_html_viewer(trace_path, html_path) + + assert html_path.exists() + assert html_path.stat().st_size > 0 + + +def _opencode_homepage_payload() -> str: + """A minimal stand-in for the kind of HTML body the forward proxy captures + when opencode hits a non-LLM upstream (e.g. opencode.ai homepage). The two + pieces that matter are the close-tag and a nested " + "

The open source AI coding agent

" + "" + ) + + +def test_html_viewer_inline_path_escapes_script_close_in_string_body(tmp_path: Path) -> None: + """Regression: embedded trace data must escape captured text. + + If a captured response.body is a string containing (e.g. + opencode.ai homepage HTML), the surrounding script tag would close + prematurely and the HTML would render as page content. The embedded compact + data block must escape <\\/ before embedding. + """ + trace_path = tmp_path / "trace.jsonl" + rec = _record(None, _opencode_homepage_payload(), request_id="req_oc_home", turn=5) + trace_path.write_text(json.dumps(rec) + "\n", encoding="utf-8") + + html_path = tmp_path / "trace.html" + _generate_html_viewer(trace_path, html_path) + html = html_path.read_text(encoding="utf-8") + + # Locate the embedded compact data block. + anchor = "const EMBEDDED_TRACE_COMPACT_DATA = " + start = html.find(anchor) + assert start >= 0, "EMBEDDED_TRACE_COMPACT_DATA block not found" + # Find where the JS data block is meant to end. It is followed by the + # surrounding `` that closes the data . + data_close = html.find("", start) + assert data_close >= 0 + data_block = html[start:data_close] + assert "" not in data_block, ( + "captured leaked into the inline data block — would close " + "the wrapping text.""" + trace_path = tmp_path / "trace.jsonl" + lines: list[str] = [] + for i in range(LAZY_THRESHOLD + 5): + if i == 3: + rec = _record(None, _opencode_homepage_payload(), request_id=f"req_{i}", turn=i) + else: + rec = _record( + {"model": "claude-x", "messages": []}, + {"usage": {}, "content": []}, + request_id=f"req_{i}", + turn=i, + ) + lines.append(json.dumps(rec)) + trace_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + html_path = tmp_path / "trace.html" + _generate_html_viewer(trace_path, html_path) + html = html_path.read_text(encoding="utf-8") + + anchor = "const EMBEDDED_TRACE_COMPACT_DATA = " + start = html.find(anchor) + assert start >= 0, "EMBEDDED_TRACE_COMPACT_DATA block not found" + block_close = html.find("", start) + assert block_close >= 0 + data_block = html[start:block_close] + assert "" not in data_block + assert "<\\/script>" in data_block diff --git a/tests/test_windows_compat.py b/tests/test_windows_compat.py new file mode 100644 index 0000000..c788a3b --- /dev/null +++ b/tests/test_windows_compat.py @@ -0,0 +1,151 @@ +"""Regression tests for Windows-specific bugs reported in issue #83. + +Patches `signal` and `shutil.which` to simulate the Windows environment so the +tests run identically on Linux/macOS CI and on real Windows. +""" + +from __future__ import annotations + +import asyncio +import signal +import sys +from pathlib import Path + +import pytest + +from claude_tap.cli import run_client +from claude_tap.history import _rel_posix + + +class _DummyProc: + def __init__(self) -> None: + self.pid = 12345 + self.returncode: int | None = None + + async def wait(self) -> int: + self.returncode = 0 + return 0 + + def terminate(self) -> None: + self.returncode = 0 + + def kill(self) -> None: + self.returncode = -9 + + +def _strip_sigtstp(monkeypatch: pytest.MonkeyPatch) -> None: + """Remove SIGTSTP and force loop.add/remove_signal_handler to NotImplementedError.""" + if hasattr(signal, "SIGTSTP"): + monkeypatch.delattr(signal, "SIGTSTP", raising=False) + + def _not_impl(*_args, **_kwargs): + raise NotImplementedError + + monkeypatch.setattr("asyncio.AbstractEventLoop.add_signal_handler", _not_impl, raising=False) + monkeypatch.setattr("asyncio.AbstractEventLoop.remove_signal_handler", _not_impl, raising=False) + + +@pytest.mark.asyncio +async def test_run_client_does_not_touch_sigtstp_when_absent(monkeypatch) -> None: + async def fake_create_subprocess_exec(*cmd, **kwargs): + return _DummyProc() + + _strip_sigtstp(monkeypatch) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: r"C:\Users\x\.local\bin\claude.cmd") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--version"], client="claude", proxy_mode="reverse") + assert code == 0 + + +@pytest.mark.asyncio +async def test_run_client_passes_resolved_path_for_cmd_shim(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + shim_path = r"C:\Users\x\.local\bin\claude.cmd" + _strip_sigtstp(monkeypatch) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: shim_path) + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client(43123, ["--version"], client="claude", proxy_mode="reverse") + assert code == 0 + cmd = captured["cmd"] + assert cmd[0] == shim_path, "resolved .cmd shim path must be preserved" + assert cmd[1] == "--settings", "--settings must be injected before forwarded args" + import json + + injected = json.loads(cmd[2]) + assert injected == {"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:43123"}} + assert cmd[3:] == ("--version",), "original args must follow --settings payload" + + +@pytest.mark.asyncio +async def test_run_client_uses_wrapper_provided_claude_binary(monkeypatch, tmp_path: Path) -> None: + captured: dict[str, object] = {} + + async def fake_create_subprocess_exec(*cmd, **kwargs): + captured["cmd"] = cmd + return _DummyProc() + + wrapped_claude = tmp_path / "claude" + wrapped_claude.write_text("#!/bin/sh\n", encoding="utf-8") + _strip_sigtstp(monkeypatch) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: None) + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["--output-format", "stream-json"], + client="claude", + proxy_mode="reverse", + client_cmd=str(wrapped_claude), + ) + assert code == 0 + cmd = captured["cmd"] + assert cmd[0] == str(wrapped_claude) + assert cmd[1] == "--settings" + assert cmd[3:] == ("--output-format", "stream-json") + + +@pytest.mark.asyncio +async def test_run_client_does_not_execute_wrapper_directory(monkeypatch, tmp_path: Path) -> None: + async def fail_create_subprocess_exec(*cmd, **kwargs): + raise AssertionError(f"directory path should not be executed: {cmd}") + + wrapped_dir = tmp_path / "claude" + wrapped_dir.mkdir() + _strip_sigtstp(monkeypatch) + monkeypatch.setattr("claude_tap.cli.shutil.which", lambda _: None) + monkeypatch.setattr(asyncio, "create_subprocess_exec", fail_create_subprocess_exec) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + + code = await run_client( + 43123, + ["--output-format", "stream-json"], + client="claude", + proxy_mode="reverse", + client_cmd=str(wrapped_dir), + ) + assert code == 1 + + +def test_module_import_reconfigures_stdout_to_utf8() -> None: + import claude_tap.cli # noqa: F401 + + for stream in (sys.stdout, sys.stderr): + encoding = getattr(stream, "encoding", "") + assert encoding and encoding.lower().replace("-", "") == "utf8", f"expected UTF-8, got {encoding!r} on {stream}" + + +def test_rel_posix_uses_forward_slashes(tmp_path: Path) -> None: + nested = tmp_path / "2026-04-29" / "trace_001234.jsonl" + nested.parent.mkdir(parents=True) + nested.write_text("{}\n", encoding="utf-8") + assert _rel_posix(nested, tmp_path) == "2026-04-29/trace_001234.jsonl" diff --git a/tests/test_ws_proxy.py b/tests/test_ws_proxy.py new file mode 100644 index 0000000..8a079df --- /dev/null +++ b/tests/test_ws_proxy.py @@ -0,0 +1,1344 @@ +"""Tests for WebSocket proxy support in reverse proxy mode.""" + +import asyncio +import json +import os +import shutil +import tempfile +import tracemalloc +from pathlib import Path + +import aiohttp +import pytest +from aiohttp import web +from yarl import URL + +from claude_tap.cli_clients import _extend_no_proxy +from claude_tap.proxy import proxy_handler +from claude_tap.trace import TraceWriter +from claude_tap.trace_store import get_trace_store, reset_trace_store +from claude_tap.ws_proxy import _build_ws_record, _get_ws_proxy_settings + + +@pytest.fixture +def trace_dir(): + d = tempfile.mkdtemp(prefix="claude_tap_ws_test_") + saved_no_proxy = {key: os.environ.get(key) for key in ("NO_PROXY", "no_proxy")} + _extend_no_proxy(os.environ, ("localhost", "127.0.0.1", "::1")) + os.environ["CLOUDTAP_DB"] = str(Path(d) / "ws-test.sqlite3") + reset_trace_store() + yield d + for key, value in saved_no_proxy.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + shutil.rmtree(d, ignore_errors=True) + reset_trace_store() + + +def _make_writer() -> tuple[object, str, TraceWriter]: + store = get_trace_store() + session_id = store.create_session() + return store, session_id, TraceWriter(session_id, store=store) + + +def _load_records(store, session_id: str) -> list[dict]: + return store.load_records(session_id) + + +async def _start_ws_upstream(handler) -> tuple[web.AppRunner, int]: + """Start a fake WebSocket upstream server, return (runner, port).""" + app = web.Application() + app.router.add_route("*", "/{path:.*}", handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, port + + +async def _start_proxy( + target_url, + writer, + strip_prefix="", + store_stream_events=False, + capture_only=False, +) -> tuple[web.AppRunner, int, aiohttp.ClientSession]: + """Start the reverse proxy, return (runner, port, session).""" + session = aiohttp.ClientSession(auto_decompress=False, trust_env=True) + app = web.Application(client_max_size=0) + app["trace_ctx"] = { + "target_url": target_url, + "writer": writer, + "session": session, + "turn_counter": 0, + "strip_path_prefix": strip_prefix, + "store_stream_events": store_stream_events, + "capture_only": capture_only, + } + app.router.add_route("*", "/{path_info:.*}", proxy_handler) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0) + await site.start() + port = site._server.sockets[0].getsockname()[1] + return runner, port, session + + +@pytest.mark.asyncio +async def test_websocket_capture_only_reverse_skips_upstream_and_reads_prompt_frame(trace_dir, monkeypatch): + """Reverse WebSocket capture-only accepts locally and waits past setup frames for the prompt.""" + store, session_id, writer = _make_writer() + + async def ws_upstream_handler(_request): + raise AssertionError("capture-only websocket must not connect upstream") + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + store_stream_events=True, + capture_only=True, + ) + + def fail_ws_connect(*args, **kwargs): + raise AssertionError("capture-only websocket must not call session.ws_connect") + + monkeypatch.setattr(proxy_session, "ws_connect", fail_ws_connect) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"type": "session.update", "tools": []}) + await ws.send_json( + { + "type": "response.create", + "model": "gpt-test", + "instructions": "ws system", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "hello"}]}], + } + ) + received = [] + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + received.append(json.loads(msg.data)) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING): + break + await ws.close() + + assert [event["type"] for event in received] == ["response.created", "response.completed"] + await asyncio.sleep(0.1) + writer.close() + records = _load_records(store, session_id) + assert len(records) == 1 + assert records[0]["request"]["body"]["instructions"] == "ws system" + assert len(records[0]["request"]["ws_events"]) == 2 + assert records[0]["response"]["body"]["status"] == "completed" + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +# --------------------------------------------------------------------------- +# Test 1: basic WebSocket relay and trace recording +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_websocket_proxy_basic(trace_dir): + """A WebSocket connection through the proxy relays messages and writes a trace.""" + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + data = json.loads(msg.data) + model = data.get("model", "test-model") + await ws.send_json( + { + "type": "response.created", + "response": {"id": "resp_1", "model": model, "status": "in_progress"}, + } + ) + await ws.send_json({"type": "response.output_text.delta", "delta": "Hello "}) + await ws.send_json({"type": "response.output_text.delta", "delta": "World"}) + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_1", + "model": model, + "status": "completed", + "output": [ + { + "type": "message", + "content": [{"type": "output_text", "text": "Hello World"}], + } + ], + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + } + ) + await ws.close() + break + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"model": "gpt-test", "input": "hello"}) + + received = [] + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=5) + if msg.type == aiohttp.WSMsgType.TEXT: + received.append(json.loads(msg.data)) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + await ws.close() + + # Verify relayed messages + assert len(received) == 4 + assert received[0]["type"] == "response.created" + assert received[1]["type"] == "response.output_text.delta" + assert received[3]["type"] == "response.completed" + assert received[3]["response"]["usage"]["output_tokens"] == 5 + + # Allow trace writer to flush + await asyncio.sleep(0.1) + writer.close() + + records = store.load_records(session_id) + assert len(records) == 1 + r = records[0] + assert r["transport"] == "websocket" + assert r["request"]["method"] == "WEBSOCKET" + assert r["request"]["path"] == "/v1/responses" + assert r["request"]["body"]["model"] == "gpt-test" + assert "ws_events" not in r["request"] + assert r["response"]["status"] == 101 + assert "ws_events" not in r["response"] + assert r["response"]["body"]["status"] == "completed" + assert r["upstream_base_url"] == f"http://127.0.0.1:{upstream_port}" + + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_completed_response_is_written_before_socket_close(trace_dir): + """A completed WS response should be visible before a long-lived socket closes.""" + allow_close = asyncio.Event() + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + data = json.loads(msg.data) + model = data.get("model", "test-model") + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_live", + "model": model, + "status": "completed", + "output": [{"type": "message", "content": [{"type": "output_text", "text": "done"}]}], + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + } + ) + await allow_close.wait() + await ws.close() + break + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"model": "gpt-test", "input": "hello"}) + + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert json.loads(msg.data)["type"] == "response.completed" + + await asyncio.sleep(0.1) + records = store.load_records(session_id) + assert len(records) == 1 + assert records[0]["response"]["body"]["status"] == "completed" + assert records[0]["request"]["body"]["model"] == "gpt-test" + + allow_close.set() + await ws.close() + + await asyncio.sleep(0.1) + writer.close() + + records = store.load_records(session_id) + assert len(records) == 1 + + finally: + allow_close.set() + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_slow_writer_does_not_clear_next_request(trace_dir): + """New client messages arriving during a slow trace write belong to the next record.""" + + class SlowFirstWrite: + def __init__(self, inner: TraceWriter): + self.inner = inner + self.first_write_started = asyncio.Event() + self.release_first_write = asyncio.Event() + self.write_count = 0 + + async def write(self, record): + self.write_count += 1 + if self.write_count == 1: + self.first_write_started.set() + await self.release_first_write.wait() + await self.inner.write(record) + + def close(self): + self.inner.close() + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + data = json.loads(msg.data) + response_id = data["response_id"] + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": response_id, + "model": data.get("model", "test-model"), + "status": "completed", + "output": [{"type": "message", "content": [{"type": "output_text", "text": response_id}]}], + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + } + ) + return ws + + store, session_id, inner_writer = _make_writer() + writer = SlowFirstWrite(inner_writer) + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"model": "gpt-test", "response_id": "resp1", "input": "first"}) + + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert json.loads(msg.data)["response"]["id"] == "resp1" + await asyncio.wait_for(writer.first_write_started.wait(), timeout=5) + + await ws.send_json({"model": "gpt-test", "response_id": "resp2", "input": "second"}) + await asyncio.sleep(0.05) + writer.release_first_write.set() + + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert json.loads(msg.data)["response"]["id"] == "resp2" + await ws.close() + + await asyncio.sleep(0.1) + writer.close() + + records = _load_records(store, session_id) + assert len(records) == 2 + assert records[0]["request"]["body"]["response_id"] == "resp1" + assert records[0]["response"]["body"]["id"] == "resp1" + assert records[1]["request"]["body"]["response_id"] == "resp2" + assert records[1]["response"]["body"]["id"] == "resp2" + + finally: + writer.release_first_write.set() + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_completed_snapshot_before_client_send_returns(trace_dir, monkeypatch): + """A next request sent while completed is still being relayed must not join the old record.""" + first_completed_sent = asyncio.Event() + release_first_send = asyncio.Event() + original_send_str = web.WebSocketResponse.send_str + + async def delayed_send_str(self, data, *args, **kwargs): + result = await original_send_str(self, data, *args, **kwargs) + try: + parsed = json.loads(data) + except json.JSONDecodeError: + return result + if parsed.get("type") == "response.completed" and (parsed.get("response") or {}).get("id") == "resp1": + first_completed_sent.set() + await release_first_send.wait() + return result + + monkeypatch.setattr(web.WebSocketResponse, "send_str", delayed_send_str) + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + data = json.loads(msg.data) + response_id = data["response_id"] + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": response_id, + "model": data.get("model", "test-model"), + "status": "completed", + "output": [{"type": "message", "content": [{"type": "output_text", "text": response_id}]}], + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + } + ) + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"model": "gpt-test", "response_id": "resp1", "input": "first"}) + + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert json.loads(msg.data)["response"]["id"] == "resp1" + await asyncio.wait_for(first_completed_sent.wait(), timeout=5) + + await ws.send_json({"model": "gpt-test", "response_id": "resp2", "input": "second"}) + await asyncio.sleep(0.05) + release_first_send.set() + + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert json.loads(msg.data)["response"]["id"] == "resp2" + await ws.close() + + await asyncio.sleep(0.1) + writer.close() + + records = _load_records(store, session_id) + assert len(records) == 2 + assert records[0]["request"]["body"]["response_id"] == "resp1" + assert records[0]["response"]["body"]["id"] == "resp1" + assert records[1]["request"]["body"]["response_id"] == "resp2" + assert records[1]["response"]["body"]["id"] == "resp2" + + finally: + release_first_send.set() + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_completed_snapshot_survives_client_close_during_write(trace_dir): + """A popped completed snapshot must still be written if the client closes immediately.""" + + class SlowFirstWrite: + def __init__(self, inner: TraceWriter): + self.inner = inner + self.first_write_started = asyncio.Event() + self.release_first_write = asyncio.Event() + self.write_count = 0 + + async def write(self, record): + self.write_count += 1 + if self.write_count == 1: + self.first_write_started.set() + await self.release_first_write.wait() + await self.inner.write(record) + + def close(self): + self.inner.close() + + allow_upstream_close = asyncio.Event() + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + data = json.loads(msg.data) + response_id = data["response_id"] + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": response_id, + "model": data.get("model", "test-model"), + "status": "completed", + "output": [{"type": "message", "content": [{"type": "output_text", "text": response_id}]}], + "usage": {"input_tokens": 3, "output_tokens": 1}, + }, + } + ) + await allow_upstream_close.wait() + await ws.close() + break + return ws + + store, session_id, inner_writer = _make_writer() + writer = SlowFirstWrite(inner_writer) + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"model": "gpt-test", "response_id": "resp1", "input": "first"}) + + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert json.loads(msg.data)["response"]["id"] == "resp1" + await asyncio.wait_for(writer.first_write_started.wait(), timeout=5) + + close_task = asyncio.create_task(ws.close()) + await asyncio.sleep(0.05) + assert _load_records(store, session_id) == [] + + writer.release_first_write.set() + await asyncio.wait_for(close_task, timeout=5) + + await asyncio.sleep(0.1) + writer.close() + + records = _load_records(store, session_id) + assert len(records) == 1 + assert records[0]["request"]["body"]["response_id"] == "resp1" + assert records[0]["response"]["body"]["id"] == "resp1" + + finally: + writer.release_first_write.set() + allow_upstream_close.set() + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_duplicate_completed_keeps_pending_trailing_events(trace_dir): + """Duplicate terminal events should not discard pending non-terminal events.""" + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_same", + "status": "completed", + "output": [], + }, + } + ) + await ws.send_json( + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "LATE_TEXT"}], + }, + } + ) + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_same", + "status": "completed", + "output": [], + }, + } + ) + await ws.close() + break + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + store_stream_events=True, + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"model": "gpt-test", "input": "hello"}) + + received = [] + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=5) + if msg.type == aiohttp.WSMsgType.TEXT: + received.append(json.loads(msg.data)) + elif msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + await ws.close() + + assert [event["type"] for event in received] == [ + "response.completed", + "response.output_item.done", + "response.completed", + ] + + await asyncio.sleep(0.1) + writer.close() + + records = _load_records(store, session_id) + assert len(records) == 2 + assert [event["type"] for event in records[0]["response"]["ws_events"]] == ["response.completed"] + assert [event["type"] for event in records[1]["response"]["ws_events"]] == [ + "response.output_item.done", + ] + assert records[1]["response"]["body"]["output"][0]["content"][0]["text"] == "LATE_TEXT" + + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_duplicate_completed_trailing_events_do_not_move_to_next_response(trace_dir): + """Trailing events after a duplicate terminal event must not be attributed to the next response.""" + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + received = 0 + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + received += 1 + if received == 1: + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_same", + "status": "completed", + "output": [], + }, + } + ) + await ws.send_json( + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "LATE_TEXT"}], + }, + } + ) + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_same", + "status": "completed", + "output": [], + }, + } + ) + continue + await ws.send_json({"type": "response.output_text.delta", "delta": "SECOND"}) + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_two", + "status": "completed", + "output": [], + }, + } + ) + await ws.close() + break + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + store_stream_events=True, + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"response_id": "req1", "model": "gpt-test", "input": "first"}) + for _ in range(3): + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + + await asyncio.sleep(0.1) + partial_records = _load_records(store, session_id) + assert len(partial_records) == 2 + assert [event["type"] for event in partial_records[1]["response"]["ws_events"]] == [ + "response.output_item.done" + ] + assert partial_records[1]["response"]["body"]["output"][0]["content"][0]["text"] == "LATE_TEXT" + + await ws.send_json({"response_id": "req2", "model": "gpt-test", "input": "second"}) + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=5) + if msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + assert msg.type == aiohttp.WSMsgType.TEXT + await ws.close() + + await asyncio.sleep(0.1) + writer.close() + + records = _load_records(store, session_id) + assert len(records) == 3 + assert records[0]["request"]["body"]["response_id"] == "req1" + assert [event["type"] for event in records[0]["response"]["ws_events"]] == ["response.completed"] + assert records[1]["request"]["body"] is None + assert [event["type"] for event in records[1]["response"]["ws_events"]] == ["response.output_item.done"] + assert records[1]["response"]["body"]["output"][0]["content"][0]["text"] == "LATE_TEXT" + assert records[2]["request"]["body"]["response_id"] == "req2" + assert [event["type"] for event in records[2]["response"]["ws_events"]] == [ + "response.output_text.delta", + "response.completed", + ] + assert "LATE_TEXT" not in json.dumps(records[2]["response"]["body"], ensure_ascii=False) + + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_duplicate_completed_does_not_flush_next_request(trace_dir): + """Duplicate terminal events must not split a buffered next request from its response.""" + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + received = 0 + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + received += 1 + if received == 1: + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_same", + "status": "completed", + "output": [], + }, + } + ) + continue + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_same", + "status": "completed", + "output": [], + }, + } + ) + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": "resp_two", + "status": "completed", + "output": [], + }, + } + ) + await ws.close() + break + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"response_id": "req1", "model": "gpt-test", "input": "first"}) + + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert json.loads(msg.data)["response"]["id"] == "resp_same" + + await ws.send_json({"response_id": "req2", "model": "gpt-test", "input": "second"}) + received = [] + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=5) + if msg.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED, + ): + break + assert msg.type == aiohttp.WSMsgType.TEXT + received.append(json.loads(msg.data)["response"]["id"]) + await ws.close() + + assert received == ["resp_same", "resp_two"] + + await asyncio.sleep(0.1) + writer.close() + + records = _load_records(store, session_id) + assert len(records) == 2 + assert records[0]["request"]["body"]["response_id"] == "req1" + assert records[0]["response"]["body"]["id"] == "resp_same" + assert records[1]["request"]["body"]["response_id"] == "req2" + assert records[1]["response"]["body"]["id"] == "resp_two" + + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +@pytest.mark.asyncio +async def test_websocket_completed_segments_do_not_accumulate_in_memory(trace_dir): + """Completed WS response segments should be released while the socket stays open.""" + allow_close = asyncio.Event() + response_count = 200 + payload = "x" * 8_000 + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + received = 0 + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + data = json.loads(msg.data) + response_id = data["response_id"] + await ws.send_json({"type": "response.output_text.delta", "delta": payload}) + await ws.send_json( + { + "type": "response.completed", + "response": { + "id": response_id, + "status": "completed", + "output": [], + }, + } + ) + received += 1 + if received == response_count: + await allow_close.wait() + await ws.close() + break + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + try: + tracemalloc.start() + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + for index in range(response_count): + await ws.send_json( + { + "type": "response.create", + "response_id": f"resp_{index}", + "model": "gpt-test", + "input": payload, + } + ) + for _ in range(2): + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + + await asyncio.sleep(0.1) + current_bytes, peak_bytes = tracemalloc.get_traced_memory() + record_count = len(_load_records(store, session_id)) + assert record_count == response_count + + allow_close.set() + await ws.close() + tracemalloc.stop() + + writer.close() + assert current_bytes < 2_000_000 + assert peak_bytes < 8_000_000 + + finally: + allow_close.set() + if tracemalloc.is_tracing(): + tracemalloc.stop() + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +# --------------------------------------------------------------------------- +# Test 2: upstream ws_connect should inherit trust_env proxy behavior +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_websocket_upstream_connect_does_not_override_proxy(trace_dir): + """Upstream ws_connect must not force proxy=None; rely on session trust_env.""" + + async def ws_upstream_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await ws.send_str(msg.data) + await ws.close() + break + return ws + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(ws_upstream_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + strip_prefix="/v1", + ) + + ws_connect_calls: list[dict] = [] + original_ws_connect = proxy_session.ws_connect + + async def _spy_ws_connect(*args, **kwargs): + ws_connect_calls.append(dict(kwargs)) + return await original_ws_connect(*args, **kwargs) + + proxy_session.ws_connect = _spy_ws_connect # type: ignore[method-assign] + + try: + async with aiohttp.ClientSession() as client: + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_str("hello") + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + assert msg.data == "hello" + await ws.close() + + assert ws_connect_calls + assert "proxy" not in ws_connect_calls[0] + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +# --------------------------------------------------------------------------- +# Test 3: upstream WebSocket failure returns 502 +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_websocket_upstream_failure(trace_dir): + """When upstream WS connect fails, return HTTP 502 and record the error.""" + + store, session_id, writer = _make_writer() + + # Point proxy at a port where nothing is listening + proxy_runner, proxy_port, proxy_session = await _start_proxy( + "http://127.0.0.1:19999", + writer, + strip_prefix="/v1", + ) + + try: + async with aiohttp.ClientSession() as client: + # Attempt WebSocket upgrade — proxy should return 502 + with pytest.raises(aiohttp.WSServerHandshakeError) as exc_info: + await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + assert exc_info.value.status == 502 + + await asyncio.sleep(0.1) + writer.close() + + records = store.load_records(session_id) + assert len(records) == 1 + r = records[0] + assert r["transport"] == "websocket" + assert r["response"]["status"] == 502 + assert r["response"]["error"] + + finally: + await proxy_session.close() + await proxy_runner.cleanup() + + +def test_build_ws_record_merges_incremental_request_and_output_items() -> None: + record = _build_ws_record( + req_id="req_test", + turn=1, + duration_ms=25, + path_qs="/v1/responses", + req_headers={"Authorization": "Bearer test-token"}, + client_messages=[ + json.dumps( + { + "type": "response.create", + "model": "gpt-5.4", + "instructions": "You are Codex.", + "input": [], + "tools": [{"type": "function", "name": "exec_command"}], + } + ), + json.dumps( + { + "type": "response.create", + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "你好,调用一个工具,然后结束"}]} + ], + } + ), + json.dumps( + { + "type": "response.create", + "previous_response_id": "resp_previous", + "input": [ + { + "type": "function_call_output", + "call_id": "call_1", + "output": "tool output", + } + ], + } + ), + ], + server_messages=[ + json.dumps( + { + "type": "response.completed", + "response": { + "id": "resp_1", + "status": "completed", + "output": [], + "usage": {"input_tokens": 10, "output_tokens": 0}, + }, + } + ), + json.dumps( + { + "type": "response.output_item.done", + "output_index": 1, + "item": { + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "HELLO_FROM_WS"}], + }, + } + ), + json.dumps( + { + "type": "response.completed", + "response": { + "id": "resp_1", + "status": "completed", + "output": [], + "usage": {"input_tokens": 10, "output_tokens": 2}, + }, + } + ), + ], + upstream_base_url="https://chatgpt.com/backend-api/codex", + ) + + assert record["request"]["body"]["input"][0]["content"][0]["text"] == "你好,调用一个工具,然后结束" + assert record["request"]["body"]["input"][1]["type"] == "function_call_output" + assert record["request"]["body"]["previous_response_id"] == "resp_previous" + assert record["request"]["body"]["tools"][0]["name"] == "exec_command" + assert len(record["request"]["ws_events"]) == 3 + assert record["request"]["ws_events"][1]["input"][0]["content"][0]["text"] == "你好,调用一个工具,然后结束" + assert record["request"]["ws_events"][2]["input"][0]["type"] == "function_call_output" + assert record["response"]["body"]["usage"] == {"input_tokens": 10, "output_tokens": 2} + assert record["response"]["body"]["output"][0]["content"][0]["text"] == "HELLO_FROM_WS" + + +def test_build_ws_record_can_omit_raw_events_after_reconstruction() -> None: + record = _build_ws_record( + req_id="req_compact", + turn=1, + duration_ms=25, + path_qs="/v1/responses", + req_headers={}, + client_messages=[json.dumps({"model": "gpt-5.4", "input": "hello"})], + server_messages=[ + json.dumps( + { + "type": "response.completed", + "response": { + "id": "resp_1", + "status": "completed", + "output": [{"type": "message", "content": [{"type": "output_text", "text": "OK"}]}], + "usage": {"input_tokens": 2, "output_tokens": 1}, + }, + } + ) + ], + upstream_base_url="https://chatgpt.com/backend-api/codex", + store_stream_events=False, + ) + + assert "ws_events" not in record["request"] + assert "ws_events" not in record["response"] + assert record["request"]["body"]["input"] == "hello" + assert record["response"]["body"]["output"][0]["content"][0]["text"] == "OK" + + +def test_build_ws_record_treats_empty_error_string_as_failure() -> None: + record = _build_ws_record( + req_id="req_fail", + turn=1, + duration_ms=25, + path_qs="/v1/responses", + req_headers={}, + client_messages=[], + server_messages=[], + upstream_base_url="https://chatgpt.com/backend-api/codex", + error="", + ) + + assert record["response"]["status"] == 502 + assert record["response"]["error"] == "" + + +# --------------------------------------------------------------------------- +# Test 4: WebSocket coexists with HTTP — mixed traffic +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_websocket_and_http_coexist(trace_dir): + """Both HTTP and WebSocket requests through the same proxy are recorded.""" + + async def mixed_handler(request): + if request.headers.get("Upgrade", "").lower() == "websocket": + ws = web.WebSocketResponse() + await ws.prepare(request) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await ws.send_json({"type": "response.completed", "response": {"id": "ws_1"}}) + await ws.close() + break + return ws + else: + body = await request.json() + return web.json_response( + { + "id": "http_1", + "content": [{"type": "text", "text": "HTTP response"}], + "model": body.get("model", "test"), + "usage": {"input_tokens": 5, "output_tokens": 3}, + } + ) + + store, session_id, writer = _make_writer() + + upstream_runner, upstream_port = await _start_ws_upstream(mixed_handler) + proxy_runner, proxy_port, proxy_session = await _start_proxy( + f"http://127.0.0.1:{upstream_port}", + writer, + ) + + try: + async with aiohttp.ClientSession() as client: + # HTTP request + resp = await client.post( + f"http://127.0.0.1:{proxy_port}/v1/messages", + json={"model": "test-model", "messages": [{"role": "user", "content": "hi"}]}, + ) + assert resp.status == 200 + await resp.json() + + # WebSocket request + ws = await client.ws_connect(f"http://127.0.0.1:{proxy_port}/v1/responses") + await ws.send_json({"model": "test-model", "input": "ws hello"}) + msg = await asyncio.wait_for(ws.receive(), timeout=5) + assert msg.type == aiohttp.WSMsgType.TEXT + # Wait for close + await asyncio.wait_for(ws.receive(), timeout=5) + await ws.close() + + await asyncio.sleep(0.1) + writer.close() + + records = store.load_records(session_id) + assert len(records) == 2 + + http_rec = next(r for r in records if r.get("transport") != "websocket") + ws_rec = next(r for r in records if r.get("transport") == "websocket") + + assert http_rec["request"]["method"] == "POST" + assert http_rec["response"]["status"] == 200 + + assert ws_rec["request"]["method"] == "WEBSOCKET" + assert ws_rec["response"]["status"] == 101 + + finally: + await proxy_session.close() + await proxy_runner.cleanup() + await upstream_runner.cleanup() + + +# --------------------------------------------------------------------------- +# Test 4: _get_ws_proxy_settings resolves proxy/auth from env +# --------------------------------------------------------------------------- + + +class TestGetWsProxySettings: + """Unit tests for _get_ws_proxy_settings helper.""" + + @pytest.fixture(autouse=True) + def _clean_proxy_env(self, monkeypatch): + """Remove all proxy env vars so tests control them explicitly.""" + for var in ( + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", + ): + monkeypatch.delenv(var, raising=False) + + def test_wss_uses_https_proxy(self, monkeypatch): + monkeypatch.setenv("HTTPS_PROXY", "http://proxy:8080") + result = _get_ws_proxy_settings("wss://api.openai.com/v1/responses") + assert result == (URL("http://proxy:8080"), None) + + def test_ws_uses_http_proxy(self, monkeypatch): + monkeypatch.setenv("HTTP_PROXY", "http://proxy:3128") + result = _get_ws_proxy_settings("ws://localhost/v1/responses") + assert result == (URL("http://proxy:3128"), None) + + def test_proxy_auth_is_preserved(self, monkeypatch): + monkeypatch.setenv("HTTPS_PROXY", "http://user:pass@proxy:8080") + result = _get_ws_proxy_settings("wss://api.openai.com/v1/responses") + assert result is not None + proxy_url, proxy_auth = result + assert proxy_url == URL("http://proxy:8080") + assert proxy_auth is not None + assert proxy_auth.login == "user" + assert proxy_auth.password == "pass" + + def test_no_proxy_returns_none(self, monkeypatch): + # Mock get_env_proxy_for_url to raise LookupError (no proxy configured). + # Necessary because macOS system proxy settings bypass env vars. + monkeypatch.setattr( + "claude_tap.ws_proxy.get_env_proxy_for_url", + lambda url: (_ for _ in ()).throw(LookupError("no proxy")), + ) + result = _get_ws_proxy_settings("wss://api.openai.com/v1/responses") + assert result is None + + def test_non_ws_scheme_returns_none(self): + result = _get_ws_proxy_settings("https://api.openai.com/v1/responses") + assert result is None diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..5cda2d1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1176 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "backports-zstd" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/28/ed31a0e35feb4538a996348362051b52912d50f00d25c2d388eccef9242c/backports_zstd-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:249f90b39d3741c48620021a968b35f268ca70e35f555abeea9ff95a451f35f9", size = 435660, upload-time = "2025-12-29T17:25:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/3db362169d80442adda9dd563c4f0bb10091c8c1c9a158037f4ecd53988e/backports_zstd-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0e71e83e46154a9d3ced6d4de9a2fea8207ee1e4832aeecf364dc125eda305c", size = 362056, upload-time = "2025-12-29T17:25:56.729Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/b67ba053a7d6f6dbe2f8a704b7d3a5e01b1d2e2e8edbc9b634f2702ef73c/backports_zstd-1.3.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:cbc6193acd21f96760c94dd71bf32b161223e8503f5277acb0a5ab54e5598957", size = 505957, upload-time = "2025-12-29T17:25:57.941Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3e/2667c0ddb53ddf28667e330bf9fe92e8e17705a481c9b698e283120565f7/backports_zstd-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1df583adc0ae84a8d13d7139f42eade6d90182b1dd3e0d28f7df3c564b9fd55d", size = 475569, upload-time = "2025-12-29T17:25:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/eb/86/4052473217bd954ccdffda5f7264a0e99e7c4ecf70c0f729845c6a45fc5a/backports_zstd-1.3.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d833fc23aa3cc2e05aeffc7cfadd87b796654ad3a7fb214555cda3f1db2d4dc2", size = 581196, upload-time = "2025-12-29T17:26:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bd/064f6fdb61db3d2c473159ebc844243e650dc032de0f8208443a00127925/backports_zstd-1.3.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:142178fe981061f1d2a57c5348f2cd31a3b6397a35593e7a17dbda817b793a7f", size = 640888, upload-time = "2025-12-29T17:26:02.134Z" }, + { url = "https://files.pythonhosted.org/packages/d8/09/0822403f40932a165a4f1df289d41653683019e4fd7a86b63ed20e9b6177/backports_zstd-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eed0a09a163f3a8125a857cb031be87ed052e4a47bc75085ed7fca786e9bb5b", size = 491100, upload-time = "2025-12-29T17:26:03.418Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/f5ac28d74039b7e182a780809dc66b9dbfc893186f5d5444340bba135389/backports_zstd-1.3.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60aa483fef5843749e993dde01229e5eedebca8c283023d27d6bf6800d1d4ce3", size = 565071, upload-time = "2025-12-29T17:26:05.022Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ac/50209aeb92257a642ee987afa1e61d5b6731ab6bf0bff70905856e5aede6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ea0886c1b619773544546e243ed73f6d6c2b1ae3c00c904ccc9903a352d731e1", size = 481519, upload-time = "2025-12-29T17:26:06.255Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/b06f64199fb4b2e9437cedbf96d0155ca08aeec35fe81d41065acd44762e/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e137657c830a5ce99be40a1d713eb1d246bae488ada28ff0666ac4387aebdd5", size = 509465, upload-time = "2025-12-29T17:26:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/2c365196e61c8fffbbc930ffd69f1ada7aa1c7210857b3e565031c787ac6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94048c8089755e482e4b34608029cf1142523a625873c272be2b1c9253871a72", size = 585552, upload-time = "2025-12-29T17:26:08.911Z" }, + { url = "https://files.pythonhosted.org/packages/93/8d/c2c4f448bb6b6c9df17410eaedce415e8db0eb25b60d09a3d22a98294d09/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d339c1ec40485e97e600eb9a285fb13169dbf44c5094b945788a62f38b96e533", size = 562893, upload-time = "2025-12-29T17:26:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/2110d4d39115130f7514cbbcec673a885f4052bb68d15e41bc96a7558856/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aeee9210c54cf8bf83f4d263a6d0d6e7a0298aeb5a14a0a95e90487c5c3157c", size = 631462, upload-time = "2025-12-29T17:26:11.99Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a8/d64b59ae0714fdace14e43873f794eff93613e35e3e85eead33a4f44cd80/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba7114a3099e5ea05cbb46568bd0e08bca2ca11e12c6a7b563a24b86b2b4a67f", size = 495125, upload-time = "2025-12-29T17:26:13.218Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/bcff0a091fcf27172c57ae463e49d8dec6dc31e01d7e7bf1ae3aad9c3566/backports_zstd-1.3.0-cp311-cp311-win32.whl", hash = "sha256:08dfdfb85da5915383bfae680b6ac10ab5769ab22e690f9a854320720011ae8e", size = 288664, upload-time = "2025-12-29T17:26:14.791Z" }, + { url = "https://files.pythonhosted.org/packages/28/1a/379061e2abf8c3150ad51c1baab9ac723e01cf7538860a6a74c48f8b73ee/backports_zstd-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8aac2e7cdcc8f310c16f98a0062b48d0a081dbb82862794f4f4f5bdafde30a4", size = 313633, upload-time = "2025-12-29T17:26:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/e7/eca40858883029fc716660106069b23253e2ec5fd34e86b4101c8cfe864b/backports_zstd-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:440ef1be06e82dc0d69dbb57177f2ce98bbd2151013ee7e551e2f2b54caa6120", size = 288814, upload-time = "2025-12-29T17:26:17.571Z" }, + { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124, upload-time = "2025-12-29T17:26:19.995Z" }, + { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378, upload-time = "2025-12-29T17:26:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201, upload-time = "2025-12-29T17:26:23.047Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659, upload-time = "2025-12-29T17:26:24.189Z" }, + { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388, upload-time = "2025-12-29T17:26:25.47Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173, upload-time = "2025-12-29T17:26:26.736Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628, upload-time = "2025-12-29T17:26:28.12Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233, upload-time = "2025-12-29T17:26:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806, upload-time = "2025-12-29T17:26:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037, upload-time = "2025-12-29T17:26:32.201Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220, upload-time = "2025-12-29T17:26:33.5Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847, upload-time = "2025-12-29T17:26:35.273Z" }, + { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665, upload-time = "2025-12-29T17:26:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809, upload-time = "2025-12-29T17:26:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815, upload-time = "2025-12-29T17:26:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463, upload-time = "2025-12-29T17:26:42.152Z" }, + { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740, upload-time = "2025-12-29T17:26:43.377Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651, upload-time = "2025-12-29T17:26:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859, upload-time = "2025-12-29T17:26:45.722Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339, upload-time = "2025-12-29T17:26:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182, upload-time = "2025-12-29T17:26:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807, upload-time = "2025-12-29T17:26:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103, upload-time = "2025-12-29T17:26:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614, upload-time = "2025-12-29T17:26:52.255Z" }, + { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207, upload-time = "2025-12-29T17:26:53.496Z" }, + { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765, upload-time = "2025-12-29T17:26:54.99Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852, upload-time = "2025-12-29T17:26:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549, upload-time = "2025-12-29T17:26:57.598Z" }, + { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104, upload-time = "2025-12-29T17:26:58.826Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447, upload-time = "2025-12-29T17:27:00.036Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664, upload-time = "2025-12-29T17:27:01.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678, upload-time = "2025-12-29T17:27:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155, upload-time = "2025-12-29T17:27:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436, upload-time = "2025-12-29T17:27:05.076Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600, upload-time = "2025-12-29T17:27:06.254Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496, upload-time = "2025-12-29T17:27:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919, upload-time = "2025-12-29T17:27:09.1Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913, upload-time = "2025-12-29T17:27:10.433Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800, upload-time = "2025-12-29T17:27:11.59Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396, upload-time = "2025-12-29T17:27:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980, upload-time = "2025-12-29T17:27:14.317Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358, upload-time = "2025-12-29T17:27:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492, upload-time = "2025-12-29T17:27:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309, upload-time = "2025-12-29T17:27:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518, upload-time = "2025-12-29T17:27:19.525Z" }, + { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429, upload-time = "2025-12-29T17:27:21.063Z" }, + { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389, upload-time = "2025-12-29T17:27:22.287Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776, upload-time = "2025-12-29T17:27:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315, upload-time = "2025-12-29T17:27:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d9/8c9c246e5ea79a4f45d551088b11b61f2dc7efcdc5dbe6df3be84a506e0c/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:968167d29f012cee7b112ad031a8925e484e97e99288e55e4d62962c3a1013e3", size = 409666, upload-time = "2025-12-29T17:27:57.37Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4f/a55b33c314ca8c9074e99daab54d04c5d212070ae7dbc435329baf1b139e/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8f6fc7d62b71083b574193dd8fb3a60e6bb34880cc0132aad242943af301f7a", size = 339199, upload-time = "2025-12-29T17:27:58.542Z" }, + { url = "https://files.pythonhosted.org/packages/9d/13/ce31bd048b1c88d0f65d7af60b6cf89cfbed826c7c978f0ebca9a8a71cfc/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e0f2eca6aac280fdb77991ad3362487ee91a7fb064ad40043fb5a0bf5a376943", size = 420332, upload-time = "2025-12-29T17:28:00.332Z" }, + { url = "https://files.pythonhosted.org/packages/cf/80/c0cdbc533d0037b57248588403a3afb050b2a83b8c38aa608e31b3a4d600/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676eb5e177d4ef528cf3baaeea4fffe05f664e4dd985d3ac06960ef4619c81a9", size = 393879, upload-time = "2025-12-29T17:28:01.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/38/c97428867cac058ed196ccaeddfdf82ecd43b8a65965f2950a6e7547e77a/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:199eb9bd8aca6a9d489c41a682fad22c587dffe57b613d0fe6d492d0d38ce7c5", size = 413842, upload-time = "2025-12-29T17:28:03.113Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684, upload-time = "2025-12-29T17:28:04.856Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "claude-tap" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, + { name = "cryptography" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage" }, + { name = "pexpect" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-timeout" }, + { name = "ruff" }, +] + +[package.dev-dependencies] +dev = [ + { name = "playwright" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = "==3.14.1" }, + { name = "backports-zstd", marker = "python_full_version < '3.14'", specifier = ">=1.0" }, + { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, + { name = "cryptography", specifier = ">=42.0" }, + { name = "pexpect", marker = "extra == 'dev'", specifier = ">=4.9" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [{ name = "playwright", specifier = ">=1.58.0" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, + { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, + { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, + { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, + { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, + { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, + { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, + { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, + { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, + { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, + { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, + { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, + { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, + { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, + { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, + { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, + { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, + { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, + { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, + { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, + { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, + { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, + { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "playwright" +version = "1.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" }, + { url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" }, + { url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" }, + { url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" }, + { url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" }, + { url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" }, + { url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +]