diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 52a9d9ec0..5d8f51532 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -6,7 +6,7 @@ on: tag: description: "Release tag to publish" required: true - default: "v7.6.8" + default: "v7.6.9" push: tags: - "v*.*.*" diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 98bcb17a0..2e65d6e6f 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -6,7 +6,7 @@ on: tag: description: "Release tag to build and upload" required: true - default: "v7.6.8" + default: "v7.6.9" push: tags: - "v*" diff --git a/CHANGELOG.md b/CHANGELOG.md index e22b741ec..c76f8d412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## v7.6.9 (2026-06-17) + +### Kimi And AGY Provider Reliability + +- **Kimi Completion Evidence Hardened**: Kimi execution now records receipt, + no-captured-output diagnostics, trace, and resume metadata so missing replies + and recovered turns are easier to diagnose. +- **AGY Delivery Reliability Improved**: AGY prompt delivery now waits for + ready evidence, handles pane fallback and ambiguous tmux send outcomes, and + reports coalesced request diagnostics more clearly. +- **Provider Trace Diagnostics Expanded**: dispatcher, mailbox trace, and text + artifact paths now expose the provider diagnostics needed to investigate + Kimi/AGY delivery and completion edge cases. +- **Release Surface Synchronized**: VERSION, CLI version constants, + package.json, release workflow defaults, README release notes, and npm + packaging metadata are aligned for 7.6.9. + ## v7.6.8 (2026-06-17) ### Role Pack Current Store diff --git a/README.md b/README.md index 83de6d515..8e5604898 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Visible, controllable multi-agent cooperative TUI workspace**

- version + version platform providers

@@ -641,6 +641,21 @@ v7 highlights: - Hardened tmux, Ghostty, release helper, Codex trust, and provider session restore paths.
+v7.6.9 - Kimi And AGY Provider Reliability + +- Kimi execution now records receipt, no-captured-output diagnostics, trace, + and resume metadata so missing replies and recovered turns are easier to + diagnose. +- AGY prompt delivery now waits for ready evidence, handles pane fallback and + ambiguous tmux send outcomes, and reports coalesced request diagnostics more + clearly. +- Dispatcher, mailbox trace, and text artifact diagnostics now expose the + provider details needed to investigate Kimi/AGY delivery and completion edge + cases. + +
+ +
v7.6.8 - Role Pack Current Store - Role Pack runtime lookup now follows the installed current package under @@ -779,6 +794,11 @@ v7 highlights: only when `.hindsight/kimi.json`, `.hindsight/codex.json`, `HINDSIGHT_API_URL`, or `HINDSIGHT_BANK_ID` is configured, and failures remain non-blocking provider diagnostics. +- Preserves trusted Codex command hooks, including Hindsight Codex hooks, when + CCB materializes managed Codex homes. Operators can extend the allowlist with + `CCB_CODEX_INHERITED_HOOK_EVENTS` and + `CCB_CODEX_INHERITED_COMMAND_HOOK_MARKERS`; arbitrary root hooks remain + filtered out. - Accepts both `HINDSIGHT_API_KEY` and `HINDSIGHT_API_TOKEN` for the Kimi bridge and the `scripts/hindsight` helper. - Documents the supported provider surface more clearly in the README while diff --git a/README_zh.md b/README_zh.md index aad25a209..19bbfe75c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -6,7 +6,7 @@ **可见、可控的多 Agent 合作TUI工作台**

- version + version platform providers

@@ -630,6 +630,18 @@ v7 线重点: - 加固 tmux、Ghostty、release helper、Codex trust 和 provider 会话恢复路径。
+v7.6.9 - Kimi / AGY Provider 可靠性 + +- Kimi execution 现在记录 receipt、无捕获输出诊断、trace 和 resume + metadata,便于定位缺失回复和恢复 turn。 +- AGY prompt delivery 现在等待 ready evidence,处理 pane fallback 和 + ambiguous tmux send 结果,并更清楚地报告合并请求诊断。 +- dispatcher、mailbox trace 和 text artifact 诊断现在会暴露排查 Kimi/AGY + delivery 与 completion 边界所需的 provider 细节。 + +
+ +
v7.6.8 - Role Pack Current Store - Role Pack 运行时现在跟随 `.roles/installed//current` 下的当前安装包; @@ -753,6 +765,10 @@ v7 线重点: - Kimi Hindsight 记忆改为 CCB 执行边界上的显式 opt-in:只有配置 `.hindsight/kimi.json`、`.hindsight/codex.json`、`HINDSIGHT_API_URL` 或 `HINDSIGHT_BANK_ID` 时才启用,失败时只记录 provider diagnostics,不阻塞任务。 +- CCB 物化 managed Codex home 时会保留可信 Codex command hook,包括 + Hindsight Codex hooks。运维可通过 `CCB_CODEX_INHERITED_HOOK_EVENTS` 和 + `CCB_CODEX_INHERITED_COMMAND_HOOK_MARKERS` 扩展 allowlist;任意 root hook + 仍会被过滤。 - Kimi bridge 和 `scripts/hindsight` helper 同时兼容 `HINDSIGHT_API_KEY` 与 `HINDSIGHT_API_TOKEN`。 - README 更明确展示支持的 provider surface,同时保持无关 provider 行为不变。 diff --git a/VERSION b/VERSION index ceddb885b..3c0381a34 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.6.8 +7.6.9 diff --git a/ccb.py b/ccb.py index 0eee8d289..901d68656 100755 --- a/ccb.py +++ b/ccb.py @@ -21,7 +21,7 @@ if backend_env and not os.environ.get("CCB_BACKEND_ENV"): os.environ["CCB_BACKEND_ENV"] = backend_env -VERSION = "7.6.8" +VERSION = "7.6.9" GIT_COMMIT = "release" GIT_DATE = "2026-06-17" diff --git a/docs/ccb-config-layout-contract.md b/docs/ccb-config-layout-contract.md index adaeff120..793f92721 100644 --- a/docs/ccb-config-layout-contract.md +++ b/docs/ccb-config-layout-contract.md @@ -391,7 +391,31 @@ and then log in that agent's managed Codex home directly. With no explicit agent API authority, `inherit_auth = false` means "do not inherit global Codex credentials"; it must preserve an existing agent-local `auth.json`. -### 4.3 Agent Model Shortcut +### 4.3 Provider Profile MCP Overlay + +Provider profiles may declare agent-local MCP server overrides: + +```toml +[agents.agent1.provider_profile.mcp_servers.codegraph] +command = "/usr/local/bin/codegraph" +args = ["serve", "--mcp"] +``` + +Contract: + +- The overlay is keyed by MCP server name. +- For Codex, CCB merges the overlay into the managed agent-local + `config.toml` under `mcp_servers`. Same-name servers override inherited + source config; different names are additive. +- The overlay is preserved in provider profile records and config rendering. +- This is source configuration, not provider runtime state. Running providers + may need the normal CCB reload/restart flow before a changed MCP set is + visible. +- Claude may still use provider-native `.claude.json` / `claude mcp` scoping + for user-level MCP configuration; this overlay is not a replacement for + provider-native Claude plugin management. + +### 4.4 Agent Model Shortcut For the common case where an agent only needs a provider model override, rich or hybrid `ccb.config` may use an agent-local model shortcut: diff --git a/docs/plantree/plans/native-cli-providers/README.md b/docs/plantree/plans/native-cli-providers/README.md index d8f47baf1..f7627de3e 100644 --- a/docs/plantree/plans/native-cli-providers/README.md +++ b/docs/plantree/plans/native-cli-providers/README.md @@ -48,6 +48,13 @@ override the shipped contracts. package, install, command, and auth findings. - [topics/integration-design.md](topics/integration-design.md): CCB provider architecture, completion detection, configuration, and testing plan. +- [topics/kimi-receipt-and-diagnostics-hardening.md](topics/kimi-receipt-and-diagnostics-hardening.md): + landed Kimi-only receipt, no-captured-reply, trace, and restore-diagnostics + hardening notes with explicit non-impact constraints for other providers. +- [topics/agy-delivery-stability-hardening.md](topics/agy-delivery-stability-hardening.md): + AGY ready-gated prompt delivery, late transcript/pane fallback, and + coalesced-request diagnostics needed to approach OpenCode-style reply + attribution stability. - [history/next-wave-cli-lab-2026-06-13.md](history/next-wave-cli-lab-2026-06-13.md): local install/source lab record for Qwen, Copilot, Cursor, Kiro, and Crush. - [history/pi-provider-landing-2026-06-13.md](history/pi-provider-landing-2026-06-13.md): @@ -80,8 +87,13 @@ In scope: with assistant message content as completion evidence. - AGY completion alignment to Antigravity transcript logs, so AGY no longer relies on `CCB_DONE` as its primary completion signal. +- AGY prompt delivery hardening so CCB waits for an input-ready Antigravity pane + before sending, avoids coalescing multiple CCB jobs into one AGY turn, and + falls back to stable pane evidence when transcript writes lag. - Empty-reply and timeout diagnostics aligned with existing pane-backed providers. +- Kimi-specific receipt hardening, no-captured-reply diagnostics, trace + visibility, and execution-resume metadata clarification. - Unit and isolated source-runtime validation in `/home/bfly/yunwei/test_ccb2`. - Local install/source research under `/home/bfly/yunwei/test_ccb2/cli-integration-lab` before source integration. diff --git a/docs/plantree/plans/native-cli-providers/implementation-status.md b/docs/plantree/plans/native-cli-providers/implementation-status.md index af8f34652..7ddd50f3f 100644 --- a/docs/plantree/plans/native-cli-providers/implementation-status.md +++ b/docs/plantree/plans/native-cli-providers/implementation-status.md @@ -16,6 +16,20 @@ Copilot CLI, Cursor Agent, Kiro CLI, Charm Crush, and Pi. Source implementation landed for the minimal built-in provider path, and the stub-backed source-runtime smoke plus real CLI version smoke have passed. +Kimi follow-up receipt and diagnostics hardening has landed in source. This work +is explicitly Kimi-only: it does not change default provider behavior for Codex, +Claude, Gemini, OpenCode, DeepSeek, MiMo, AGY, or next-wave native CLI +providers. Topic: +[topics/kimi-receipt-and-diagnostics-hardening.md](topics/kimi-receipt-and-diagnostics-hardening.md). + +AGY delivery stability hardening has landed in source after a real +`main -> frontend_engineer:agy` empty-reply investigation. AGY now defers prompt +delivery until the Antigravity pane is input-ready, keeps busy-pane jobs running +instead of stacking retries into one native turn, records coalesced native +`USER_INPUT` diagnostics, keeps observing after ambiguous tmux send errors, and +can use stable pane fallback when transcript flushes lag. Topic: +[topics/agy-delivery-stability-hardening.md](topics/agy-delivery-stability-hardening.md). + ## Last Landed - Shared pane-quiet support and Kimi/DeepSeek provider backends were added in @@ -81,6 +95,30 @@ source-runtime smoke plus real CLI version smoke have passed. - Storage classification now recognizes Qwen, Cursor, Copilot, Crush, Kiro, and Pi provider-state contents as native CLI provider-owned session/cache or projected skill evidence instead of leaving them as unknown paths. +- Kimi receipt and diagnostics hardening: + - Kimi inherited ask skill now projects a structured receipt contract with + `status`, inspected files, findings, reject cases, required tests, no-open, + and blockers fields. + - Kimi native timeout with no captured reply emits `no_captured_reply`, + `provider_no_reply`, `receipt_valid=false`, and + `receipt_class=no_captured_reply`. + - Forced `--artifact-reply` on a no-captured Kimi reply now stores the empty + artifact as metadata while the visible reply says no Kimi provider reply was + captured. + - `ccb trace` job summaries expose Kimi terminal reason, reply chars, elapsed + seconds, forced-artifact status, and receipt class. + - Kimi provider manifest now reports `supports_resume=false` for CCB + in-flight execution restore, matching adapter restore diagnostics. +- AGY delivery stability hardening: + - AGY prompt delivery now waits for an input-ready Antigravity prompt before + sending, storing pending prompts while the pane is busy. + - AGY polling no longer treats old anchor-missing windows as terminal while + the pane remains busy. +- AGY can emit stable pane fallback evidence when transcript writes lag. +- AGY preserves ambiguous tmux send errors as diagnostics and still accepts + native transcript evidence instead of failing before attribution is known. +- Coalesced native `USER_INPUT` rows with multiple `CCB_REQ_ID` anchors are + diagnosed as `agy_request_coalesced` for superseded jobs. ## Active TODO @@ -98,8 +136,39 @@ Kimi/DeepSeek/MiMo/Qwen/Copilot/Cursor/Kiro/Crush/Pi credentials; CCB integratio can still be validated with provider command templates, installed CLI help/version checks, and source-backed parser tests. +Kimi hardening source work is unblocked. Remaining Kimi prompt-mode and auth +diagnostic ideas stay deferred/open until real usage needs them. + ## Last Verified +AGY delivery stability focused verification: + +- `PYTHONPATH=lib python -m pytest -q test/test_agy_execution_polling.py test/test_native_cli_completion.py -k agy`: + `8 passed, 12 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_agy_execution_polling.py test/test_native_cli_completion.py test/test_native_cli_providers.py test/test_v2_provider_catalog.py test/test_opencode_execution_polling.py`: + `34 passed`. +- Isolated source-runtime smoke from + `/home/bfly/yunwei/test_ccb2/native_provider_smoke` with + `/home/bfly/yunwei/ccb_source/ccb_test`, stub AGY, isolated `HOME` and + `CCB_SOURCE_HOME`: completed `job_74d4989cca04` with + `agy_transcript_response_done`. + +Kimi hardening focused verification: + +- `PYTHONPATH=lib python -m pytest -q test/test_ask_skill_templates.py`: + `3 passed`. +- `PYTHONPATH=lib python -m pytest -q test/test_native_cli_completion.py -k kimi`: + `8 passed, 5 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_v2_message_bureau_dispatcher_integration.py -k artifact`: + `7 passed, 54 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_v2_cli_render.py -k trace`: + `3 passed, 21 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_v2_provider_catalog.py`: + `4 passed`. +- `PYTHONPATH=lib python -m py_compile` for touched Kimi, artifact, trace, and + catalog modules: passed. +- `git diff --check`: passed. + Historical first-slice verification: - `node --version` returned `v22.20.0`. diff --git a/docs/plantree/plans/native-cli-providers/open-questions.md b/docs/plantree/plans/native-cli-providers/open-questions.md index a2234d5f2..1d5efd600 100644 --- a/docs/plantree/plans/native-cli-providers/open-questions.md +++ b/docs/plantree/plans/native-cli-providers/open-questions.md @@ -47,3 +47,16 @@ Date: 2026-06-13 - Kiro first adapter decision: use `kiro-cli chat --no-interactive --wrap never` with subprocess exit/stdout until stable structured chat output is confirmed. +- Kimi receipt contract decision: project the structured receipt contract in + the inherited Kimi ask skill first. CCB prompt injection remains unchanged for + other providers and can be reconsidered only if Kimi ignores the projected + skill. +- Kimi no-captured timeout decision: keep `kimi_native_turn_timeout` as + `failed`, but add explicit `no_captured_reply`, `provider_no_reply`, + `receipt_valid=false`, and `receipt_class=no_captured_reply` diagnostics. +- Kimi resume semantics decision: `supports_resume` describes CCB in-flight + execution restore. Kimi manifest now reports `supports_resume=false`, matching + adapter restore diagnostics and resubmit-only behavior. +- Kimi routing decision: keep `sl_ki` task sizing and receipt acceptance as an + operator convention enforced by `mn_c` for now. Do not add role/config routing + policy until there is repeated evidence that convention is insufficient. diff --git a/docs/plantree/plans/native-cli-providers/roadmap.md b/docs/plantree/plans/native-cli-providers/roadmap.md index 279d814fb..d866834c4 100644 --- a/docs/plantree/plans/native-cli-providers/roadmap.md +++ b/docs/plantree/plans/native-cli-providers/roadmap.md @@ -9,7 +9,10 @@ Date: 2026-06-13 `CCB_DONE` as their primary completion signal. Kimi and OpenCode inherited ask skill injection landed in commit `a4395c2`; MiMo inherited ask instruction injection and native `mimo run --format json` execution landed - in commit `fce17c3`. + in commit `fce17c3`. Kimi receipt/diagnostics hardening has landed in source + with Kimi-only guarded behavior. AGY delivery stability hardening has landed + in source with ready-gated prompt delivery, pane fallback, and coalesced + request diagnostics. - Last verified: focused native completion tests, provider catalog tests, Kimi/OpenCode skill projection tests, and a real MiMo CCB ask passed after switching CCB MiMo execution to `mimo run --pure --format json`; full @@ -222,6 +225,31 @@ Date: 2026-06-13 add-on smoke passed with `pi_run_stop`; full source test gate passed with `2621 passed, 2 skipped, 21 deselected`; `git diff --check` passed. +- Landed Kimi-only receipt and diagnostics hardening: + - Kimi inherited ask skill projects the structured receipt contract: + `status`, `inspected`, `exact_files`, `findings`, `reject_cases`, + `required_tests`, `no_open`, and `blockers`. + - Kimi no-captured timeout decisions emit machine-readable + `receipt_class=no_captured_reply` diagnostics. + - Forced Kimi empty artifact replies no longer instruct callers to read an + empty artifact as task evidence. + - `ccb trace` surfaces Kimi terminal reason, reply chars, elapsed seconds, + artifact-forced status, and receipt class. + - Kimi manifest `supports_resume=false` now matches CCB in-flight execution + restore semantics. +- Landed AGY delivery stability hardening: + - AGY start stores pending prompts while the Antigravity pane is busy and + sends only after the empty input prompt is observed. + - AGY poll keeps busy-pane jobs running past the old anchor-missing window + instead of terminalizing and allowing later retries to merge into the same + native turn. + - AGY poll can use stable pane fallback when transcript writes lag. + - AGY preserves ambiguous tmux send errors as diagnostics and continues + observing transcript/pane evidence before deciding failure. + - AGY transcript parsing detects multiple `CCB_REQ_ID` anchors in one native + `USER_INPUT` and reports superseded requests as `agy_request_coalesced`. + - Focused AGY/native provider verification passed with `34 passed`; isolated + stub source-runtime smoke completed with `agy_transcript_response_done`. ## In Progress @@ -235,7 +263,10 @@ Date: 2026-06-13 2. Decide whether to keep `/home/bfly/yunwei/test_ccb2/next_wave_provider_smoke` as a reusable smoke fixture. -3. Review the full dirty source diff, excluding unrelated managed-tool/neovim +3. Run a real source-runtime AGY smoke in `/home/bfly/yunwei/test_ccb2` when an + authenticated AGY account is available; unit coverage currently validates + the delivery and parsing mechanics without provider login. +4. Review the full dirty source diff, excluding unrelated managed-tool/neovim changes already present in the worktree. ## Deferred diff --git a/docs/plantree/plans/native-cli-providers/topics/agy-delivery-stability-hardening.md b/docs/plantree/plans/native-cli-providers/topics/agy-delivery-stability-hardening.md new file mode 100644 index 000000000..4f2a4cba8 --- /dev/null +++ b/docs/plantree/plans/native-cli-providers/topics/agy-delivery-stability-hardening.md @@ -0,0 +1,83 @@ +# AGY Delivery Stability Hardening + +Date: 2026-06-17 + +## Problem + +Real project evidence from `/home/bfly/Documents/工作室/服务器` showed +`main -> frontend_engineer:agy` jobs reaching the AGY pane but returning empty +`incomplete` replies with `agy_native_anchor_missing`. + +The failure was not mailbox delivery loss. The weak points were AGY-specific: + +- CCB sent prompts into the AGY pane without first checking that Antigravity had + returned to an empty input prompt. +- When AGY was still processing a previous task, retry prompts could be folded + into one native `USER_INPUT` with multiple `CCB_REQ_ID` anchors. +- The 120 second anchor-missing path could terminalize before AGY wrote a late + transcript response. +- AGY transcript logs are provider-owned observed evidence, not a structured + CCB protocol stream like Codex or OpenCode storage. + +## Target Behavior + +AGY should approach OpenCode-style attribution stability while preserving AGY's +native transcript authority: + +- Do not send a CCB prompt until the AGY pane is input-ready. +- Keep an unsent job running while AGY is busy, instead of terminalizing early + and letting later jobs stack into the same provider turn. +- If transcript writes lag but the pane shows a stable completed answer for the + submitted `CCB_REQ_ID`, emit pane fallback evidence after a stability window. +- If tmux reports an ambiguous send error, keep observing transcript/pane + evidence for the submitted `CCB_REQ_ID` instead of immediately failing or + blindly resending the prompt. +- If a historical transcript contains multiple `CCB_REQ_ID` anchors in one + `USER_INPUT`, never attribute a later combined answer to an older superseded + job; report `agy_request_coalesced` with the involved ids. +- Keep Codex, Claude, OpenCode, Kimi, DeepSeek, MiMo, and next-wave provider + behavior unchanged. + +## Implementation Notes + +- `AgyPaneReader` remains a thin tmux/pane snapshot reader; readiness detection + is a small AGY-specific helper keyed to the Antigravity empty prompt plus + `? for shortcuts` / model footer. +- `start_submission` stores `pending_prompt` and sets + `prompt_deferred_until_ready` when AGY is busy. +- `poll_submission` sends deferred prompts only after readiness, resets + `started_at` when the prompt is actually sent, and uses longer AGY-specific + windows for busy/long-running observed turns. +- Ambiguous tmux delivery errors are preserved in diagnostics while polling + continues for native transcript or pane evidence; clearly fatal backend + capability errors still fail immediately. +- Pane fallback is secondary evidence: it emits an answer before final boundary + only after the same candidate remains stable for the fallback window. +- Native transcript parsing records `coalesced_request_ids` and whether the + target request was the latest anchor inside that native user input. + +## Acceptance + +- AGY start defers prompt delivery when the pane is busy. +- AGY poll dispatches the pending prompt once the pane returns to the empty + input prompt. +- AGY no longer emits anchor-missing terminal decisions merely because the pane + is still busy past the old 120 second window. +- AGY can complete from stable pane fallback when transcript persistence lags. +- AGY can still complete when tmux reports an ambiguous send error but the + native transcript proves the request was accepted and answered. +- Coalesced native input is diagnosed as coalesced instead of accepted as a + valid old-job reply. +- Focused AGY/native provider tests pass without changing OpenCode behavior. + +## Verification + +- `PYTHONPATH=lib python -m pytest -q test/test_agy_execution_polling.py test/test_native_cli_completion.py -k agy`: + `8 passed, 12 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_agy_execution_polling.py test/test_native_cli_completion.py test/test_native_cli_providers.py test/test_v2_provider_catalog.py test/test_opencode_execution_polling.py`: + `34 passed`. +- Isolated source-runtime smoke from + `/home/bfly/yunwei/test_ccb2/native_provider_smoke` with + `/home/bfly/yunwei/ccb_source/ccb_test`, stub AGY, isolated `HOME` and + `CCB_SOURCE_HOME`: completed `job_74d4989cca04` with + `agy_transcript_response_done`. diff --git a/docs/plantree/plans/native-cli-providers/topics/integration-design.md b/docs/plantree/plans/native-cli-providers/topics/integration-design.md index 3cc6412ce..b010fdd22 100644 --- a/docs/plantree/plans/native-cli-providers/topics/integration-design.md +++ b/docs/plantree/plans/native-cli-providers/topics/integration-design.md @@ -79,7 +79,11 @@ result streams: messages, and emits `TURN_BOUNDARY` on native `status=completed`. 6. AGY polls Antigravity transcript logs, binds `USER_INPUT` by `CCB_REQ_ID`, emits `ASSISTANT_FINAL` from model response events, and emits - `TURN_BOUNDARY` when a completed response is observed. + `TURN_BOUNDARY` when a completed response is observed. AGY prompt delivery + is ready-gated: CCB defers the prompt while the Antigravity pane is busy, + sends only after an empty input prompt is observed, diagnoses native + coalesced `CCB_REQ_ID` rows, and uses stable pane fallback only when + transcript persistence lags. 7. MiMo asks run as native subprocesses using `mimo run --format json --dir `. CCB emits `ASSISTANT_FINAL` from nested `part.text` events and emits `TURN_BOUNDARY` / terminal completed on diff --git a/docs/plantree/plans/native-cli-providers/topics/kimi-receipt-and-diagnostics-hardening.md b/docs/plantree/plans/native-cli-providers/topics/kimi-receipt-and-diagnostics-hardening.md new file mode 100644 index 000000000..c00ef5c47 --- /dev/null +++ b/docs/plantree/plans/native-cli-providers/topics/kimi-receipt-and-diagnostics-hardening.md @@ -0,0 +1,234 @@ +# Kimi Receipt And Diagnostics Hardening + +Date: 2026-06-17 + +## Purpose + +Record Kimi-only reliability improvements for CCB-managed `kimi` workers without +changing runtime semantics for Codex, Claude, Gemini, OpenCode, DeepSeek, MiMo, +AGY, or the next-wave native CLI providers. + +The goal is not to make Kimi a governed completion owner. The goal is to make +Kimi's known limits explicit, keep Kimi tasks small and structured, and prevent +empty or process-style Kimi receipts from being mistaken for accepted lifecycle +evidence. + +## Landed Status + +Status: landed in source on 2026-06-17. + +Implemented surfaces: + +- Kimi inherited ask skill: structured final receipt contract. +- Kimi provider execution: explicit no-captured-reply diagnostics for + `kimi_native_turn_timeout + reply_chars=0`. +- Dispatcher finalization: Kimi-guarded forced-artifact wording for empty + no-captured replies. +- Trace summary/rendering: Kimi terminal metadata fields. +- Kimi provider manifest: `supports_resume=false` for CCB in-flight execution + restore semantics. + +The optional timeout knob remains deferred. + +## Current Finding + +Observed Kimi issues are cooperation and diagnostics issues, not a new provider +crash class: + +- `sl_ki` can return process-style replies such as "reading" or "doing" that + do not prove completion. +- Retried small, structured Kimi tasks can return useful inventory evidence. +- `kimi_native_turn_timeout` with `reply_chars=0` can produce an artifact-reply + transport stub that looks actionable even though no provider reply was + captured. +- Kimi execution restore diagnostics already say `resume_supported=false` and + `resubmit_required`; interrupted in-flight Kimi execution should be treated as + resubmit-only. + +## Non-Impact Constraint + +All implementation slices must be Kimi-scoped: + +- Prefer `provider == "kimi"` checks, Kimi adapter diagnostics, Kimi inherited + skill text, and Kimi-specific tests. +- Do not change default timeout, artifact, trace, retry, or receipt semantics + for other providers. +- Shared code may only receive additive fields or Kimi-guarded formatting. If a + shared helper would change output for other providers, split the Kimi behavior + into a provider-specific branch instead. +- Do not require provider login during source validation. Stub-backed tests and + no-login source runtime validation are the default. + +## Landed Slices + +### Slice 1: Kimi Worker Receipt Contract + +Updated the Kimi inherited ask skill so CCB-managed +Kimi workers must answer with a structured receipt: + +```text +status: +inspected: +exact_files: +findings: +reject_cases: +required_tests: +no_open: +blockers: +``` + +Rules: + +- `sl_ki` tasks should be small: one repo/package, three to six files when + possible, one or two verifier commands, and a 180-240 second target. +- Process replies are invalid: "I am reading", "I will test", or "completed" + without file/evidence fields is not a receipt. +- Kimi output is candidate evidence only. `mn_c` keeps ownership of diff + review, verification, final receipt, and commit decisions. + +Acceptance: + +- Managed Kimi skill/projection tests prove the receipt contract is projected + only to Kimi. +- Existing non-Kimi ask-skill projection tests do not change expected output. + +### Slice 2: Kimi No-Captured-Reply Diagnostics + +For Kimi terminal decisions where: + +- `reason == "kimi_native_turn_timeout"` +- `reply_chars == 0` +- no assistant reply was captured + +emit explicit Kimi diagnostics such as: + +- `no_captured_reply=true` +- `provider_no_reply=true` +- `receipt_valid=false` +- `receipt_class=no_captured_reply` + +If `--artifact-reply` is forced, the visible terminal reply should explain that +no Kimi provider reply was captured instead of implying that a useful full reply +exists in the artifact. + +Implementation boundary: + +- Prefer implementing this in `provider_backends/kimi/execution.py` or a + Kimi-guarded finalization branch. +- Do not alter generic `artifact_stub()` wording for other providers in this + slice. + +Acceptance: + +- A focused Kimi timeout test covers empty reply, forced artifact reply, and + diagnostics. +- Existing artifact-reply behavior for non-Kimi providers remains unchanged. + +### Slice 3: Kimi Trace Visibility + +Expose enough Kimi terminal metadata for `ccb trace ` to classify the +result without opening artifacts: + +- `terminal_reason` +- `reply_chars` +- `total_secs` +- `artifact_reply_forced` +- `receipt_class` + +Implementation boundary: + +- Add fields only when present, or only for Kimi/Kimi-prefixed reasons. +- Keep existing trace lines stable for providers that do not emit these fields. + +Acceptance: + +- Trace rendering for a Kimi timeout shows `kimi_native_turn_timeout`, + `reply_chars=0`, and `receipt_class=no_captured_reply`. +- Existing trace rendering tests for normal replies continue to pass. + +### Slice 4: Kimi Resume Metadata Consistency + +Kimi execution restore diagnostics report `resume_supported=false`, while the +provider manifest previously represented a broader session/provider capability. +This distinction is now clarified so UI/doctor/trace users do not infer that an +interrupted Kimi CCB execution can resume. + +Selected semantics: + +- Kimi manifest `supports_resume=false` describes CCB execution restore. +- Provider session continuity, if Kimi later supports a separate prompt/session + restore mode, must be represented by a separate capability rather than this + manifest flag. + +Acceptance: + +- Restore-report and execution-state diagnostics do not imply Kimi in-flight + execution recovery. +- Provider catalog tests document the selected semantics. + +### Slice 5: Optional Timeout Knob, Deferred + +Do not increase Kimi timeout by default. If needed later, add an opt-in +Kimi-only setting such as an env/config override for native-turn timeout. + +Acceptance threshold before implementation: + +- Real workload evidence shows useful Kimi replies are being cut off shortly + after 300 seconds. +- Queue occupancy impact is understood. + +## Verification + +Focused tests passed: + +- `PYTHONPATH=lib python -m pytest -q test/test_ask_skill_templates.py`: + `3 passed`. +- `PYTHONPATH=lib python -m pytest -q test/test_native_cli_completion.py -k kimi`: + `8 passed, 5 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_v2_message_bureau_dispatcher_integration.py -k artifact`: + `7 passed, 54 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_v2_cli_render.py -k trace`: + `3 passed, 21 deselected`. +- `PYTHONPATH=lib python -m pytest -q test/test_v2_provider_catalog.py`: + `4 passed`. +- `PYTHONPATH=lib python -m py_compile` for touched Kimi, artifact, trace, and + catalog modules: passed. +- `git diff --check`: passed. + +Source runtime validation: + +- Run from `/home/bfly/yunwei/test_ccb2`. +- Use `/home/bfly/yunwei/ccb_source/ccb_test`. +- Isolate `HOME=/home/bfly/yunwei/test_ccb2/source_home` and + `CCB_SOURCE_HOME=/home/bfly/yunwei/test_ccb2/source_home`. +- Prefer Kimi stubs or existing no-login source fixtures; do not require a + fresh Kimi login. + +Regression guard: + +- Include at least one non-Kimi provider artifact/trace test when touching + shared finalization or trace code. +- `git diff --check` on touched files. + +## Rollout Result + +1. Receipt-contract projection landed first and only changed Kimi inherited + skill text. +2. Kimi no-captured-reply diagnostics landed with focused timeout and artifact + tests. +3. Trace visibility landed after diagnostics fields were stable. +4. Kimi resume metadata was resolved as CCB in-flight execution restore + capability. +5. Timeout configurability remains deferred until measured evidence shows that + useful Kimi replies are being cut off shortly after 300 seconds. + +## Resolved Questions + +- Kimi structured receipt contract lives in inherited Kimi skill projection for + this slice. CCB generic `ask` prompt injection remains unchanged. +- `kimi_native_turn_timeout` with no reply remains `failed`, with + `no_captured_reply` diagnostics to classify it. +- Kimi manifest `supports_resume` describes CCB in-flight execution restore and + is now `false`. +- `sl_ki` routing policy remains an operator convention enforced by `mn_c` for + now; role/config enforcement is deferred until convention proves insufficient. diff --git a/inherit_skills/kimi_skills/ask/SKILL.md b/inherit_skills/kimi_skills/ask/SKILL.md index 467d8c5c6..a8bd28d06 100644 --- a/inherit_skills/kimi_skills/ask/SKILL.md +++ b/inherit_skills/kimi_skills/ask/SKILL.md @@ -49,6 +49,30 @@ Before every ask, decide: explicit debugging requests, not normal ask workflow tools. - Do not manually append output-policy text; `ask` injects reply guidance. +## Kimi Receipt Contract + +For implementation, review, inventory, or verification work, return one final +receipt in this exact shape: + +```text +status: +inspected: +exact_files: +findings: +reject_cases: +required_tests: +no_open: +blockers: +``` + +Use `status: complete`, `status: partial`, or `status: blocked`. +Process updates are invalid receipts: do not answer with "I am reading", +"I will test", or "completed" without the evidence fields above. If you cannot +finish within the current turn, use `status: partial` or `status: blocked` and +name the exact files inspected, remaining command, and blocker. Keep the reply +short. Your output is candidate evidence; the caller owns diff review, +verification, final lifecycle judgment, and commits. + Always send `MESSAGE` through the `<<'EOF' ... EOF` heredoc below. No other form is allowed. Use no flags or insert selected flags before `"$TARGET"`: diff --git a/lib/agents/config_loader_runtime/common.py b/lib/agents/config_loader_runtime/common.py index d77251ab1..c5454382b 100644 --- a/lib/agents/config_loader_runtime/common.py +++ b/lib/agents/config_loader_runtime/common.py @@ -30,6 +30,7 @@ 'mode', 'home', 'env', + 'mcp_servers', 'inherit_api', 'inherit_auth', 'inherit_config', diff --git a/lib/agents/config_loader_runtime/defaults_runtime/rendering_runtime/serialization.py b/lib/agents/config_loader_runtime/defaults_runtime/rendering_runtime/serialization.py index 82ff25dcd..65a250434 100644 --- a/lib/agents/config_loader_runtime/defaults_runtime/rendering_runtime/serialization.py +++ b/lib/agents/config_loader_runtime/defaults_runtime/rendering_runtime/serialization.py @@ -101,6 +101,8 @@ def _provider_profile_config_dict(spec) -> dict[str, object] | None: payload['home'] = profile.home if filtered_env: payload['env'] = filtered_env + if profile.mcp_servers: + payload['mcp_servers'] = dict(profile.mcp_servers) if profile.inherit_auth != default_profile.inherit_auth: payload['inherit_auth'] = profile.inherit_auth if profile.inherit_config != default_profile.inherit_config: diff --git a/lib/agents/config_loader_runtime/parsing_runtime/provider_profiles.py b/lib/agents/config_loader_runtime/parsing_runtime/provider_profiles.py index 3cba9407d..e1ad2a070 100644 --- a/lib/agents/config_loader_runtime/parsing_runtime/provider_profiles.py +++ b/lib/agents/config_loader_runtime/parsing_runtime/provider_profiles.py @@ -31,6 +31,10 @@ def parse_provider_profile(agent_name: str, value: Any) -> ProviderProfileSpec: raw.get('env', {}), field_name=f'agents.{agent_name}.provider_profile.env', ), + mcp_servers=expect_mapping( + raw.get('mcp_servers', {}), + field_name=f'agents.{agent_name}.provider_profile.mcp_servers', + ), inherit_api=( expect_bool(raw['inherit_api'], field_name=f'agents.{agent_name}.provider_profile.inherit_api') if 'inherit_api' in raw diff --git a/lib/agents/config_loader_runtime/parsing_runtime/topology.py b/lib/agents/config_loader_runtime/parsing_runtime/topology.py index 9dc1b98fa..612b18281 100644 --- a/lib/agents/config_loader_runtime/parsing_runtime/topology.py +++ b/lib/agents/config_loader_runtime/parsing_runtime/topology.py @@ -65,8 +65,8 @@ def parse_sidebar_view(raw_ui: Any) -> SidebarViewSpec | None: try: return SidebarViewSpec( agents_height=view.get('agents_height', '50%'), - comms_height=view.get('comms_height', '15%'), - tips_height=view.get('tips_height', '35%'), + comms_height=view.get('comms_height', '23%'), + tips_height=view.get('tips_height', '27%'), comms_limit=view.get('comms_limit', 5), comms_compact=expect_bool(view.get('comms_compact', True), field_name='ui.sidebar.view.comms_compact'), tips_enabled=expect_bool(view.get('tips_enabled', True), field_name='ui.sidebar.view.tips_enabled'), diff --git a/lib/agents/models_runtime/config_runtime/topology.py b/lib/agents/models_runtime/config_runtime/topology.py index c3f9e21f2..58728e898 100644 --- a/lib/agents/models_runtime/config_runtime/topology.py +++ b/lib/agents/models_runtime/config_runtime/topology.py @@ -66,8 +66,8 @@ def to_record(self) -> dict[str, object]: @dataclass(frozen=True) class SidebarViewSpec: agents_height: str | int = '50%' - comms_height: str | int = '15%' - tips_height: str | int = '35%' + comms_height: str | int = '23%' + tips_height: str | int = '27%' comms_limit: int = 5 comms_compact: bool = True tips_enabled: bool = True diff --git a/lib/ccbd/services/dispatcher_runtime/finalization_runtime/artifacts.py b/lib/ccbd/services/dispatcher_runtime/finalization_runtime/artifacts.py index 789f8a432..e89c1aa36 100644 --- a/lib/ccbd/services/dispatcher_runtime/finalization_runtime/artifacts.py +++ b/lib/ccbd/services/dispatcher_runtime/finalization_runtime/artifacts.py @@ -24,11 +24,24 @@ def spill_terminal_reply_if_needed( owner_id=current.job_id, now=finished_at, ) - reply = artifact_stub( - prefix=f'CCB completion reply for job {current.job_id} was stored as an artifact by --artifact-reply.', - artifact=artifact, - include_preview=False, - ) + if _is_kimi_no_captured_reply(current, decision, diagnostics): + reply = artifact_stub( + prefix=( + f'CCB completion reply for job {current.job_id} has no captured Kimi provider reply; ' + '--artifact-reply stored an empty artifact for transport metadata only.' + ), + artifact=artifact, + include_preview=False, + instruction='Instruction: no provider reply was captured; do not treat this artifact as task evidence.', + ) + diagnostics['artifact_instruction'] = 'no_provider_reply_captured' + diagnostics['artifact_empty_no_provider_reply'] = True + else: + reply = artifact_stub( + prefix=f'CCB completion reply for job {current.job_id} was stored as an artifact by --artifact-reply.', + artifact=artifact, + include_preview=False, + ) diagnostics['artifact_reply_forced'] = True else: reply, artifact = maybe_spill_text( @@ -50,4 +63,26 @@ def _force_reply_artifact(current) -> bool: return bool(options.get('artifact_reply')) +def _is_kimi_no_captured_reply(current, decision: CompletionDecision, diagnostics: dict[str, object]) -> bool: + provider = str(getattr(current, 'provider', '') or '').strip().lower() + if provider != 'kimi': + return False + if str(decision.reason or '') != 'kimi_native_turn_timeout': + return False + if _int_value(diagnostics.get('reply_chars')) != 0: + return False + return bool( + diagnostics.get('no_captured_reply') + or diagnostics.get('provider_no_reply') + or diagnostics.get('receipt_class') == 'no_captured_reply' + ) + + +def _int_value(value: object) -> int: + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + __all__ = ['spill_terminal_reply_if_needed'] diff --git a/lib/cli/render_runtime/mailbox_views_runtime/trace.py b/lib/cli/render_runtime/mailbox_views_runtime/trace.py index 5b5e2e246..8e470c536 100644 --- a/lib/cli/render_runtime/mailbox_views_runtime/trace.py +++ b/lib/cli/render_runtime/mailbox_views_runtime/trace.py @@ -82,6 +82,7 @@ def _reply_line(reply) -> str: f'notice={str(bool(reply.get("notice"))).lower()} kind={reply.get("notice_kind")} ' f'reason={reply.get("reason")} finished={reply.get("finished_at")} ' f'preview={reply.get("reply_preview")}' + f'{_reply_artifact_suffix(reply)}' ) @@ -97,12 +98,44 @@ def _event_line(event) -> str: def _job_line(job) -> str: - return ( + line = ( 'job: ' f'id={job.get("job_id")} agent={job.get("agent_name")} provider={job.get("provider")} ' f'status={job.get("status")} submission={job.get("submission_id")} ' f'created={job.get("created_at")} updated={job.get("updated_at")}' ) + extra = _job_extra_fields(job) + if extra: + return f'{line} {extra}' + return line + + +def _job_extra_fields(job) -> str: + fields: list[str] = [] + for key in ('terminal_reason', 'reply_chars', 'total_secs', 'artifact_reply_forced', 'receipt_class'): + if key not in job: + continue + fields.append(f'{key}={_format_trace_value(job.get(key))}') + return ' '.join(fields) + + +def _format_trace_value(value: object) -> str: + if isinstance(value, bool): + return str(value).lower() + return str(value) + + +def _reply_artifact_suffix(reply) -> str: + artifact = reply.get('reply_artifact') + has_artifact = isinstance(artifact, Mapping) or reply.get('reply_artifact_path') is not None + if not has_artifact: + return '' + return ( + f' artifact_forced={str(bool(reply.get("artifact_reply_forced"))).lower()}' + f' artifact_bytes={reply.get("reply_artifact_bytes")}' + f' no_captured_reply={str(bool(reply.get("no_captured_reply"))).lower()}' + f' artifact_path={reply.get("reply_artifact_path")}' + ) __all__ = ['render_trace'] diff --git a/lib/message_bureau/control_trace_runtime/summaries.py b/lib/message_bureau/control_trace_runtime/summaries.py index 45e33bad4..f4b7ec5f6 100644 --- a/lib/message_bureau/control_trace_runtime/summaries.py +++ b/lib/message_bureau/control_trace_runtime/summaries.py @@ -56,21 +56,38 @@ def attempt_summary(attempt) -> dict[str, object]: def reply_summary(reply) -> dict[str, object]: + artifact = _reply_artifact(reply) + decision_diagnostics = _decision_diagnostics(reply) + artifact_bytes = _artifact_bytes(artifact) + terminal_status = reply.terminal_status.value + artifact_reply_forced = bool(decision_diagnostics.get('artifact_reply_forced')) + empty_reply_artifact = artifact is not None and artifact_bytes == 0 return { 'reply_id': reply.reply_id, 'message_id': reply.message_id, 'attempt_id': reply.attempt_id, 'agent_name': reply.agent_name, - 'terminal_status': reply.terminal_status.value, + 'terminal_status': terminal_status, 'reply': reply.reply, 'reply_preview': preview_text(reply.reply), 'reply_size': len(reply.reply or ''), + 'reply_artifact': artifact, + 'reply_artifact_path': artifact.get('path') if artifact is not None else None, + 'reply_artifact_bytes': artifact_bytes, + 'reply_artifact_sha256': artifact.get('sha256') if artifact is not None else None, + 'artifact_reply_forced': artifact_reply_forced, + 'empty_reply_artifact': empty_reply_artifact, + 'no_captured_reply': bool(artifact_reply_forced and empty_reply_artifact and terminal_status != 'completed'), 'notice': reply_notice(reply), 'notice_kind': reply_notice_kind(reply), 'last_progress_at': reply_last_progress_at(reply), 'heartbeat_silence_seconds': reply_heartbeat_silence_seconds(reply), 'reason': reply.diagnostics.get('reason'), 'status': reply.diagnostics.get('status'), + 'completion_mode': decision_diagnostics.get('mode'), + 'total_secs': decision_diagnostics.get('total_secs'), + 'anchor_seen': decision_diagnostics.get('anchor_seen'), + 'captured_reply_chars': decision_diagnostics.get('reply_chars'), 'silence_on_success': bool(reply.diagnostics.get('silence_on_success')), 'provider_turn_ref': reply.diagnostics.get('provider_turn_ref'), 'finished_at': reply.finished_at, @@ -109,7 +126,7 @@ def job_summary(service, job_id: str, *, hint_agent: str | None = None) -> dict[ break if job is None: return None - return { + summary = { 'job_id': job.job_id, 'agent_name': job.agent_name, 'provider': job.provider, @@ -118,6 +135,28 @@ def job_summary(service, job_id: str, *, hint_agent: str | None = None) -> dict[ 'created_at': job.created_at, 'updated_at': job.updated_at, } + _add_kimi_terminal_fields(summary, job) + return summary + + +def _add_kimi_terminal_fields(summary: dict[str, object], job) -> None: + terminal = job.terminal_decision if isinstance(job.terminal_decision, dict) else {} + reason = str(terminal.get('reason') or '') + provider = str(getattr(job, 'provider', '') or '').strip().lower() + if provider != 'kimi' and not reason.startswith('kimi_'): + return + + diagnostics = terminal.get('diagnostics') if isinstance(terminal.get('diagnostics'), dict) else {} + _set_present(summary, 'terminal_reason', reason) + _set_present(summary, 'terminal_confidence', terminal.get('confidence')) + for key in ('reply_chars', 'total_secs', 'artifact_reply_forced', 'receipt_class'): + _set_present(summary, key, diagnostics.get(key)) + + +def _set_present(target: dict[str, object], key: str, value: object) -> None: + if value is None or value == '': + return + target[key] = value def preview_text(value: str, *, limit: int = 120) -> str: @@ -127,6 +166,28 @@ def preview_text(value: str, *, limit: int = 120) -> str: return text[:limit].rstrip() + '...' +def _reply_artifact(reply) -> dict[str, object] | None: + artifact = getattr(reply, 'reply_artifact', None) + return dict(artifact) if isinstance(artifact, dict) else None + + +def _decision_diagnostics(reply) -> dict[str, object]: + diagnostics = getattr(reply, 'diagnostics', None) + if not isinstance(diagnostics, dict): + return {} + payload = diagnostics.get('decision_diagnostics') + return dict(payload) if isinstance(payload, dict) else {} + + +def _artifact_bytes(artifact: dict[str, object] | None) -> int | None: + if artifact is None: + return None + try: + return int(artifact.get('bytes') or 0) + except (TypeError, ValueError): + return None + + __all__ = [ 'attempt_summary', 'event_summary', diff --git a/lib/provider_backends/agy/comm.py b/lib/provider_backends/agy/comm.py index 9e3586d28..cd8909fd9 100644 --- a/lib/provider_backends/agy/comm.py +++ b/lib/provider_backends/agy/comm.py @@ -5,6 +5,8 @@ _ANSI_RE = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]') +_PROMPT_LINE_RE = re.compile(r'^\s*>\s*$') +_BUSY_MARKERS = ('▸ Thought', '● ', 'Running…', 'Running...', 'ctrl+o to expand') @dataclass @@ -35,4 +37,34 @@ def snapshot(self) -> str: return _ANSI_RE.sub('', content) -__all__ = ['AgyPaneReader'] +def agy_pane_ready_for_input(content: str) -> bool: + """Return true when the Antigravity TUI is at an empty input prompt.""" + text = _ANSI_RE.sub('', str(content or '')) + if not text.strip(): + return False + lines = text.replace('\r\n', '\n').replace('\r', '\n').splitlines() + tail = lines[-80:] + lowered_tail = '\n'.join(tail).lower() + if '? for shortcuts' not in lowered_tail and 'gemini' not in lowered_tail: + return False + + for index in range(len(tail) - 1, -1, -1): + if not _PROMPT_LINE_RE.match(tail[index]): + continue + if _has_busy_activity(tail[index + 1 :]): + continue + after = '\n'.join(tail[index:]).lower() + if '? for shortcuts' in after or 'gemini' in after: + return True + return False + + +def _has_busy_activity(lines: list[str]) -> bool: + for line in lines: + stripped = line.strip() + if any(marker in stripped for marker in _BUSY_MARKERS): + return True + return False + + +__all__ = ['AgyPaneReader', 'agy_pane_ready_for_input'] diff --git a/lib/provider_backends/agy/execution_runtime/poll.py b/lib/provider_backends/agy/execution_runtime/poll.py index 9aa052625..aadc04137 100644 --- a/lib/provider_backends/agy/execution_runtime/poll.py +++ b/lib/provider_backends/agy/execution_runtime/poll.py @@ -11,30 +11,38 @@ CompletionStatus, ) from provider_execution.base import ProviderPollResult, ProviderSubmission -from provider_execution.common import build_item +from provider_execution.common import build_item, send_prompt_to_runtime_target -from ..native_log import observe_agy_transcript +from ..comm import agy_pane_ready_for_input +from ..native_log import AgyTranscriptObservation, observe_agy_transcript from .helpers import hash_text, seconds_between, state_int, state_str -MAX_WAIT_SECS = 300.0 -ANCHOR_WAIT_SECS = 120.0 +MAX_WAIT_SECS = 900.0 +ANCHOR_WAIT_SECS = 300.0 +READY_WAIT_SECS = 900.0 +PANE_FALLBACK_STABLE_SECS = 10.0 def poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPollResult | None: state = dict(submission.runtime_state) - send_error = state.get('send_error') + send_error = state_str(state, 'send_error') if send_error: - return _terminal( - submission, - state, - now, - status=CompletionStatus.FAILED, - reason=f'send_failed:{send_error}', - reply='', - confidence=CompletionConfidence.DEGRADED, - ) + if _send_error_is_fatal(send_error): + return _terminal( + submission, + state, + now, + status=CompletionStatus.FAILED, + reason=f'send_failed:{send_error}', + reply='', + confidence=CompletionConfidence.DEGRADED, + ) + state['delivery_ambiguous_send_error'] = send_error + if not bool(state.get('prompt_sent')): + state['prompt_sent'] = True + state['prompt_deferred_until_ready'] = False pane_id = state_str(state, 'pane_id') req_id = state_str(state, 'request_anchor') or state_str(state, 'req_id') @@ -50,7 +58,8 @@ def poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPoll confidence=CompletionConfidence.DEGRADED, ) - if state.get('backend') is None: + backend = state.get('backend') + if backend is None: return _terminal( submission, state, @@ -61,6 +70,9 @@ def poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPoll confidence=CompletionConfidence.DEGRADED, ) + if not bool(state.get('prompt_sent')): + return _poll_deferred_prompt(submission, state, now=now, backend=backend, pane_id=pane_id) + started_at = state_str(state, 'started_at') or submission.accepted_at or now state['last_poll_at'] = now @@ -73,8 +85,33 @@ def poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPoll req_id, home_candidates=_home_candidates(state), ) + pane_observation = _observe_agy_pane_turn(backend, pane_id, req_id) + if pane_observation is not None: + pane_observation = _stabilize_pane_observation(state, pane_observation, now) + if pane_observation is not None and ( + observation is None or (pane_observation.completed and not observation.completed) + ): + observation = pane_observation + state['pane_fallback_observed'] = True + if observation is None: - if total_secs >= ANCHOR_WAIT_SECS: + pane_content = _pane_snapshot(backend, pane_id) + pane_ready = agy_pane_ready_for_input(pane_content) + if total_secs >= MAX_WAIT_SECS and not pane_ready: + return _terminal( + submission, + state, + now, + status=CompletionStatus.INCOMPLETE, + reason='agy_input_busy_timeout', + reply='', + confidence=CompletionConfidence.DEGRADED, + diagnostics_extra={ + 'input_not_ready': True, + 'diagnosis': 'AGY pane did not return to an input-ready state while waiting for transcript anchor.', + }, + ) + if total_secs >= ANCHOR_WAIT_SECS and pane_ready: return _terminal( submission, state, @@ -130,6 +167,26 @@ def poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPoll ) state['anchor_emitted'] = True + coalesced_ids = tuple(getattr(observation, 'coalesced_request_ids', ()) or ()) + if coalesced_ids and not bool(getattr(observation, 'request_is_latest', True)): + return _terminal( + submission, + state, + now, + status=CompletionStatus.INCOMPLETE, + reason='agy_request_coalesced', + reply='', + confidence=CompletionConfidence.DEGRADED, + diagnostics_extra={ + 'request_coalesced': True, + 'coalesced_request_ids': list(coalesced_ids), + 'diagnosis': ( + 'AGY recorded this CCB_REQ_ID inside a USER_INPUT that also contained a later ' + 'CCB_REQ_ID, so no distinct reply can be safely attributed to this job.' + ), + }, + ) + reply = observation.reply or '' reply_signature = hash_text(reply) if reply and reply_signature != state_str(state, 'last_reply_signature'): @@ -172,6 +229,8 @@ def poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPoll 'provider_turn_ref': observation.provider_turn_ref, 'native_completed_at': observation.native_completed_at, 'latest_status': observation.latest_status, + 'request_coalesced': bool(coalesced_ids), + 'coalesced_request_ids': list(coalesced_ids), }, cursor_kwargs={'session_path': transcript_path or None}, ) @@ -195,6 +254,80 @@ def poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPoll return None +def _poll_deferred_prompt( + submission: ProviderSubmission, + state: dict[str, object], + *, + now: str, + backend: object, + pane_id: str, +) -> ProviderPollResult: + started_at = state_str(state, 'started_at') or submission.accepted_at or now + ready_wait_secs = seconds_between(started_at, now) + state['ready_wait_secs'] = ready_wait_secs + content = _pane_snapshot(backend, pane_id) + if agy_pane_ready_for_input(content): + pending_prompt = state_str(state, 'pending_prompt') + if not pending_prompt: + return _terminal( + submission, + state, + now, + status=CompletionStatus.FAILED, + reason='runtime_state_invalid', + reply='', + confidence=CompletionConfidence.DEGRADED, + diagnostics_extra={'missing_pending_prompt': True}, + ) + send_error = _send_prompt(backend, pane_id, pending_prompt) + if send_error: + state['send_error'] = send_error + if _send_error_is_fatal(send_error): + return _terminal( + submission, + state, + now, + status=CompletionStatus.FAILED, + reason=f'send_failed:{send_error}', + reply='', + confidence=CompletionConfidence.DEGRADED, + ) + state['delivery_ambiguous_send_error'] = send_error + state['prompt_sent'] = True + state['prompt_sent_at'] = now + state['prompt_deferred_until_ready'] = False + state['started_at'] = now + state['last_poll_at'] = now + state['next_seq'] = state_int(state, 'next_seq', 1) + return ProviderPollResult(submission=replace(submission, runtime_state=state), items=(), decision=None) + state['prompt_sent'] = True + state['prompt_sent_at'] = now + state['prompt_deferred_until_ready'] = False + state['started_at'] = now + state['last_poll_at'] = now + state['next_seq'] = state_int(state, 'next_seq', 1) + return ProviderPollResult(submission=replace(submission, runtime_state=state), items=(), decision=None) + + if ready_wait_secs >= READY_WAIT_SECS: + return _terminal( + submission, + state, + now, + status=CompletionStatus.INCOMPLETE, + reason='agy_input_not_ready', + reply='', + confidence=CompletionConfidence.DEGRADED, + diagnostics_extra={ + 'input_not_ready': True, + 'ready_wait_secs': ready_wait_secs, + 'diagnosis': 'AGY pane did not reach an input-ready state before prompt delivery.', + }, + ) + state['last_poll_at'] = now + state['next_seq'] = state_int(state, 'next_seq', 1) + return ProviderPollResult(submission=replace(submission, runtime_state=state), items=(), decision=None) + + def _home_candidates(state: dict[str, object]) -> list[Path]: candidates: list[Path] = [] for key in ('agy_home', 'home', 'runtime_home'): @@ -207,6 +340,145 @@ def _home_candidates(state: dict[str, object]) -> list[Path]: return candidates +def _pane_snapshot(backend: object, pane_id: str) -> str: + getter = getattr(backend, 'get_pane_content', None) + if not callable(getter): + getter = getattr(backend, 'get_text', None) + if not callable(getter): + return '' + try: + return str(getter(pane_id, lines=2000) or '') + except Exception: + return '' + + +def _observe_agy_pane_turn(backend: object, pane_id: str, req_id: str) -> AgyTranscriptObservation | None: + content = _pane_snapshot(backend, pane_id) + if not content or req_id not in content: + return None + completed = agy_pane_ready_for_input(content) + reply = _extract_agy_pane_reply(content, req_id) if completed else '' + return AgyTranscriptObservation( + request_seen=True, + completed=bool(completed and reply), + reply=reply, + conversation_id=pane_id, + transcript_path=f'pane:{pane_id}', + provider_turn_ref=f'pane:{pane_id}:{req_id}', + line_count=len(content.splitlines()), + native_started_at=None, + native_completed_at=None, + latest_status='PANE_FALLBACK' if reply else None, + ) + + +def _stabilize_pane_observation( + state: dict[str, object], + observation: AgyTranscriptObservation, + now: str, +) -> AgyTranscriptObservation: + reply = observation.reply or '' + if not reply: + state.pop('pane_fallback_candidate_signature', None) + state.pop('pane_fallback_candidate_since', None) + return observation + + signature = hash_text(reply) + if signature != state_str(state, 'pane_fallback_candidate_signature'): + state['pane_fallback_candidate_signature'] = signature + state['pane_fallback_candidate_since'] = now + return replace(observation, completed=False) + + stable_since = state_str(state, 'pane_fallback_candidate_since') or now + stable_secs = seconds_between(stable_since, now) + state['pane_fallback_stable_secs'] = stable_secs + if stable_secs < PANE_FALLBACK_STABLE_SECS: + return replace(observation, completed=False) + return observation + + +def _extract_agy_pane_reply(content: str, req_id: str) -> str: + normalized = str(content or '').replace('\r\n', '\n').replace('\r', '\n') + anchor_index = normalized.rfind(req_id) + if anchor_index < 0: + return '' + tail = normalized[anchor_index:].splitlines() + start = _agy_answer_start(tail) + if start is None: + return '' + lines: list[str] = [] + for raw in tail[start:]: + stripped = raw.strip() + if _agy_answer_end(stripped): + break + if _agy_drop_answer_line(stripped): + continue + lines.append(raw.rstrip()) + return _clean_agy_pane_reply('\n'.join(lines), req_id) + + +def _agy_answer_start(lines: list[str]) -> int | None: + for index, raw in enumerate(lines): + stripped = raw.strip() + if stripped.startswith('▸ Thought') or stripped.startswith('Thought for'): + return index + 1 + return None + + +def _agy_answer_end(stripped: str) -> bool: + if not stripped: + return False + if stripped == '>' or stripped.startswith('> '): + return True + if stripped.startswith('─') and len(stripped) >= 8: + return True + if stripped.startswith('? for shortcuts'): + return True + return False + + +def _agy_drop_answer_line(stripped: str) -> bool: + if not stripped: + return False + lowered = stripped.lower() + if lowered.startswith('ccb reply guidance:'): + return True + if lowered.startswith('- answer directly') or lowered.startswith('- include only relevant'): + return True + if lowered.startswith('- avoid raw logs'): + return True + if stripped.startswith('CCB_REQ_ID:'): + return True + if stripped.startswith('Eligibility Check'): + return True + if stripped.startswith('⎿ Eligibility check failed'): + return True + return False + + +def _clean_agy_pane_reply(text: str, req_id: str) -> str: + del req_id + lines = [line.rstrip() for line in str(text or '').splitlines()] + while lines and not lines[0].strip(): + lines.pop(0) + while lines and not lines[-1].strip(): + lines.pop() + return '\n'.join(lines).strip() + + +def _send_prompt(backend: object, pane_id: str, prompt: str) -> str | None: + try: + send_prompt_to_runtime_target(backend, pane_id, prompt) + except Exception as exc: + return f'send_text_failed:{exc!r}' + return None + + +def _send_error_is_fatal(send_error: str) -> bool: + lowered = str(send_error or '').lower() + return 'does not support text submission' in lowered + + def _terminal( submission: ProviderSubmission, state: dict[str, object], @@ -234,10 +506,14 @@ def _terminal( ) diagnostics = { 'mode': 'native_transcript_log', - 'total_secs': float(state.get('total_secs') or 0.0), + 'total_secs': float(state.get('total_secs') or state.get('ready_wait_secs') or 0.0), 'anchor_seen': bool(state.get('anchor_emitted')), 'reply_chars': len(cleaned_reply), } + if state.get('send_error'): + diagnostics['send_error'] = str(state.get('send_error')) + if state.get('delivery_ambiguous_send_error'): + diagnostics['delivery_ambiguous_send_error'] = str(state.get('delivery_ambiguous_send_error')) diagnostics.update(diagnostics_extra or {}) decision = CompletionDecision( terminal=True, diff --git a/lib/provider_backends/agy/execution_runtime/start.py b/lib/provider_backends/agy/execution_runtime/start.py index a15699192..785cf9384 100644 --- a/lib/provider_backends/agy/execution_runtime/start.py +++ b/lib/provider_backends/agy/execution_runtime/start.py @@ -6,7 +6,7 @@ from provider_execution.base import ProviderRuntimeContext, ProviderSubmission from provider_execution.common import error_submission, send_prompt_to_runtime_target -from ..comm import AgyPaneReader +from ..comm import AgyPaneReader, agy_pane_ready_for_input from ..native_log import agy_home_from_start_cmd from ..protocol import wrap_agy_prompt from ..session import load_project_session @@ -87,12 +87,17 @@ def start_submission( prompt = wrap_agy_prompt(job.request.body or '', req_id) reader = AgyPaneReader(backend=backend, pane_id=pane_id, lines=_PANE_LINES_DEFAULT) + initial_content = reader.snapshot() + prompt_deferred_until_ready = not agy_pane_ready_for_input(initial_content) send_error: str | None = None - try: - send_prompt_to_runtime_target(backend, pane_id, prompt) - except Exception as exc: - send_error = f'send_text_failed:{exc!r}' + prompt_sent = False + if not prompt_deferred_until_ready: + try: + send_prompt_to_runtime_target(backend, pane_id, prompt) + prompt_sent = True + except Exception as exc: + send_error = f'send_text_failed:{exc!r}' diagnostics: dict[str, object] = { 'provider': provider, @@ -104,6 +109,8 @@ def start_submission( } if send_error: diagnostics['send_error'] = send_error + if prompt_deferred_until_ready: + diagnostics['prompt_deferred_until_ready'] = True return ProviderSubmission( job_id=job.job_id, @@ -129,10 +136,17 @@ def start_submission( 'last_hash': None, 'last_change_at': now, 'last_poll_at': now, - 'prompt_sent': send_error is None, + 'prompt_sent': prompt_sent, + 'pending_prompt': prompt, + 'prompt_deferred_until_ready': prompt_deferred_until_ready, 'send_error': send_error, 'snapshot_errors': 0, 'next_seq': 1, + 'anchor_emitted': False, + 'reply_buffer': '', + 'last_reply_signature': '', + 'turn_boundary_ref': '', + 'session_path': '', }, ) diff --git a/lib/provider_backends/agy/native_log.py b/lib/provider_backends/agy/native_log.py index 3711813b4..1a3d6bf11 100644 --- a/lib/provider_backends/agy/native_log.py +++ b/lib/provider_backends/agy/native_log.py @@ -4,13 +4,18 @@ import json import os from pathlib import Path +import re import shlex from typing import Iterable from provider_backends.native_cli_support import clean_native_reply +from provider_core.protocol import REQ_ID_PREFIX from provider_core.source_home import current_provider_source_home +_REQ_ID_RE = re.compile(rf"{re.escape(REQ_ID_PREFIX)}\s*([^\s`]+)") + + @dataclass(frozen=True) class AgyTranscriptObservation: request_seen: bool @@ -23,6 +28,8 @@ class AgyTranscriptObservation: native_started_at: object | None = None native_completed_at: object | None = None latest_status: str | None = None + coalesced_request_ids: tuple[str, ...] = () + request_is_latest: bool = True def observe_agy_transcript( @@ -133,6 +140,8 @@ def _observe_transcript(path: Path, *, req_id: str) -> AgyTranscriptObservation completed_at: object | None = None provider_turn_ref: str | None = None latest_line = 0 + coalesced_request_ids: tuple[str, ...] = () + request_is_latest = True for index, line in enumerate(lines, 1): try: @@ -144,15 +153,18 @@ def _observe_transcript(path: Path, *, req_id: str) -> AgyTranscriptObservation if _is_user_input(event): content = _event_content(event) - if req_id in content: - active = True + request_ids = _request_ids(content) + if req_id in request_ids: + request_is_latest = request_ids[-1] == req_id + active = request_is_latest request_line = index latest_reply = "" - latest_status = _event_status(event) + latest_status = 'COALESCED_SUPERSEDED' if not request_is_latest else _event_status(event) started_at = event.get("created_at") or event.get("timestamp") completed_at = None provider_turn_ref = _event_ref(event) latest_line = index + coalesced_request_ids = request_ids if len(request_ids) > 1 else () elif active: active = False continue @@ -188,6 +200,8 @@ def _observe_transcript(path: Path, *, req_id: str) -> AgyTranscriptObservation native_started_at=started_at, native_completed_at=completed_at, latest_status=latest_status, + coalesced_request_ids=coalesced_request_ids, + request_is_latest=request_is_latest, ) @@ -235,6 +249,15 @@ def _event_content(event: dict[str, object]) -> str: return text if isinstance(text, str) else "" +def _request_ids(content: str) -> tuple[str, ...]: + ids: list[str] = [] + for match in _REQ_ID_RE.finditer(content or ""): + candidate = match.group(1).strip().strip(".,;:)") + if candidate: + ids.append(candidate) + return tuple(ids) + + def _event_status(event: dict[str, object]) -> str | None: text = str(event.get("status") or "").strip().upper() return text or None diff --git a/lib/provider_backends/kimi/execution.py b/lib/provider_backends/kimi/execution.py index 7e6567693..7f1dcf622 100644 --- a/lib/provider_backends/kimi/execution.py +++ b/lib/provider_backends/kimi/execution.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import os from dataclasses import replace from datetime import datetime from pathlib import Path @@ -26,6 +27,7 @@ PANE_LINES_DEFAULT = 2000 MAX_WAIT_SECS = 300.0 +KIMI_NATIVE_TURN_TIMEOUT_ENV = "CCB_KIMI_NATIVE_TURN_TIMEOUT_S" ANCHOR_WAIT_SECS = 120.0 READY_WAIT_SECS = 60.0 PANE_FALLBACK_STABLE_SECS = 10.0 @@ -253,7 +255,9 @@ def _poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPol state["next_seq"] = _state_int(state, "next_seq", 1) started_at = _state_str(state, "started_at") or submission.accepted_at or now total_secs = _seconds_between(started_at, now) + max_wait_secs = _native_turn_timeout_secs() state["total_secs"] = total_secs + state["max_wait_secs"] = max_wait_secs observation = observe_kimi_turn(Path(work_dir), req_id) pane_observation = _observe_kimi_pane_turn(backend, pane_id, req_id) @@ -400,7 +404,7 @@ def _poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPol ) state["turn_boundary_ref"] = boundary_ref - if total_secs >= MAX_WAIT_SECS and not observation.completed: + if total_secs >= max_wait_secs and not observation.completed: return _terminal( submission, state, @@ -409,6 +413,7 @@ def _poll_submission(submission: ProviderSubmission, *, now: str) -> ProviderPol reason="kimi_native_turn_timeout", reply=str(state.get("reply_buffer") or ""), confidence=CompletionConfidence.DEGRADED, + diagnostics_extra={"max_wait_secs": max_wait_secs}, ) updated = replace(submission, reply=str(state.get("reply_buffer") or ""), runtime_state=state) @@ -514,6 +519,20 @@ def _terminal( "reply_chars": len(cleaned_reply), } diagnostics.update(diagnostics_extra or {}) + if _is_no_captured_reply_timeout(reason=reason, reply=cleaned_reply): + diagnostics.update( + { + "no_captured_reply": True, + "provider_no_reply": True, + "receipt_valid": False, + "receipt_class": "no_captured_reply", + "error_type": "empty_provider_reply", + "diagnosis": ( + "Kimi native turn polling timed out after observing the submitted CCB_REQ_ID, " + "but no assistant reply text was captured." + ), + } + ) decision = CompletionDecision( terminal=True, status=status, @@ -531,6 +550,10 @@ def _terminal( return ProviderPollResult(submission=progress, items=(), decision=decision) +def _is_no_captured_reply_timeout(*, reason: str, reply: str) -> bool: + return reason == "kimi_native_turn_timeout" and not (reply or "") + + def _pane_snapshot(backend: object, pane_id: str, *, lines: int) -> str: getter = getattr(backend, "get_pane_content", None) if not callable(getter): @@ -752,6 +775,19 @@ def _seconds_between(start: str, end: str) -> float: return max(0.0, (end_dt - start_dt).total_seconds()) +def _native_turn_timeout_secs() -> float: + raw = os.environ.get(KIMI_NATIVE_TURN_TIMEOUT_ENV) + if raw is None or not raw.strip(): + return MAX_WAIT_SECS + try: + value = float(raw) + except ValueError: + return MAX_WAIT_SECS + if value <= 0: + return MAX_WAIT_SECS + return value + + def _next_seq(state: dict[str, object]) -> int: seq = _state_int(state, "next_seq", 1) state["next_seq"] = seq + 1 diff --git a/lib/provider_backends/kimi/manifest.py b/lib/provider_backends/kimi/manifest.py index 2a509f67a..9f549f732 100644 --- a/lib/provider_backends/kimi/manifest.py +++ b/lib/provider_backends/kimi/manifest.py @@ -9,7 +9,7 @@ def build_manifest() -> ProviderManifest: return ProviderManifest( provider="kimi", - supports_resume=True, + supports_resume=False, supports_permission_auto=True, supports_stream_watch=False, supports_subagents=True, diff --git a/lib/provider_profiles/codex_home_config.py b/lib/provider_profiles/codex_home_config.py index 4ceef2b71..645ddcfcb 100644 --- a/lib/provider_profiles/codex_home_config.py +++ b/lib/provider_profiles/codex_home_config.py @@ -54,8 +54,10 @@ 'Stop', ) _CODEX_ACTIVITY_HOOK_TIMEOUT_S = 5 -_CODEX_INHERITED_HOOK_EVENTS = frozenset({'SessionStart', 'UserPromptSubmit', 'Stop'}) -_CODEX_INHERITED_HOOK_COMMAND_MARKERS = ('.hindsight/codex/scripts/',) +_CODEX_DEFAULT_INHERITED_HOOK_EVENTS = frozenset({'SessionStart', 'UserPromptSubmit', 'Stop'}) +_CODEX_DEFAULT_INHERITED_COMMAND_HOOK_MARKERS = ('.hindsight/codex/scripts/',) +_CODEX_INHERITED_HOOK_EVENTS_ENV = 'CCB_CODEX_INHERITED_HOOK_EVENTS' +_CODEX_INHERITED_COMMAND_HOOK_MARKERS_ENV = 'CCB_CODEX_INHERITED_COMMAND_HOOK_MARKERS' _TOML_TABLE_HEADER_RE = re.compile(r'^\s*\[{1,2}[^\]]+\]{1,2}\s*(?:#.*)?$') @@ -93,6 +95,7 @@ def materialize_codex_home_config( _write_codex_api_authority_config( target_config, authority, + profile=profile, source_config=source_config, project_root=project_root, workspace_path=workspace_path, @@ -100,10 +103,11 @@ def materialize_codex_home_config( elif _inherits_config(profile) and _inherits_api(profile) and _source_config_valid(source_config): if source_config.is_file(): payload = _read_source_config_payload(source_config) - if payload: + if payload or _profile_mcp_servers(profile): _write_managed_codex_config( target_config, payload, + profile=profile, project_root=project_root, workspace_path=workspace_path, ) @@ -112,9 +116,19 @@ def materialize_codex_home_config( _append_managed_codex_feature_overrides(target_config) _append_managed_codex_project_trust(target_config, project_root=project_root, workspace_path=workspace_path) else: - _write_managed_config_stub(target_config, project_root=project_root, workspace_path=workspace_path) + _write_managed_config_stub( + target_config, + profile=profile, + project_root=project_root, + workspace_path=workspace_path, + ) else: - _write_managed_config_stub(target_config, project_root=project_root, workspace_path=workspace_path) + _write_managed_config_stub( + target_config, + profile=profile, + project_root=project_root, + workspace_path=workspace_path, + ) _materialize_auth_file( source_home / 'auth.json', @@ -264,19 +278,28 @@ def _write_codex_api_authority_config( target: Path, authority: CodexApiAuthority, *, + profile, source_config: Path, project_root: Path | None, workspace_path: Path | None, ) -> None: target.parent.mkdir(parents=True, exist_ok=True) payload = _managed_codex_config_payload(source_config, authority=authority) + _merge_codex_mcp_server_overrides(payload, profile=profile) _trust_managed_codex_project_paths(payload, project_root=project_root, workspace_path=workspace_path) target.write_text(_render_toml_document(payload), encoding='utf-8') -def _write_managed_config_stub(target: Path, *, project_root: Path | None, workspace_path: Path | None) -> None: +def _write_managed_config_stub( + target: Path, + *, + profile, + project_root: Path | None, + workspace_path: Path | None, +) -> None: target.parent.mkdir(parents=True, exist_ok=True) payload: dict[str, object] = {} + _merge_codex_mcp_server_overrides(payload, profile=profile) _trust_managed_codex_project_paths(payload, project_root=project_root, workspace_path=workspace_path) rendered = _render_toml_document(payload) if payload else '# ccb agent-local codex config\n' target.write_text(rendered, encoding='utf-8') @@ -286,12 +309,14 @@ def _write_managed_codex_config( target: Path, payload: dict[str, object], *, + profile, project_root: Path | None, workspace_path: Path | None, ) -> None: target.parent.mkdir(parents=True, exist_ok=True) sanitized = _disable_interactive_migration_features(payload) _strip_unmanaged_hook_config(sanitized) + _merge_codex_mcp_server_overrides(sanitized, profile=profile) _trust_managed_codex_project_paths(sanitized, project_root=project_root, workspace_path=workspace_path) target.write_text(_render_toml_document(sanitized), encoding='utf-8') @@ -475,6 +500,52 @@ def _strip_unmanaged_hook_config(payload: dict[str, object]) -> None: payload.pop('hooks', None) +def _merge_codex_mcp_server_overrides(payload: dict[str, object], *, profile) -> None: + overrides = _profile_mcp_servers(profile) + if not overrides: + return + + existing = _codex_mcp_servers_as_mapping(payload.get('mcp_servers')) + for name, server in overrides.items(): + existing[name] = _clone_mapping(server) + payload['mcp_servers'] = existing + + +def _profile_mcp_servers(profile) -> dict[str, dict[str, object]]: + raw = getattr(profile, 'mcp_servers', {}) if profile is not None else {} + if not isinstance(raw, dict): + return {} + normalized: dict[str, dict[str, object]] = {} + for raw_name, raw_server in raw.items(): + name = str(raw_name or '').strip() + if not name or not isinstance(raw_server, dict): + continue + normalized[name] = _clone_mapping(raw_server) + return normalized + + +def _codex_mcp_servers_as_mapping(value: object) -> dict[str, dict[str, object]]: + if isinstance(value, dict): + return { + str(name): _clone_mapping(server) + for name, server in value.items() + if str(name).strip() and isinstance(server, dict) + } + if isinstance(value, list): + servers: dict[str, dict[str, object]] = {} + for item in value: + if not isinstance(item, dict): + continue + name = str(item.get('name') or '').strip() + if not name: + continue + server = _clone_mapping(item) + server.pop('name', None) + servers[name] = server + return servers + return {} + + def _import_optional_toml_reader(): for module_name in ('tomllib', 'tomli', 'toml'): try: @@ -821,6 +892,10 @@ def _codex_activity_hook_events(command: str) -> dict[str, list[dict[str, object def _allowed_inherited_codex_hooks(source_home: Path | None) -> dict[str, list[dict[str, object]]]: if source_home is None: return {} + policy_events = _codex_inherited_hook_events() + command_markers = _codex_inherited_command_hook_markers() + if not policy_events or not command_markers: + return {} hooks_path = Path(source_home).expanduser() / 'hooks.json' try: payload = json.loads(hooks_path.read_text(encoding='utf-8')) @@ -833,11 +908,11 @@ def _allowed_inherited_codex_hooks(source_home: Path | None) -> dict[str, list[d selected: dict[str, list[dict[str, object]]] = {} for event_name, raw_groups in hooks.items(): event = str(event_name) - if event not in _CODEX_INHERITED_HOOK_EVENTS or not isinstance(raw_groups, list): + if event not in policy_events or not isinstance(raw_groups, list): continue groups: list[dict[str, object]] = [] for raw_group in raw_groups: - group = _allowed_inherited_codex_hook_group(raw_group) + group = _allowed_inherited_codex_hook_group(raw_group, command_markers=command_markers) if group is not None: groups.append(group) if groups: @@ -845,7 +920,38 @@ def _allowed_inherited_codex_hooks(source_home: Path | None) -> dict[str, list[d return selected -def _allowed_inherited_codex_hook_group(raw_group: object) -> dict[str, object] | None: +def _codex_inherited_hook_events() -> frozenset[str]: + configured = _split_codex_inherited_hook_env(os.environ.get(_CODEX_INHERITED_HOOK_EVENTS_ENV)) + return frozenset([*_CODEX_DEFAULT_INHERITED_HOOK_EVENTS, *configured]) + + +def _codex_inherited_command_hook_markers() -> tuple[str, ...]: + configured = _split_codex_inherited_hook_env(os.environ.get(_CODEX_INHERITED_COMMAND_HOOK_MARKERS_ENV)) + markers = [*_CODEX_DEFAULT_INHERITED_COMMAND_HOOK_MARKERS, *configured] + normalized: list[str] = [] + seen: set[str] = set() + for marker in markers: + normalized_marker = str(marker or '').strip().replace('\\', '/') + if not normalized_marker or normalized_marker in seen: + continue + normalized.append(normalized_marker) + seen.add(normalized_marker) + return tuple(normalized) + + +def _split_codex_inherited_hook_env(value: object) -> tuple[str, ...]: + raw = str(value or '').strip() + if not raw: + return () + parts = re.split(f'[,{re.escape(os.pathsep)}]+', raw) + return tuple(part.strip() for part in parts if part.strip()) + + +def _allowed_inherited_codex_hook_group( + raw_group: object, + *, + command_markers: tuple[str, ...], +) -> dict[str, object] | None: if not isinstance(raw_group, dict): return None raw_handlers = raw_group.get('hooks') @@ -853,7 +959,7 @@ def _allowed_inherited_codex_hook_group(raw_group: object) -> dict[str, object] return None handlers: list[dict[str, object]] = [] for raw_handler in raw_handlers: - handler = _allowed_inherited_codex_hook_handler(raw_handler) + handler = _allowed_inherited_codex_hook_handler(raw_handler, command_markers=command_markers) if handler is None: return None handlers.append(handler) @@ -865,14 +971,18 @@ def _allowed_inherited_codex_hook_group(raw_group: object) -> dict[str, object] return group -def _allowed_inherited_codex_hook_handler(raw_handler: object) -> dict[str, object] | None: +def _allowed_inherited_codex_hook_handler( + raw_handler: object, + *, + command_markers: tuple[str, ...], +) -> dict[str, object] | None: if not isinstance(raw_handler, dict): return None if str(raw_handler.get('type') or '') != 'command': return None command = str(raw_handler.get('command') or '') normalized_command = command.replace('\\', '/') - if not any(marker in normalized_command for marker in _CODEX_INHERITED_HOOK_COMMAND_MARKERS): + if not any(marker in normalized_command for marker in command_markers): return None handler: dict[str, object] = { 'type': 'command', diff --git a/lib/provider_profiles/materializer.py b/lib/provider_profiles/materializer.py index e9843593f..2eabda343 100644 --- a/lib/provider_profiles/materializer.py +++ b/lib/provider_profiles/materializer.py @@ -81,6 +81,7 @@ def materialize_provider_profile( profile_root=str(profile_root) if profile_root is not None else None, runtime_home=None, env=dict(profile_spec.env), + mcp_servers=dict(profile_spec.mcp_servers), inherit_api=profile_spec.inherit_api, inherit_auth=profile_spec.inherit_auth, inherit_config=profile_spec.inherit_config, @@ -157,6 +158,7 @@ def _materialize_codex_profile( profile_root: Path, workspace_path: Path, ) -> ResolvedProviderProfile: + runtime_dir = layout.agent_provider_runtime_dir(spec.name, spec.provider) runtime_home = _effective_provider_runtime_home(layout=layout, spec=spec) if not _codex_profile_uses_explicit_runtime_home(profile_spec): migrated_legacy_home = _migrate_legacy_codex_profile_runtime_home( @@ -171,8 +173,12 @@ def _materialize_codex_profile( runtime_home, profile=profile_spec, project_root=layout.project_root, + agent_name=spec.name, + runtime_dir=runtime_dir, workspace_path=workspace_path, shared_cache_root=layout.shared_cache_dir, + memory_projection_event_path=layout.agent_events_path(spec.name), + memory_projection_marker_path=runtime_dir / 'codex-memory-projection.json', ) return ResolvedProviderProfile( @@ -182,6 +188,7 @@ def _materialize_codex_profile( profile_root=str(profile_root) if _codex_profile_uses_explicit_runtime_home(profile_spec) else None, runtime_home=str(runtime_home) if runtime_home is not None else None, env=dict(profile_spec.env), + mcp_servers=dict(profile_spec.mcp_servers), inherit_api=profile_spec.inherit_api, inherit_auth=profile_spec.inherit_auth, inherit_config=profile_spec.inherit_config, @@ -206,6 +213,7 @@ def _materialize_api_profile( profile_root=str(profile_root), runtime_home=None, env=env, + mcp_servers=dict(profile_spec.mcp_servers), inherit_api=profile_spec.inherit_api, inherit_auth=profile_spec.inherit_auth, inherit_config=profile_spec.inherit_config, @@ -233,6 +241,7 @@ def _materialize_claude_profile( profile_root=str(profile_root), runtime_home=None, env=env, + mcp_servers=dict(profile_spec.mcp_servers), inherit_api=profile_spec.inherit_api, inherit_auth=profile_spec.inherit_auth, inherit_config=profile_spec.inherit_config, diff --git a/lib/provider_profiles/models.py b/lib/provider_profiles/models.py index f511de3a6..aaa11acf1 100644 --- a/lib/provider_profiles/models.py +++ b/lib/provider_profiles/models.py @@ -13,6 +13,7 @@ class ProviderProfileSpec: mode: str = "inherit" home: str | None = None env: dict[str, str] = field(default_factory=dict) + mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict) inherit_api: bool = True inherit_auth: bool = True inherit_config: bool = True @@ -28,9 +29,10 @@ def __post_init__(self) -> None: home = str(self.home).strip() if self.home is not None else None object.__setattr__(self, 'home', home or None) object.__setattr__(self, 'env', {str(key): str(value) for key, value in dict(self.env).items()}) + object.__setattr__(self, 'mcp_servers', _normalize_mcp_servers(self.mcp_servers)) def to_record(self) -> dict[str, Any]: - return { + payload = { 'mode': self.mode, 'home': self.home, 'env': dict(self.env), @@ -41,6 +43,9 @@ def to_record(self) -> dict[str, Any]: 'inherit_commands': bool(self.inherit_commands), 'inherit_memory': bool(self.inherit_memory), } + if self.mcp_servers: + payload['mcp_servers'] = _clone_jsonish_mapping(self.mcp_servers) + return payload @dataclass(frozen=True) @@ -51,6 +56,7 @@ class ResolvedProviderProfile: profile_root: str | None = None runtime_home: str | None = None env: dict[str, str] = field(default_factory=dict) + mcp_servers: dict[str, dict[str, Any]] = field(default_factory=dict) inherit_api: bool = True inherit_auth: bool = True inherit_config: bool = True @@ -74,6 +80,7 @@ def __post_init__(self) -> None: object.__setattr__(self, 'profile_root', _normalize_path_text(self.profile_root)) object.__setattr__(self, 'runtime_home', _normalize_path_text(self.runtime_home)) object.__setattr__(self, 'env', {str(key): str(value) for key, value in dict(self.env).items()}) + object.__setattr__(self, 'mcp_servers', _normalize_mcp_servers(self.mcp_servers)) @property def profile_root_path(self) -> Path | None: @@ -88,7 +95,7 @@ def runtime_home_path(self) -> Path | None: return Path(self.runtime_home) def to_record(self) -> dict[str, Any]: - return { + payload = { 'provider': self.provider, 'agent_name': self.agent_name, 'mode': self.mode, @@ -102,6 +109,9 @@ def to_record(self) -> dict[str, Any]: 'inherit_commands': bool(self.inherit_commands), 'inherit_memory': bool(self.inherit_memory), } + if self.mcp_servers: + payload['mcp_servers'] = _clone_jsonish_mapping(self.mcp_servers) + return payload @classmethod def from_record(cls, record: dict[str, Any]) -> "ResolvedProviderProfile": @@ -112,6 +122,7 @@ def from_record(cls, record: dict[str, Any]) -> "ResolvedProviderProfile": profile_root=record.get('profile_root'), runtime_home=record.get('runtime_home'), env=dict(record.get('env') or {}), + mcp_servers=dict(record.get('mcp_servers') or {}), inherit_api=bool(record.get('inherit_api', True)), inherit_auth=bool(record.get('inherit_auth', True)), inherit_config=bool(record.get('inherit_config', True)), @@ -136,4 +147,28 @@ def _normalize_path_text(value: object) -> str | None: return raw +def _normalize_mcp_servers(value: object) -> dict[str, dict[str, Any]]: + if not isinstance(value, dict): + return {} + normalized: dict[str, dict[str, Any]] = {} + for raw_name, raw_payload in value.items(): + name = str(raw_name or '').strip() + if not name or not isinstance(raw_payload, dict): + continue + normalized[name] = _clone_jsonish_mapping(raw_payload) + return normalized + + +def _clone_jsonish_mapping(payload: dict[object, object]) -> dict[str, Any]: + return {str(key): _clone_jsonish(value) for key, value in payload.items()} + + +def _clone_jsonish(value: object) -> Any: + if isinstance(value, dict): + return _clone_jsonish_mapping(value) + if isinstance(value, (list, tuple)): + return [_clone_jsonish(item) for item in value] + return value + + __all__ = ['ProviderProfileSpec', 'ResolvedProviderProfile'] diff --git a/lib/rolepacks/service.py b/lib/rolepacks/service.py index b88ec650e..a77acd693 100644 --- a/lib/rolepacks/service.py +++ b/lib/rolepacks/service.py @@ -397,10 +397,10 @@ def _run_architec_doctor(*, action: str, required: bool) -> dict[str, object]: package=package, install_command=f'npm install -g {package}', archi_binary=archi or '', - bundled_hippo=bundle_status, + bundled_hippos=bundle_status, bundled_llmgateway=bundle_status, archi_probe=archi_probe_status, - bundle_check='npm package bundle provides Hippo and llmgateway capabilities', + bundle_check='npm package bundle provides Hippos and llmgateway capabilities', reason=_architec_doctor_reason( architec_status, package=package, diff --git a/lib/storage/text_artifacts.py b/lib/storage/text_artifacts.py index 3ba53cefb..536ceab1e 100644 --- a/lib/storage/text_artifacts.py +++ b/lib/storage/text_artifacts.py @@ -86,7 +86,13 @@ def write_text_artifact( } -def artifact_stub(*, prefix: str, artifact: dict[str, Any], include_preview: bool = True) -> str: +def artifact_stub( + *, + prefix: str, + artifact: dict[str, Any], + include_preview: bool = True, + instruction: str | None = None, +) -> str: preview = str(artifact.get('preview') or '').rstrip() show_preview = bool(include_preview and preview) lines = [ @@ -97,10 +103,11 @@ def artifact_stub(*, prefix: str, artifact: dict[str, Any], include_preview: boo ] if show_preview: lines.extend(['', 'Preview:', preview]) - if show_preview: - instruction = 'Instruction: read the full text file above before acting when the preview is insufficient.' - else: - instruction = 'Instruction: read the full text file above before acting.' + if instruction is None: + if show_preview: + instruction = 'Instruction: read the full text file above before acting when the preview is insufficient.' + else: + instruction = 'Instruction: read the full text file above before acting.' lines.extend(['', instruction]) return '\n'.join(lines).rstrip() diff --git a/package.json b/package.json index 811e79bb8..0126a8a2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@seemseam/ccb", - "version": "7.6.8", + "version": "7.6.9", "description": "Visible, controllable multi-agent CLI workspace for Codex, Claude, Gemini, Kimi, MiMo, Qwen, Cursor, Copilot, Crush, Kiro, Pi, OpenCode, Antigravity, and Droid.", "license": "AGPL-3.0-only", "homepage": "https://github.com/SeemSeam/claude_codex_bridge#readme", diff --git a/test/stubs/provider_stub.py b/test/stubs/provider_stub.py index fd3caa888..45aeb5195 100755 --- a/test/stubs/provider_stub.py +++ b/test/stubs/provider_stub.py @@ -918,6 +918,14 @@ def _handle_agy(req_id: str, prompt: str, delay_s: float) -> None: }, ) print(reply, flush=True) + _print_agy_ready_prompt() + + +def _print_agy_ready_prompt() -> None: + print("────────────────────────────────────────────────────────────", flush=True) + print(">", flush=True) + print("────────────────────────────────────────────────────────────", flush=True) + print("? for shortcuts Gemini 3.1 Pro (High)", flush=True) def main(argv: list[str]) -> int: @@ -1034,6 +1042,8 @@ def main(argv: list[str]) -> int: print("Welcome to Kimi Code CLI!", flush=True) print("── input ─────────", flush=True) print("agent (stub-kimi ○)", flush=True) + if provider == "agy": + _print_agy_ready_prompt() def _handle_request(req_id: str, prompt: str) -> None: if provider == "codex": diff --git a/test/test_agy_execution_polling.py b/test/test_agy_execution_polling.py index 7cf30a00a..01b942760 100644 --- a/test/test_agy_execution_polling.py +++ b/test/test_agy_execution_polling.py @@ -3,13 +3,26 @@ import json from pathlib import Path -from completion.models import CompletionItemKind, CompletionSourceKind +from ccbd.api_models import DeliveryScope, JobRecord, JobStatus, MessageEnvelope +from completion.models import CompletionItemKind, CompletionSourceKind, CompletionStatus +from provider_backends.agy.comm import agy_pane_ready_for_input +from provider_backends.agy.execution_runtime import start as agy_start from provider_backends.agy.execution_runtime.poll import poll_submission +from provider_backends.agy.native_log import observe_agy_transcript from provider_execution.base import ProviderSubmission class _Backend: - pass + def __init__(self, text: str = '') -> None: + self.text = text + self.sent: list[tuple[str, str]] = [] + + def get_pane_content(self, pane_id: str, *, lines: int) -> str: + del pane_id, lines + return self.text + + def send_text_to_pane(self, pane_id: str, text: str) -> None: + self.sent.append((pane_id, text)) def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> None: @@ -40,6 +53,7 @@ def _submission(work_dir: Path, home: Path) -> ProviderSubmission: 'agy_home': str(home), 'started_at': '2026-06-13T00:00:00Z', 'last_poll_at': '2026-06-13T00:00:00Z', + 'prompt_sent': True, 'next_seq': 1, 'anchor_emitted': False, 'reply_buffer': '', @@ -50,6 +64,46 @@ def _submission(work_dir: Path, home: Path) -> ProviderSubmission: ) +def _job(work_dir: Path) -> JobRecord: + return JobRecord( + job_id='job_agynative123', + submission_id='sub_1', + agent_name='agy1', + provider='agy', + request=MessageEnvelope( + project_id='proj', + to_agent='agy1', + from_actor='main', + body='hello', + task_id=None, + reply_to=None, + message_type='ask', + delivery_scope=DeliveryScope.SINGLE, + ), + status=JobStatus.RUNNING, + terminal_decision=None, + cancel_requested_at=None, + created_at='2026-06-13T00:00:00Z', + updated_at='2026-06-13T00:00:00Z', + workspace_path=str(work_dir), + ) + + +def _ready_pane(req_id: str = 'job_agynative123', reply: str = 'native agy pane reply') -> str: + return ( + f'> CCB_REQ_ID: {req_id}\n' + ' hello\n' + '\n' + '▸ Thought for 3s, 400 tokens\n' + f' {reply}\n' + '\n' + '────────────────────────────────────────────────────────────\n' + '>\n' + '────────────────────────────────────────────────────────────\n' + '? for shortcuts Gemini 3.1 Pro (High)\n' + ) + + def test_agy_poll_completes_from_native_transcript(tmp_path: Path) -> None: home = tmp_path / 'home' work_dir = tmp_path / 'project' @@ -98,3 +152,195 @@ def test_agy_poll_completes_from_native_transcript(tmp_path: Path) -> None: CompletionItemKind.TURN_BOUNDARY, ] assert result.items[-1].payload['reason'] == 'agy_transcript_response_done' + + +def test_agy_start_defers_prompt_until_pane_ready(monkeypatch, tmp_path: Path) -> None: + work_dir = tmp_path / 'project' + home = tmp_path / 'home' + runtime_dir = tmp_path / 'runtime' + work_dir.mkdir() + backend = _Backend('▸ Thought for 30s, 1.1k tokens\n still working\n') + session = type( + 'Session', + (), + { + 'pane_id': '%9', + 'runtime_dir': runtime_dir, + 'start_cmd': f"export HOME='{home}'; agy", + 'backend': lambda self: backend, + }, + )() + monkeypatch.setattr(agy_start, 'load_project_session', lambda *_args, **_kwargs: session) + + submission = agy_start.start_submission( + _job(work_dir), + context=None, + now='2026-06-13T00:00:00Z', + provider='agy', + ) + + assert submission.runtime_state['prompt_sent'] is False + assert submission.runtime_state['prompt_deferred_until_ready'] is True + assert backend.sent == [] + + backend.text = _ready_pane() + result = poll_submission(submission, now='2026-06-13T00:00:05Z') + + assert result is not None + assert result.decision is None + assert result.submission.runtime_state['prompt_sent'] is True + assert result.submission.runtime_state['started_at'] == '2026-06-13T00:00:05Z' + assert len(backend.sent) == 1 + assert 'CCB_REQ_ID: job_agynative123' in backend.sent[0][1] + + +def test_agy_ready_detector_ignores_old_prompt_before_active_thought() -> None: + pane_text = ( + '────────────────────────────────────────────────────────────\n' + '>\n' + '────────────────────────────────────────────────────────────\n' + '▸ Thought for 30s, 1.1k tokens\n' + ' still working\n' + '? for shortcuts Gemini 3.1 Pro (High)\n' + ) + + assert agy_pane_ready_for_input(pane_text) is False + + +def test_agy_poll_does_not_anchor_timeout_while_pane_busy(tmp_path: Path) -> None: + home = tmp_path / 'home' + work_dir = tmp_path / 'project' + work_dir.mkdir() + submission = _submission(work_dir, home) + submission.runtime_state['backend'] = _Backend('▸ Thought for 301s, 2.0k tokens\n still working\n') + + result = poll_submission(submission, now='2026-06-13T00:05:01Z') + + assert result is not None + assert result.decision is None + + +def test_agy_poll_accepts_transcript_after_ambiguous_send_error(tmp_path: Path) -> None: + home = tmp_path / 'home' + work_dir = tmp_path / 'project' + work_dir.mkdir() + transcript = ( + home + / '.gemini' + / 'antigravity-cli' + / 'brain' + / 'conv-1' + / '.system_generated' + / 'logs' + / 'transcript.jsonl' + ) + _write_jsonl( + transcript, + [ + { + 'step_index': 1, + 'source': 'USER_EXPLICIT', + 'type': 'USER_INPUT', + 'status': 'DONE', + 'created_at': '2026-06-13T00:00:01Z', + 'content': 'CCB_REQ_ID: job_agynative123\nhello', + }, + { + 'step_index': 2, + 'source': 'MODEL', + 'type': 'PLANNER_RESPONSE', + 'status': 'DONE', + 'created_at': '2026-06-13T00:00:03Z', + 'content': 'reply despite tmux send warning', + }, + ], + ) + submission = _submission(work_dir, home) + submission.runtime_state['prompt_sent'] = False + submission.runtime_state['send_error'] = "send_text_failed:CalledProcessError(1, ['tmux', 'send-keys'])" + + result = poll_submission(submission, now='2026-06-13T00:00:05Z') + + assert result is not None + assert result.decision is None + assert result.submission.reply == 'reply despite tmux send warning' + assert result.items[-1].payload['reason'] == 'agy_transcript_response_done' + assert result.submission.runtime_state['delivery_ambiguous_send_error'] + + +def test_agy_poll_completes_from_stable_pane_fallback_when_transcript_lags(tmp_path: Path) -> None: + home = tmp_path / 'home' + work_dir = tmp_path / 'project' + work_dir.mkdir() + submission = _submission(work_dir, home) + submission.runtime_state['backend'] = _Backend(_ready_pane()) + + first = poll_submission(submission, now='2026-06-13T00:00:05Z') + + assert first is not None + assert first.decision is None + assert first.submission.reply == 'native agy pane reply' + assert [item.kind for item in first.items] == [ + CompletionItemKind.SESSION_ROTATE, + CompletionItemKind.ANCHOR_SEEN, + CompletionItemKind.ASSISTANT_FINAL, + ] + + stable = poll_submission(first.submission, now='2026-06-13T00:00:16Z') + + assert stable is not None + assert stable.decision is None + assert stable.submission.reply == 'native agy pane reply' + assert [item.kind for item in stable.items] == [CompletionItemKind.TURN_BOUNDARY] + + +def test_agy_coalesced_user_input_marks_non_latest_request_incomplete(tmp_path: Path) -> None: + home = tmp_path / 'home' + work_dir = tmp_path / 'project' + work_dir.mkdir() + transcript = ( + home + / '.gemini' + / 'antigravity-cli' + / 'brain' + / 'conv-1' + / '.system_generated' + / 'logs' + / 'transcript.jsonl' + ) + _write_jsonl( + transcript, + [ + { + 'step_index': 1, + 'source': 'USER_EXPLICIT', + 'type': 'USER_INPUT', + 'status': 'DONE', + 'created_at': '2026-06-13T00:00:01Z', + 'content': 'CCB_REQ_ID: job_agynative123\nold\n\nCCB_REQ_ID: job_next456\nnew', + }, + { + 'step_index': 2, + 'source': 'MODEL', + 'type': 'PLANNER_RESPONSE', + 'status': 'DONE', + 'created_at': '2026-06-13T00:00:03Z', + 'content': 'reply for latest', + }, + ], + ) + + observed = observe_agy_transcript(work_dir, 'job_agynative123', home_candidates=[home]) + + assert observed is not None + assert observed.completed is False + assert observed.coalesced_request_ids == ('job_agynative123', 'job_next456') + assert observed.request_is_latest is False + + result = poll_submission(_submission(work_dir, home), now='2026-06-13T00:00:05Z') + + assert result is not None + assert result.decision is not None + assert result.decision.status is CompletionStatus.INCOMPLETE + assert result.decision.reason == 'agy_request_coalesced' + assert result.decision.diagnostics['request_coalesced'] is True diff --git a/test/test_ask_skill_templates.py b/test/test_ask_skill_templates.py index bf0a83d21..cb10d7373 100644 --- a/test/test_ask_skill_templates.py +++ b/test/test_ask_skill_templates.py @@ -85,3 +85,23 @@ def test_powershell_ask_skill_template_uses_short_ask_command() -> None: assert 'In callback chains, each waiting hop uses callback' in text assert '@("--callback", "--artifact-reply", "$TARGET")' in text assert re.search(r'[\u4e00-\u9fff]', text) is None + + +def test_kimi_ask_skill_projects_structured_receipt_contract_only_to_kimi() -> None: + kimi_text = (REPO_ROOT / 'inherit_skills/kimi_skills/ask/SKILL.md').read_text(encoding='utf-8') + codex_text = (REPO_ROOT / 'inherit_skills/codex_skills/ask/SKILL.md').read_text(encoding='utf-8') + + assert '## Kimi Receipt Contract' in kimi_text + for field in ( + 'status:', + 'inspected:', + 'exact_files:', + 'findings:', + 'reject_cases:', + 'required_tests:', + 'no_open:', + 'blockers:', + ): + assert field in kimi_text + assert field not in codex_text + assert 'Process updates are invalid receipts' in kimi_text diff --git a/test/test_ccbd_project_view.py b/test/test_ccbd_project_view.py index a34343604..a119e3909 100644 --- a/test/test_ccbd_project_view.py +++ b/test/test_ccbd_project_view.py @@ -854,8 +854,8 @@ def test_project_view_returns_minimal_windows_agents_and_comms(tmp_path: Path) - assert view['project']['display_name'] == 'repo' assert view['ccbd']['state'] == MountState.MOUNTED.value assert view['namespace']['sidebar']['view']['agents_height'] == '50%' - assert view['namespace']['sidebar']['view']['comms_height'] == '15%' - assert view['namespace']['sidebar']['view']['tips_height'] == '35%' + assert view['namespace']['sidebar']['view']['comms_height'] == '23%' + assert view['namespace']['sidebar']['view']['tips_height'] == '27%' assert view['namespace']['sidebar']['view']['comms_limit'] == 5 assert view['namespace']['sidebar']['view']['tips'][0] == 'C-b d detach' assert 'C-b h/j/k/l pane' in view['namespace']['sidebar']['view']['tips'] diff --git a/test/test_native_cli_completion.py b/test/test_native_cli_completion.py index 58bb97e00..4c3d96eae 100644 --- a/test/test_native_cli_completion.py +++ b/test/test_native_cli_completion.py @@ -295,6 +295,42 @@ def test_kimi_completed_empty_reply_is_incomplete(monkeypatch, tmp_path: Path) - assert result.decision.diagnostics["empty_reply"] is True +def test_kimi_turn_timeout_without_reply_marks_no_captured_receipt(monkeypatch, tmp_path: Path) -> None: + home = tmp_path / "home" + work_dir = tmp_path / "project" + work_dir.mkdir() + monkeypatch.setenv("HOME", str(home)) + wire = kimi_sessions_root(work_dir, home=home) / "session-1" / "wire.jsonl" + _write_jsonl( + wire, + [ + { + "timestamp": "2026-06-13T00:00:01Z", + "message": { + "type": "TurnBegin", + "payload": {"user_input": [{"type": "text", "text": "CCB_REQ_ID: job_native123"}]}, + }, + }, + ], + ) + + result = KimiProviderAdapter().poll( + _submission(provider="kimi", source_kind=CompletionSourceKind.SESSION_EVENT_LOG, work_dir=work_dir), + now="2026-06-13T00:05:01Z", + ) + + assert result is not None + assert result.decision is not None + assert result.decision.status is CompletionStatus.FAILED + assert result.decision.reason == "kimi_native_turn_timeout" + assert result.decision.reply == "" + assert result.decision.diagnostics["reply_chars"] == 0 + assert result.decision.diagnostics["no_captured_reply"] is True + assert result.decision.diagnostics["provider_no_reply"] is True + assert result.decision.diagnostics["receipt_valid"] is False + assert result.decision.diagnostics["receipt_class"] == "no_captured_reply" + + def test_kimi_observes_source_style_turn_events(monkeypatch, tmp_path: Path) -> None: home = tmp_path / "home" work_dir = tmp_path / "project" diff --git a/test/test_provider_hook_settings.py b/test/test_provider_hook_settings.py index 519a31eec..0077905a8 100644 --- a/test/test_provider_hook_settings.py +++ b/test/test_provider_hook_settings.py @@ -566,6 +566,20 @@ def test_prepare_provider_workspace_preserves_allowed_codex_hindsight_hooks( assert any('ccb-provider-activity-hook' in command for command in user_prompt_commands) assert any('.hindsight/codex/scripts/recall.py' in command for command in user_prompt_commands) assert not any('unmanaged-root-hook' in command for commands in hooks_payload['hooks'].values() for group in commands for command in [group['hooks'][0]['command']]) + session_start_handlers = [ + hook + for group in hooks_payload['hooks']['SessionStart'] + for hook in group['hooks'] + if '.hindsight/codex/scripts/session_start.py' in str(hook.get('command') or '') + ] + stop_handlers = [ + hook + for group in hooks_payload['hooks']['Stop'] + for hook in group['hooks'] + if '.hindsight/codex/scripts/retain.py' in str(hook.get('command') or '') + ] + assert session_start_handlers[0]['timeout'] == 5 + assert stop_handlers[0]['timeout'] == 30 config = tomllib.loads((codex_home / 'config.toml').read_text(encoding='utf-8')) state = config['hooks']['state'] @@ -573,6 +587,93 @@ def test_prepare_provider_workspace_preserves_allowed_codex_hindsight_hooks( assert len(state) == 9 +def test_prepare_provider_workspace_preserves_configured_codex_command_hooks( + tmp_path: Path, + monkeypatch, +) -> None: + project_root = tmp_path / 'repo' + workspace = project_root / 'workspace' + system_home = tmp_path / 'system-home' + system_codex = system_home / '.codex' + generic_hook_root = tmp_path / 'generic-hooks' + system_codex.mkdir(parents=True, exist_ok=True) + generic_hook_root.mkdir(parents=True, exist_ok=True) + (system_codex / 'AGENTS.md').write_text('system codex memory\n', encoding='utf-8') + (system_codex / 'config.toml').write_text('model = "gpt-test"\n', encoding='utf-8') + (system_codex / 'hooks.json').write_text( + json.dumps( + { + 'hooks': { + 'UserPromptSubmit': [ + {'hooks': [{'type': 'command', 'command': f'{generic_hook_root / "recall.sh"}', 'timeout': 19}]}, + {'hooks': [{'type': 'command', 'command': 'echo unmanaged-root-hook'}]}, + ], + 'Stop': [ + {'hooks': [{'type': 'command', 'command': f'{generic_hook_root / "retain.sh"}', 'timeout': 31}]} + ], + 'PreToolUse': [ + {'hooks': [{'type': 'command', 'command': f'{generic_hook_root / "pre.sh"}', 'timeout': 7}]} + ], + } + }, + indent=2, + ), + encoding='utf-8', + ) + project_root.mkdir(parents=True, exist_ok=True) + _write_project_memory(project_root, 'shared ccb memory\n') + monkeypatch.setenv('CODEX_HOME', str(system_codex)) + monkeypatch.setenv('CCB_CODEX_INHERITED_COMMAND_HOOK_MARKERS', str(generic_hook_root)) + layout = PathLayout(project_root) + runtime_dir = layout.agent_provider_runtime_dir('agent1', 'codex') + + prepare_provider_workspace( + layout=layout, + spec=_spec('agent1', provider='codex'), + workspace_path=workspace, + completion_dir=runtime_dir / 'completion', + agent_name='agent1', + refresh_profile=True, + ) + + codex_home = project_root / '.ccb' / 'agents' / 'agent1' / 'provider-state' / 'codex' / 'home' + hooks_payload = json.loads((codex_home / 'hooks.json').read_text(encoding='utf-8')) + user_prompt_handlers = [ + hook + for group in hooks_payload['hooks']['UserPromptSubmit'] + for hook in group['hooks'] + ] + stop_handlers = [ + hook + for group in hooks_payload['hooks']['Stop'] + for hook in group['hooks'] + ] + all_commands = [ + str(hook.get('command') or '') + for commands in hooks_payload['hooks'].values() + for group in commands + for hook in group.get('hooks', []) + ] + + assert any('ccb-provider-activity-hook' in command for command in all_commands) + assert any( + str(generic_hook_root / 'recall.sh') == str(hook.get('command') or '') and hook['timeout'] == 19 + for hook in user_prompt_handlers + ) + assert any( + str(generic_hook_root / 'retain.sh') == str(hook.get('command') or '') and hook['timeout'] == 31 + for hook in stop_handlers + ) + assert 'echo unmanaged-root-hook' not in all_commands + assert str(generic_hook_root / 'pre.sh') not in all_commands + + config = tomllib.loads((codex_home / 'config.toml').read_text(encoding='utf-8')) + state = config['hooks']['state'] + assert any(key.endswith(':user_prompt_submit:1:0') for key in state) + assert any(key.endswith(':stop:1:0') for key in state) + assert len(state) == 8 + + def test_prepare_provider_workspace_respects_codex_explicit_runtime_home( tmp_path: Path, monkeypatch, diff --git a/test/test_provider_profiles.py b/test/test_provider_profiles.py index 17cc78036..c4b4393c6 100644 --- a/test/test_provider_profiles.py +++ b/test/test_provider_profiles.py @@ -46,6 +46,19 @@ def _write_project_memory(project_root: Path, text: str) -> None: path.write_text(text, encoding='utf-8') +def _latest_agent_event(layout: PathLayout, agent_name: str, event_type: str) -> dict: + events_path = layout.agent_events_path(agent_name) + events = [ + json.loads(line) + for line in events_path.read_text(encoding='utf-8').splitlines() + if line.strip() + ] + for event in reversed(events): + if event.get('event_type') == event_type: + return event + raise AssertionError(f'{event_type} event not found: {events}') + + def _write_codex_plugin_source( home: Path, *, @@ -226,6 +239,55 @@ def test_materialize_codex_profile_preserves_inline_table_arrays( assert 'external_migration = false' in config_text +def test_materialize_codex_profile_merges_agent_mcp_server_overrides( + tmp_path: Path, + monkeypatch, +) -> None: + project_root = tmp_path / 'repo' + source_home = tmp_path / 'system-codex-home' + source_home.mkdir(parents=True, exist_ok=True) + (source_home / 'config.toml').write_text( + '\n'.join( + [ + 'model = "gpt-5.5"', + '', + '[mcp_servers.shared]', + 'command = "old-shared"', + '', + '[mcp_servers.keep]', + 'command = "keep-cmd"', + '', + ] + ), + encoding='utf-8', + ) + monkeypatch.setenv('CODEX_HOME', str(source_home)) + + profile = materialize_provider_profile( + layout=PathLayout(project_root), + spec=_spec( + 'agent1', + provider_profile=ProviderProfileSpec( + mode='isolated', + mcp_servers={ + 'codegraph': {'command': '/usr/local/bin/codegraph', 'args': ['serve', '--mcp']}, + 'shared': {'command': 'new-shared', 'env': {'MODE': 'agent'}}, + }, + ), + ), + workspace_path=project_root, + ) + + config = tomllib.loads((Path(profile.runtime_home or '') / 'config.toml').read_text(encoding='utf-8')) + + assert config['mcp_servers']['keep']['command'] == 'keep-cmd' + assert config['mcp_servers']['shared']['command'] == 'new-shared' + assert config['mcp_servers']['shared']['env'] == {'MODE': 'agent'} + assert config['mcp_servers']['codegraph']['command'] == '/usr/local/bin/codegraph' + assert config['mcp_servers']['codegraph']['args'] == ['serve', '--mcp'] + assert config['features']['external_migration'] is False + + def test_materialize_codex_profile_preserves_nested_inline_table_arrays( tmp_path: Path, monkeypatch, @@ -673,14 +735,9 @@ def test_materialize_codex_profile_migrates_legacy_profile_runtime_home(tmp_path assert f'CODEX_SESSION_ROOT={runtime_home / "sessions"}' in payload['start_cmd'] assert str(legacy_home) not in payload['codex_start_cmd'] assert f'UNCHANGED={legacy_home}-suffix' in payload['start_cmd'] - events = [ - json.loads(line) - for line in layout.agent_events_path('agent1').read_text(encoding='utf-8').splitlines() - if line.strip() - ] - assert events[-1]['event_type'] == 'codex_profile_migration' - assert events[-1]['status'] == 'migrated' - assert events[-1]['reason'] == 'legacy_profile_runtime_home_migrated' + event = _latest_agent_event(layout, 'agent1', 'codex_profile_migration') + assert event['status'] == 'migrated' + assert event['reason'] == 'legacy_profile_runtime_home_migrated' def test_materialize_codex_profile_migration_respects_inherit_auth_false(tmp_path: Path, monkeypatch) -> None: @@ -780,14 +837,9 @@ def test_materialize_codex_profile_does_not_migrate_when_session_authority_is_ma runtime_home = Path(profile.runtime_home or '') assert legacy_session.is_file() assert not (runtime_home / 'sessions' / '2026' / '05' / '10' / 'legacy.jsonl').exists() - events = [ - json.loads(line) - for line in layout.agent_events_path('agent1').read_text(encoding='utf-8').splitlines() - if line.strip() - ] - assert events[-1]['event_type'] == 'codex_profile_migration' - assert events[-1]['status'] == 'skipped' - assert events[-1]['reason'] == 'session_authority_preflight_failed' + event = _latest_agent_event(layout, 'agent1', 'codex_profile_migration') + assert event['status'] == 'skipped' + assert event['reason'] == 'session_authority_preflight_failed' assert session_file.read_text(encoding='utf-8') == '{not json}\n' @@ -837,14 +889,9 @@ def test_materialize_codex_profile_migrates_legacy_sessions_with_unrelated_tmp_s assert not legacy_session.exists() assert (runtime_home / 'sessions' / '2026' / '05' / '10' / 'legacy.jsonl').is_file() assert (tmp_dir / 'linked-outside').is_symlink() - events = [ - json.loads(line) - for line in layout.agent_events_path('agent1').read_text(encoding='utf-8').splitlines() - if line.strip() - ] - assert events[-1]['event_type'] == 'codex_profile_migration' - assert events[-1]['status'] == 'migrated' - assert events[-1]['reason'] == 'legacy_profile_runtime_home_migrated' + event = _latest_agent_event(layout, 'agent1', 'codex_profile_migration') + assert event['status'] == 'migrated' + assert event['reason'] == 'legacy_profile_runtime_home_migrated' def test_materialize_codex_profile_does_not_migrate_session_material_with_symlink( @@ -891,14 +938,9 @@ def test_materialize_codex_profile_does_not_migrate_session_material_with_symlin runtime_home = Path(profile.runtime_home or '') assert legacy_session.is_file() assert not (runtime_home / 'sessions' / '2026' / '05' / '10' / 'legacy.jsonl').exists() - events = [ - json.loads(line) - for line in layout.agent_events_path('agent1').read_text(encoding='utf-8').splitlines() - if line.strip() - ] - assert events[-1]['event_type'] == 'codex_profile_migration' - assert events[-1]['status'] == 'skipped' - assert events[-1]['reason'] == 'legacy_home_contains_symlink' + event = _latest_agent_event(layout, 'agent1', 'codex_profile_migration') + assert event['status'] == 'skipped' + assert event['reason'] == 'legacy_home_contains_symlink' def test_materialize_codex_profile_does_not_migrate_when_agent_runtime_is_active( @@ -944,14 +986,9 @@ def test_materialize_codex_profile_does_not_migrate_when_agent_runtime_is_active runtime_home = Path(profile.runtime_home or '') assert legacy_session.is_file() assert not (runtime_home / 'sessions' / '2026' / '05' / '10' / 'legacy.jsonl').exists() - events = [ - json.loads(line) - for line in layout.agent_events_path('agent1').read_text(encoding='utf-8').splitlines() - if line.strip() - ] - assert events[-1]['event_type'] == 'codex_profile_migration' - assert events[-1]['status'] == 'skipped' - assert events[-1]['reason'] == 'agent_runtime_active' + event = _latest_agent_event(layout, 'agent1', 'codex_profile_migration') + assert event['status'] == 'skipped' + assert event['reason'] == 'agent_runtime_active' def test_materialize_codex_profile_migrates_with_stale_idle_runtime_record( @@ -2066,6 +2103,39 @@ def test_materialize_codex_home_config_writes_project_memory_bundle(tmp_path: Pa assert 'agent1 private memory' in text +def test_materialize_codex_provider_profile_writes_project_memory_bundle( + tmp_path: Path, + monkeypatch, +) -> None: + project_root = tmp_path / 'repo' + source_home = tmp_path / 'system-codex-home' + source_home.mkdir(parents=True, exist_ok=True) + (source_home / 'AGENTS.md').write_text('user codex memory\n', encoding='utf-8') + monkeypatch.setenv('CODEX_HOME', str(source_home)) + _write_project_memory(project_root, 'shared profile memory\n') + + layout = PathLayout(project_root) + profile = materialize_provider_profile( + layout=layout, + spec=_spec('agent1'), + workspace_path=project_root, + ) + + target_home = Path(str(profile.runtime_home)) + text = (target_home / 'AGENTS.md').read_text(encoding='utf-8') + assert 'agent: agent1' in text + assert 'user codex memory' in text + assert 'shared profile memory' in text + marker = json.loads( + (layout.agent_provider_runtime_dir('agent1', 'codex') / 'codex-memory-projection.json').read_text( + encoding='utf-8' + ) + ) + assert marker['status'] == 'ok' + assert marker['reason'] == 'written' + assert marker['sha256'] + + def test_materialize_codex_home_config_respects_inherit_memory_flag(tmp_path: Path) -> None: project_root = tmp_path / 'repo' source_home = tmp_path / 'system-codex-home' diff --git a/test/test_rolepacks.py b/test/test_rolepacks.py index 5c1559275..00edbc73b 100644 --- a/test/test_rolepacks.py +++ b/test/test_rolepacks.py @@ -527,14 +527,14 @@ def _write_agent_roles_archi_fixture(catalog: Path) -> Path: (role / 'memory.md').write_text( 'Architecture Reviewer Memory\n' 'Architec is the architecture analysis CLI installed from the @seemseam/archi npm package.\n' - 'The package also provides the Hippo and llmgateway capabilities Archi uses.\n', + 'The package also provides the Hippos and llmgateway capabilities Archi uses.\n', encoding='utf-8', ) (role / 'adapters' / 'ccb' / 'memory.md').write_text( 'CCB Adapter Memory\n' 'Use the `archi` CLI provided by the global `@seemseam/archi` npm package.\n' 'If the Archi CLI is missing, install or update `@seemseam/archi`.\n' - 'Do not split Hippo or llmgateway into CCB-managed pip, venv, git, or editable installs.\n' + 'Do not split Hippos or llmgateway into CCB-managed pip, venv, git, or editable installs.\n' 'Do not copy llmgateway secrets into role memory.\n', encoding='utf-8', ) @@ -578,7 +578,7 @@ def _write_agent_roles_archi_fixture(catalog: Path) -> Path: " print('install_command: npm install -g @seemseam/archi')", " print('archi_binary: ' + (path_archi or ''))", " print('archi_probe: ' + archi_probe)", - " print('bundled_hippo: ' + bundle_status)", + " print('bundled_hippos: ' + bundle_status)", " print('bundled_llmgateway: ' + bundle_status)", " return 0 if status == 'ok' else 1", '', @@ -1866,7 +1866,7 @@ def test_archi_doctor_accepts_bundled_capabilities_from_main_cli(tmp_path: Path, assert 'package: @seemseam/archi' in result.stdout assert 'install_command: npm install -g @seemseam/archi' in result.stdout assert f'archi_binary: {fake_archi}' in result.stdout - assert 'bundled_hippo: available' in result.stdout + assert 'bundled_hippos: available' in result.stdout assert 'bundled_llmgateway: available' in result.stdout @@ -1921,7 +1921,7 @@ def test_archi_tool_doctor_accepts_cli_without_help_keywords(tmp_path: Path, mon stdout = str(results[0]['stdout']) assert 'architec_status: ok' in stdout assert f'archi_binary: {fake_bin / "archi"}' in stdout - assert 'bundled_hippo: available' in stdout + assert 'bundled_hippos: available' in stdout assert 'bundled_llmgateway: available' in stdout assert 'install_command: npm install -g @seemseam/archi' in stdout @@ -2175,7 +2175,7 @@ def test_role_memory_is_included_before_agent_private_memory(tmp_path: Path, mon assert 'architecture reviewer' in role_memory.lower() assert 'Architec is the architecture analysis CLI' in role_memory assert '@seemseam/archi' in role_memory - assert 'Do not split Hippo or llmgateway into CCB-managed pip, venv, git, or editable installs' in role_memory + assert 'Do not split Hippos or llmgateway into CCB-managed pip, venv, git, or editable installs' in role_memory assert 'llmgateway secrets' in role_memory diff --git a/test/test_v2_cli_render.py b/test/test_v2_cli_render.py index 855ba76f3..c86cd9c03 100644 --- a/test/test_v2_cli_render.py +++ b/test/test_v2_cli_render.py @@ -24,6 +24,7 @@ render_wait, render_watch_batch, ) +from message_bureau.control_trace_runtime.summaries import job_summary def test_render_ask_includes_submission_and_jobs() -> None: @@ -1055,6 +1056,107 @@ def test_render_trace_keeps_line_protocol_shape() -> None: assert 'job: id=job_123 agent=codex provider=codex status=completed submission=None created=2026-03-30T00:00:00Z updated=2026-03-30T00:00:10Z' in lines +def test_render_trace_appends_kimi_terminal_metadata_when_present() -> None: + payload = { + 'target': 'job_kimi', + 'resolved_kind': 'job', + 'submission_id': None, + 'message_id': None, + 'attempt_id': None, + 'reply_id': None, + 'job_id': 'job_kimi', + 'message_count': 0, + 'attempt_count': 0, + 'reply_count': 0, + 'event_count': 0, + 'job_count': 1, + 'jobs': [ + { + 'job_id': 'job_kimi', + 'agent_name': 'sl_ki', + 'provider': 'kimi', + 'status': 'failed', + 'submission_id': 'sub_1', + 'created_at': '2026-03-30T00:00:00Z', + 'updated_at': '2026-03-30T00:05:01Z', + 'terminal_reason': 'kimi_native_turn_timeout', + 'reply_chars': 0, + 'total_secs': 301.0, + 'artifact_reply_forced': True, + 'receipt_class': 'no_captured_reply', + } + ], + } + + lines = render_trace(payload) + + assert ( + 'job: id=job_kimi agent=sl_ki provider=kimi status=failed submission=sub_1 ' + 'created=2026-03-30T00:00:00Z updated=2026-03-30T00:05:01Z ' + 'terminal_reason=kimi_native_turn_timeout reply_chars=0 total_secs=301.0 ' + 'artifact_reply_forced=true receipt_class=no_captured_reply' + ) in lines + + +def test_trace_job_summary_projects_kimi_terminal_metadata_only_for_kimi() -> None: + class _JobStore: + def __init__(self, jobs): + self._jobs = jobs + + def get_latest(self, agent_name: str, job_id: str): + return self._jobs.get((agent_name, job_id)) + + kimi_job = SimpleNamespace( + job_id='job_kimi', + agent_name='sl_ki', + provider='kimi', + status=SimpleNamespace(value='failed'), + submission_id='sub_1', + created_at='2026-03-30T00:00:00Z', + updated_at='2026-03-30T00:05:01Z', + terminal_decision={ + 'reason': 'kimi_native_turn_timeout', + 'confidence': 'degraded', + 'diagnostics': { + 'reply_chars': 0, + 'total_secs': 301.0, + 'artifact_reply_forced': True, + 'receipt_class': 'no_captured_reply', + }, + }, + ) + codex_job = SimpleNamespace( + job_id='job_codex', + agent_name='codex', + provider='codex', + status=SimpleNamespace(value='completed'), + submission_id='sub_2', + created_at='2026-03-30T00:00:00Z', + updated_at='2026-03-30T00:00:10Z', + terminal_decision={ + 'reason': 'task_complete', + 'diagnostics': {'reply_chars': 4, 'total_secs': 10.0}, + }, + ) + service = SimpleNamespace( + _config=SimpleNamespace(agents={'sl_ki': object(), 'codex': object()}), + _job_store=_JobStore({('sl_ki', 'job_kimi'): kimi_job, ('codex', 'job_codex'): codex_job}), + ) + + kimi_summary = job_summary(service, 'job_kimi') + codex_summary = job_summary(service, 'job_codex') + + assert kimi_summary is not None + assert kimi_summary['terminal_reason'] == 'kimi_native_turn_timeout' + assert kimi_summary['reply_chars'] == 0 + assert kimi_summary['total_secs'] == 301.0 + assert kimi_summary['artifact_reply_forced'] is True + assert kimi_summary['receipt_class'] == 'no_captured_reply' + assert codex_summary is not None + assert 'terminal_reason' not in codex_summary + assert 'reply_chars' not in codex_summary + + def test_render_inbox_and_ack_include_reply_delivery_details() -> None: inbox_payload = { 'target': 'claude', diff --git a/test/test_v2_config_loader.py b/test/test_v2_config_loader.py index cd3a6469b..9d5cbca88 100644 --- a/test/test_v2_config_loader.py +++ b/test/test_v2_config_loader.py @@ -313,8 +313,8 @@ def test_render_default_project_config_text_omits_optional_tool_windows(tmp_path assert '[tool_windows.' not in rendered assert '[ui.sidebar.view]' in rendered assert 'agents_height = "50%"' in rendered - assert 'comms_height = "15%"' in rendered - assert 'tips_height = "35%"' in rendered + assert 'comms_height = "23%"' in rendered + assert 'tips_height = "27%"' in rendered config_path = tmp_path / 'repo-render-default' / '.ccb' / 'ccb.config' _write(config_path, rendered) loaded = load_project_config(config_path.parents[1]).config @@ -518,6 +518,13 @@ def test_load_project_config_supports_toml_provider_profile(tmp_path: Path) -> N [agents.agent1.provider_profile.env] OPENAI_API_KEY = "sk-test" + +[agents.agent1.provider_profile.mcp_servers.codegraph] +command = "/usr/local/bin/codegraph" +args = ["serve", "--mcp"] + +[agents.agent1.provider_profile.mcp_servers.hindsight.env] +HINDSIGHT_AGENT_NAME = "agent1" """, ) @@ -532,6 +539,10 @@ def test_load_project_config_supports_toml_provider_profile(tmp_path: Path) -> N assert spec.provider_profile.inherit_commands is False assert spec.provider_profile.inherit_memory is False assert spec.provider_profile.env == {'OPENAI_API_KEY': 'sk-test'} + assert spec.provider_profile.mcp_servers == { + 'codegraph': {'command': '/usr/local/bin/codegraph', 'args': ['serve', '--mcp']}, + 'hindsight': {'env': {'HINDSIGHT_AGENT_NAME': 'agent1'}}, + } def test_load_project_config_supports_workspace_path_and_group_fields(tmp_path: Path) -> None: @@ -1268,8 +1279,8 @@ def test_load_project_config_supports_windows_topology_without_default_agents(tm assert result.config.sidebar.width == '15%' assert result.config.sidebar.bottom_height == 20 assert result.config.sidebar_view.agents_height == '50%' - assert result.config.sidebar_view.comms_height == '15%' - assert result.config.sidebar_view.tips_height == '35%' + assert result.config.sidebar_view.comms_height == '23%' + assert result.config.sidebar_view.tips_height == '27%' assert result.config.sidebar_view.comms_limit == 5 assert result.config.sidebar_view.tips[0] == 'C-b d detach' assert 'C-b h/j/k/l pane' in result.config.sidebar_view.tips diff --git a/test/test_v2_message_bureau_dispatcher_integration.py b/test/test_v2_message_bureau_dispatcher_integration.py index bd0edb122..c25d50b0c 100644 --- a/test/test_v2_message_bureau_dispatcher_integration.py +++ b/test/test_v2_message_bureau_dispatcher_integration.py @@ -446,6 +446,67 @@ def test_dispatcher_forces_short_completion_reply_artifact(tmp_path: Path) -> No assert reply.reply == reply_body +def test_dispatcher_forced_artifact_reports_kimi_no_captured_reply(tmp_path: Path) -> None: + project_root = tmp_path / 'repo-kimi-empty-reply-artifact' + ctx = _bootstrap_test_project(project_root) + layout = PathLayout(project_root) + config = _provider_config('kimi', 'codex') + registry = AgentRegistry(layout, config) + registry.upsert(_runtime('kimi', project_id=ctx.project_id, layout=layout, pid=101)) + registry.upsert(_runtime('codex', project_id=ctx.project_id, layout=layout, pid=102)) + dispatcher = JobDispatcher(layout, config, registry, clock=lambda: '2026-03-30T00:00:00Z') + + job_id = dispatcher.submit( + MessageEnvelope( + project_id=ctx.project_id, + to_agent='kimi', + from_actor='codex', + body='produce receipt', + task_id='task-kimi-empty-reply-artifact', + reply_to=None, + message_type='ask', + delivery_scope=DeliveryScope.SINGLE, + route_options={'artifact_reply': True}, + ) + ).jobs[0].job_id + dispatcher.tick() + dispatcher.complete( + job_id, + _failed_decision( + reason='kimi_native_turn_timeout', + diagnostics={ + 'reply_chars': 0, + 'no_captured_reply': True, + 'provider_no_reply': True, + 'receipt_valid': False, + 'receipt_class': 'no_captured_reply', + }, + ), + ) + + job = dispatcher.get(job_id) + assert job is not None + terminal = dict(job.terminal_decision or {}) + reply_body = str(terminal['reply']) + assert 'no captured Kimi provider reply' in reply_body + assert 'empty artifact for transport metadata only' in reply_body + assert 'Instruction: no provider reply was captured' in reply_body + assert 'Instruction: read the full text file above before acting.' not in reply_body + artifact = dict(terminal['diagnostics']['reply_artifact']) + assert terminal['diagnostics']['artifact_reply_forced'] is True + assert terminal['diagnostics']['artifact_instruction'] == 'no_provider_reply_captured' + assert terminal['diagnostics']['artifact_empty_no_provider_reply'] is True + artifact_path = Path(str(artifact['path'])) + assert artifact_path.exists() + assert artifact_path.read_text(encoding='utf-8') == '' + assert artifact['bytes'] == 0 + + message = MessageStore(layout).list_all()[-1] + reply = ReplyStore(layout).list_message(message.message_id)[-1] + assert reply.reply_artifact == artifact + assert reply.reply == reply_body + + def test_dispatcher_routes_reply_into_registered_caller_mailbox(tmp_path: Path) -> None: project_root = tmp_path / 'repo-registered-caller' ctx = _bootstrap_test_project(project_root) diff --git a/test/test_v2_provider_catalog.py b/test/test_v2_provider_catalog.py index 708655b88..95beb7048 100644 --- a/test/test_v2_provider_catalog.py +++ b/test/test_v2_provider_catalog.py @@ -56,6 +56,7 @@ def test_default_provider_catalog_contains_expected_profiles() -> None: assert agy.supports_observed_completion is True assert agy.supports_anchor_binding is True kimi = catalog.resolve_completion_manifest('kimi', RuntimeMode.PANE_BACKED) + assert catalog.get('kimi').supports_resume is False assert kimi.completion_family is CompletionFamily.SESSION_BOUNDARY assert kimi.completion_source_kind is CompletionSourceKind.SESSION_EVENT_LOG assert kimi.supports_observed_completion is True diff --git a/tools/ccb-agent-sidebar/src/model.rs b/tools/ccb-agent-sidebar/src/model.rs index ead246e9e..719b2e0af 100644 --- a/tools/ccb-agent-sidebar/src/model.rs +++ b/tools/ccb-agent-sidebar/src/model.rs @@ -219,11 +219,11 @@ fn default_agents_height() -> serde_json::Value { } fn default_comms_height() -> serde_json::Value { - serde_json::Value::String("15%".into()) + serde_json::Value::String("23%".into()) } fn default_tips_height() -> serde_json::Value { - serde_json::Value::String("35%".into()) + serde_json::Value::String("27%".into()) } fn default_comms_limit() -> usize { diff --git a/tools/ccb-agent-sidebar/src/tui.rs b/tools/ccb-agent-sidebar/src/tui.rs index 2779020ee..8fedb52ff 100644 --- a/tools/ccb-agent-sidebar/src/tui.rs +++ b/tools/ccb-agent-sidebar/src/tui.rs @@ -15,10 +15,13 @@ use crossterm::terminal::{ }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect}; use ratatui::prelude::{Color, Frame, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; +use ratatui::widgets::{ + Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, + ScrollbarState, +}; use crate::args::Args; use crate::client::CcbdClient; @@ -32,14 +35,16 @@ const PROJECT_VIEW_REFRESH_MIN_MS: u64 = 100; const PROJECT_VIEW_REFRESH_MAX_MS: u64 = 5000; const PROJECT_VIEW_REFRESH_DEFAULT_MS: u64 = 1000; const DEFAULT_TREE_HEIGHT_PERCENT: u16 = 50; -const DEFAULT_COMMS_HEIGHT_PERCENT: u16 = 15; -const DEFAULT_TIPS_HEIGHT_PERCENT: u16 = 35; +const DEFAULT_COMMS_HEIGHT_PERCENT: u16 = 23; +const DEFAULT_TIPS_HEIGHT_PERCENT: u16 = 27; const TREE_CONTROL_CONTENT_WIDTH: u16 = 3; const TREE_REFRESH_SYMBOL: &str = "↻"; const TREE_KILL_SYMBOL: &str = "×"; const COMMS_ACTION_RETRY_COLS: std::ops::RangeInclusive = 0..=1; const COMMS_ACTION_CANCEL_COLS: std::ops::RangeInclusive = 3..=4; const COMMS_ACTION_CLEAR_COLS: std::ops::RangeInclusive = 6..=7; +const COMMS_DETAIL_INDENT_WIDTH: usize = 3; +const MIN_SECTION_HEIGHT: u16 = 3; pub fn run(args: Args) -> io::Result<()> { let action = run_tui(&args)?; @@ -90,14 +95,29 @@ fn run_tui(args: &Args) -> io::Result { _ => {} }, Event::Mouse(mouse) => { - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - let size = terminal.size()?; - let area = Rect::new(0, 0, size.width, size.height); - if let Some(action) = - app.handle_mouse_down(mouse.column, mouse.row, area, &client) - { - return Ok(action); + let size = terminal.size()?; + let area = Rect::new(0, 0, size.width, size.height); + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(action) = + app.handle_mouse_down(mouse.column, mouse.row, area, &client) + { + return Ok(action); + } + } + MouseEventKind::Drag(MouseButton::Left) => { + app.handle_mouse_drag(mouse.column, mouse.row, area); + } + MouseEventKind::Up(MouseButton::Left) => { + app.handle_mouse_up(); + } + MouseEventKind::ScrollDown => { + app.scroll_panel_at(mouse.column, mouse.row, area, 1); + } + MouseEventKind::ScrollUp => { + app.scroll_panel_at(mouse.column, mouse.row, area, -1); } + _ => {} } } _ => {} @@ -112,6 +132,12 @@ enum ExitAction { KillProject, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SidebarResizeDivider { + TreeComms, + CommsTips, +} + fn run_ccb_kill(project_root: &Path) -> io::Result<()> { run_ccb_kill_with_program(ccb_program(), project_root) } @@ -164,6 +190,11 @@ pub struct SidebarApp { selected: usize, selected_comms: Option, hidden_comms: HashSet, + tree_scroll: usize, + comms_scroll: usize, + tips_scroll: usize, + section_heights: Option<(u16, u16, u16)>, + dragging_divider: Option, selection_follows_focus: bool, refresh_after: Instant, } @@ -178,6 +209,11 @@ impl SidebarApp { selected: 0, selected_comms: None, hidden_comms: HashSet::new(), + tree_scroll: 0, + comms_scroll: 0, + tips_scroll: 0, + section_heights: None, + dragging_divider: None, selection_follows_focus: true, refresh_after: Instant::now(), } @@ -266,7 +302,8 @@ impl SidebarApp { area: Rect, client: &CcbdClient, ) -> Option { - match header_action_at(column, row, sidebar_areas(area, self.sidebar_view()).tree) { + let areas = self.sidebar_areas(area); + match header_action_at(column, row, areas.tree) { Some(HeaderMouseAction::Refresh) => { self.restart_panes(client); return None; @@ -274,6 +311,9 @@ impl SidebarApp { Some(HeaderMouseAction::KillProject) => return Some(ExitAction::KillProject), None => {} } + if self.start_resize_at(column, row, area) { + return None; + } if self.handle_comms_mouse_down(column, row, area, client) { return None; } @@ -281,6 +321,17 @@ impl SidebarApp { None } + fn handle_mouse_drag(&mut self, column: u16, row: u16, area: Rect) -> bool { + let Some(divider) = self.dragging_divider else { + return false; + }; + self.drag_resize_to(divider, column, row, area) + } + + fn handle_mouse_up(&mut self) { + self.dragging_divider = None; + } + pub fn recover_first_visible_comms(&mut self, client: &CcbdClient) { let Some(item) = self .visible_comms_limited() @@ -327,9 +378,50 @@ impl SidebarApp { true } + fn scroll_panel_at(&mut self, column: u16, row: u16, area: Rect, delta: isize) -> bool { + let areas = self.sidebar_areas(area); + if rect_contains(areas.tree, column, row) { + self.scroll_tree(delta, areas.tree.height.saturating_sub(2)); + return true; + } + if rect_contains(areas.comms, column, row) { + self.scroll_comms(delta, areas.comms); + return true; + } + if let Some(tips) = areas.tips + && rect_contains(tips, column, row) + { + self.scroll_tips(delta, tips.height.saturating_sub(2)); + return true; + } + false + } + + fn scroll_tree(&mut self, delta: isize, viewport_height: u16) { + let max_scroll = tree_scroll_max_for_len(tree_rows(self).len(), viewport_height); + let current = self.tree_scroll.min(max_scroll); + self.tree_scroll = offset_after_scroll(current, delta, max_scroll); + } + + fn scroll_comms(&mut self, delta: isize, area: Rect) { + let max_scroll = self.comms_scroll_max(area); + let current = self.comms_scroll.min(max_scroll); + self.comms_scroll = offset_after_scroll(current, delta, max_scroll); + } + + fn scroll_tips(&mut self, delta: isize, viewport_height: u16) { + let max_scroll = tips_scroll_max(self.sidebar_view(), viewport_height); + let current = self.tips_scroll.min(max_scroll); + self.tips_scroll = offset_after_scroll(current, delta, max_scroll); + } + fn target_index_at(&self, column: u16, row: u16, area: Rect) -> Option { - let areas = sidebar_areas(area, self.sidebar_view()); - target_index_at_tree_area(self.targets().len(), areas.tree, column, row) + let areas = self.sidebar_areas(area); + let scroll = self.tree_scroll.min(tree_scroll_max_for_len( + self.targets().len(), + areas.tree.height.saturating_sub(2), + )); + target_index_at_tree_area(self.targets().len(), areas.tree, column, row, scroll) } #[cfg(test)] @@ -344,18 +436,40 @@ impl SidebarApp { row: u16, area: Rect, ) -> Option<(usize, CommsMouseAction)> { - let areas = sidebar_areas(area, self.sidebar_view()); + let areas = self.sidebar_areas(area); let prefix_lines = u16::from(self.last_error.is_some()) .saturating_add(u16::from(self.sidebar_config_error().is_some())); + let items = self.visible_comms_limited(); + let content_width = usize::from(areas.comms.width.saturating_sub(2)); + let scroll = self.comms_scroll.min(comms_scroll_max_for_items( + &items, + comms_body_capacity(areas.comms, prefix_lines), + content_width, + self.sidebar_view().comms_compact, + )); + let visible_items = items.into_iter().skip(scroll).collect::>(); comms_action_at_area( - &self.visible_comms_limited(), + &visible_items, areas.comms, column, row, - usize::from(areas.comms.width.saturating_sub(2)), + content_width, self.sidebar_view().comms_compact, prefix_lines, ) + .map(|(index, action)| (index + scroll, action)) + } + + fn comms_scroll_max(&self, area: Rect) -> usize { + let prefix_lines = u16::from(self.last_error.is_some()) + .saturating_add(u16::from(self.sidebar_config_error().is_some())); + let items = self.visible_comms_limited(); + comms_scroll_max_for_items( + &items, + comms_body_capacity(area, prefix_lines), + usize::from(area.width.saturating_sub(2)), + self.sidebar_view().comms_compact, + ) } fn visible_comms(&self) -> Vec { @@ -383,6 +497,102 @@ impl SidebarApp { } } + fn sidebar_areas(&self, area: Rect) -> SidebarAreas { + sidebar_areas_with_override(area, self.sidebar_view(), self.section_heights) + } + + fn start_resize_at(&mut self, column: u16, row: u16, area: Rect) -> bool { + let Some(divider) = self.resize_divider_at(column, row, area) else { + return false; + }; + self.dragging_divider = Some(divider); + true + } + + fn resize_divider_at(&self, column: u16, row: u16, area: Rect) -> Option { + if column < area.x || column >= area.x.saturating_add(area.width) { + return None; + } + let areas = self.sidebar_areas(area); + if areas.comms.height > 0 && row == areas.comms.y { + return Some(SidebarResizeDivider::TreeComms); + } + if let Some(tips) = areas.tips + && tips.height > 0 + && row == tips.y + { + return Some(SidebarResizeDivider::CommsTips); + } + None + } + + fn drag_resize_to( + &mut self, + divider: SidebarResizeDivider, + _column: u16, + row: u16, + area: Rect, + ) -> bool { + if area.height == 0 { + return false; + } + let (tree_height, _, tips_height) = self.current_section_heights(area); + match divider { + SidebarResizeDivider::TreeComms => { + if self.sidebar_view().tips_enabled { + let available = area.height.saturating_sub(tips_height); + if available < MIN_SECTION_HEIGHT.saturating_mul(2) { + return false; + } + let desired_tree = row.saturating_sub(area.y); + let new_tree = desired_tree.clamp( + MIN_SECTION_HEIGHT, + available.saturating_sub(MIN_SECTION_HEIGHT), + ); + self.section_heights = + Some((new_tree, available.saturating_sub(new_tree), tips_height)); + } else { + if area.height < MIN_SECTION_HEIGHT.saturating_mul(2) { + return false; + } + let desired_tree = row.saturating_sub(area.y); + let new_tree = desired_tree.clamp( + MIN_SECTION_HEIGHT, + area.height.saturating_sub(MIN_SECTION_HEIGHT), + ); + self.section_heights = + Some((new_tree, area.height.saturating_sub(new_tree), 0)); + } + } + SidebarResizeDivider::CommsTips => { + if !self.sidebar_view().tips_enabled { + return false; + } + let available = area.height.saturating_sub(tree_height); + if available < MIN_SECTION_HEIGHT.saturating_mul(2) { + return false; + } + let desired_comms = row.saturating_sub(area.y).saturating_sub(tree_height); + let new_comms = desired_comms.clamp( + MIN_SECTION_HEIGHT, + available.saturating_sub(MIN_SECTION_HEIGHT), + ); + self.section_heights = + Some((tree_height, new_comms, available.saturating_sub(new_comms))); + } + } + true + } + + fn current_section_heights(&self, area: Rect) -> (u16, u16, u16) { + let areas = self.sidebar_areas(area); + ( + areas.tree.height, + areas.comms.height, + areas.tips.map(|area| area.height).unwrap_or(0), + ) + } + fn sidebar_config_error(&self) -> Option<&str> { self.view() .and_then(|view| view.namespace.sidebar.view_error.as_deref()) @@ -554,7 +764,7 @@ fn refresh_backoff_for_failures(failure_count: u32) -> Duration { pub fn draw(frame: &mut Frame<'_>, app: &SidebarApp) { let area = frame.area(); frame.render_widget(Clear, area); - let areas = sidebar_areas(area, app.sidebar_view()); + let areas = app.sidebar_areas(area); draw_tree(frame, areas.tree, app); draw_comms(frame, areas.comms, app); if let Some(tips_area) = areas.tips { @@ -569,7 +779,16 @@ struct SidebarAreas { tips: Option, } +#[cfg_attr(not(test), allow(dead_code))] fn sidebar_areas(area: Rect, view: &SidebarViewInfo) -> SidebarAreas { + sidebar_areas_with_override(area, view, None) +} + +fn sidebar_areas_with_override( + area: Rect, + view: &SidebarViewInfo, + section_heights: Option<(u16, u16, u16)>, +) -> SidebarAreas { if area.height == 0 { return SidebarAreas { tree: area, @@ -577,6 +796,42 @@ fn sidebar_areas(area: Rect, view: &SidebarViewInfo) -> SidebarAreas { tips: None, }; } + if let Some(heights) = section_heights { + if view.tips_enabled { + if let Some((tree_height, comms_height, tips_height)) = + clamp_three_section_heights(area.height, heights) + { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(tree_height), + Constraint::Length(comms_height), + Constraint::Length(tips_height), + ]) + .split(area); + return SidebarAreas { + tree: chunks[0], + comms: chunks[1], + tips: (tips_height > 0).then_some(chunks[2]), + }; + } + } else if let Some((tree_height, comms_height)) = + clamp_two_section_heights(area.height, (heights.0, heights.1)) + { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(tree_height), + Constraint::Length(comms_height), + ]) + .split(area); + return SidebarAreas { + tree: chunks[0], + comms: chunks[1], + tips: None, + }; + } + } if !view.tips_enabled { let tree_height = tree_height_for(area.height, view).min(area.height); let chunks = Layout::default() @@ -611,6 +866,40 @@ fn sidebar_areas(area: Rect, view: &SidebarViewInfo) -> SidebarAreas { } } +fn clamp_three_section_heights( + total_height: u16, + requested: (u16, u16, u16), +) -> Option<(u16, u16, u16)> { + if total_height < MIN_SECTION_HEIGHT.saturating_mul(3) { + return None; + } + let tree_height = requested.0.clamp( + MIN_SECTION_HEIGHT, + total_height.saturating_sub(MIN_SECTION_HEIGHT.saturating_mul(2)), + ); + let remaining_after_tree = total_height.saturating_sub(tree_height); + let comms_height = requested.1.clamp( + MIN_SECTION_HEIGHT, + remaining_after_tree.saturating_sub(MIN_SECTION_HEIGHT), + ); + let tips_height = total_height + .saturating_sub(tree_height) + .saturating_sub(comms_height); + Some((tree_height, comms_height, tips_height)) +} + +fn clamp_two_section_heights(total_height: u16, requested: (u16, u16)) -> Option<(u16, u16)> { + if total_height < MIN_SECTION_HEIGHT.saturating_mul(2) { + return None; + } + let tree_height = requested.0.clamp( + MIN_SECTION_HEIGHT, + total_height.saturating_sub(MIN_SECTION_HEIGHT), + ); + let comms_height = total_height.saturating_sub(tree_height); + Some((tree_height, comms_height)) +} + fn tree_height_for(total_height: u16, view: &SidebarViewInfo) -> u16 { view_height_for( total_height, @@ -628,18 +917,10 @@ fn comms_height_for(total_height: u16, view: &SidebarViewInfo) -> u16 { } fn tips_height_for(total_height: u16, view: &SidebarViewInfo) -> u16 { - view_height_for( - total_height, - &view.tips_height, - DEFAULT_TIPS_HEIGHT_PERCENT, - ) + view_height_for(total_height, &view.tips_height, DEFAULT_TIPS_HEIGHT_PERCENT) } -fn view_height_for( - total_height: u16, - value: &serde_json::Value, - default_percent: u16, -) -> u16 { +fn view_height_for(total_height: u16, value: &serde_json::Value, default_percent: u16) -> u16 { match value { serde_json::Value::Number(number) => number .as_u64() @@ -681,6 +962,7 @@ fn target_index_at_tree_area( area: Rect, column: u16, row: u16, + scroll_offset: usize, ) -> Option { if target_count == 0 || area.width < 3 || area.height < 3 { return None; @@ -695,7 +977,7 @@ fn target_index_at_tree_area( if row < top || row >= bottom { return None; } - let index = usize::from(row - top); + let index = usize::from(row - top).saturating_add(scroll_offset); if index < target_count { Some(index) } else { @@ -703,6 +985,25 @@ fn target_index_at_tree_area( } } +fn offset_after_scroll(current: usize, delta: isize, max_scroll: usize) -> usize { + if delta < 0 { + current.saturating_sub(delta.unsigned_abs()) + } else { + current.saturating_add(delta as usize).min(max_scroll) + } +} + +fn tree_scroll_max_for_len(row_count: usize, viewport_height: u16) -> usize { + row_count.saturating_sub(usize::from(viewport_height.max(1))) +} + +fn rect_contains(area: Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CommsMouseAction { Select, @@ -761,6 +1062,9 @@ fn comms_action_at_area( for (index, item) in items.iter().enumerate() { let height = comms_lines(item, width, compact).len().max(1) as u16; if row >= current && row < current.saturating_add(height) { + if row != current { + return Some((index, CommsMouseAction::Select)); + } let relative_column = column.saturating_sub(left); return Some((index, comms_mouse_action_for_column(relative_column))); } @@ -797,6 +1101,53 @@ fn draw_tree(frame: &mut Frame<'_>, area: Rect, app: &SidebarApp) { ) }); let focus_style = tree_focus_style(app); + let rows = tree_rows(app); + let row_count = rows.len(); + let content_height = area.height.saturating_sub(2); + let needs_scrollbar = content_height > 0 && row_count > usize::from(content_height); + let scroll = app + .tree_scroll + .min(tree_scroll_max_for_len(row_count, content_height)); + let items = rows + .into_iter() + .enumerate() + .skip(scroll) + .take(usize::from(content_height.max(1))) + .map(|(index, item)| { + if index == app.selected { + item.style(Style::default().add_modifier(Modifier::REVERSED)) + } else { + item + } + }) + .collect::>(); + let list = List::new(items).block( + Block::default() + .title_top(Line::from(title).style(focus_style).left_aligned()) + .title_top(tree_controls_line().right_aligned()) + .borders(Borders::ALL) + .border_style(focus_style), + ); + frame.render_widget(list, area); + if needs_scrollbar { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + let mut scrollbar_state = ScrollbarState::new(row_count) + .position(scroll) + .viewport_content_length(usize::from(content_height)); + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } +} + +fn tree_rows(app: &SidebarApp) -> Vec> { let mut rows = Vec::new(); if let Some(view) = app.view() { for window in &view.windows { @@ -820,25 +1171,7 @@ fn draw_tree(frame: &mut Frame<'_>, area: Rect, app: &SidebarApp) { "waiting for ProjectView" }))); } - let items = rows - .into_iter() - .enumerate() - .map(|(index, item)| { - if index == app.selected { - item.style(Style::default().add_modifier(Modifier::REVERSED)) - } else { - item - } - }) - .collect::>(); - let list = List::new(items).block( - Block::default() - .title_top(Line::from(title).style(focus_style).left_aligned()) - .title_top(tree_controls_line().right_aligned()) - .borders(Borders::ALL) - .border_style(focus_style), - ); - frame.render_widget(list, area); + rows } fn tree_controls_area(area: Rect) -> Rect { @@ -929,10 +1262,7 @@ fn window_row(window: &WindowView) -> ListItem<'static> { }; ListItem::new(Line::from(vec![ Span::raw(format!("{active} ")), - Span::styled( - label, - Style::default().add_modifier(Modifier::BOLD), - ), + Span::styled(label, Style::default().add_modifier(Modifier::BOLD)), ])) } @@ -980,20 +1310,58 @@ fn draw_comms(frame: &mut Frame<'_>, area: Rect, app: &SidebarApp) { Style::default().fg(Color::Yellow), ))); } + let prefix_lines = lines.len() as u16; if app.view().is_some() { - let comms_capacity = usize::from(area.height.saturating_sub(2)) - .saturating_sub(lines.len()) - .max(1); - let content_width = usize::from(area.width.saturating_sub(2)); let visible_comms = app.visible_comms_limited(); let compact = app.sidebar_view().comms_compact; - for item in visible_comms.iter() { + let body_capacity = comms_body_capacity(area, prefix_lines); + let full_width = usize::from(area.width.saturating_sub(2)); + let visible_capacity = + comms_visible_item_capacity(&visible_comms, body_capacity, full_width, compact); + let needs_scrollbar = body_capacity > 0 && visible_comms.len() > visible_capacity; + let content_width = + usize::from( + area.width + .saturating_sub(if needs_scrollbar { 3 } else { 2 }), + ); + let scroll = app.comms_scroll.min(comms_scroll_max_for_items( + &visible_comms, + body_capacity, + content_width, + compact, + )); + let mut body_lines_used = 0usize; + for item in visible_comms.iter().skip(scroll) { let item_lines = comms_lines(item, content_width, compact); - if lines.len() + item_lines.len() > comms_capacity { + if body_lines_used + item_lines.len() > body_capacity { + if body_lines_used == 0 && body_capacity > 0 { + lines.extend(item_lines.into_iter().take(body_capacity)); + } break; } + body_lines_used += item_lines.len(); lines.extend(item_lines); } + if needs_scrollbar { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + let mut scrollbar_state = ScrollbarState::new(visible_comms.len()) + .position(scroll) + .viewport_content_length(visible_capacity); + let paragraph = + Paragraph::new(lines).block(Block::default().title("Comms").borders(Borders::ALL)); + frame.render_widget(paragraph, area); + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + return; + } } if lines.is_empty() { lines.push(Line::from("no comms")); @@ -1003,25 +1371,100 @@ fn draw_comms(frame: &mut Frame<'_>, area: Rect, app: &SidebarApp) { frame.render_widget(paragraph, area); } +fn comms_body_capacity(area: Rect, prefix_lines: u16) -> usize { + usize::from(area.height.saturating_sub(2)) + .saturating_sub(usize::from(prefix_lines)) + .max(1) +} + +fn comms_visible_item_capacity( + items: &[CommsItem], + body_capacity: usize, + width: usize, + compact: bool, +) -> usize { + if items.is_empty() { + return 0; + } + let mut used = 0usize; + let mut count = 0usize; + for item in items { + let height = comms_lines(item, width, compact).len().max(1); + if count > 0 && used.saturating_add(height) > body_capacity { + break; + } + if count == 0 && height > body_capacity { + return 1; + } + used = used.saturating_add(height); + count += 1; + } + count.max(1).min(items.len()) +} + +fn comms_scroll_max_for_items( + items: &[CommsItem], + body_capacity: usize, + width: usize, + compact: bool, +) -> usize { + items.len().saturating_sub(comms_visible_item_capacity( + items, + body_capacity, + width, + compact, + )) +} + fn draw_tips(frame: &mut Frame<'_>, area: Rect, app: &SidebarApp) { if area.height == 0 { return; } let content_height = usize::from(area.height.saturating_sub(2)); - let content_width = usize::from(area.width.saturating_sub(2)); + let tips_count = app.sidebar_view().tips.len(); + let needs_scrollbar = content_height > 0 && tips_count > content_height; + let content_width = usize::from( + area.width + .saturating_sub(if needs_scrollbar { 3 } else { 2 }), + ); let mut lines = app .sidebar_view() .tips .iter() - .take(content_height) .map(|tip| Line::from(truncate_comms_preview(tip, content_width))) .collect::>(); if lines.is_empty() { lines.push(Line::from("no tips")); } - let paragraph = - Paragraph::new(lines).block(Block::default().title("Tips").borders(Borders::ALL)); + let scroll = app + .tips_scroll + .min(lines.len().saturating_sub(content_height.max(1))); + let paragraph = Paragraph::new(lines.clone()) + .scroll((scroll as u16, 0)) + .block(Block::default().title("Tips").borders(Borders::ALL)); frame.render_widget(paragraph, area); + if needs_scrollbar { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + let mut scrollbar_state = ScrollbarState::new(lines.len()) + .position(scroll) + .viewport_content_length(content_height); + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } +} + +fn tips_scroll_max(view: &SidebarViewInfo, viewport_height: u16) -> usize { + view.tips + .len() + .saturating_sub(usize::from(viewport_height.max(1))) } fn empty_dash(value: &str) -> &str { @@ -1044,7 +1487,7 @@ fn comms_line_text(item: &CommsItem) -> String { fn comms_lines(item: &CommsItem, width: usize, compact: bool) -> Vec> { if compact { - return vec![compact_comms_line(item, width)]; + return compact_comms_lines(item, width); } let status = if item.status_label.trim().is_empty() { empty_dash(&item.status) @@ -1057,7 +1500,7 @@ fn comms_lines(item: &CommsItem, width: usize, compact: bool) -> Vec{} ", + "{} > {} ", empty_dash(&item.sender), empty_dash(&item.target) ))); @@ -1075,13 +1518,17 @@ fn comms_lines(item: &CommsItem, width: usize, compact: bool) -> Vec Line<'static> { +fn compact_comms_lines(item: &CommsItem, width: usize) -> Vec> { let status = if item.status_label.trim().is_empty() { empty_dash(&item.status) } else { item.status_label.trim() }; - let route = format!("{}>{} ", empty_dash(&item.sender), empty_dash(&item.target)); + let route = format!( + "{} > {} ", + empty_dash(&item.sender), + empty_dash(&item.target) + ); let compact_status = compact_comms_status(status).to_string(); let mut spans = comms_action_spans(item); spans.push(Span::raw(route.clone())); @@ -1089,16 +1536,20 @@ fn compact_comms_line(item: &CommsItem, width: usize) -> Line<'static> { compact_status.clone(), Style::default().fg(comms_status_color(item)), )); - let used = spans_text_width(&spans); - if width > used.saturating_add(1) { - let detail = compact_comms_detail(item); - if !detail.is_empty() { - let available = width.saturating_sub(used).saturating_sub(1); - spans.push(Span::raw(" ")); - spans.push(Span::raw(truncate_comms_preview(&detail, available))); - } - } - Line::from(spans) + vec![Line::from(spans), compact_comms_detail_line(item, width)] +} + +fn compact_comms_detail_line(item: &CommsItem, width: usize) -> Line<'static> { + let indent = " ".repeat(COMMS_DETAIL_INDENT_WIDTH.min(width)); + let available = width.saturating_sub(COMMS_DETAIL_INDENT_WIDTH); + let detail = compact_comms_detail(item); + if detail.is_empty() || available == 0 { + return Line::from(indent); + } + Line::from(format!( + "{indent}{}", + truncate_comms_preview(&detail, available) + )) } fn compact_comms_detail(item: &CommsItem) -> String { @@ -1112,10 +1563,6 @@ fn compact_comms_detail(item: &CommsItem) -> String { } } -fn spans_text_width(spans: &[Span<'_>]) -> usize { - spans.iter().map(|span| span.content.chars().count()).sum() -} - fn comms_action_spans(_item: &CommsItem) -> Vec> { let retry_style = Style::default() .fg(Color::Yellow) @@ -1422,7 +1869,7 @@ mod tests { assert!(rendered.contains("◐* agent1 [codex]")); assert!(!rendered.contains("#job1")); assert!(rendered.contains("Comms")); - assert!(rendered.contains("↻ X ⌫ agent2>agent1 run")); + assert!(rendered.contains("↻ X ⌫ agent2 > agent1 run")); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 0)].fg, Color::DarkGray); @@ -1552,7 +1999,7 @@ mod tests { assert!(rendered.contains("config ✕")); assert!(rendered.contains("config error: invalid TOML config")); - assert!(rendered.contains("↻ X ⌫ agent2>agent1 run")); + assert!(rendered.contains("↻ X ⌫ agent2 > agent1 run")); } #[test] @@ -1565,19 +2012,20 @@ mod tests { terminal.draw(|frame| draw(frame, &app)).unwrap(); let rendered = terminal.backend().to_string(); - assert!(rendered.contains("↻ X ⌫ agent4>agent1 ok")); - assert!(!rendered.contains("agent5>agent1")); - assert!(!rendered.contains("agent6>agent1")); + assert!(rendered.contains("↻ X ⌫ agent3 > agent1 ok")); + assert!(!rendered.contains("agent4 > agent1")); + assert!(!rendered.contains("agent5 > agent1")); + assert!(!rendered.contains("agent6 > agent1")); assert!(rendered.contains("Tips")); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(0, 18)].symbol(), "┌"); assert_eq!(buffer[(1, 18)].symbol(), "C"); - assert_eq!(buffer[(0, 24)].symbol(), "┌"); - assert_eq!(buffer[(1, 24)].symbol(), "T"); + assert_eq!(buffer[(0, 27)].symbol(), "┌"); + assert_eq!(buffer[(1, 27)].symbol(), "T"); } #[test] - fn tall_sidebar_uses_default_half_fifteen_and_thirty_five_split() { + fn tall_sidebar_uses_default_half_twenty_three_and_twenty_seven_split() { let mut app = SidebarApp::new("main".into()); app.apply_response(sample_response_with_comms(6)); let area = Rect::new(0, 0, 24, 40); @@ -1585,15 +2033,16 @@ mod tests { let areas = sidebar_areas(area, app.sidebar_view()); assert_eq!(areas.tree.height, 20); - assert_eq!(areas.comms.height, 6); - assert_eq!(areas.tips.map(|area| area.height), Some(14)); + assert_eq!(areas.comms.height, 10); + assert_eq!(areas.tips.map(|area| area.height), Some(10)); } #[test] fn configured_sidebar_view_can_adjust_all_three_sections() { let mut app = SidebarApp::new("main".into()); let mut response = sample_response_with_comms(6); - response.view.namespace.sidebar.view.agents_height = serde_json::Value::String("40%".into()); + response.view.namespace.sidebar.view.agents_height = + serde_json::Value::String("40%".into()); response.view.namespace.sidebar.view.comms_height = serde_json::Value::String("20%".into()); response.view.namespace.sidebar.view.tips_height = serde_json::Value::String("40%".into()); app.apply_response(response); @@ -1606,6 +2055,94 @@ mod tests { assert_eq!(areas.tips.map(|area| area.height), Some(16)); } + #[test] + fn tree_area_scrolls_independently_and_clicks_scrolled_targets() { + let mut app = SidebarApp::new("main".into()); + app.apply_response(sample_response_with_agents(8)); + let area = Rect::new(0, 0, 40, 18); + app.tree_scroll = 2; + + let rendered = render_to_string(&app, 40, 18); + + assert!(!rendered.contains("◐* agent1 [codex]")); + assert!(rendered.contains("agent8 [codex]")); + assert_eq!(app.target_index_at(1, 1, area), Some(2)); + } + + #[test] + fn comms_area_scrolls_independently_and_clicks_scrolled_items() { + let mut app = SidebarApp::new("main".into()); + app.apply_response(sample_response_with_comms(8)); + let area = Rect::new(0, 0, 80, 36); + app.comms_scroll = 2; + + let rendered = render_to_string(&app, 80, 36); + + assert!(!rendered.contains("agent1 > agent1 ok")); + assert!(rendered.contains("agent3 > agent1 ok")); + assert_eq!( + app.comms_index_at(1, comms_row_y(&app, area, 0), area), + Some(2) + ); + } + + #[test] + fn mouse_wheel_routes_to_panel_under_cursor() { + let mut app = SidebarApp::new("main".into()); + let mut response = sample_response_with_agents(24); + response.view.comms = sample_response_with_comms(8).view.comms; + response.view.namespace.sidebar.view.tips = + (1..=20).map(|index| format!("tip {index}")).collect(); + app.apply_response(response); + let area = Rect::new(0, 0, 80, 36); + let areas = app.sidebar_areas(area); + + assert!(app.scroll_panel_at(1, areas.tree.y + 1, area, 1)); + assert_eq!(app.tree_scroll, 1); + assert!(app.scroll_panel_at(1, areas.comms.y + 1, area, 1)); + assert_eq!(app.comms_scroll, 1); + let tips = areas.tips.expect("tips area should render"); + assert!(app.scroll_panel_at(1, tips.y + 1, area, 1)); + assert_eq!(app.tips_scroll, 1); + } + + #[test] + fn dragging_dividers_resizes_all_three_sections() { + let mut app = SidebarApp::new("main".into()); + app.apply_response(sample_response_with_comms(6)); + let area = Rect::new(0, 0, 24, 40); + let areas = app.sidebar_areas(area); + + assert_eq!(areas.tree.height, 20); + assert_eq!(areas.comms.height, 10); + assert_eq!(areas.tips.map(|area| area.height), Some(10)); + assert_eq!( + app.resize_divider_at(1, areas.comms.y, area), + Some(SidebarResizeDivider::TreeComms) + ); + assert!(app.start_resize_at(1, areas.comms.y, area)); + assert!(app.handle_mouse_drag(1, areas.comms.y + 4, area)); + + let resized = app.sidebar_areas(area); + assert_eq!(resized.tree.height, 24); + assert_eq!(resized.comms.height, 6); + assert_eq!(resized.tips.map(|area| area.height), Some(10)); + + app.handle_mouse_up(); + let tips = resized.tips.expect("tips area should render"); + assert_eq!( + app.resize_divider_at(1, tips.y, area), + Some(SidebarResizeDivider::CommsTips) + ); + assert!(app.start_resize_at(1, tips.y, area)); + assert!(app.handle_mouse_drag(1, tips.y - 3, area)); + + let resized = app.sidebar_areas(area); + assert_eq!(resized.tree.height, 24); + assert_eq!(resized.comms.height, 3); + assert_eq!(resized.tips.map(|area| area.height), Some(13)); + } + #[test] fn tree_header_marks_focus_in_another_window() { let mut app = SidebarApp::new("review".into()); @@ -1646,7 +2183,7 @@ mod tests { assert!(rendered.contains("◐* agent1 [codex]")); assert!(!rendered.contains("#job1")); assert!(rendered.contains("stale ProjectView")); - assert!(rendered.contains("↻ X ⌫ agent2>agent1 run")); + assert!(rendered.contains("↻ X ⌫ agent2 > agent1 run")); assert!(!rendered.contains("connect /tmp/ccbd.sock")); } @@ -1696,11 +2233,33 @@ mod tests { assert_eq!( comms_line_text(&item), - "↻ X ⌫ agent2>agent1 err check agent status timeout" + "↻ X ⌫ agent2 > agent1 err\n check agent status timeout" ); assert_eq!(comms_status_color(&item), Color::Red); } + #[test] + fn compact_comms_rows_use_two_lines_with_message_under_cancel_column() { + let item = crate::model::CommsItem { + sender: "aaa".into(), + target: "bbb".into(), + status: "completed".into(), + business_status: "replied".into(), + status_label: "done".into(), + body_preview: "message content".into(), + ..Default::default() + }; + let rendered = comms_lines(&item, 32, true) + .into_iter() + .map(line_text) + .collect::>(); + + assert_eq!( + rendered.as_slice(), + ["↻ X ⌫ aaa > bbb ok", " message content"] + ); + } + #[test] fn comms_line_marks_recoverable_items_with_block_reason() { let item = crate::model::CommsItem { @@ -1720,7 +2279,7 @@ mod tests { assert_eq!( comms_line_text(&item), - "↻ X ⌫ agent2>agent1 run check agent status pane_dead" + "↻ X ⌫ agent2 > agent1 run\n check agent status pane_dead" ); assert_eq!(recover_job_id(&item), Some("job1")); assert_eq!(recover_reply_delivery_job_id(&item), Some("job2")); @@ -1740,7 +2299,10 @@ mod tests { ..Default::default() }; - assert_eq!(comms_line_text(&item), "↻ X ⌫ agent2>agent1 ok all set"); + assert_eq!( + comms_line_text(&item), + "↻ X ⌫ agent2 > agent1 ok\n all set" + ); assert_eq!(comms_status_color(&item), Color::Blue); } @@ -1765,12 +2327,12 @@ mod tests { assert_eq!( rendered.as_slice(), - ["↻ X ⌫ agent2>agent1 ok", "COMMS_BUS..."] + ["↻ X ⌫ agent2 > agent1 ok", "COMMS_BUS..."] ); } #[test] - fn compact_comms_preview_truncates_to_available_width() { + fn compact_comms_preview_truncates_to_available_width_on_second_line() { let item = crate::model::CommsItem { sender: "agent2".into(), target: "agent1".into(), @@ -1780,22 +2342,29 @@ mod tests { }; let rendered = comms_lines(&item, 35, true) .into_iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) + .map(line_text) .collect::>(); - assert_eq!(rendered.as_slice(), ["↻ X ⌫ agent2>agent1 ok COMMS_..."]); + assert_eq!( + rendered.as_slice(), + ["↻ X ⌫ agent2 > agent1 ok", " COMMS_BUSINESS_VIEW_OK"] + ); + let narrow = comms_lines(&item, 10, true) + .into_iter() + .map(line_text) + .collect::>(); + assert_eq!( + narrow.as_slice(), + ["↻ X ⌫ agent2 > agent1 ok", " COMM..."] + ); } #[test] fn mouse_coordinates_map_to_comms_rows() { let mut app = SidebarApp::new("main".into()); let mut response = sample_response_with_comms(3); - response.view.namespace.sidebar.view.agents_height = serde_json::Value::String("50%".into()); + response.view.namespace.sidebar.view.agents_height = + serde_json::Value::String("50%".into()); response.view.namespace.sidebar.view.comms_height = serde_json::Value::String("40%".into()); response.view.namespace.sidebar.view.tips_height = serde_json::Value::String("10%".into()); response.view.comms[0].body_preview = "line two".into(); @@ -1803,13 +2372,15 @@ mod tests { app.apply_response(response); let area = Rect::new(0, 0, 24, 20); let row0 = comms_row_y(&app, area, 0); - let row1 = comms_row_y(&app, area, 1); + let row0_detail = comms_row_y(&app, area, 1); + let row1 = comms_row_y(&app, area, 2); let before_comms = sidebar_areas(area, app.sidebar_view()) .comms .y .saturating_sub(1); assert_eq!(app.comms_index_at(1, row0, area), Some(0)); + assert_eq!(app.comms_index_at(1, row0_detail, area), Some(0)); assert_eq!(app.comms_index_at(1, row1, area), Some(1)); assert_eq!(app.comms_index_at(0, row0, area), None); assert_eq!(app.comms_index_at(1, before_comms, area), None); @@ -1889,6 +2460,30 @@ mod tests { comms_action_at_area(&[sample_comms_item("msg1")], area, 10, 11, 22, true, 0), Some((0, CommsMouseAction::Select)) ); + assert_eq!( + comms_action_at_area(&[sample_comms_item("msg1")], area, 4, 12, 22, true, 0), + Some((0, CommsMouseAction::Select)) + ); + } + + #[test] + fn tips_rendering_respects_scroll_offset() { + let mut app = SidebarApp::new("main".into()); + let mut response = sample_response(); + response.view.namespace.sidebar.view.tips = vec![ + "tip one".into(), + "tip two".into(), + "tip three".into(), + "tip four".into(), + ]; + app.tips_scroll = 2; + app.apply_response(response); + app.tips_scroll = 2; + + let rendered = render_to_string(&app, 40, 12); + + assert!(!rendered.contains("tip one")); + assert!(rendered.contains("tip three")); } #[cfg(unix)] @@ -2167,6 +2762,29 @@ mod tests { response } + fn sample_response_with_agents(count: usize) -> ProjectViewResponse { + let mut response = sample_response(); + response.view.windows[0].agents = + (1..=count).map(|index| format!("agent{index}")).collect(); + response.view.agents = (1..=count) + .map(|index| AgentView { + name: format!("agent{index}"), + provider: "codex".into(), + window: "main".into(), + active: index == 1, + activity_state: if index == 1 { + "pending".into() + } else { + "idle".into() + }, + activity_symbol: Some(if index == 1 { "◐" } else { "●" }.into()), + activity_color: Some(if index == 1 { "yellow" } else { "green" }.into()), + ..AgentView::default() + }) + .collect(); + response + } + fn sample_response_with_comms(count: usize) -> ProjectViewResponse { let mut response = sample_response(); response.view.comms = (1..=count) @@ -2201,8 +2819,15 @@ mod tests { terminal.backend().to_string() } + fn line_text(line: Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + } + fn comms_row_y(app: &SidebarApp, area: Rect, offset: u16) -> u16 { - sidebar_areas(area, app.sidebar_view()) + app.sidebar_areas(area) .comms .y .saturating_add(1)