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**
-
+
@@ -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工作台**
-
+
@@ -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)