From 1fb8945516e0d465dc3a15414bfdf1c5c2bbe3f5 Mon Sep 17 00:00:00 2001 From: zynx <3362922+piorpua@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:03:19 +0800 Subject: [PATCH 1/9] test(ai-agent): avoid racing terminal output assertions (#866) - return the complete terminal snapshot from the bounded output polling helper - wait for expected output before asserting after `wait_for_exit` - apply the same synchronization to all terminal tests with the same timing assumption - preserve exact output, truncation, exit-status, and working-directory assertions - `cargo test -p aionui-ai-agent --lib terminal::tests::output_byte_limit_truncates_from_front -- --exact` - target test repeated 100 times - `cargo test -p aionui-ai-agent` - `cargo clippy -p aionui-ai-agent -- -D warnings` - `just push -u origin fix/terminal-output-test-race` (8,546 passed; 50 skipped) Co-authored-by: zynx <> --- crates/aionui-ai-agent/src/terminal.rs | 27 +++++++++++++------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/crates/aionui-ai-agent/src/terminal.rs b/crates/aionui-ai-agent/src/terminal.rs index ea0e09169..dd6517cef 100644 --- a/crates/aionui-ai-agent/src/terminal.rs +++ b/crates/aionui-ai-agent/src/terminal.rs @@ -383,12 +383,12 @@ mod tests { /// taken the instant after exit can legitimately still be empty. Poll for /// the expected text instead of racing it; the bounded wait keeps a real /// regression (output never delivered) failing. - async fn output_contains(reg: &TerminalRegistry, id: &str, needle: &str) -> String { + async fn wait_for_output_contains(reg: &TerminalRegistry, id: &str, needle: &str) -> TerminalOutputSnapshot { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); - let mut last = String::new(); + let mut last = reg.output(id).await.expect("terminal should remain registered"); while tokio::time::Instant::now() < deadline { - last = reg.output(id).await.map(|s| s.output).unwrap_or_default(); - if last.contains(needle) { + last = reg.output(id).await.expect("terminal should remain registered"); + if last.output.contains(needle) { return last; } tokio::time::sleep(std::time::Duration::from_millis(20)).await; @@ -403,8 +403,7 @@ mod tests { let exit = reg.wait_for_exit(&id).await.unwrap(); assert_eq!(exit.exit_code, Some(0)); assert!(!exit.signaled); - output_contains(®, &id, "hello_term").await; - let snap = reg.output(&id).await.unwrap(); + let snap = wait_for_output_contains(®, &id, "hello_term").await; assert!(snap.output.contains("hello_term")); assert!(!snap.truncated); assert!(snap.exit.is_some()); @@ -417,8 +416,7 @@ mod tests { p.output_byte_limit = Some(4); let id = reg.create(p).await.unwrap(); reg.wait_for_exit(&id).await.unwrap(); - output_contains(®, &id, "BBBB").await; - let snap = reg.output(&id).await.unwrap(); + let snap = wait_for_output_contains(®, &id, "BBBB").await; assert_eq!(snap.output, "BBBB"); assert!(snap.truncated); } @@ -464,8 +462,8 @@ mod tests { let id = reg.create(p).await.unwrap(); let exit = reg.wait_for_exit(&id).await.unwrap(); assert_eq!(exit.exit_code, Some(0)); - let output = output_contains(®, &id, "shell_interpreted").await; - assert!(output.contains("shell_interpreted"), "got: {output}"); + let snap = wait_for_output_contains(®, &id, "shell_interpreted").await; + assert!(snap.output.contains("shell_interpreted"), "got: {}", snap.output); } #[tokio::test] @@ -483,8 +481,8 @@ mod tests { .expect("exit must be reported once the direct child exits, not when the pipe closes") .unwrap(); assert_eq!(exit.exit_code, Some(0)); - let output = output_contains(®, &id, "parent_done").await; - assert!(output.contains("parent_done"), "got: {output}"); + let snap = wait_for_output_contains(®, &id, "parent_done").await; + assert!(snap.output.contains("parent_done"), "got: {}", snap.output); } #[tokio::test] @@ -496,14 +494,15 @@ mod tests { let reg = TerminalRegistry::new("conv-t", Some(dir.path().to_path_buf())); let id = reg.create(params("pwd", &[])).await.unwrap(); reg.wait_for_exit(&id).await.unwrap(); - let snap = reg.output(&id).await.unwrap(); // `pwd` prints the resolved path (on macOS temp_dir lives under // /var -> /private/var), so compare against the canonicalized full path // rather than only the trailing directory name. let canonical = std::fs::canonicalize(dir.path()).unwrap(); + let expected = canonical.to_str().unwrap(); + let snap = wait_for_output_contains(®, &id, expected).await; assert_eq!( snap.output.trim(), - canonical.to_str().unwrap(), + expected, "pwd should report the default cwd; output: {}", snap.output ); From bc026816c276ab6a2ca2572a48b1bcda47180fde Mon Sep 17 00:00:00 2001 From: kaizhou-lab <1558390418@qq.com> Date: Sat, 29 Aug 2026 02:26:48 +0800 Subject: [PATCH 2/9] chore(cli-version): verify claude against 2.1.236 (#944) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves `VERIFIED_CLAUDE_VERSION` from 2.1.235 to 2.1.236, and **discharges the hold placed on 2026-08-21**. | Gate | Result | | --- | --- | | A — contract | DEGRADED — claude publishes no protocol schema; gate B carries the weight | | B — live e2e | **PASS 14/14**, 304.35s | | C — release notes | **CLEAR** — 33 notes, 0 REVIEW | ## Why this one was stuck The constant has been sitting **above** the stable channel for eight days. An earlier version of the detector read npm’s `latest` tag, which for `@anthropic-ai/claude-code` points at the bleeding edge rather than the release most users run, so 2.1.235 was qualified off the `next` channel. Once the detector was corrected to track `stable`, every nightly run reported `drift: behind`, and the standing decision was to hold the floor rather than walk it down — walking it down would have flagged the majority of users (who install via `npm` or the native installer, both ahead of stable) as `Newer`. stable has now caught up and passed it, so the normal flow resumes: ``` stable: 2.1.236 next: 2.1.251 latest: 2.1.250 ``` ## The candidate is proven from the suite log ``` version=2.1.236 (Claude Code) ``` with the drift lines that only appear because the binary disagrees with the not-yet-bumped constant. Run through an npm PATH shim into a temp dir; `~/.local/bin/claude` was read before and after and did not move off 2.1.239. ## Gate C Both flagged notes are usage-credits UI — a Fable 5 first-run prompt auto-selecting a fallback model under Remote Control, and a `/usage` spend row for Team and Enterprise members. Neither is a surface this repo drives. ## Also checked The promotion condition this repo does not machine-check: `THINKING_DISPLAY_MIN_VERSION` is `2.1.191` (`claude_flags.rs:34`), comfortably below the new constant. So nobody on exactly the verified release is told they match while thinking display is silently off. ## Tests The literal verified-release assertion moves with the constant, so a future bump that forgets to re-verify still breaks the test. `cargo test -p aionui-session --lib cli_version`: 14/14. `cargo test -p aionui-ai-agent --lib claude_flags`: 5/5. Clippy clean, fmt clean. Record: `~/aion/protocols/samples/claude-cli/2.1.236/` Constants and record only — no source change. Co-authored-by: zk <> --- crates/aionui-session/src/backend/cli_version.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/aionui-session/src/backend/cli_version.rs b/crates/aionui-session/src/backend/cli_version.rs index 697e01efe..15ec1b606 100644 --- a/crates/aionui-session/src/backend/cli_version.rs +++ b/crates/aionui-session/src/backend/cli_version.rs @@ -36,7 +36,7 @@ use crate::event::{LocalizedText, NoticeLevel}; /// back-to-back control after three clean versions). Every release from 0.148.0 /// on does complete turns and passes the suite, so the gate walks forward over /// 0.147.0 and leaves it unverified rather than a floor anyone can install into. -pub const VERIFIED_CLAUDE_VERSION: &str = "2.1.235"; +pub const VERIFIED_CLAUDE_VERSION: &str = "2.1.236"; pub const VERIFIED_CODEX_VERSION: &str = "0.150.1"; pub const VERIFIED_AGY_VERSION: &str = "1.1.22"; @@ -450,8 +450,8 @@ mod tests { fn the_verified_release_says_nothing() { // Literal on purpose: this is the exact string a user on the verified // release reports, so the test breaks if a bump forgets to re-verify. - assert_eq!(classify("2.1.235", VERIFIED_CLAUDE_VERSION), VersionVerdict::Verified); - assert!(drift_notice("claude", "2.1.235", VERIFIED_CLAUDE_VERSION).is_none()); + assert_eq!(classify("2.1.236", VERIFIED_CLAUDE_VERSION), VersionVerdict::Verified); + assert!(drift_notice("claude", "2.1.236", VERIFIED_CLAUDE_VERSION).is_none()); } #[test] From 6c8451bf96f547d531d4402229872426672b9abe Mon Sep 17 00:00:00 2001 From: kaizhou-lab <1558390418@qq.com> Date: Sat, 29 Aug 2026 01:18:26 +0800 Subject: [PATCH 3/9] chore(acp): bump codebuddy and dimcode registry pins to probed versions (#943) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Scheduled ACP Registry version sync. Two npx pins drifted since #941, each backed by a fresh serial ACP probe of the exact pinned version. The diff is the lock file plus the one lock-derived test assertion that embeds codebuddy's version. | backend | package | old → new | initialize | session/new | |---|---|---|---|---| | codebuddy | `@tencent-ai/codebuddy-code` | 2.140.0 → **2.141.0** | ok (protocolVersion 1) | auth required (`-32000`, `data.category: auth`) | | dimcode | `dimcode` | 0.3.21 → **0.3.22** | ok (agentInfo version 0.3.22) | auth required (`-32000`, "Provider credentials are required") | Both meet the release-lock criterion: `initialize` succeeds and `session/new` returns a clearly classified authentication requirement. Probes ran serially with no inherited HOME or credentials, and both entrypoints (`--acp` for codebuddy, `acp` for dimcode) are unchanged from the previous snapshot. **Derived assertion updated:** `registry_npx_lock.rs` pins codebuddy's exact version inside a `--package`-form argument list, so it moves with the lock — `2.140.0` → `2.141.0`. Both outgoing versions were grepped across `crates/**/*.rs` before staging: codebuddy's was the only real assertion, and `0.3.21`'s single hit was a false positive (a `"003421"` string literal in `pairing.rs`, matched because `.` is a regex wildcard). `npx_cache_repair.rs` keeps its own version literals: those are cache-path hash fixtures, not lock assertions, and changing them would break their hash expectations. The other 9 Registry-pinned packages (autohand, deepagents, dirac, glm-acp-agent, grok, kilo, nova, pi, sigit) match the snapshot exactly. Package names and entrypoint args are unchanged for all 11. Drifted but not upgraded: none. `mimo-code` remains the one non-Registry builtin (no `registry_json_id`), excluded from drift reconciliation. dimcode continues to self-report `agentInfo.title` as "DimAgent" against a public listing that says "DimCode" — a standing observation since #814; no metadata change. ## Registry snapshot - Audit pinned to release tag [`v2026.08.28-e9b1b2d`](https://cdn.agentclientprotocol.com/registry/v1/v2026.08.28-e9b1b2d/registry.json) of `agentclientprotocol/registry`, fetched via the versioned CDN path for reproducibility. - 39 ids in the raw snapshot: no newly listed and no delisted agents versus the baseline. (`antigravity-acp` remains listed and deferred as binary-only since 2026-08-21; `fast-agent` and `minion-code` remain listed but uvx-only and therefore out of scope.) ## Validation - `just migration-check` — pass - `just lint-fix` (`cargo fix` + `clippy --fix --workspace -D warnings`) — clean - `just fmt` — clean - **Local `cargo nextest` intentionally skipped, by standing policy for lock-only bumps** (established 2026-08-11). The Test check on this PR is the authority for this change: the merge decision depends on CI rather than the local run, and this host's load only manufactures timeout-shaped test failures, which nothing in the local steps above is subject to. ## Logging No logging changes: lock version bumps plus one test assertion; existing startup/session error paths already identify a failing agent by backend. Co-authored-by: zk <> --- crates/aionui-runtime/resources/acp-registry-npx-lock.json | 4 ++-- crates/aionui-runtime/src/registry_npx_lock.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/aionui-runtime/resources/acp-registry-npx-lock.json b/crates/aionui-runtime/resources/acp-registry-npx-lock.json index f31ff8403..5138dd9fd 100644 --- a/crates/aionui-runtime/resources/acp-registry-npx-lock.json +++ b/crates/aionui-runtime/resources/acp-registry-npx-lock.json @@ -9,7 +9,7 @@ "codebuddy": { "registry_json_id": "codebuddy-code", "package": "@tencent-ai/codebuddy-code", - "version": "2.140.0" + "version": "2.141.0" }, "deepagents": { "registry_json_id": "deepagents", @@ -19,7 +19,7 @@ "dimcode": { "registry_json_id": "dimcode", "package": "dimcode", - "version": "0.3.21" + "version": "0.3.22" }, "dirac": { "registry_json_id": "dirac", diff --git a/crates/aionui-runtime/src/registry_npx_lock.rs b/crates/aionui-runtime/src/registry_npx_lock.rs index f7251f98b..40de7c5df 100644 --- a/crates/aionui-runtime/src/registry_npx_lock.rs +++ b/crates/aionui-runtime/src/registry_npx_lock.rs @@ -126,7 +126,7 @@ mod tests { [ "-y", "--package", - "@tencent-ai/codebuddy-code@2.140.0", + "@tencent-ai/codebuddy-code@2.141.0", "codebuddy", "--acp" ] From 9d0b26211ad340079bc9ec0badfa0074a7957c1a Mon Sep 17 00:00:00 2001 From: suoak <5143514+suoak@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:19:55 +0000 Subject: [PATCH 4/9] test: retarget claude_flags verified-release fixture to 2.1.236 WorkMate 0.2.2 pins VERIFIED_CLAUDE_VERSION to 2.1.236; keep the floor-gate fixture on the same release. --- crates/aionui-ai-agent/src/claude_flags.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/aionui-ai-agent/src/claude_flags.rs b/crates/aionui-ai-agent/src/claude_flags.rs index 09f0b290c..f3880fd1d 100644 --- a/crates/aionui-ai-agent/src/claude_flags.rs +++ b/crates/aionui-ai-agent/src/claude_flags.rs @@ -126,7 +126,7 @@ mod tests { assert!(!supported("2.1.0"), "2.1.0 hard-errors on --thinking-display"); assert!(!supported("2.1.190"), "below the verified floor stays off"); assert!(supported("2.1.191"), "lowest live-probed OK version"); - assert!(supported("2.1.235"), "the release this integration is verified against"); + assert!(supported("2.1.236"), "the release this integration is verified against"); assert!(supported("2.1.220")); assert!(supported("3.0.0"), "a future major must not regress the gate"); } From 536e25bc19b5b2545df545cf2ca06bfc7526d877 Mon Sep 17 00:00:00 2001 From: zynx <3362922+piorpua@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:00:00 +0800 Subject: [PATCH 5/9] perf: slim auto-inject skill descriptions to the injection budget (#930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-inject skill descriptions are a per-session resident cost: they are injected into every conversation's skills index and count toward a native CLI's always-on catalog, so their length is paid on every conversation whether or not the skill is used. `aionui-config` alone was 666 chars — 47% of the entire index — because its description duplicated the command list that `aioncore config capabilities` already reports at runtime. This PR brings the three over-budget descriptions under 200 chars and adds a guard so new ones cannot regress. | Skill | description before | after | index entry before | after | | --- | --- | --- | --- | --- | | aionui-config | 666 | **194** | 687 | 215 | | skill-creator | 226 | **186** | 247 | 207 | | officecli | 222 | **194** | 239 | 211 | | cron | 121 | unchanged | 133 | 133 | | session-message | 119 | unchanged | 142 | 142 | | **index entries total** | | | **1453** | **908** | Every description keeps the hooks an agent matches user intent against: what the skill configures or does, which CLI it drives, and when to use it. What was dropped is the enumeration of operation verbs, the second restatement of the same trigger inside `Use when ...`, and behavioural contracts that belong in the skill body and are already documented there. All three are now single-line plain scalars instead of `>-` block scalars, consistent with the other auto-inject skills. New guard `auto_inject_skill_descriptions_stay_within_injection_budget` walks the embedded builtin corpus's `auto-inject/` entries and asserts each parsed description is at most 200 chars. It measures the parsed frontmatter value — what actually gets injected — rather than the raw YAML text. No migration needed: startup sync upserts builtin descriptions into the `skills` table, so the new text propagates on the first launch after the corpus fingerprint changes. Automated: - `cargo test -p aionui-extension` — all green - `cargo test -p aionui-app --test config_cli_e2e --test skills_builtin_e2e` — all green; the static SKILL.md body assertions are unaffected since only frontmatter changed - `cargo clippy -p aionui-extension --all-targets -- -D warnings` — clean - `cargo fmt --all -- --check` — clean - Guard test red/green: with the three descriptions reverted it fails and reports 666 / 222 / 226; with them applied it passes Dev build run-through, covering both skill delivery modes: 1. The built binary embeds only the new text; the old text is absent. 2. Startup re-materialized the builtin corpus purely because the corpus fingerprint changed — no version bump was involved. 3. The materialized on-disk tree and the `skills` table rows both report the new lengths. 4. Native-skills backends, where skills are symlinked into the conversation workspace: the linked SKILL.md files carry the new descriptions, and the prompt contains no skills index — the expected light-mode behaviour for that delivery path. 5. Injected-index backend: the first-message `## Available Skills` block measures 1004 chars, down from 1544, with all five entries showing the new text. - [x] Unit and integration tests for the affected crates - [x] Lint and format gates - [x] Guard test fails on an over-budget description - [x] Dev verification of native-skills delivery - [x] Dev verification of injected-index delivery - [ ] Reviewer sanity check: each shortened description still reads clearly on its own in the skills list UI, which renders the same string The `aioncore config capabilities` contract is hand-maintained and has no completeness assertion against the command tree. That is the more likely source of future drift and is left to a separate change. Co-authored-by: zynx <> --- .../auto-inject/aionui-config/SKILL.md | 3 +- .../auto-inject/officecli/SKILL.md | 2 +- .../auto-inject/skill-creator/SKILL.md | 2 +- crates/aionui-extension/src/skill_service.rs | 44 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md b/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md index 176cf143d..730cc5ac7 100644 --- a/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md +++ b/crates/aionui-app/assets/builtin-skills/auto-inject/aionui-config/SKILL.md @@ -1,7 +1,6 @@ --- name: csbu-workmate-config -description: >- - Configure CSBU WorkMate itself through the bundled aioncore config CLI: create and edit assistants, update assistant rules, inspect and import skills, manage MCP servers, configure model providers, update settings, manage agents, configure scheduled tasks, and manage app configuration from an agent conversation. Use when the user wants you to set up or modify an CSBU WorkMate assistant, attach skills, change an assistant's system prompt, add MCP or model provider configuration, schedule recurring work, or otherwise configure their CSBU WorkMate installation, including when the user needs to know whether assistant changes affect the current conversation or only new conversations. +description: Configure CSBU WorkMate with the bundled aioncore CLI. Use when asked to set up or change assistants, assistant rules, skills, MCP servers, model providers, agents, scheduled tasks, or settings. --- # CSBU WorkMate Config diff --git a/crates/aionui-app/assets/builtin-skills/auto-inject/officecli/SKILL.md b/crates/aionui-app/assets/builtin-skills/auto-inject/officecli/SKILL.md index fabe26098..41b652733 100644 --- a/crates/aionui-app/assets/builtin-skills/auto-inject/officecli/SKILL.md +++ b/crates/aionui-app/assets/builtin-skills/auto-inject/officecli/SKILL.md @@ -1,6 +1,6 @@ --- name: officecli -description: Create, analyze, proofread, and modify Office documents (.docx, .xlsx, .pptx) using the officecli CLI tool. Use when the user wants to create, inspect, check formatting, find issues, add charts, or modify Office documents. +description: Create, analyze, proofread, and modify Office documents (.docx, .xlsx, .pptx) with the officecli CLI. Use when the user wants to build, inspect, check formatting, or chart a Word/Excel/PPT file. --- > **⚠️ Platform note — read before running any command.** The shell snippets in this skill are written for **macOS / Linux** (bash/zsh). Always check which OS you are on first. On **Windows** do **not** run them verbatim — the underlying tool/CLI commands are usually cross-platform, but the surrounding shell syntax is not. Translate it to PowerShell before running: diff --git a/crates/aionui-app/assets/builtin-skills/auto-inject/skill-creator/SKILL.md b/crates/aionui-app/assets/builtin-skills/auto-inject/skill-creator/SKILL.md index b08f80b82..3177d7f04 100644 --- a/crates/aionui-app/assets/builtin-skills/auto-inject/skill-creator/SKILL.md +++ b/crates/aionui-app/assets/builtin-skills/auto-inject/skill-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: skill-creator -description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. +description: Guide for creating effective skills. Use when the user wants to create a new skill or update an existing one to extend Claude with specialized knowledge, workflows, or tool integrations. license: Complete terms in LICENSE.txt --- diff --git a/crates/aionui-extension/src/skill_service.rs b/crates/aionui-extension/src/skill_service.rs index 151bc2593..d017bc5da 100644 --- a/crates/aionui-extension/src/skill_service.rs +++ b/crates/aionui-extension/src/skill_service.rs @@ -3317,6 +3317,50 @@ mod tests { } } + /// Auto-inject descriptions are a per-session resident cost: they are + /// injected into every conversation's skills index and count toward a + /// native CLI's always-on catalog. Keep each one inside the budget so + /// no description is ever truncated mid-sentence. + const MAX_AUTO_INJECT_DESCRIPTION_CHARS: usize = 200; + + #[test] + fn auto_inject_skill_descriptions_stay_within_injection_budget() { + let auto_dir = BUILTIN_SKILLS + .get_dir(BUILTIN_AUTO_SKILLS_SUBDIR) + .expect("embedded corpus must contain the auto-inject subdirectory"); + + let mut checked = 0; + let mut failures = Vec::new(); + + for subdir in auto_dir.dirs() { + let location = subdir.path().join(SKILL_MANIFEST_FILE); + let Some(file) = BUILTIN_SKILLS.get_file(&location) else { + failures.push(format!("{}: missing {SKILL_MANIFEST_FILE}", subdir.path().display())); + continue; + }; + + let content = std::str::from_utf8(file.contents()) + .unwrap_or_else(|err| panic!("{}: not UTF-8: {err}", location.display())); + // Measure the parsed value, not the raw YAML: a folded block + // scalar is what gets injected, not the `>-` source text. + let (_, description) = parse_frontmatter_fields(content) + .unwrap_or_else(|| panic!("{}: invalid frontmatter or missing description", location.display())); + + checked += 1; + let length = description.chars().count(); + if length > MAX_AUTO_INJECT_DESCRIPTION_CHARS { + failures.push(format!("{}: description is {length} chars", location.display())); + } + } + + assert!(checked >= 5, "expected at least 5 auto-inject skills, got {checked}"); + assert!( + failures.is_empty(), + "auto-inject skill descriptions must stay within {MAX_AUTO_INJECT_DESCRIPTION_CHARS} chars:\n{}", + failures.join("\n") + ); + } + #[tokio::test] async fn embedded_lists_auto_inject_from_corpus() { let tmp = TempDir::new().unwrap(); From 4664fa7ec5fe3cf1fb1b33fba966627b3b3ef364 Mon Sep 17 00:00:00 2001 From: kaizhou-lab <1558390418@qq.com> Date: Mon, 17 Aug 2026 15:10:32 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat(conversation):=20mid-turn=20interjecti?= =?UTF-8?q?on=20=E2=80=94=20deliver=20messages=20while=20a=20turn=20is=20i?= =?UTF-8?q?n=20flight=20(#836)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend half of the mid-turn interjection feature (frontend: iOfficeAI/AionUi#4012). claude and codex sessions can now receive a user message while a turn is in flight; other backends (antigravity, ACP, aionrs) are completely unchanged. Design spec with measured wire data for every CLI-behavior claim lives in the internal design doc (2026-08-12 mid-turn interjection design, verified against claude 2.1.226/2.1.227 and codex 0.144.6). **T1 — capability bit.** New `Capabilities::supports_midturn_delivery` (default false; claude/codex true; antigravity explicitly false — one process per turn, ignores stdin mid-turn; ACP false). Exposed on `ConversationRuntimeSummary` (serde-defaulted). Neither `accepts_proactive_input` nor `can_queue` goes on the wire — only this single-semantic derived bit. **T2 — command_lifecycle.** claude's `command_lifecycle` frames (echoing the uuid we mint on user frames) parse into `SessionEvent::MessageLifecycle { client_msg_id, phase }` with phases queued/started/completed/cancelled. Unknown states degrade to no event. `MessageLifecyclePhase` never goes on the wire. **T3 — agent-driven turn claim.** `claim_for_agent_turn` (None-when-claimed is the normal case, never 409). The background stream claims an agent-started turn only when a TTL-bounded (30s, spec-justified) pending `MessageLifecycle{Started}` marks it as serving a user message; pure background continuations stay unclaimed so #758's detached-exec flow keeps the input box usable. `mint_turn_id` moved inside the claim branch. **T4 — mid-turn routing (B5).** Active turn + supporting backend → no claim, no new turn id: codex goes through `turn/steer` (with `clientUserMessageId`; both `-32600` rejections discriminated by message text and locked by tests — turn-ended falls back to a new turn, different-turn retries with the id parsed from the message), claude through the resident stdin. Response is 200 with the current active `turn_id` and `delivered_midturn: true`. Pending confirmation (requires_action) still refuses mid-turn delivery (409). `message.userCreated` now carries `client_msg_id` + `status`; new WS event `message.statusChanged` flips pending→finish when the agent consumes the message. codex `accepts_proactive_input` flipped true (B5 now wired). Skill injection is threaded through the mid-turn path. - TDD throughout; targeted suites green per crate (session 553, conversation 399 lib + integration, app midturn e2e), clippy `-D warnings`, fmt; full pre-push gate green. - Live e2e against real claude 2.1.227: mid-turn send 200 into the active turn, real `command_lifecycle` sequence observed, follow-up serving turn claimed (state stays `running`), pending marker clears, later background continuation stays unclaimed. - Live e2e against real codex 0.144.6: steer ack 200 with same turn id, message consumed mid-turn, status flip observed. - Rejection-text matrix, TTL expiry, claim gating (incl. the #758 no-claim guard), requires_action 409, and wire-hygiene invariants are all locked by tests. - No migrations; message status reuses existing DB CHECK values (`pending`/`finish`). - Logging: routing decision and claim at `info` (ids only), steer rejection at `warn`; message bodies are never logged. - Merge together with iOfficeAI/AionUi#4012 (frontend gate + badge). Old frontend against this backend is unaffected (additive serde-defaulted fields). --------- Co-authored-by: zk <> --- crates/aionui-ai-agent/src/agent_task.rs | 37 ++ crates/aionui-ai-agent/src/lib.rs | 4 + .../src/protocol/events/mod.rs | 25 + crates/aionui-ai-agent/src/session_agent.rs | 271 ++++++---- .../tests/acp_agent_integration.rs | 1 + crates/aionui-api-types/src/conversation.rs | 27 + crates/aionui-api-types/src/lib.rs | 6 +- crates/aionui-app/tests/midturn_e2e.rs | 248 +++++++++ crates/aionui-channel/src/message_service.rs | 5 +- .../src/background_stream.rs | 367 ++++++++++++- crates/aionui-conversation/src/routes.rs | 33 +- .../aionui-conversation/src/runtime_state.rs | 59 ++- crates/aionui-conversation/src/service.rs | 417 +++++++++++++-- .../aionui-conversation/src/service_test.rs | 304 +++++++++++ .../aionui-conversation/src/stream_relay.rs | 7 + .../tests/force_kill_convergence.rs | 4 +- .../tests/common/mod.rs | 7 + .../tests/delivery_semantics.rs | 32 +- .../tests/e2e_cross_session.rs | 27 +- crates/aionui-session/src/adapter/claude.rs | 85 ++- crates/aionui-session/src/backend/acp_conn.rs | 9 + .../src/backend/antigravity/conn.rs | 15 +- .../aionui-session/src/backend/claude_conn.rs | 109 +++- .../aionui-session/src/backend/codex_conn.rs | 499 +++++++++++++----- crates/aionui-session/src/backend/types.rs | 8 +- crates/aionui-session/src/capability.rs | 64 +++ crates/aionui-session/src/event.rs | 61 ++- crates/aionui-session/src/lib.rs | 10 +- crates/aionui-session/src/reducer.rs | 5 +- crates/aionui-session/src/state.rs | 25 +- crates/aionui-session/src/testing.rs | 2 +- 31 files changed, 2390 insertions(+), 383 deletions(-) create mode 100644 crates/aionui-app/tests/midturn_e2e.rs diff --git a/crates/aionui-ai-agent/src/agent_task.rs b/crates/aionui-ai-agent/src/agent_task.rs index fc6d27579..ac84aaa36 100644 --- a/crates/aionui-ai-agent/src/agent_task.rs +++ b/crates/aionui-ai-agent/src/agent_task.rs @@ -74,6 +74,16 @@ pub trait IAgentTask: Send + Sync { PromptMediaCaps::default() } + /// Whether a message sent right now reaches the agent without waiting for + /// the current turn to end (task-1 brief: mid-turn interjection). Mirrors + /// `aionui_session::Capabilities::supports_midturn_delivery` — defaults + /// false (ACP-like) so only backends that genuinely deliver mid-turn + /// (the clean-slate `Session` variant reading claude/codex capabilities) + /// opt in. + fn supports_midturn_delivery(&self) -> bool { + false + } + /// Send a user message to the agent. Returns once the agent has /// accepted the turn; actual streaming proceeds on the broadcast /// channel returned by [`Self::subscribe`]. @@ -132,6 +142,11 @@ pub trait IMockAgent: IAgentTask { fn get_session_key(&self) -> Option { None } + /// B5 mid-turn delivery test seam. Default: forward to `send_message` so + /// simple mocks behave; mid-turn tests override to record the routed call. + async fn deliver_midturn(&self, data: SendMessageData) -> Result<(), AgentSendError> { + self.send_message(data).await + } async fn mode(&self) -> Result { Ok(aionui_api_types::AgentModeResponse { mode: "default".into(), @@ -273,6 +288,12 @@ impl AgentInstance { self.as_task().subscribe() } + /// Whether a message sent right now reaches the agent without waiting + /// for the current turn to end. See `IAgentTask::supports_midturn_delivery`. + pub fn supports_midturn_delivery(&self) -> bool { + self.as_task().supports_midturn_delivery() + } + /// Send a user message to the agent. pub async fn send_message(&self, data: SendMessageData) -> Result<(), AgentSendError> { self.as_task().send_message(data).await @@ -308,6 +329,22 @@ impl AgentInstance { } } + /// B5 mid-turn delivery: hand a message to the RUNNING turn instead of + /// opening a new one. Only meaningful when + /// [`Self::supports_midturn_delivery`] is true — the conversation layer + /// gates on that bit before routing here. Variants without the path reject + /// (the caller must not have routed them here). + pub async fn deliver_midturn(&self, data: SendMessageData) -> Result<(), AgentSendError> { + match self { + Self::Acp(_) | Self::Aionrs(_) => Err(AgentSendError::from_agent_error(AgentError::BadRequest( + "mid-turn delivery is not supported by this agent".into(), + ))), + Self::Session(m) => m.deliver_midturn(data).await, + #[cfg(any(test, feature = "test-support"))] + Self::Mock(m) => m.deliver_midturn(data).await, + } + } + /// Cancel the current streaming response without killing the agent. pub async fn cancel(&self) -> Result<(), AgentError> { self.as_task().cancel().await diff --git a/crates/aionui-ai-agent/src/lib.rs b/crates/aionui-ai-agent/src/lib.rs index 6fe8eb3e8..3ce87fb8f 100644 --- a/crates/aionui-ai-agent/src/lib.rs +++ b/crates/aionui-ai-agent/src/lib.rs @@ -42,6 +42,10 @@ pub use agent_runtime::AgentRuntime; pub use agent_task::IMockAgent; pub use agent_task::{AgentInstance, IAgentTask}; pub use aionui_api_types::{AcpBuildExtra, AcpModelInfo, AionrsBuildExtra, SlashCommandItem}; +// Backend-static capability table (session layer's single source of truth) — +// re-exported so the conversation layer can read the mid-turn bit for a +// conversation whose agent task is not currently live. +pub use aionui_session::backend_supports_midturn_delivery; pub use aionui_session::effective_agent_capabilities; pub use capability::skill_manager::{ AcpSkillManager, SkillDefinition, SkillIndex, build_skills_index_text, build_system_instructions, diff --git a/crates/aionui-ai-agent/src/protocol/events/mod.rs b/crates/aionui-ai-agent/src/protocol/events/mod.rs index 647b11fd7..76ee7f3ff 100644 --- a/crates/aionui-ai-agent/src/protocol/events/mod.rs +++ b/crates/aionui-ai-agent/src/protocol/events/mod.rs @@ -107,6 +107,16 @@ pub enum AgentStreamEvent { /// Never counts as user-visible turn output (see `event_is_user_visible_output`): /// it is an out-of-band status refresh, not the turn "saying something". WorkflowProgress(WorkflowProgressData), + /// Internal-only: lifecycle echo for a user message we wrote to a direct + /// CLI (claude `command_lifecycle` → `SessionEvent::MessageLifecycle`, + /// verified 2.1.226 — mid-turn interjection design spec §6.1). + /// `client_msg_id` is the uuid WE minted on the user frame, echoed back + /// verbatim. Consumed by the conversation layer's BackgroundStreamWatcher + /// to decide whether an agent-started follow-up turn SERVES a user message + /// (claim it) or is a pure background continuation (leave it unclaimed); + /// the per-turn relay consumes it silently. Never forwarded to the + /// WebSocket; never counts as user-visible turn output. + MessageLifecycle(MessageLifecycleData), /// Internal-only signal: the tolerant transport layer absorbed a CodeBuddy /// dialect notification (`session_end` / `compact-maxtoken`) that the stock /// ACP schema hard-rejects as `-32602`. Consumed by the empty-turn judgment @@ -230,6 +240,21 @@ pub struct WorkflowProgressData { pub settle_only: bool, } +/// Data for the internal-only [`AgentStreamEvent::MessageLifecycle`] event. +/// +/// `phase` reuses the session layer's [`aionui_session::MessageLifecyclePhase`] +/// verbatim — the pump is a pass-through here. Re-exported below so consumers +/// of this event (the conversation layer's watcher) can match on the enum +/// without taking their own `aionui-session` dependency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageLifecycleData { + /// The uuid we minted on the user frame, echoed back by the CLI. + pub client_msg_id: String, + pub phase: MessageLifecyclePhase, +} + +pub use aionui_session::MessageLifecyclePhase; + /// Data for the internal-only [`AgentStreamEvent::AcpDialectSignal`] event. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AcpDialectSignalData { diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 50317bac2..4e78bf777 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -526,6 +526,120 @@ impl SessionAgentTask { self.command_seq.fetch_add(1, Ordering::Relaxed) as u64 } + /// Build the multimodal `ContentBlock` vector for a prompt: partition + /// attachments by the backend's declared prompt blocks — capable media + /// becomes native Image/Audio blocks; everything else keeps the + /// pre-multimodal form (path in the [[AION_FILES]] text + resource link). + /// A read failure degrades that attachment back to a resource link — the + /// path also remains in the original text because partition already ran, + /// which the adapters tolerate (they resolve links independently of the + /// text). Shared by `send_message` and `deliver_midturn`. + async fn build_prompt_blocks( + &self, + data: &SendMessageData, + ) -> (Vec, Vec) { + // Partition attachments by the backend's declared prompt blocks: + // capable media becomes native Image/Audio blocks PAIRED with a + // ResourceLink to the same file; everything else keeps the + // pre-multimodal form (path in the [[AION_FILES]] text + resource + // link). The pair is how the disk path reaches an agent: a native + // image/audio block has no uri field, and partition already stripped + // that path out of the text. Shared by `send_message` and `deliver_midturn`. + let mut partition = + crate::media::partition_media(&data.content, &data.files, &data.attachments, self.prompt_media_caps()) + .await; + let link_media_paths = self.backend.capabilities().prompt_blocks.resource; + let mut content: Vec = Vec::new(); + if !partition.content.is_empty() { + content.push(ContentBlock::Text(partition.content)); + } + for path in partition.path_files { + content.push(ContentBlock::ResourceLink { + uri: path, + mime_type: None, + }); + } + let mut media_links = 0usize; + for attachment in &partition.media { + match crate::media::read_media_bytes(attachment).await { + Some(bytes) => { + content.push(match attachment.kind { + crate::media::MediaKind::Image => ContentBlock::Image { + data: bytes, + media_type: attachment.mime.clone(), + }, + crate::media::MediaKind::Audio => ContentBlock::Audio { + data: bytes, + media_type: attachment.mime.clone(), + }, + }); + if link_media_paths { + content.push(ContentBlock::ResourceLink { + uri: attachment.path.clone(), + mime_type: Some(attachment.mime.clone()), + }); + media_links += 1; + } + } + None => { + if let Some(delivery) = partition.deliveries.get_mut(attachment.attachment_index) { + delivery.delivery = aionui_api_types::PromptAttachmentDelivery::PathFallback; + delivery.reason = Some("native_read_failed".to_owned()); + } + content.push(ContentBlock::ResourceLink { + uri: attachment.path.clone(), + mime_type: Some(attachment.mime.clone()), + }); + } + } + } + let deliveries = std::mem::take(&mut partition.deliveries); + if !partition.media.is_empty() { + let (images, audios) = content.iter().fold((0usize, 0usize), |(i, a), b| match b { + ContentBlock::Image { .. } => (i + 1, a), + ContentBlock::Audio { .. } => (i, a + 1), + _ => (i, a), + }); + tracing::info!( + conversation_id = %self.conversation_id, + msg_id = %data.msg_id, + images, + audios, + media_links, + "session prompt carries native media content blocks" + ); + } + (content, deliveries) + } + + /// B5 mid-turn delivery: hand a message to the RUNNING turn instead of + /// opening a new one. Dispatches `Command::Steer` (codex `turn/steer`; + /// claude direct stdin user-frame write) with `data.msg_id` as the + /// correlation id both CLIs round-trip (claude user-frame `uuid` echoed via + /// `command_lifecycle`; codex `clientUserMessageId`). + /// + /// Deliberately NOT `send_message`: no `AgentStreamEvent::Start` emit and + /// no status flip — the message folds into the ACTIVE turn, whose relay and + /// status are already live (a stray Start would open a phantom turn + /// boundary mid-stream). + pub async fn deliver_midturn(&self, data: SendMessageData) -> Result<(), AgentSendError> { + self.runtime.touch(); + let (content, _) = self.build_prompt_blocks(&data).await; + self.dump_session_cli_final_input(&content, Some(data.msg_id.as_str())); + let cmd = Command::Steer { + content, + client_msg_id: Some(data.msg_id), + }; + self.backend + .dispatch(cmd) + .await + .map(|_| ()) + // Preserve the backend's message text: the conversation layer + // classifies codex's "no active turn to steer" rejection to fall + // back to the normal new-turn path. + .map_err(|e| AgentSendError::from_agent_error(AgentError::bad_gateway(e.to_string()))) + } + /// DEV (`--dump-prompts`): dump this turn's final input blocks as a /// `session-cli-final-input` JSON, symmetric with the ACP path's /// `acp-final-input`. Best-effort: a failure only warns and never affects @@ -1179,12 +1293,16 @@ impl SessionAgentTask { self.backend .dispatch(Command::Steer { content: vec![ContentBlock::Text(content)], - client_user_message_id: Some(input_id), + client_msg_id: Some(input_id), }) .await .map(|_| ()) .map_err(|error| match &error { - BackendError::Transport(code) if code == "too_late" => AgentError::bad_request("too_late"), + BackendError::Transport(code) + if code == "too_late" || code.contains("no active turn to steer") => + { + AgentError::bad_request("too_late") + } _ => AgentError::bad_gateway(error.to_string()), }) } @@ -1220,6 +1338,10 @@ impl IAgentTask for SessionAgentTask { self.runtime.tx.subscribe() } + fn supports_midturn_delivery(&self) -> bool { + self.backend.capabilities().supports_midturn_delivery + } + fn prompt_media_caps(&self) -> PromptMediaCaps { let blocks = self.backend.capabilities().prompt_blocks; PromptMediaCaps { @@ -1230,93 +1352,7 @@ impl IAgentTask for SessionAgentTask { async fn send_message(&self, data: SendMessageData) -> Result<(), AgentSendError> { self.runtime.touch(); - // Partition attachments by the backend's declared prompt blocks: - // capable media becomes native Image/Audio blocks PAIRED with a - // ResourceLink to the same file; everything else keeps the - // pre-multimodal form (path in the [[AION_FILES]] text + resource - // link). The pair is how the disk path reaches an agent: a native - // image/audio block has no uri field, and partition already stripped - // that path out of the text. Adapters render a link as - // `[Attached file: ]` (see `adapter/claude.rs` / - // `backend/codex_conn.rs`), which is how every non-media attachment - // already travels and how images travelled before the multimodal - // split. Without the pair, an agent that can both see and read files - // gets pixels it cannot open. The pair is gated on the backend - // advertising `resource`: an un-advertised block is rejected at - // dispatch and would kill the whole Send (`BlockSet::allows`). - // - // A read failure degrades that attachment back to a resource link - // alone — the path also remains in the original text because - // partition already ran, which the adapters tolerate (they resolve - // links independently of the text). - let mut partition = - crate::media::partition_media(&data.content, &data.files, &data.attachments, self.prompt_media_caps()) - .await; - let link_media_paths = self.backend.capabilities().prompt_blocks.resource; - let mut content: Vec = Vec::new(); - if !partition.content.is_empty() { - content.push(ContentBlock::Text(partition.content)); - } - for path in partition.path_files { - // File paths ride as resource links; the claude/codex adapters resolve - // them (Read tool / base64) at dispatch time. - content.push(ContentBlock::ResourceLink { - uri: path, - mime_type: None, - }); - } - let mut media_links = 0usize; - for attachment in &partition.media { - match crate::media::read_media_bytes(attachment).await { - Some(bytes) => { - content.push(match attachment.kind { - crate::media::MediaKind::Image => ContentBlock::Image { - data: bytes, - media_type: attachment.mime.clone(), - }, - crate::media::MediaKind::Audio => ContentBlock::Audio { - data: bytes, - media_type: attachment.mime.clone(), - }, - }); - // Pair the bytes with the path (see the fn doc): the block - // itself has no uri field and the text no longer lists it. - if link_media_paths { - content.push(ContentBlock::ResourceLink { - uri: attachment.path.clone(), - mime_type: Some(attachment.mime.clone()), - }); - media_links += 1; - } - } - None => { - if let Some(delivery) = partition.deliveries.get_mut(attachment.attachment_index) { - delivery.delivery = aionui_api_types::PromptAttachmentDelivery::PathFallback; - delivery.reason = Some("native_read_failed".to_owned()); - } - content.push(ContentBlock::ResourceLink { - uri: attachment.path.clone(), - mime_type: Some(attachment.mime.clone()), - }); - } - } - } - let deliveries = std::mem::take(&mut partition.deliveries); - if !partition.media.is_empty() { - let (images, audios) = content.iter().fold((0usize, 0usize), |(i, a), b| match b { - ContentBlock::Image { .. } => (i + 1, a), - ContentBlock::Audio { .. } => (i, a + 1), - _ => (i, a), - }); - tracing::info!( - conversation_id = %self.conversation_id, - msg_id = %data.msg_id, - images, - audios, - media_links, - "session prompt carries native media content blocks" - ); - } + let (content, deliveries) = self.build_prompt_blocks(&data).await; // DEV (`--dump-prompts`): borrow the final blocks BEFORE they move into // Command::Send. No-op / best-effort — never affects the dispatch. self.dump_session_cli_final_input(&content, Some(data.msg_id.as_str())); @@ -4616,6 +4652,17 @@ fn translate_event(event: SessionEvent, conversation_id: &str, terminal_result_s SessionEvent::SessionTitle { title } => { vec![AgentStreamEvent::AcpSessionInfo(serde_json::json!({ "title": title }))] } + // Mid-turn interjection (Task 3): lower the claude command_lifecycle echo + // to an internal-only stream frame so the conversation layer's + // BackgroundStreamWatcher can tell an agent-started turn that SERVES a + // user message (claim it) from a pure background continuation (leave it + // unclaimed). Consumed inside the relay/watcher, never forwarded to the + // WebSocket. + SessionEvent::MessageLifecycle { client_msg_id, phase } => { + vec![AgentStreamEvent::MessageLifecycle( + crate::protocol::events::MessageLifecycleData { client_msg_id, phase }, + )] + } // Events with no origin-side counterpart (or purely internal) are dropped. // Cancel folds into the Finish emitted by the resulting terminal; Heartbeat, // PromptAccepted, Snapshot, Lagged, item lifecycle, subagent/rewound/etc. are @@ -10294,6 +10341,52 @@ mod force_kill_tests { } } + /// Task-1 brief: `SessionAgentTask::supports_midturn_delivery` must read + /// straight through to the backend's declared capability bit — no + /// reinterpretation, no default override. + struct MidturnCapableBackend { + supports_midturn_delivery: bool, + } + + #[async_trait::async_trait] + impl SessionBackend for MidturnCapableBackend { + async fn dispatch(&self, _c: Command) -> Result { + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: 1, + }) + } + fn events(&self) -> BoxStream<'static, SessionEnvelope> { + use futures_util::StreamExt as _; + futures_util::stream::empty().boxed() + } + fn capabilities(&self) -> Capabilities { + Capabilities { + supports_midturn_delivery: self.supports_midturn_delivery, + ..Capabilities::default() + } + } + } + + #[tokio::test] + async fn supports_midturn_delivery_reads_through_backend_capabilities() { + for expected in [true, false] { + let backend: Arc = Arc::new(MidturnCapableBackend { + supports_midturn_delivery: expected, + }); + let task = SessionAgentTask::new( + AgentType::Acp, + "conv-1".into(), + "user-1".into(), + "/w".into(), + backend, + None, + ); + assert_eq!(IAgentTask::supports_midturn_delivery(task.as_ref()), expected); + } + } + fn build_task_with_counter() -> (Arc, Arc) { let counter = Arc::new(AtomicUsize::new(0)); let backend: Arc = Arc::new(TerminateCountingBackend { diff --git a/crates/aionui-ai-agent/tests/acp_agent_integration.rs b/crates/aionui-ai-agent/tests/acp_agent_integration.rs index 1bdaf3132..8e0825503 100644 --- a/crates/aionui-ai-agent/tests/acp_agent_integration.rs +++ b/crates/aionui-ai-agent/tests/acp_agent_integration.rs @@ -204,6 +204,7 @@ fn event_type_name(event: &AgentStreamEvent) -> &'static str { AgentStreamEvent::BackendTurnBound(_) => "BackendTurnBound", AgentStreamEvent::WorkflowProgress(_) => "WorkflowProgress", AgentStreamEvent::AcpDialectSignal(_) => "AcpDialectSignal", + AgentStreamEvent::MessageLifecycle(_) => "MessageLifecycle", } } diff --git a/crates/aionui-api-types/src/conversation.rs b/crates/aionui-api-types/src/conversation.rs index 42b817471..2ec6b42dd 100644 --- a/crates/aionui-api-types/src/conversation.rs +++ b/crates/aionui-api-types/src/conversation.rs @@ -174,6 +174,12 @@ pub struct SendMessageRequest { pub struct SendMessageResponse { pub msg_id: String, pub turn_id: String, + /// B5 mid-turn interjection: `true` when the message was delivered INTO the + /// already-running turn (`turn_id` is then the ACTIVE turn's id, no new + /// turn was opened, and the HTTP status is 200 instead of the normal 202). + /// Absent/false for an ordinary send that opened a new turn. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub delivered_midturn: bool, pub runtime: ConversationRuntimeSummary, #[serde(default, skip_serializing_if = "Option::is_none")] pub input_id: Option, @@ -335,6 +341,12 @@ pub struct ConversationRuntimeSummary { pub is_processing: bool, pub pending_confirmations: usize, pub turn_id: Option, + /// Whether a message sent right now reaches the agent without waiting for the + /// current turn to end. The ONLY capability bit the frontend may gate mid-turn + /// UI on — see `Capabilities::supports_midturn_delivery` for why + /// `accepts_proactive_input` must never be exposed. + #[serde(default)] + pub supports_midturn_delivery: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -523,6 +535,21 @@ pub struct ConversationNameUpdatedPayload { pub name: String, } +/// Payload of the `message.statusChanged` websocket event (B5 mid-turn +/// interjection): a persisted message's `status` field changed outside the +/// normal stream flow. Today it carries the mid-turn user-message receipt +/// transition — `"pending"` (delivered to the CLI, not yet consumed) → +/// `"finish"` (the agent took it: claude `command_lifecycle` echo / codex +/// steer ack). The frontend flips the 待接收/已接收 badge on it, keyed by +/// `msg_id`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MessageStatusChangedPayload { + pub user_id: String, + pub conversation_id: String, + pub msg_id: String, + pub status: String, +} + /// A single item from cross-conversation message search. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MessageSearchItem { diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 51082ad46..abe371b2a 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -108,9 +108,9 @@ pub use conversation::{ ConversationMcpStatusKind, ConversationNameUpdatedPayload, ConversationResponse, ConversationRuntimeStateKind, ConversationRuntimeSummary, CreateConversationRequest, EnsureConversationRuntimeResponse, ForkCapabilityView, ForkConversationRequest, InputChangedEvent, ListConversationInputsQuery, ListConversationsQuery, ListMessagesQuery, - MessageListResponse, MessageResponse, MessageSearchItem, MessageSearchResponse, PromptCapabilityView, - SearchMessagesQuery, SendMessageRequest, SendMessageResponse, SessionRef, SubmitConversationInputRequest, - ToolEnforcementLevel, UpdateConversationArtifactRequest, UpdateConversationRequest, + MessageListResponse, MessageResponse, MessageSearchItem, MessageSearchResponse, MessageStatusChangedPayload, + PromptCapabilityView, SearchMessagesQuery, SendMessageRequest, SendMessageResponse, SessionRef, + SubmitConversationInputRequest, ToolEnforcementLevel, UpdateConversationArtifactRequest, UpdateConversationRequest, }; pub use cron::{ CreateConversationCronRequest, CreateConversationCronResponse, CreateCronJobRequest, CronAgentConfigReadDto, diff --git a/crates/aionui-app/tests/midturn_e2e.rs b/crates/aionui-app/tests/midturn_e2e.rs new file mode 100644 index 000000000..88d531f9e --- /dev/null +++ b/crates/aionui-app/tests/midturn_e2e.rs @@ -0,0 +1,248 @@ +//! E2E tests for mid-turn message delivery (B5, mid-turn interjection Task 4). +//! +//! While a turn is ACTIVE: +//! - a backend with `supports_midturn_delivery=true` accepts a second message +//! with HTTP 200 and folds it into the CURRENT turn (response `turn_id` == +//! the active turn's id, no new turn opened); +//! - a backend without it keeps today's 409 CONFLICT. + +mod common; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use axum::http::StatusCode; +use serde_json::json; +use tokio::sync::Mutex; +use tower::ServiceExt; + +use aionui_ai_agent::{AgentInstance, IAgentTask, IMockAgent, WorkerTaskManagerImpl}; +use aionui_app::{AppConfig, AppServices}; +use common::{body_json, get_with_token, json_with_token, setup_and_login}; + +/// A mock agent whose event channel stays OPEN (the sender is held), so the +/// spawned turn relay keeps waiting for frames and the turn claim stays held — +/// modelling a long-running turn. `deliver_midturn` records the delivered +/// message instead of opening a turn. +struct MidturnMockAgent { + conversation_id: String, + supports_midturn: bool, + tx: tokio::sync::broadcast::Sender, + delivered: Arc>>, + send_called: Arc, +} + +#[async_trait::async_trait] +impl IAgentTask for MidturnMockAgent { + fn agent_type(&self) -> aionui_common::AgentType { + aionui_common::AgentType::Acp + } + fn conversation_id(&self) -> &str { + &self.conversation_id + } + fn workspace(&self) -> &str { + "/tmp/test" + } + fn status(&self) -> Option { + None + } + fn last_activity_at(&self) -> aionui_common::TimestampMs { + aionui_common::now_ms() + } + fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.tx.subscribe() + } + fn supports_midturn_delivery(&self) -> bool { + self.supports_midturn + } + async fn send_message( + &self, + _data: aionui_ai_agent::types::SendMessageData, + ) -> Result<(), aionui_ai_agent::AgentSendError> { + self.send_called.store(true, Ordering::SeqCst); + Ok(()) + } + async fn cancel(&self) -> Result<(), aionui_ai_agent::AgentError> { + Ok(()) + } + fn kill(&self, _reason: Option) -> Result<(), aionui_ai_agent::AgentError> { + Ok(()) + } +} + +#[async_trait::async_trait] +impl IMockAgent for MidturnMockAgent { + async fn deliver_midturn( + &self, + data: aionui_ai_agent::types::SendMessageData, + ) -> Result<(), aionui_ai_agent::AgentSendError> { + self.delivered.lock().await.push(data.content); + Ok(()) + } +} + +struct MidturnRig { + app: axum::Router, + services: AppServices, + delivered: Arc>>, +} + +async fn build_midturn_app(supports_midturn: bool) -> MidturnRig { + let db = aionui_db::init_database_memory().await.unwrap(); + let delivered: Arc>> = Arc::new(Mutex::new(Vec::new())); + let delivered_for_factory = Arc::clone(&delivered); + let factory: std::sync::Arc< + dyn Fn( + aionui_ai_agent::types::BuildTaskOptions, + ) + -> futures_util::future::BoxFuture<'static, Result> + + Send + + Sync, + > = std::sync::Arc::new(move |opts| { + let delivered = Arc::clone(&delivered_for_factory); + Box::pin(async move { + let (tx, _keep_open) = tokio::sync::broadcast::channel(16); + Ok(AgentInstance::Mock(std::sync::Arc::new(MidturnMockAgent { + conversation_id: opts.conversation_id().to_owned(), + supports_midturn, + tx, + delivered, + send_called: Arc::new(AtomicBool::new(false)), + }))) + }) + }); + let wtm: std::sync::Arc = + std::sync::Arc::new(WorkerTaskManagerImpl::new(factory)); + let services = AppServices::from_config(db, &AppConfig::default()) + .await + .unwrap() + .with_worker_task_manager(wtm); + let app = aionui_app::create_router(&services).await.expect("build router"); + MidturnRig { + app, + services, + delivered, + } +} + +async fn create_conversation(app: &mut axum::Router, token: &str, csrf: &str) -> String { + let req = json_with_token( + "POST", + "/api/conversations", + json!({ "type": "acp", "name": "midturn", "extra": { "backend": "gemini" } }), + token, + csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + let json = body_json(resp).await; + json["data"]["id"].as_str().unwrap().to_owned() +} + +async fn send_message( + app: &mut axum::Router, + conv_id: &str, + content: &str, + token: &str, + csrf: &str, +) -> (StatusCode, serde_json::Value) { + let req = json_with_token( + "POST", + &format!("/api/conversations/{conv_id}/messages"), + json!({ "content": content }), + token, + csrf, + ); + let resp = app.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let body = body_json(resp).await; + (status, body) +} + +/// Brief Step 1: during an active turn, a second message to a +/// `supports_midturn_delivery` backend gets HTTP 200 (not 409) and the response +/// `turn_id` equals the CURRENT active turn's id. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn midturn_send_returns_200_with_the_active_turn_id() { + let MidturnRig { + mut app, + services, + delivered, + } = build_midturn_app(true).await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "pw").await; + let conv_id = create_conversation(&mut app, &token, &csrf).await; + + // Turn 1 opens and stays active: the mock's event channel never finishes. + let (status1, body1) = send_message(&mut app, &conv_id, "first message", &token, &csrf).await; + assert_eq!(status1, StatusCode::ACCEPTED, "first send scheduled a turn: {body1}"); + let turn1 = body1["data"]["turn_id"].as_str().unwrap().to_owned(); + + // Mid-turn second message: 200, folded into the SAME turn. + let (status2, body2) = send_message(&mut app, &conv_id, "midturn interjection", &token, &csrf).await; + assert_eq!( + status2, + StatusCode::OK, + "mid-turn send to a supporting backend must be 200, got {status2}: {body2}" + ); + let turn2 = body2["data"]["turn_id"].as_str().unwrap(); + assert_eq!( + turn2, turn1, + "mid-turn delivery folds into the CURRENT turn — no new turn id" + ); + assert_eq!( + body2["data"]["delivered_midturn"], true, + "the response flags mid-turn delivery for the frontend" + ); + + // Delivered through the mid-turn path (not a second normal turn). + let delivered = delivered.lock().await.clone(); + assert_eq!( + delivered, + vec!["midturn interjection".to_owned()], + "the message must go through the mid-turn delivery path" + ); + + // The user message is persisted with the pending-receipt status and the + // list view shows it. + let resp = app + .clone() + .oneshot(get_with_token( + &format!("/api/conversations/{conv_id}/messages"), + &token, + )) + .await + .unwrap(); + let body = body_json(resp).await; + let items = body["data"]["items"].as_array().unwrap(); + let row = items + .iter() + .find(|m| m["content"]["content"] == "midturn interjection") + .expect("mid-turn user message persisted"); + assert_eq!( + row["status"], "pending", + "a mid-turn message starts in the pending-receipt state" + ); +} + +/// Backends WITHOUT mid-turn delivery keep today's behavior: a second send +/// during an active turn is 409 CONFLICT with the running-conversation error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn midturn_send_to_non_supporting_backend_stays_409() { + let MidturnRig { mut app, services, .. } = build_midturn_app(false).await; + let (token, csrf) = setup_and_login(&mut app, &services, "admin", "pw").await; + let conv_id = create_conversation(&mut app, &token, &csrf).await; + + let (status1, _) = send_message(&mut app, &conv_id, "first message", &token, &csrf).await; + assert_eq!(status1, StatusCode::ACCEPTED); + + let (status2, body2) = send_message(&mut app, &conv_id, "second message", &token, &csrf).await; + assert_eq!( + status2, + StatusCode::CONFLICT, + "non-supporting backend keeps the 409 gate, got {status2}: {body2}" + ); + let err = body2["error"].as_str().unwrap_or_default(); + assert!( + err.contains("already running"), + "the running-conversation error is preserved, got: {err}" + ); +} diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index d0cf43bc4..861ff038f 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -251,7 +251,10 @@ impl ChannelMessageService { // web UI; an IM transcript has no card to update, and streaming one // message per refresh would spam the channel. | AgentStreamEvent::WorkflowProgress(_) - | AgentStreamEvent::AcpDialectSignal(_) => None, + | AgentStreamEvent::AcpDialectSignal(_) + // Internal-only correlation frame for mid-turn interjection; never + // user-facing (consumed by the conversation layer's watcher). + | AgentStreamEvent::MessageLifecycle(_) => None, } } diff --git a/crates/aionui-conversation/src/background_stream.rs b/crates/aionui-conversation/src/background_stream.rs index 9d5059a14..f1cc2ec1e 100644 --- a/crates/aionui-conversation/src/background_stream.rs +++ b/crates/aionui-conversation/src/background_stream.rs @@ -25,6 +25,7 @@ use std::sync::Arc; +use aionui_ai_agent::protocol::events::MessageLifecyclePhase; use aionui_ai_agent::protocol::events::{AgentStreamEvent, WorkflowProgressData}; use aionui_common::{ErrorChain, normalize_keys_to_snake_case}; use aionui_db::{IConversationRepository, IUsageEventRepository}; @@ -44,6 +45,21 @@ use crate::stream_relay::StreamRelay; /// with what it has, so the watcher can never be wedged forever. const ORPHAN_TURN_IDLE_MS: u64 = 180_000; +/// How long a `MessageLifecycle{Started}` echo stays armed as "the next +/// agent-started turn serves a user message" before it is presumed lost. +/// +/// The design spec's measured data (docs/superpowers/specs/ +/// 2026-08-12-midturn-interjection-design.md §6.1, real claude 2.1.226 run) +/// shows `started` firing at the boundary where the message is consumed into a +/// turn, with that turn's content following within seconds (12.24s started → +/// 16.74s assistant text); the pure-text follow-up turn opens immediately after +/// the current turn's `result` (§五 table). 30s is an order of magnitude of +/// headroom over that, while staying far below the minutes a detached exec can +/// run — so a Started whose terminal `completed`/`cancelled` echo was lost +/// (broadcast lag, CLI crash between phases) cannot claim an unrelated +/// background continuation and lock the input box (#758's design intent). +pub(crate) const PENDING_STARTED_TTL: std::time::Duration = std::time::Duration::from_secs(30); + pub(crate) struct BackgroundStreamWatcher { pub conversation_id: String, pub user_id: String, @@ -56,6 +72,9 @@ pub(crate) struct BackgroundStreamWatcher { /// delivery paths (orphan turns / card refreshes are Session semantics). pub title_only: bool, pub usage_event_repo: Option>, + /// Expiry for a pending `MessageLifecycle{Started}` (see + /// [`PENDING_STARTED_TTL`]); injectable so tests can use a short bound. + pub pending_started_ttl: std::time::Duration, } impl BackgroundStreamWatcher { @@ -88,6 +107,13 @@ impl BackgroundStreamWatcher { conversation_id = %self.conversation_id, "background stream watcher started" ); + // Mid-turn interjection (Task 3): the client_msg_id of the user message + // the NEXT agent-started turn serves, taken from the most recent + // `MessageLifecycle{Started}` echo and consumed by exactly one orphan + // turn (or cleared by the message's Completed/Cancelled echo). The + // arming instant bounds its lifetime: a Started whose terminal echo was + // lost must not claim an unrelated later turn (see PENDING_STARTED_TTL). + let mut pending_user_msg: Option<(String, std::time::Instant)> = None; loop { let ev = match rx.recv().await { Ok(ev) => ev, @@ -147,6 +173,61 @@ impl BackgroundStreamWatcher { } continue; } + // Lifecycle echoes are pure bookkeeping, handled BEFORE the + // active-turn gate: `Started` can race the previous turn claim's + // release by a frame, and missing it would leave claude's + // follow-up turn unclaimed. Only Session instances emit these. + if let AgentStreamEvent::MessageLifecycle(data) = &ev { + // B5 receipt badge (Task 4): any consumption/terminal echo means + // the agent TOOK the message — flip its persisted status from + // "pending" (待接收) to "finish" (已接收) and broadcast + // message.statusChanged. Guarded to rows currently "pending", so + // an echo can never touch an ordinary message. Cancelled also + // counts: claude only cancels an echo it had already started + // (still-queued messages are unaffected by an interrupt and + // self-start later — spec §6甲.7). + if !matches!(data.phase, MessageLifecyclePhase::Queued) { + crate::service::apply_message_receipt( + &self.repo, + &self.broadcaster, + &self.user_id, + &self.conversation_id, + &data.client_msg_id, + crate::service::MIDTURN_STATUS_RECEIVED, + true, + ) + .await; + } + match data.phase { + MessageLifecyclePhase::Started => { + if let Some((prev, _)) = pending_user_msg.as_ref() + && prev != &data.client_msg_id + { + // A Started should be terminated by its own + // Completed/Cancelled before the next one arrives; + // an overwrite means that echo was lost or reordered. + warn!( + conversation_id = %self.conversation_id, + prev_client_msg_id = %prev, + client_msg_id = %data.client_msg_id, + "background stream: pending Started overwritten without a terminal echo" + ); + } + pending_user_msg = Some((data.client_msg_id.clone(), std::time::Instant::now())); + } + MessageLifecyclePhase::Completed | MessageLifecyclePhase::Cancelled => { + if pending_user_msg + .as_ref() + .is_some_and(|(id, _)| id == &data.client_msg_id) + { + pending_user_msg = None; + } + } + // Not yet consumed into a turn — nothing to correlate. + MessageLifecyclePhase::Queued => {} + } + continue; + } // Title-only watchers (ACP manager instances) stop here: orphan // turns and card refreshes are Session-instance semantics. if self.title_only { @@ -167,7 +248,24 @@ impl BackgroundStreamWatcher { self.forward_out_of_turn_frame(&ev) } ev_ref if Self::is_orphan_turn_content(ev_ref) => { - self.run_orphan_turn(ev.clone(), &mut rx).await; + // A serving turn's first frame follows its Started within + // seconds (spec §6.1); anything armed longer than the TTL + // is a leftover from a lost terminal echo and must not + // claim this (unrelated) turn. + let serving_client_msg_id = pending_user_msg.take().and_then(|(id, armed_at)| { + if armed_at.elapsed() <= self.pending_started_ttl { + Some(id) + } else { + warn!( + conversation_id = %self.conversation_id, + client_msg_id = %id, + armed_for_ms = armed_at.elapsed().as_millis() as u64, + "background stream: stale pending Started discarded (terminal echo lost)" + ); + None + } + }); + self.run_orphan_turn(ev.clone(), &mut rx, serving_client_msg_id).await; } // Start/Finish strays, usage (the pump broadcasts usage itself), // internal signals — nothing to deliver. @@ -287,13 +385,31 @@ impl BackgroundStreamWatcher { /// Run a CLI-initiated turn through a REGULAR relay so it gets everything a /// user turn gets: WS forwarding, text segments, persistence, and the /// `turn.completed` bookkeeping (the relay's default `complete_turn`). - async fn run_orphan_turn(&self, first: AgentStreamEvent, rx: &mut broadcast::Receiver) { + async fn run_orphan_turn( + &self, + first: AgentStreamEvent, + rx: &mut broadcast::Receiver, + serving_client_msg_id: Option, + ) { let msg_id = ConversationService::mint_msg_id(); let turn_id = ConversationService::mint_turn_id(); + // Claim ONLY when this agent-started turn serves a user message (claude + // opens a follow-up turn for a mid-turn message it could not fold into the + // running one — design spec §6甲.2). A pure background continuation (a + // detached exec still streaming) must NOT be claimed: #758's whole point is + // that the user keeps working while it runs. + // + // Bind by name — `let _ = …` would drop the claim immediately. + let _agent_claim = serving_client_msg_id + .is_some() + .then(|| self.runtime_state.claim_for_agent_turn(&self.conversation_id, &turn_id)) + .flatten(); info!( conversation_id = %self.conversation_id, turn_id = %turn_id, first_frame = frame_kind(&first), + serving_client_msg_id = serving_client_msg_id.as_deref(), + claimed = _agent_claim.is_some(), "background stream: CLI-initiated turn started" ); let relay = StreamRelay::new( @@ -320,10 +436,12 @@ impl BackgroundStreamWatcher { Ok(Ok(ev)) => { // A user turn starting mid-orphan-turn takes the stream // over; stop feeding so two relays never double-process. + // Our OWN claim (an agent turn serving a user message) + // must not trip this — only a FOREIGN turn id counts. if self .runtime_state .active_turn_id_for(&self.conversation_id) - .is_some() + .is_some_and(|active| active != turn_id) { warn!( conversation_id = %self.conversation_id, @@ -394,7 +512,7 @@ mod tests { use aionui_ai_agent::protocol::events::tool_call::{ ToolCallEventData, ToolCallStatus, ToolGroupEntry, ToolGroupStatus, }; - use aionui_ai_agent::protocol::events::{FinishEventData, TextEventData}; + use aionui_ai_agent::protocol::events::{FinishEventData, MessageLifecycleData, TextEventData}; use aionui_common::now_ms; use aionui_db::models::ConversationRow; use aionui_db::{ @@ -412,10 +530,14 @@ mod tests { } async fn rig() -> Rig { - rig_with(false).await + rig_with_opts(false, PENDING_STARTED_TTL).await } async fn rig_with(title_only: bool) -> Rig { + rig_with_opts(title_only, PENDING_STARTED_TTL).await + } + + async fn rig_with_opts(title_only: bool, pending_started_ttl: std::time::Duration) -> Rig { let db = init_database_memory().await.unwrap(); let user_repo = SqliteUserRepository::new(db.pool().clone()); let user = user_repo.create_user("user-1", "hash").await.unwrap(); @@ -452,6 +574,7 @@ mod tests { runtime_state: Arc::clone(&runtime_state), title_only, usage_event_repo: None, + pending_started_ttl, }; let handle = tokio::spawn(watcher.run(tx.subscribe())); Rig { @@ -690,6 +813,240 @@ mod tests { ); } + /// Mid-turn interjection (Task 3): claude opens a follow-up turn for a + /// queued user message and announces it with `MessageLifecycle{Started}`. + /// That turn SERVES a user message, so the watcher must claim it — the UI + /// keeps showing `running` instead of flashing idle while the answer + /// streams — and release the claim when the turn finishes. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn agent_turn_serving_a_user_message_is_claimed() { + let rig = rig().await; + rig.tx + .send(AgentStreamEvent::MessageLifecycle(MessageLifecycleData { + client_msg_id: "cmsg-1".into(), + phase: MessageLifecyclePhase::Started, + })) + .unwrap(); + rig.tx + .send(AgentStreamEvent::Text(TextEventData { + content: "answer to the interjected message".into(), + })) + .unwrap(); + + // The orphan turn must hold the conversation claim while it streams. + let turn = eventually(async || rig.runtime_state.active_turn_id_for("conv-1")) + .await + .expect("agent-started turn serving a user message must be claimed"); + assert!(!turn.is_empty()); + + // …and release it once the turn ends. + rig.tx + .send(AgentStreamEvent::Finish(FinishEventData::default())) + .unwrap(); + eventually(async || (!rig.runtime_state.is_claimed("conv-1")).then_some(())) + .await + .expect("the claim must be released after the turn finished"); + } + + /// B5 receipt badge (Task 4): a `MessageLifecycle{Started}` echo for a + /// mid-turn user message flips its persisted status "pending" → "finish" + /// and broadcasts `message.statusChanged`, so the 待接收 badge resolves. + /// A second echo (Completed) or an echo for an unknown/ordinary message + /// must not produce further updates. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn lifecycle_started_flips_pending_user_message_and_broadcasts() { + let rig = rig().await; + // Seed the mid-turn user row exactly as deliver_midturn_message writes it. + rig.repo + .insert_message( + &rig.user_id, + &aionui_db::models::MessageRow { + id: "um-1".into(), + conversation_id: "conv-1".into(), + msg_id: Some("um-1".into()), + r#type: "text".into(), + content: serde_json::json!({"content": "mid-turn interjection"}).to_string(), + position: Some("right".into()), + status: Some("pending".into()), + hidden: false, + created_at: now_ms(), + backend_turn_id: None, + }, + ) + .await + .unwrap(); + // An ordinary (finish) user row an echo must never touch. + rig.repo + .insert_message( + &rig.user_id, + &aionui_db::models::MessageRow { + id: "um-2".into(), + conversation_id: "conv-1".into(), + msg_id: Some("um-2".into()), + r#type: "text".into(), + content: serde_json::json!({"content": "ordinary"}).to_string(), + position: Some("right".into()), + status: Some("finish".into()), + hidden: false, + created_at: now_ms(), + backend_turn_id: None, + }, + ) + .await + .unwrap(); + let mut ws = rig.bus.subscribe(); + rig.tx + .send(AgentStreamEvent::MessageLifecycle(MessageLifecycleData { + client_msg_id: "um-1".into(), + phase: MessageLifecyclePhase::Started, + })) + .unwrap(); + + let row = eventually(async || { + rows_of_type(&rig, "text") + .await + .into_iter() + .find(|m| m.id == "um-1" && m.status.as_deref() == Some("finish")) + }) + .await + .expect("Started echo must flip the pending user row to finish"); + assert_eq!(row.status.as_deref(), Some("finish")); + + let mut saw_status_changed = false; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while std::time::Instant::now() < deadline && !saw_status_changed { + match tokio::time::timeout(std::time::Duration::from_millis(200), ws.recv()).await { + Ok(Ok(evt)) => { + if evt.name == "message.statusChanged" { + assert_eq!(evt.data["conversation_id"], "conv-1"); + assert_eq!(evt.data["msg_id"], "um-1"); + assert_eq!(evt.data["status"], "finish"); + saw_status_changed = true; + } + } + _ => break, + } + } + assert!(saw_status_changed, "message.statusChanged must be broadcast"); + + // An echo for an already-finish row is a no-op (guarded flip). + rig.tx + .send(AgentStreamEvent::MessageLifecycle(MessageLifecycleData { + client_msg_id: "um-2".into(), + phase: MessageLifecyclePhase::Completed, + })) + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let mut extra = 0; + while let Ok(evt) = ws.try_recv() { + if evt.name == "message.statusChanged" { + extra += 1; + } + } + assert_eq!(extra, 0, "an echo for an ordinary message must not broadcast"); + } + + /// #758 design intent: a pure background continuation (a detached exec + /// still streaming, with NO client_msg_id association) must stay UNCLAIMED + /// so the user keeps working — claiming it would lock the input box. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn pure_background_continuation_is_not_claimed() { + let rig = rig().await; + rig.tx + .send(AgentStreamEvent::Text(TextEventData { + content: "BG_DONE — the sleep finished.".into(), + })) + .unwrap(); + // Assert WHILE the turn is still open — a wrongly taken claim would + // already be released again after Finish, hiding the bug. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + !rig.runtime_state.is_claimed("conv-1"), + "a pure background continuation must not be claimed" + ); + rig.tx + .send(AgentStreamEvent::Finish(FinishEventData::default())) + .unwrap(); + // And the turn is still delivered (persisted) as before. + eventually(async || rows_of_type(&rig, "text").await.into_iter().next()) + .await + .expect("background turn content must still be delivered"); + } + + /// Review fix (Task 3): a `Started` whose terminal echo was LOST (broadcast + /// lag dropping frames, a CLI crash between phases) must not stay armed + /// forever — a LATER background continuation is not the turn that Started + /// announced (a serving turn's first frame follows Started within seconds) + /// and must NOT be claimed, or the input box locks for the whole detached + /// exec (#758's regression). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn stale_started_without_terminal_echo_does_not_claim_a_later_turn() { + let rig = rig_with_opts(false, std::time::Duration::from_millis(50)).await; + rig.tx + .send(AgentStreamEvent::MessageLifecycle(MessageLifecycleData { + client_msg_id: "cmsg-lost".into(), + phase: MessageLifecyclePhase::Started, + })) + .unwrap(); + // No Completed/Cancelled ever arrives; wait past the pending TTL. + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + rig.tx + .send(AgentStreamEvent::Text(TextEventData { + content: "much later background report".into(), + })) + .unwrap(); + // Assert WHILE the turn is still open (a wrongly taken claim would be + // released again after Finish, hiding the bug). + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + !rig.runtime_state.is_claimed("conv-1"), + "a stale Started must not claim a later background continuation" + ); + rig.tx + .send(AgentStreamEvent::Finish(FinishEventData::default())) + .unwrap(); + eventually(async || rows_of_type(&rig, "text").await.into_iter().next()) + .await + .expect("background turn content must still be delivered"); + } + + /// A `Completed`/`Cancelled` lifecycle echo consumes the pending Started: + /// a LATER agent-started turn with no fresh association is a pure + /// background continuation again and must not inherit a stale claim. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn completed_lifecycle_clears_the_pending_user_message() { + let rig = rig().await; + rig.tx + .send(AgentStreamEvent::MessageLifecycle(MessageLifecycleData { + client_msg_id: "cmsg-1".into(), + phase: MessageLifecyclePhase::Started, + })) + .unwrap(); + rig.tx + .send(AgentStreamEvent::MessageLifecycle(MessageLifecycleData { + client_msg_id: "cmsg-1".into(), + phase: MessageLifecyclePhase::Completed, + })) + .unwrap(); + rig.tx + .send(AgentStreamEvent::Text(TextEventData { + content: "later unrelated background report".into(), + })) + .unwrap(); + // Assert WHILE the turn is still open (see the test above). + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + assert!( + !rig.runtime_state.is_claimed("conv-1"), + "a consumed lifecycle must not leave a stale claim behind" + ); + rig.tx + .send(AgentStreamEvent::Finish(FinishEventData::default())) + .unwrap(); + eventually(async || rows_of_type(&rig, "text").await.into_iter().next()) + .await + .expect("background turn content must still be delivered"); + } + /// While a USER turn is active, its own relay owns every frame — the watcher /// must not double-deliver. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/crates/aionui-conversation/src/routes.rs b/crates/aionui-conversation/src/routes.rs index 94a7b4cb2..9b228b6c5 100644 --- a/crates/aionui-conversation/src/routes.rs +++ b/crates/aionui-conversation/src/routes.rs @@ -324,34 +324,19 @@ async fn send_msg( body: Result, JsonRejection>, ) -> Result<(StatusCode, Json>), ApiError> { let Json(req) = body.map_err(ApiError::from)?; - let receipt = state + let response = state .service - .submit_input( - &user.id, - &id, - SubmitConversationInputRequest { - mode: ConversationInputMode::Followup, - content: req.content, - files: req.files, - inject_skills: req.inject_skills, - hidden: req.hidden, - client_key: format!("legacy_{}", aionui_common::generate_short_id()), - }, - ) + .send_message(&user.id, &id, req, &state.task_manager) .await .map_err(ApiError::from)?; - let response = SendMessageResponse { - msg_id: receipt - .input - .msg_id - .clone() - .unwrap_or_else(|| receipt.input.input_id.clone()), - turn_id: receipt.input.turn_id.clone().unwrap_or_default(), - runtime: receipt.runtime, - input_id: Some(receipt.input.input_id), - input_status: Some(receipt.input.status), + // B5: a mid-turn delivery already handed the message to the RUNNING turn — + // that is a completed delivery (200), not a scheduled one (202). + let status = if response.delivered_midturn { + StatusCode::OK + } else { + StatusCode::ACCEPTED }; - Ok((StatusCode::ACCEPTED, Json(ApiResponse::ok(response)))) + Ok((status, Json(ApiResponse::ok(response)))) } async fn submit_input( diff --git a/crates/aionui-conversation/src/runtime_state.rs b/crates/aionui-conversation/src/runtime_state.rs index 33196e005..49ae8118d 100644 --- a/crates/aionui-conversation/src/runtime_state.rs +++ b/crates/aionui-conversation/src/runtime_state.rs @@ -154,6 +154,17 @@ impl ConversationRuntimeStateService { }) } + /// Claim a turn the AGENT started on its own (a CLI-initiated turn, or the + /// follow-up turn claude opens for a mid-turn message it could not fold into + /// the running one — verified in the design spec §6甲.2). + /// + /// Returns `None` when a turn is already claimed: for an agent-driven turn + /// that is the NORMAL case (the message folded into the running turn), not an + /// error, so it must not surface as 409. + pub fn claim_for_agent_turn(self: &Arc, conversation_id: &str, turn_id: &str) -> Option { + self.try_claim_turn(conversation_id, turn_id).ok() + } + pub fn is_claimed(&self, conversation_id: &str) -> bool { self.state .lock() @@ -537,6 +548,7 @@ impl ConversationRuntimeStateService { task_status: Option, has_task: bool, pending_confirmations: usize, + supports_midturn_delivery: bool, ) -> ConversationRuntimeSummary { let (active_turn_id, cancelling) = self .state @@ -572,6 +584,7 @@ impl ConversationRuntimeStateService { is_processing, pending_confirmations, turn_id: active_turn_id, + supports_midturn_delivery, } } @@ -686,7 +699,7 @@ mod tests { assert_eq!(state.active_turn_id_for("conv-1").as_deref(), Some("turn-a")); - let summary = state.summary_from_parts("conv-1", None, false, 0); + let summary = state.summary_from_parts("conv-1", None, false, 0, false); assert_eq!(summary.turn_id.as_deref(), Some("turn-a")); assert_eq!(summary.state, ConversationRuntimeStateKind::Starting); } @@ -706,6 +719,26 @@ mod tests { assert!(!state.is_claimed("conv-1")); } + #[test] + fn a_midturn_send_does_not_mint_a_phantom_turn_id() { + let svc = Arc::new(ConversationRuntimeStateService::default()); + let _claim = svc.try_claim_turn("conv-1", "t-1").expect("first claim"); + // A second send while t-1 runs must NOT create a second claim, and the + // summary must keep reporting the REAL active turn. + assert!(svc.claim_for_agent_turn("conv-1", "t-2").is_none()); + assert_eq!(svc.active_turn_id_for("conv-1").as_deref(), Some("t-1")); + } + + #[test] + fn an_agent_started_turn_claims_after_the_previous_one_released() { + let svc = Arc::new(ConversationRuntimeStateService::default()); + let claim = svc.try_claim_turn("conv-1", "t-1").expect("first claim"); + drop(claim); + let adopted = svc.claim_for_agent_turn("conv-1", "t-2").expect("adopted"); + assert_eq!(svc.active_turn_id_for("conv-1").as_deref(), Some("t-2")); + drop(adopted); + } + #[test] fn claim_rejects_second_active_turn() { let state = Arc::new(ConversationRuntimeStateService::default()); @@ -902,13 +935,27 @@ mod tests { .try_claim_turn("conv-1", "turn-1") .expect("claim should be created"); - let summary = state.summary_from_parts("conv-1", None, false, 0); + let summary = state.summary_from_parts("conv-1", None, false, 0, false); assert_eq!(summary.state, ConversationRuntimeStateKind::Starting); assert!(summary.is_processing); assert!(!summary.can_send_message); } + /// Task-1 brief: `summary_from_parts` must pass the caller-supplied + /// `supports_midturn_delivery` straight through, unmodified by any other + /// runtime-state derivation. + #[test] + fn summary_from_parts_carries_supports_midturn_delivery_through() { + let state = Arc::new(ConversationRuntimeStateService::default()); + + let summary_true = state.summary_from_parts("conv-1", None, false, 0, true); + assert!(summary_true.supports_midturn_delivery); + + let summary_false = state.summary_from_parts("conv-1", None, false, 0, false); + assert!(!summary_false.supports_midturn_delivery); + } + #[test] fn summary_waiting_confirmation_takes_priority() { let state = Arc::new(ConversationRuntimeStateService::default()); @@ -916,7 +963,7 @@ mod tests { .try_claim_turn("conv-1", "turn-1") .expect("claim should be created"); - let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Running), true, 1); + let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Running), true, 1, false); assert_eq!(summary.state, ConversationRuntimeStateKind::WaitingConfirmation); assert!(summary.is_processing); @@ -931,7 +978,7 @@ mod tests { .expect("claim should be created"); state.mark_cancelling("conv-1"); - let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Running), true, 0); + let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Running), true, 0, false); assert_eq!(summary.state, ConversationRuntimeStateKind::Cancelling); assert_eq!(summary.turn_id.as_deref(), Some("turn-a")); @@ -943,7 +990,7 @@ mod tests { fn summary_uses_running_task_without_claim() { let state = Arc::new(ConversationRuntimeStateService::default()); - let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Running), true, 0); + let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Running), true, 0, false); assert_eq!(summary.state, ConversationRuntimeStateKind::Running); assert!(summary.is_processing); @@ -954,7 +1001,7 @@ mod tests { fn summary_idle_when_no_claim_running_task_or_confirmation() { let state = Arc::new(ConversationRuntimeStateService::default()); - let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Finished), true, 0); + let summary = state.summary_from_parts("conv-1", Some(ConversationStatus::Finished), true, 0, false); assert_eq!(summary.state, ConversationRuntimeStateKind::Idle); assert!(!summary.is_processing); diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 127faddf7..3507853f3 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -756,6 +756,7 @@ impl ConversationService { runtime_state: Arc::clone(&self.runtime_state), title_only, usage_event_repo: self.usage_event_repo(), + pending_started_ttl: crate::background_stream::PENDING_STARTED_TTL, }; let rx = agent.subscribe(); let join = tokio::spawn(watcher.run(rx)); @@ -986,9 +987,49 @@ impl ConversationService { let has_task = agent.is_some(); let task_status = agent.as_ref().and_then(|agent| agent.status()); let pending_confirmations = agent.as_ref().map(|agent| agent.get_confirmations().len()).unwrap_or(0); + // `supports_midturn_delivery` is a STATIC property of the backend TYPE: + // a live agent reads it from its capabilities (authoritative), and + // without one it MUST come from the conversation's backend identity — + // hardcoding false here made a fresh (pre-ensure) or dormant claude + // conversation report false, so the frontend hydrate fetch raced the + // send accept and gated the whole first turn into the queue panel. + let supports_midturn_delivery = match agent.as_ref() { + Some(agent) => agent.supports_midturn_delivery(), + None => self.static_supports_midturn_delivery(conversation_id).await, + }; + + self.runtime_state.summary_from_parts( + conversation_id, + task_status, + has_task, + pending_confirmations, + supports_midturn_delivery, + ) + } - self.runtime_state - .summary_from_parts(conversation_id, task_status, has_task, pending_confirmations) + /// Backend-static `supports_midturn_delivery` for a conversation with no + /// live agent task, resolved from the persisted backend identity + /// (`extra.backend` — the same string create() persists from the assistant + /// snapshot and the session factories dispatch on) through the session + /// layer's static table. Any resolution failure (missing row, unparsable + /// extra, unknown backend) conservatively reports `false`. + /// + /// Perf note: the DB load only fires when no live agent exists, and + /// `runtime_summary_for` is only invoked on single-conversation paths + /// (detail GET, send/turn responses) — `list()` never embeds a runtime + /// summary — so this adds no per-row N+1. + async fn static_supports_midturn_delivery(&self, conversation_id: &str) -> bool { + let Ok(Some(user_id)) = self.conversation_repo.owner_user_id(conversation_id).await else { + return false; + }; + let Ok(Some(row)) = self.conversation_repo.get(&user_id, conversation_id).await else { + return false; + }; + serde_json::from_str::(&row.extra) + .ok() + .as_ref() + .and_then(|extra| extra.get("backend").and_then(serde_json::Value::as_str)) + .is_some_and(aionui_ai_agent::backend_supports_midturn_delivery) } pub async fn active_count_for_user(&self, user_id: &str) -> Result { @@ -1045,6 +1086,7 @@ impl ConversationService { SendMessageResponse { msg_id, turn_id, + delivered_midturn: false, runtime: self.runtime_summary_for(conversation_id).await, input_id: None, input_status: None, @@ -3693,7 +3735,250 @@ impl ConversationService { // ── Message Flow (send / stop / warmup) ───────────────────────────── +/// A mid-turn user message's persisted `status` while the CLI holds it but has +/// not yet consumed it into a turn (待接收). Flipped to +/// [`MIDTURN_STATUS_RECEIVED`] on the agent's receipt signal. +/// +/// `"pending"` because the messages table CHECK constraint only admits +/// ('finish','pending','error','work') (002_legacy_data_normalize.sql:170), +/// and the stale-runtime startup cleanup only touches `position='left'` rows — +/// a pending USER (right) row is never swept. +pub(crate) const MIDTURN_STATUS_QUEUED: &str = "pending"; +/// The terminal user-message status (已接收) — same value ordinary user +/// messages are persisted with, so downstream consumers need no new case. +pub(crate) const MIDTURN_STATUS_RECEIVED: &str = "finish"; + +/// Outcome of a mid-turn delivery attempt (B5). +enum MidturnOutcome { + /// The message rides the ACTIVE turn (or failed terminally and was + /// surfaced as a failure tip — mirroring the normal path's build-failure + /// contract; the tip response has `delivered_midturn=false`, so the route + /// returns the ordinary 202 for it, 200 only for a real delivery). The + /// caller returns this response. + Delivered(SendMessageResponse), + /// codex rejected the steer with "no active turn to steer" (§6甲.1): the + /// turn ended between our read and the write. The caller opens a NEW turn + /// through the normal path, reusing the already-persisted message row + /// (`Some`) or persisting normally (`None` when runtime persistence + /// disallowed the write). + TurnEnded { user_msg_id: Option }, +} + +/// Is this delivery error codex's "the turn already ended" steer rejection? +/// codex returns a bare -32600 for both steer rejections; the message text is +/// the ONLY discriminator (verified 0.144.6, design spec §6甲.1). Locked by +/// test so a codex wording change fails here instead of silently degrading. +/// (The different-turn-active rejection is retried INSIDE the codex backend +/// and never surfaces here unless the retry also failed.) +pub(crate) fn steer_rejection_is_turn_ended(err: &AgentSendError) -> bool { + // `AgentSendError` classifies a BadGateway into a generic user-facing + // `message` and moves the raw backend text into `detail` — check both, or + // the classification silently degrades into a hard error (caught by + // `steer_rejection_classifier_matches_only_the_turn_ended_text`). + let stream = err.stream_error(); + stream.message.contains("no active turn to steer") + || stream + .detail + .as_deref() + .is_some_and(|d| d.contains("no active turn to steer")) +} + +/// Update a persisted message's `status` and broadcast the +/// `message.statusChanged` event (B5 receipt badge). With `only_if_queued`, +/// the flip applies ONLY to a row currently in [`MIDTURN_STATUS_QUEUED`] — +/// the lifecycle-echo consumers use this so an unrelated echo can never touch +/// an ordinary message. +pub(crate) async fn apply_message_receipt( + repo: &Arc, + broadcaster: &Arc, + user_id: &str, + conversation_id: &str, + msg_id: &str, + status: &str, + only_if_queued: bool, +) { + if only_if_queued { + match repo + .get_message_by_msg_id(user_id, conversation_id, msg_id, "text") + .await + { + Ok(Some(row)) if row.status.as_deref() == Some(MIDTURN_STATUS_QUEUED) => {} + Ok(_) => return, + Err(e) => { + warn!(msg_id = %msg_id, error = %ErrorChain(&e), "message receipt lookup failed"); + return; + } + } + } + if let Err(e) = repo + .update_message( + user_id, + conversation_id, + msg_id, + &aionui_db::MessageRowUpdate { + content: None, + status: Some(Some(status.to_owned())), + hidden: None, + }, + ) + .await + { + warn!(msg_id = %msg_id, error = %ErrorChain(&e), "message receipt status update failed"); + return; + } + let payload = aionui_api_types::MessageStatusChangedPayload { + user_id: user_id.to_owned(), + conversation_id: conversation_id.to_owned(), + msg_id: msg_id.to_owned(), + status: status.to_owned(), + }; + match serde_json::to_value(&payload) { + Ok(value) => broadcaster.broadcast(WebSocketMessage::new("message.statusChanged", value)), + Err(e) => warn!(msg_id = %msg_id, error = %ErrorChain(&e), "statusChanged payload serialize failed"), + } +} + impl ConversationService { + /// B5: deliver a message into the RUNNING turn (no claim, no new turn id). + /// + /// Persists the user message with the pending-receipt status + /// ([`MIDTURN_STATUS_QUEUED`]) and broadcasts `message.userCreated` + /// carrying `client_msg_id` — the correlation id (== `msg_id`) that claude + /// echoes via `command_lifecycle` and codex round-trips as + /// `clientUserMessageId` — so the frontend can key the receipt badge + /// without text/time guessing (spec §4.5). The receipt flip to + /// [`MIDTURN_STATUS_RECEIVED`] arrives via `MessageLifecycle` echoes + /// (background watcher) for both backends. + #[allow(clippy::too_many_arguments)] + async fn deliver_midturn_message( + &self, + user_id: &str, + conversation_id: &str, + resolved: &ResolvedChatMessage, + hidden: bool, + agent: AgentInstance, + active_turn_id: String, + inject_skills: Vec, + ) -> Result { + let user_msg_id = Self::mint_msg_id(); + let persisted = self + .runtime_persistence() + .allows(conversation_id, RuntimeWriteKind::UserMessage); + if persisted { + let user_msg = aionui_db::models::MessageRow { + id: user_msg_id.clone(), + conversation_id: conversation_id.to_owned(), + msg_id: Some(user_msg_id.clone()), + r#type: "text".into(), + content: serde_json::json!({ "content": resolved.content }).to_string(), + position: Some("right".into()), + status: Some(MIDTURN_STATUS_QUEUED.into()), + hidden, + created_at: now_ms(), + backend_turn_id: None, + }; + if let Err(e) = self.conversation_repo.insert_message(user_id, &user_msg).await { + warn!(msg_id = %user_msg_id, error = %ErrorChain(&e), "Failed to insert mid-turn user message"); + return Err(e.into()); + } + info!(msg_id = %user_msg_id, "Mid-turn user message persisted"); + self.broadcaster.broadcast(WebSocketMessage::new( + "message.userCreated", + serde_json::json!({ + "user_id": user_id, + "conversation_id": conversation_id, + "msg_id": &user_msg_id, + // Present ⇔ the message was delivered mid-turn; equals the + // correlation id echoed on message.statusChanged. + "client_msg_id": &user_msg_id, + "content": &resolved.content, + "position": "right", + "status": MIDTURN_STATUS_QUEUED, + "hidden": hidden, + "created_at": user_msg.created_at, + }), + )); + } + let data = aionui_ai_agent::types::SendMessageData { + content: resolved.content.clone(), + msg_id: user_msg_id.clone(), + turn_id: Some(active_turn_id.clone()), + files: resolved.files.clone(), + inject_skills, + }; + match agent.deliver_midturn(data).await { + Ok(()) => { + info!( + conversation_id = %conversation_id, + route = "midturn_delivery", + active_turn_id = %active_turn_id, + msg_id = %user_msg_id, + "mid-turn message delivered into the active turn" + ); + let mut response = self + .send_message_response(conversation_id, user_msg_id, active_turn_id) + .await; + response.delivered_midturn = true; + Ok(MidturnOutcome::Delivered(response)) + } + Err(e) if steer_rejection_is_turn_ended(&e) => { + info!( + conversation_id = %conversation_id, + route = "new_turn", + active_turn_id = %active_turn_id, + msg_id = %user_msg_id, + "mid-turn delivery rejected (turn ended); opening a new turn" + ); + if persisted { + // The message now opens its own turn — it is no longer + // waiting on a mid-turn receipt. + apply_message_receipt( + &self.conversation_repo, + &self.broadcaster, + user_id, + conversation_id, + &user_msg_id, + MIDTURN_STATUS_RECEIVED, + false, + ) + .await; + } + Ok(MidturnOutcome::TurnEnded { + user_msg_id: persisted.then_some(user_msg_id), + }) + } + Err(e) => { + // Terminal delivery failure: mirror the normal path's + // build-failure contract — surface a failure tip, mark the + // message errored, and return the 200-with-tip response. + error!( + conversation_id = %conversation_id, + active_turn_id = %active_turn_id, + msg_id = %user_msg_id, + error = %e, + "mid-turn delivery failed" + ); + if persisted { + apply_message_receipt( + &self.conversation_repo, + &self.broadcaster, + user_id, + conversation_id, + &user_msg_id, + "error", + false, + ) + .await; + } + self.persist_and_broadcast_send_failure_tip(user_id, conversation_id, &active_turn_id, &e, None) + .await; + Ok(MidturnOutcome::Delivered( + self.send_message_response(conversation_id, user_msg_id, active_turn_id) + .await, + )) + } + } + } /// Send a user message to the conversation. /// /// 1. Validates the conversation belongs to the user @@ -3773,15 +4058,71 @@ impl ConversationService { .resolve_message_attachments(user_id, &content_with_sessions, &req.files) .await?; - let turn_id = Self::mint_turn_id(); - let turn_claim = self.runtime_state.try_claim_turn(conversation_id, &turn_id)?; + // ── Mid-turn delivery (B5, spec §4.3) ──────────────────────────── + // An ACTIVE turn + a backend that supports mid-turn delivery → the + // message rides the CURRENT turn: no claim, no new turn id, HTTP 200 + // with the active turn's id. Every other case (including the 409 for + // non-supporting backends) is unchanged and handled by the claim below. + let mut fallback_user_msg: Option = None; + if let Some(active_turn_id) = self.runtime_state.active_turn_id_for(conversation_id) + && let Some(agent) = task_manager.get_task(conversation_id) + && agent.supports_midturn_delivery() + { + // §4.6: a turn blocked on a permission confirmation / question card + // must NOT be steered into — the card is the required answer + // channel, not a new instruction on the stream. Falling through to + // the claim below restores the exact pre-B5 contract (409) at the + // HTTP layer, so a direct API client cannot bypass the frontend + // gate. Same authoritative source the runtime summary's + // `pending_confirmations` reads (`get_confirmations`). + if !agent.get_confirmations().is_empty() { + info!( + conversation_id = %conversation_id, + route = "rejected_requires_action", + active_turn_id = %active_turn_id, + "mid-turn delivery refused: a confirmation is pending (spec §4.6)" + ); + } else { + match self + .deliver_midturn_message( + user_id, + conversation_id, + &resolved, + req.hidden, + agent, + active_turn_id, + req.inject_skills.clone(), + ) + .await? + { + MidturnOutcome::Delivered(response) => return Ok(response), + // codex rejected the steer because the turn just ended → fall + // through and open a NEW turn for the already-persisted message. + MidturnOutcome::TurnEnded { user_msg_id } => fallback_user_msg = user_msg_id, + } + } + } + + // Open a NEW turn: mint the id only on this branch. The mid-turn + // delivery path above instead reuses the running turn's id — a + // phantom turn id minted outside an actual claim must never exist. + let (turn_id, turn_claim) = { + let turn_id = Self::mint_turn_id(); + let turn_claim = self.runtime_state.try_claim_turn(conversation_id, &turn_id)?; + (turn_id, turn_claim) + }; // Store user message. `msg_id` is server-generated so the WebSocket // stream, DB row, and client-side message index all agree on the same // key. We reuse the same value for `id` (primary key) and `msg_id` // to preserve legacy callers that still rely on `id == msg_id`. - let user_msg_id = Self::mint_msg_id(); - scope_prompt_attachment_ids(&user_msg_id, &mut resolved.attachments); + // A mid-turn TurnEnded fallback already persisted + broadcast the + // message — reuse its id instead of writing a duplicate row. + let was_fallback = fallback_user_msg.is_some(); + let user_msg_id = fallback_user_msg.unwrap_or_else(Self::mint_msg_id); + if !was_fallback { + scope_prompt_attachment_ids(&user_msg_id, &mut resolved.attachments); + } let user_msg = aionui_db::models::MessageRow { id: user_msg_id.clone(), conversation_id: conversation_id.to_owned(), @@ -3804,38 +4145,44 @@ impl ConversationService { .await; return Ok(self.send_message_response(conversation_id, user_msg_id, turn_id).await); } - if let Err(error) = self - .journal_user_prompt(user_id, conversation_id, &user_msg_id, &resolved.content) - .await - { - let mut turn_claim = turn_claim; - let was_deleting = turn_claim.release(); - self.complete_released_turn(user_id, conversation_id, &turn_id, was_deleting) + // The TurnEnded fallback already persisted + broadcast this message + // (with the statusChanged flip) — doing it again would duplicate the + // row and the live-view bubble. + if !was_fallback { + if let Err(error) = self + .journal_user_prompt(user_id, conversation_id, &user_msg_id, &resolved.content) + .await + { + let mut turn_claim = turn_claim; + let was_deleting = turn_claim.release(); + self.complete_released_turn(user_id, conversation_id, &turn_id, was_deleting) + .await; + return Err(error); + } + self.journal_prompt_attachments(user_id, conversation_id, &user_msg_id, &resolved.attachments) .await; - return Err(error); - } - self.journal_prompt_attachments(user_id, conversation_id, &user_msg_id, &resolved.attachments) - .await; - if let Err(e) = self.conversation_repo.insert_message(user_id, &user_msg).await { - warn!(msg_id = %user_msg_id, error = %ErrorChain(&e), "Failed to insert user message"); - return Err(e.into()); - } + if let Err(e) = self.conversation_repo.insert_message(user_id, &user_msg).await { + warn!(msg_id = %user_msg_id, error = %ErrorChain(&e), "Failed to insert user message"); + return Err(e.into()); + } - info!(msg_id = %user_msg_id, "User message persisted"); + info!(msg_id = %user_msg_id, "User message persisted"); - self.broadcaster.broadcast(WebSocketMessage::new( - "message.userCreated", - serde_json::json!({ - "user_id": user_id, - "conversation_id": conversation_id, - "msg_id": &user_msg_id, - "content": &resolved.content, - "position": "right", - "status": "finish", - "hidden": req.hidden, - "created_at": user_msg.created_at, - }), - )); + self.broadcaster.broadcast(WebSocketMessage::new( + "message.userCreated", + serde_json::json!({ + "user_id": user_id, + "conversation_id": conversation_id, + "msg_id": &user_msg_id, + "client_msg_id": &user_msg_id, + "content": &resolved.content, + "position": "right", + "status": "finish", + "hidden": req.hidden, + "created_at": user_msg.created_at, + }), + )); + } // Build task options from conversation row let mut build_opts = match self.build_task_options(&row).await { diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 066d0b641..9448fe006 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -3943,6 +3943,271 @@ async fn send_message_returns_accepted() { assert_ne!(response.msg_id, response.turn_id, "turn_id must not reuse msg_id"); } +/// B5 test double: a mid-turn-capable agent that records `deliver_midturn` +/// calls; `scripted_error` lets a test replay a steer rejection. `held_claim` +/// (when set) is dropped BEFORE the rejection returns — modelling the live +/// race where codex's turn ends (claim released) between the routing check and +/// the steer write, so the fallback's fresh claim succeeds deterministically. +struct MidturnMockAgent { + conversation_id: String, + event_tx: broadcast::Sender, + delivered: Mutex>, + scripted_error: Mutex>, + held_claim: Mutex>, + /// Pending permission confirmations (spec §4.6 gate): while non-empty the + /// mid-turn routing branch must fall through to the 409 claim path. + confirmations: Mutex>, +} + +impl MidturnMockAgent { + fn new(conversation_id: &str) -> Self { + let (event_tx, _) = broadcast::channel(16); + Self { + conversation_id: conversation_id.to_owned(), + event_tx, + delivered: Mutex::new(Vec::new()), + scripted_error: Mutex::new(None), + held_claim: Mutex::new(None), + confirmations: Mutex::new(Vec::new()), + } + } +} + +#[async_trait::async_trait] +impl IAgentTask for MidturnMockAgent { + fn agent_type(&self) -> AgentType { + AgentType::Acp + } + fn conversation_id(&self) -> &str { + &self.conversation_id + } + fn workspace(&self) -> &str { + "/tmp/test" + } + fn status(&self) -> Option { + None + } + fn last_activity_at(&self) -> aionui_common::TimestampMs { + aionui_common::now_ms() + } + fn subscribe(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + fn supports_midturn_delivery(&self) -> bool { + true + } + async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> { + // Emit finish so a fallback-opened normal turn's relay completes. + let _ = self.event_tx.send(AgentStreamEvent::Finish(FinishEventData::default())); + Ok(()) + } + async fn cancel(&self) -> Result<(), AgentError> { + Ok(()) + } + fn kill(&self, _reason: Option) -> Result<(), AgentError> { + Ok(()) + } +} + +#[async_trait::async_trait] +impl IMockAgent for MidturnMockAgent { + fn get_confirmations(&self) -> Vec { + self.confirmations.lock().unwrap().clone() + } + async fn deliver_midturn(&self, data: SendMessageData) -> Result<(), AgentSendError> { + if let Some(err) = self.scripted_error.lock().unwrap().take() { + // The turn "ended": release the held claim so the fallback's + // fresh claim succeeds (deterministic stand-in for the live race). + self.held_claim.lock().unwrap().take(); + return Err(err); + } + self.delivered.lock().unwrap().push(data); + Ok(()) + } +} + +/// B5 routing (spec §4.3): an ACTIVE turn + a supporting backend → the message +/// is delivered mid-turn (no 409, no new turn id), persisted with the +/// pending-receipt status, and `message.userCreated` carries the correlation +/// id. +#[tokio::test] +async fn midturn_send_delivers_into_the_active_turn() { + let (svc, broadcaster, repo, _d) = make_service(); + let conv = svc.create("user_1", make_create_req()).await.unwrap(); + let _claim = svc + .runtime_state() + .try_claim_turn(&conv.id, "turn_active") + .expect("claim the active turn"); + let agent = Arc::new(MidturnMockAgent::new(&conv.id)); + let task_mgr = Arc::new(MockTaskManager::new()); + task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent.clone())); + let task_mgr_dyn: Arc = task_mgr; + broadcaster.take_events(); + + let response = svc + .send_message("user_1", &conv.id, make_send_req(), &task_mgr_dyn) + .await + .expect("mid-turn send must not 409"); + + assert_eq!(response.turn_id, "turn_active", "response carries the ACTIVE turn id"); + assert!(response.delivered_midturn, "response flags the mid-turn delivery"); + let delivered = agent.delivered.lock().unwrap().clone(); + assert_eq!(delivered.len(), 1, "delivered through the mid-turn path exactly once"); + assert_eq!(delivered[0].content, "Hello"); + assert_eq!( + delivered[0].msg_id, response.msg_id, + "the wire correlation id IS the persisted msg_id" + ); + assert_eq!(delivered[0].turn_id.as_deref(), Some("turn_active")); + // The active claim was never stolen and no new turn id was minted. + assert_eq!( + svc.runtime_state().active_turn_id_for(&conv.id).as_deref(), + Some("turn_active") + ); + // userCreated carries the correlation id + pending-receipt status. + let events = broadcaster.take_events(); + let created = events + .iter() + .find(|e| e.name == "message.userCreated") + .expect("userCreated broadcast"); + assert_eq!(created.data["client_msg_id"], response.msg_id.as_str()); + assert_eq!(created.data["status"], "pending"); + // Persisted with the pending-receipt status. + let rows: Vec<_> = repo + .messages + .lock() + .unwrap() + .iter() + .filter(|m| m.msg_id.as_deref() == Some(response.msg_id.as_str())) + .cloned() + .collect(); + assert_eq!(rows.len(), 1, "persisted exactly once"); + assert_eq!(rows[0].status.as_deref(), Some("pending")); +} + +/// B5 fallback (spec §6甲.1): codex rejected the steer because the turn ended +/// → the send falls back to opening a NEW turn; the already-persisted message +/// is reused (no duplicate row) and its status leaves the pending state. +#[tokio::test] +async fn midturn_steer_rejection_turn_ended_falls_back_to_a_new_turn() { + let (svc, _broadcaster, repo, _d) = make_service(); + let conv = svc.create("user_1", make_create_req()).await.unwrap(); + let claim = svc + .runtime_state() + .try_claim_turn(&conv.id, "turn_active") + .expect("claim the active turn"); + let agent = Arc::new(MidturnMockAgent::new(&conv.id)); + // codex wording verbatim (locked): both rejections are -32600; only the + // message text distinguishes them. + *agent.scripted_error.lock().unwrap() = Some(AgentSendError::from_agent_error(AgentError::bad_gateway( + "backend transport error: no active turn to steer", + ))); + *agent.held_claim.lock().unwrap() = Some(claim); + let task_mgr = Arc::new(MockTaskManager::new()); + task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent.clone())); + let task_mgr_dyn: Arc = task_mgr; + + let response = svc + .send_message("user_1", &conv.id, make_send_req(), &task_mgr_dyn) + .await + .expect("the fallback must open a new turn, not fail"); + + assert_ne!(response.turn_id, "turn_active", "a NEW turn id is minted on fallback"); + assert!(response.turn_id.starts_with("turn_")); + assert!( + !response.delivered_midturn, + "a fallback send is an ordinary new-turn send" + ); + assert!( + agent.delivered.lock().unwrap().is_empty(), + "nothing was delivered mid-turn" + ); + // Exactly ONE persisted user row (reused, not duplicated), no longer pending. + let rows: Vec<_> = repo + .messages + .lock() + .unwrap() + .iter() + .filter(|m| m.msg_id.as_deref() == Some(response.msg_id.as_str())) + .cloned() + .collect(); + assert_eq!(rows.len(), 1, "the fallback must reuse the row, not duplicate it"); + assert_eq!(rows[0].status.as_deref(), Some("finish")); + wait_for_turn_released(&svc, &conv.id).await; +} + +/// §4.6 (review fix): a turn blocked on a pending permission confirmation +/// must NOT be steered into, even for a supporting backend — the mid-turn +/// branch falls through to the claim, restoring the exact pre-B5 409 at the +/// HTTP API (the frontend gate alone does not bind direct API clients). +#[tokio::test] +async fn midturn_send_during_pending_confirmation_stays_409() { + let (svc, broadcaster, repo, _d) = make_service(); + let conv = svc.create("user_1", make_create_req()).await.unwrap(); + let _claim = svc + .runtime_state() + .try_claim_turn(&conv.id, "turn_active") + .expect("claim the active turn"); + let agent = Arc::new(MidturnMockAgent::new(&conv.id)); + *agent.confirmations.lock().unwrap() = vec![Confirmation { + id: "c1".into(), + call_id: "call-1".into(), + title: Some("Allow file edit".into()), + action: Some("edit_file".into()), + description: "Edit main.rs".into(), + command_type: Some("bash".into()), + questions: None, + options: vec![], + }]; + let task_mgr = Arc::new(MockTaskManager::new()); + task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent.clone())); + let task_mgr_dyn: Arc = task_mgr; + broadcaster.take_events(); + + let err = svc + .send_message("user_1", &conv.id, make_send_req(), &task_mgr_dyn) + .await + .expect_err("a send during requires_action must be rejected"); + assert!( + err.to_string().contains("already running"), + "the pre-B5 running-conversation conflict is preserved, got: {err}" + ); + // Nothing was delivered, persisted, or broadcast. + assert!( + agent.delivered.lock().unwrap().is_empty(), + "no mid-turn delivery may happen while a confirmation is pending" + ); + assert!( + repo.messages.lock().unwrap().is_empty(), + "the rejected message must not be persisted" + ); + let events = broadcaster.take_events(); + assert!( + !events + .iter() + .any(|e| e.name == "message.userCreated" || e.name == "message.statusChanged"), + "no message events for a rejected send, got: {:?}", + events.iter().map(|e| e.name.clone()).collect::>() + ); +} + +/// The §6甲.1 message-text classification is load-bearing — lock it so a codex +/// wording change (or an error-mapping change that drops the text) fails here +/// instead of silently degrading every turn-ended fallback into a hard error. +#[test] +fn steer_rejection_classifier_matches_only_the_turn_ended_text() { + let turn_ended = AgentSendError::from_agent_error(AgentError::bad_gateway( + "backend transport error: no active turn to steer", + )); + assert!(crate::service::steer_rejection_is_turn_ended(&turn_ended)); + let other = AgentSendError::from_agent_error(AgentError::bad_gateway( + "backend transport error: turn/steer rejected: expected active turn id `a` but found `b`", + )); + assert!(!crate::service::steer_rejection_is_turn_ended(&other)); + let unrelated = AgentSendError::from_agent_error(AgentError::bad_gateway("boom")); + assert!(!crate::service::steer_rejection_is_turn_ended(&unrelated)); +} + #[tokio::test] async fn send_message_injects_conversation_runtime_context() { let (svc, _broadcaster, _repo, _default_task_mgr) = make_service(); @@ -4067,6 +4332,45 @@ async fn send_message_returns_msg_id_and_turn_id_and_summary_tracks_turn() { assert!(!runtime.can_send_message); } +/// Bugfix: `supports_midturn_delivery` is a STATIC property of the +/// conversation's backend type, not of agent liveness. A fresh (pre-ensure) +/// or dormant claude conversation must still report `true`, otherwise the +/// frontend hydrate fetch races the send accept and gates the whole first +/// turn into the client queue panel. +#[tokio::test] +async fn runtime_summary_reports_backend_static_midturn_bit_without_live_agent() { + let (svc, _broadcaster, _repo, _task_mgr) = make_service(); + + // No runtime/ensure has run: MockTaskManager holds no task for any of + // these conversations, so the bit must come from the backend identity. + for (backend, expected) in [ + ("claude", true), + ("codex", true), + ("antigravity", false), + ("gemini", false), + ] { + let conv = svc + .create("user_1", make_create_req_with_backend(backend)) + .await + .unwrap(); + let runtime = svc.runtime_summary_for(&conv.id).await; + assert!(!runtime.has_task, "precondition: no live agent for {backend}"); + assert_eq!( + runtime.supports_midturn_delivery, expected, + "backend {backend} must report static supports_midturn_delivery={expected} without a live agent" + ); + } + + // No backend identity at all (legacy/unknown row) → conservative false. + let conv = svc.create("user_1", make_create_req()).await.unwrap(); + let runtime = svc.runtime_summary_for(&conv.id).await; + assert!(!runtime.supports_midturn_delivery); + + // Unknown conversation id → conservative false, no panic. + let runtime = svc.runtime_summary_for("conv-does-not-exist").await; + assert!(!runtime.supports_midturn_delivery); +} + #[tokio::test] async fn send_message_rejects_legacy_runtime_conversations_as_archived() { let (svc, _broadcaster, repo, _task_mgr) = make_service(); diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index 0bce0bbe4..398656463 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -789,6 +789,12 @@ impl StreamRelay { // Journal-only diagnostic. The trajectory change notification // above tells the client to fetch the compact semantic record. } + AgentStreamEvent::MessageLifecycle(_) => { + // Internal-only correlation frame (mid-turn interjection + // Task 3): consumed by the BackgroundStreamWatcher between + // turns; inside a turn it is pure bookkeeping. Never + // forwarded to the WebSocket. + } // Agent session titles. The BackgroundStreamWatcher is the // between-turns consumer, but while it lends its receiver to // an orphan-turn relay THIS relay is the only consumer — live @@ -1134,6 +1140,7 @@ impl StreamRelay { AgentStreamEvent::BackendTurnBound(_) => "BackendTurnBound", AgentStreamEvent::WorkflowProgress(_) => "WorkflowProgress", AgentStreamEvent::AcpDialectSignal(_) => "AcpDialectSignal", + AgentStreamEvent::MessageLifecycle(_) => "MessageLifecycle", } } diff --git a/crates/aionui-conversation/tests/force_kill_convergence.rs b/crates/aionui-conversation/tests/force_kill_convergence.rs index 753dd0308..b501f6af6 100644 --- a/crates/aionui-conversation/tests/force_kill_convergence.rs +++ b/crates/aionui-conversation/tests/force_kill_convergence.rs @@ -106,7 +106,7 @@ async fn user_cancel_kill_converges_session_turn_to_idle() { let runtime = Arc::new(ConversationRuntimeStateService::default()); let mut claim = runtime.try_claim_turn("conv-1", "turn-1").expect("turn claim"); runtime.mark_cancelling("conv-1"); - let before = runtime.summary_from_parts("conv-1", IAgentTask::status(task.as_ref()), true, 0); + let before = runtime.summary_from_parts("conv-1", IAgentTask::status(task.as_ref()), true, 0, false); assert_eq!( before.state, ConversationRuntimeStateKind::Cancelling, @@ -144,7 +144,7 @@ async fn user_cancel_kill_converges_session_turn_to_idle() { assert!(!runtime.is_claimed("conv-1"), "turn claim released"); assert!(!runtime.is_cancelling("conv-1"), "cancelling cleared on release"); - let after = runtime.summary_from_parts("conv-1", IAgentTask::status(task.as_ref()), false, 0); + let after = runtime.summary_from_parts("conv-1", IAgentTask::status(task.as_ref()), false, 0, false); assert_eq!( after.state, ConversationRuntimeStateKind::Idle, diff --git a/crates/aionui-session-message/tests/common/mod.rs b/crates/aionui-session-message/tests/common/mod.rs index cc2695b38..0c1fbbab7 100644 --- a/crates/aionui-session-message/tests/common/mod.rs +++ b/crates/aionui-session-message/tests/common/mod.rs @@ -139,6 +139,9 @@ impl IAgentTask for FakeAgent { fn subscribe(&self) -> broadcast::Receiver { self.event_tx.subscribe() } + fn supports_midturn_delivery(&self) -> bool { + self.supports_midturn + } async fn send_message(&self, data: SendMessageData) -> Result<(), AgentSendError> { self.sent.lock().unwrap().push(data); // Finish so the relay of a newly-opened turn completes and the claim is @@ -160,6 +163,10 @@ impl IMockAgent for FakeAgent { fn get_confirmations(&self) -> Vec { self.confirmations.lock().unwrap().clone() } + async fn deliver_midturn(&self, data: SendMessageData) -> Result<(), AgentSendError> { + self.delivered_midturn.lock().unwrap().push(data); + Ok(()) + } } // ── Task manager ──────────────────────────────────────────────────── diff --git a/crates/aionui-session-message/tests/delivery_semantics.rs b/crates/aionui-session-message/tests/delivery_semantics.rs index b6a98baae..cfc8cb58a 100644 --- a/crates/aionui-session-message/tests/delivery_semantics.rs +++ b/crates/aionui-session-message/tests/delivery_semantics.rs @@ -290,11 +290,6 @@ async fn a_busy_target_without_midturn_support_is_queued_not_delivered() { ); } -/// WorkMate intentionally skipped upstream Core #836 mid-turn interjection -/// (UI also skipped #4012). `SessionMessageService::deliver_now` always goes -/// through `send_message`, which returns Busy for a running turn — even when -/// `FakeAgent.supports_midturn` is true (leftover from a half-applied test -/// patch). The message must QUEUE, not merge into the running turn. #[tokio::test] async fn a_busy_midturn_capable_target_takes_the_message_into_its_running_turn() { let ctx = setup().await; @@ -307,27 +302,24 @@ async fn a_busy_midturn_capable_target_takes_the_message_into_its_running_turn() .service .send(USER, &from.id, &request("conv_target", "hi")) .await - .expect("busy must not be an error"); + .expect("mid-turn delivery succeeds"); - assert_eq!( - response.status, - SessionDeliveryStatus::Queued, - "Core #836 was not synced: a busy target queues even if FakeAgent claims mid-turn support" - ); - assert_eq!(ctx.queue.len_for("conv_target"), 1); - assert_eq!( - ctx.user_message_count("conv_target").await, - 0, - "a queued message must not be persisted yet" - ); + assert_eq!(response.status, SessionDeliveryStatus::Delivered); + assert!(ctx.queue.is_empty(), "mid-turn delivery must not queue"); + // `status: delivered` alone does NOT prove the message merged into the + // running turn — an ordinary new-turn send reports `delivered` too. The + // active turn id must be UNCHANGED. assert_eq!( ctx.active_turn_id("conv_target").as_deref(), Some(turn_before.as_str()), - "must not open a new turn and must not merge into the running one" + "the message must ride the SAME turn, not open a new one" ); + let delivered = agent.delivered_midturn.lock().unwrap().clone(); + assert_eq!(delivered.len(), 1, "delivered through the mid-turn path exactly once"); assert!( - agent.delivered_midturn.lock().unwrap().is_empty(), - "must not take the mid-turn path (Core #836 was not synced)" + delivered[0].content.starts_with("[[AION_SESSION_MESSAGE]]"), + "{}", + delivered[0].content ); } diff --git a/crates/aionui-session-message/tests/e2e_cross_session.rs b/crates/aionui-session-message/tests/e2e_cross_session.rs index 0a96e9c04..0261fd8e7 100644 --- a/crates/aionui-session-message/tests/e2e_cross_session.rs +++ b/crates/aionui-session-message/tests/e2e_cross_session.rs @@ -135,10 +135,6 @@ async fn three_queued_messages_reach_the_target_one_turn_each_in_order() { } } -/// Same fork skip as delivery_semantics: Core #836 mid-turn interjection is -/// not implemented (UI also skipped #4012). A busy target whose FakeAgent -/// claims mid-turn support still queues; the message is not merged into the -/// running turn. #[tokio::test] async fn a_midturn_capable_target_takes_the_message_into_its_running_turn() { let ctx = setup().await; @@ -149,26 +145,17 @@ async fn a_midturn_capable_target_takes_the_message_into_its_running_turn() { let response = ctx.service.send(USER, &a.id, &request(&b.id, "hi")).await.unwrap(); - assert_eq!( - response.status, - SessionDeliveryStatus::Queued, - "Core #836 was not synced: a busy target queues even if FakeAgent claims mid-turn support" - ); - assert_eq!(ctx.queue.len_for(&b.id), 1); - assert_eq!( - ctx.user_message_count(&b.id).await, - 0, - "a queued message must not be persisted yet" - ); + assert_eq!(response.status, SessionDeliveryStatus::Delivered); + assert!(ctx.queue.is_empty(), "mid-turn delivery must not queue"); + // `status: delivered` alone does NOT prove the message merged into the + // running turn — an ordinary new-turn send reports `delivered` too. The + // active turn id must be UNCHANGED. assert_eq!( ctx.active_turn_id(&b.id).as_deref(), Some(turn_before.as_str()), - "must not open a new turn and must not merge into the running one" - ); - assert!( - agent.delivered_midturn.lock().unwrap().is_empty(), - "must not take the mid-turn path (Core #836 was not synced)" + "the message must ride the SAME turn, not open a new one" ); + assert_eq!(agent.delivered_midturn.lock().unwrap().len(), 1); } /// The branch mid-turn interjection added that is easy to miss: `send_message` diff --git a/crates/aionui-session/src/adapter/claude.rs b/crates/aionui-session/src/adapter/claude.rs index b52e0827d..62d5c9002 100644 --- a/crates/aionui-session/src/adapter/claude.rs +++ b/crates/aionui-session/src/adapter/claude.rs @@ -319,6 +319,10 @@ impl ClaudeAdapter { // message_stop) carries no FSM signal — the regular `assistant`/`user` // frames already deliver the content — so they stay opaque. "stream_event" => self.parse_stream_event(v), + // Task 2 (mid-turn interjection observability): claude echoes back the + // uuid WE minted on the user frame, reporting where it landed in the + // turn lifecycle (verified 2.1.226, design spec §6.1). + "command_lifecycle" => self.parse_command_lifecycle(v), // unknown top-level type → opaque catch-all (I8/I13, never panic). other => vec![SessionEvent::AdapterSpecific { tag: other.to_string(), @@ -1024,6 +1028,28 @@ impl ClaudeAdapter { }], } } + + /// A `command_lifecycle` frame: claude echoing back the `uuid` WE minted on + /// a user frame, reporting where it landed (§6.1). `command_uuid` is that + /// SAME correlation key; missing it degrades to no event (never guess an id). + fn parse_command_lifecycle(&self, v: &Value) -> Vec { + let Some(id) = v.get("command_uuid").and_then(Value::as_str) else { + return Vec::new(); + }; + // An unrecognised phase degrades to NO event rather than a guess: this + // frame is additive and claude may grow states we have not probed. + let phase = match v.get("state").and_then(Value::as_str) { + Some("queued") => crate::event::MessageLifecyclePhase::Queued, + Some("started") => crate::event::MessageLifecyclePhase::Started, + Some("completed") => crate::event::MessageLifecyclePhase::Completed, + Some("cancelled") | Some("canceled") => crate::event::MessageLifecyclePhase::Cancelled, + _ => return Vec::new(), + }; + vec![SessionEvent::MessageLifecycle { + client_msg_id: id.to_string(), + phase, + }] + } } #[async_trait::async_trait] @@ -1242,7 +1268,12 @@ impl BackendAdapter for ClaudeAdapter { terminal_result: true, }, supported_commands: crate::capability::CommandSet { - steer: false, + // B5: mid-turn delivery routes Command::Steer to a direct stdin + // user-frame write (claude's persistent stdin accepts writes any + // time; the CLI queues and consumes them itself — command_lifecycle + // echoes our uuid, design spec §6.1/§6甲.2). No turn_gen bump, no + // FSM involvement (NoTurn admission). + steer: true, cancel_tool: false, answer_permission: true, answer_auth: false, @@ -1297,9 +1328,13 @@ impl BackendAdapter for ClaudeAdapter { auth_methods: Vec::new(), // no mid-session re-auth on local path // 009 R2: claude's persistent stdin is a FIFO — a write while a turn // is in flight is buffered and consumed as the next turn, so the conv - // layer CAN proactively queue. This (NOT supported_commands.steer, - // which is false here anyway) is what can_queue gates on. + // layer CAN proactively queue. This (NOT supported_commands.steer) + // is what can_queue gates on. accepts_proactive_input: true, + // Verified backend matrix (see `Capabilities::supports_midturn_delivery`): + // claude is a direct-CLI backend that can deliver a mid-turn message to + // the agent without waiting for the current turn to end. + supports_midturn_delivery: true, // #101: static default empty; the clean-slate ClaudeConnection fills it // from the control_request{initialize} response (the legacy adapter has // no discovery wire). capabilities() merges the discovered set on read. @@ -1613,6 +1648,50 @@ mod tests { } /// 009 R8: a STRING tool_result content → one Text part (e.g. Bash stdout). + /// Verified backend matrix (task-1 brief): claude MUST advertise + /// `supports_midturn_delivery` so mid-turn UI can gate on it. + #[test] + fn capabilities_advertise_midturn_delivery() { + let a = ClaudeAdapter::new(); + assert!(a.capabilities().supports_midturn_delivery); + // Lock the backend-static table against the real constructor (claude + // has no pub capability constructor, so the lock lives here). + assert_eq!( + crate::capability::backend_supports_midturn_delivery("claude"), + a.capabilities().supports_midturn_delivery, + ); + } + + /// Task 2: claude echoes back the uuid WE minted on a user frame via a + /// `command_lifecycle` frame (verified 2.1.226, design spec §6.1) — the + /// adapter must normalize it into `SessionEvent::MessageLifecycle`. + #[test] + fn parses_command_lifecycle_into_message_lifecycle_events() { + let a = ClaudeAdapter::new(); + let frame = r#"{"type":"command_lifecycle","command_uuid":"u-1","state":"queued"}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + match a.parse_command_lifecycle(&v).as_slice() { + [SessionEvent::MessageLifecycle { client_msg_id, phase }] => { + assert_eq!(client_msg_id, "u-1"); + assert_eq!(*phase, crate::event::MessageLifecyclePhase::Queued); + } + other => panic!("expected one MessageLifecycle, got {other:?}"), + } + } + + /// An unrecognised `state` degrades to NO event rather than a guess — this + /// frame is additive and claude may grow states we have not probed. + #[test] + fn unknown_command_lifecycle_state_is_ignored_not_panicked() { + let a = ClaudeAdapter::new(); + let frame = r#"{"type":"command_lifecycle","command_uuid":"u-1","state":"future_state"}"#; + let v: serde_json::Value = serde_json::from_str(frame).unwrap(); + assert!( + a.parse_command_lifecycle(&v).is_empty(), + "an unknown phase must degrade to no event" + ); + } + #[test] fn parse_user_tool_result_string_content_to_text() { let mut a = ClaudeAdapter::new(); diff --git a/crates/aionui-session/src/backend/acp_conn.rs b/crates/aionui-session/src/backend/acp_conn.rs index 798a264f1..8303f6768 100644 --- a/crates/aionui-session/src/backend/acp_conn.rs +++ b/crates/aionui-session/src/backend/acp_conn.rs @@ -290,6 +290,7 @@ pub fn acp_capabilities() -> Capabilities { // 009 R2: ACP is one session/prompt at a time — no proactive next-turn // input path from the conv layer. can_queue degrades to false (= can_send). accepts_proactive_input: false, + supports_midturn_delivery: false, // #101: static default empty; filled from the `available_commands_update` // session/update (capabilities() merges the discovered set on read). slash_commands: Vec::new(), @@ -2605,6 +2606,14 @@ mod tests { use crate::backend::{McpServerSpec, McpTransport}; use crate::testing::FakeAgentIo; + /// Verified backend matrix (task-1 brief): ACP MUST NOT advertise + /// `supports_midturn_delivery` — one `session/prompt` at a time, no + /// proactive mid-turn input path. + #[test] + fn capabilities_do_not_advertise_midturn_delivery() { + assert!(!acp_capabilities().supports_midturn_delivery); + } + /// PROPERTY (§F.3 input field-value boundary for the acp `map_update` entry, /// sibling of codex `prop_map_item_*` and claude `prop_parse_assistant_*`): for /// ANY `session/update` params shape — arbitrary `sessionUpdate` kind (known / diff --git a/crates/aionui-session/src/backend/antigravity/conn.rs b/crates/aionui-session/src/backend/antigravity/conn.rs index 703eddcfa..fe6fd6ac4 100644 --- a/crates/aionui-session/src/backend/antigravity/conn.rs +++ b/crates/aionui-session/src/backend/antigravity/conn.rs @@ -172,6 +172,11 @@ pub fn antigravity_capabilities() -> Capabilities { // take input mid-turn, but its next turn is a fresh process anyway, so // the input box can stay usable instead of locking until the turn ends. accepts_proactive_input: true, + // agy runs ONE PROCESS PER TURN and ignores stdin mid-turn (see the + // `capabilities_allow_queueing_but_not_steering` test), so a message sent + // during a turn cannot reach it — it waits for the next process. agy MUST + // therefore behave exactly like ACP in the UI. + supports_midturn_delivery: false, ..Default::default() } } @@ -1235,7 +1240,7 @@ mod tests { let err = backend .dispatch(Command::Steer { content: vec![ContentBlock::Text("stop".into())], - client_user_message_id: None, + client_msg_id: None, }) .await .expect_err("steer must not be silently accepted"); @@ -1852,6 +1857,14 @@ mod tests { assert!(c.accepts_proactive_input); } + /// Verified backend matrix (task-1 brief): agy MUST NOT advertise + /// `supports_midturn_delivery` — it is one-process-per-turn and ignores + /// stdin mid-turn, so it must behave like ACP in the UI. + #[test] + fn capabilities_do_not_advertise_midturn_delivery() { + assert!(!antigravity_capabilities().supports_midturn_delivery); + } + #[test] fn capabilities_reflect_the_one_process_per_turn_shape() { let c = antigravity_capabilities(); diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index 31832bf01..cf8e1a8e6 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -3250,7 +3250,49 @@ impl SessionBackend for ClaudeSessionBackend { }) } Command::AnswerAuth { .. } => Err(BackendError::CommandNotSupported { command: "answer_auth" }), - Command::Steer { .. } => Err(BackendError::CommandNotSupported { command: "steer" }), + // B5 mid-turn delivery: a Steer is a DIRECT stdin user-frame write. + // claude's persistent stdin accepts writes at any time; the CLI's own + // kernel queue decides consumption (next tool_result boundary folds it + // into the current turn; a pure-text turn opens a follow-up turn after + // its `result` — design spec §6.1/§6甲.2, live 2.1.226). Deliberately + // NOT dispatch(Send): no drain_pending_controls (draining is a + // next-prompt concern and a Steer opens no prompt), no + // turn_in_flight change and NO turn_gen bump — the message folds into + // the live turn, and the session pump's per-turn suppression state + // must not reset mid-turn (see session_agent's gen-advance reset). + // `client_msg_id` is stamped as the user frame's `uuid`, which claude + // echoes in `command_lifecycle` (the three-state receipt). + Command::Steer { content, client_msg_id } => { + let blocks = self.capabilities().prompt_blocks; + if let Some(bad) = content.iter().find(|b| !blocks.allows(b)) { + return Err(BackendError::CommandNotSupported { + command: crate::capability::block_kind_name(bad), + }); + } + self.suspend + .ensure_awake(aionui_common::now_ms(), || self.wake_handle()) + .await?; + { + let mut guard = self.stdin.lock().await; + let stdin = guard + .as_mut() + .ok_or_else(|| BackendError::Transport("steer: stdin unavailable".into()))?; + self.adapter + .deliver_prompt(stdin, &content, client_msg_id.as_deref()) + .await + .map_err(|e| BackendError::Transport(format!("steer deliver_prompt: {e}")))?; + } // stdin lock released (microsecond frame-write lock, §5.4) + tracing::info!( + conversation_id = %self.session_id, + block_count = content.len(), + "claude dispatch(Steer): mid-turn user frame written to stdin" + ); + Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }) + } // G2: in-band config switch via control_request (probe-verified, mirrors // F1). set_permission_mode / set_model are written over the retained // stdin WITHOUT restarting the process. set_permission_mode goes out @@ -4555,6 +4597,58 @@ mod tests { ); } + /// B5 mid-turn delivery: dispatch(Steer) writes a uuid-stamped user frame + /// straight to stdin and does NOT bump turn_gen (the message folds into the + /// live turn — a bump would reset the session pump's per-turn suppression + /// state mid-turn and misattribute the turn's remaining frames). + #[tokio::test] + async fn dispatch_steer_writes_midturn_user_frame_without_turn_gen_bump() { + let io = FakeAgentIo::never_exits(Vec::new()); + let captured = io.captured_stdin(); + let backend = ClaudeSessionBackend::build_with_io("s", Box::new(io)).await; + // Open a turn the normal way so turn_gen has a live value. + let send_receipt = backend + .dispatch(Command::Send { + content: vec![ContentBlock::Text("start the turn".into())], + metadata: CommandMeta::default(), + }) + .await + .expect("send accepted"); + let steer_receipt = backend + .dispatch(Command::Steer { + content: vec![ContentBlock::Text("mid-turn interjection".into())], + client_msg_id: Some("cmsg-42".into()), + }) + .await + .expect("steer accepted"); + assert_eq!( + steer_receipt.admission, + Admission::NoTurn, + "steer folds into the live turn" + ); + assert_eq!( + steer_receipt.turn_gen, send_receipt.turn_gen, + "steer must NOT bump turn_gen" + ); + // The stdin→capture copy is a background task; poll briefly. + let mut written = String::new(); + for _ in 0..40 { + written = String::from_utf8_lossy(&captured.lock().await.clone()).to_string(); + if written.contains("mid-turn interjection") { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + assert!( + written.contains("mid-turn interjection"), + "steer text written to stdin, got: {written}" + ); + assert!( + written.contains(r#""uuid":"cmsg-42""#), + "correlation id stamped as the user frame uuid (claude echoes it via command_lifecycle), got: {written}" + ); + } + #[tokio::test] async fn unsupported_commands_are_rejected_by_capability() { // Reject matrix: every cap=false command MUST return the EXACT @@ -4568,7 +4662,9 @@ mod tests { assert!(!caps.supported_commands.rewind); assert!(!caps.supported_commands.list_checkpoints); assert!(!caps.supported_commands.answer_auth); - assert!(!caps.supported_commands.steer); + // B5: steer is now advertised TRUE (mid-turn stdin write wired); its + // accept path is covered by dispatch_steer_writes_midturn_user_frame. + assert!(caps.supported_commands.steer); // G2: set_mode/set_model are now advertised TRUE (wired in-band). assert!(caps.supported_commands.set_mode); assert!(caps.supported_commands.set_model); @@ -4600,15 +4696,6 @@ mod tests { .await, Err(BackendError::CommandNotSupported { command: "answer_auth" }) )); - assert!(matches!( - backend - .dispatch(Command::Steer { - content: Vec::new(), - client_user_message_id: None, - }) - .await, - Err(BackendError::CommandNotSupported { command: "steer" }) - )); assert!(matches!( backend .dispatch(Command::Cancel { diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index daa115c50..1bd399f7d 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -44,9 +44,6 @@ use crate::capability::{BlockSet, Capabilities, CapabilityTier, CommandSet, Prom use crate::event::{CancelReason, ProvisioningPhase, SessionEvent, StopReason, SubagentStatus, TurnOutcome}; use futures_util::stream::{BoxStream, StreamExt}; -type PendingSteerResult = oneshot::Sender>; -type PendingSteers = Arc>>; - const CODEX_CONFIG_FLAG: &str = "-c"; const CODEX_ENV_POLICY_INHERIT_ALL: &str = "shell_environment_policy.inherit=all"; const CODEX_ENV_POLICY_CLEAR_INCLUDE_ONLY: &str = "shell_environment_policy.include_only=[]"; @@ -725,11 +722,17 @@ pub fn codex_capabilities() -> Capabilities { current_mode: None, current_effort: None, auth_methods: vec!["chatgptAuthTokens".into(), "refresh".into()], - // 009 R2: codex advertises steer, but the conv layer does not route Steer - // today (B5), so there is no proactive next-turn input path → false. (Keying - // can_queue off steer here would be the MX-QUEUE-3 dead button.) Flips true - // only when B5 wires Steer routing. - accepts_proactive_input: false, + // B5 (mid-turn interjection Task 4): the conversation layer now routes a + // mid-turn send to Command::Steer (turn/steer soft injection), so a + // message written while a turn is in flight genuinely reaches codex — + // the MX-QUEUE-3 dead-button concern no longer applies. NOTE this bit + // never goes on the wire (only the derived `supports_midturn_delivery` + // does, §4.2 of the mid-turn design spec). + accepts_proactive_input: true, + // Verified backend matrix (see `Capabilities::supports_midturn_delivery`): + // codex is a direct-CLI backend that can deliver a mid-turn message to + // the agent without waiting for the current turn to end. + supports_midturn_delivery: true, // #101: codex's app-server has no slash-command discovery wire (112 methods // audited, none lists commands — samples/codex-cli/0.137.0/schema-full/ // ClientRequest.json). The legacy codex-acp bridge instead advertised a @@ -777,6 +780,12 @@ fn builtin_slash_commands() -> Vec { ] } +/// How long dispatch(Steer) waits for the synchronous `turn/steer` RPC ack +/// before degrading to fire-and-forget. The live ack is ~0ms (design spec +/// §6.2); 5s is orders of magnitude of headroom while keeping a wedged pipe +/// from blocking the send path indefinitely. +const STEER_ACK_TIMEOUT_MS: u64 = 5_000; + /// Per-session codex handle. `&self`-concurrent (stdin write behind a Mutex). pub struct CodexSessionBackend { session_id: String, @@ -872,8 +881,6 @@ pub struct CodexSessionBackend { /// or surfaces a Notice (NoTurn) — NEVER a silent drop (a dropped rejection /// left the turn hanging Running forever, ELECTRON-3Q0). pending_sends: Arc>>, - /// rpc id to completion channel for `turn/steer` responses. - pending_steers: PendingSteers, /// B-CODEX-MODEL-LIST (§9.10 discovery): rpc ids of the `model/list` + /// `collaborationMode/list` calls `open_session` issues at handshake, mapped to /// which list they fill. The reader claims the matching responses and writes @@ -894,6 +901,32 @@ pub struct CodexSessionBackend { /// `map_notification` → ConfigChanged, live-verified), so emitting here too would /// duplicate the ConfigChanged. The codex analogue of acp_conn's `pending_set`. pending_set: Arc>>, + /// B5 mid-turn delivery: rpc-id → in-flight `turn/steer` ack correlation. + /// dispatch(Steer) inserts and AWAITS the oneshot so the caller learns the + /// synchronous accept/reject (codex acks a steer with `{turnId}` ~0ms, + /// design spec §6.2); the reader claims the response and resolves it + /// (`None` = accepted, `Some(message)` = the JSON-RPC error text — codex + /// rejects with a bare -32600 whose MESSAGE is the only discriminator, + /// §6甲.1). + pending_steers: Arc>>, + /// How long dispatch(Steer) waits for the ack before degrading to + /// fire-and-forget (Ok + warn). Milliseconds; injectable for tests. + steer_ack_timeout_ms: AtomicU64, +} + +/// One in-flight `turn/steer` awaiting its synchronous RPC ack (B5). +struct PendingSteer { + /// The mid-turn correlation id (sent as `clientUserMessageId`). On a result + /// the reader emits `MessageLifecycle{Completed}` for it — codex has no + /// command_lifecycle wire, and the RPC result IS its acceptance of the + /// injection into the ACTIVE turn (delivery follows as a userMessage item + /// within ~1s, §6.2/§6甲.5). Deliberately NOT `Started`: Started arms the + /// conversation watcher's orphan-turn claim, which exists for claude's + /// follow-up-turn case only — codex always folds into the current turn + /// (§6甲.6), so arming it would risk claiming an unrelated background + /// continuation (#758). + client_msg_id: Option, + ack: tokio::sync::oneshot::Sender>, } /// One in-flight prompt-carrying client request (GAP-A correlation entry). @@ -976,9 +1009,9 @@ struct CodexReaderState { pending_auth_id: Arc>>, pending_tool_approvals: Arc>>, pending_sends: Arc>>, - pending_steers: PendingSteers, pending_discovery: Arc>>, pending_set: Arc>>, + pending_steers: Arc>>, pending_resume: Arc>>, resume_poison: Arc>>, pending_fork: Arc>>, @@ -1014,9 +1047,9 @@ fn start_codex_reader( state.pending_auth_id, state.pending_tool_approvals, state.pending_sends, - state.pending_steers, state.pending_discovery, state.pending_set, + state.pending_steers, state.pending_resume, state.resume_poison, state.pending_fork, @@ -1184,6 +1217,14 @@ impl CodexSessionBackend { *self.pending_resume.lock().await = Some(rpc_id); } + /// Test-support seam: shrink the steer-ack await so a fixture without a + /// scripted `turn/steer` response exercises the fire-and-forget degradation + /// without stalling the test for the production timeout. + #[cfg(any(test, feature = "test-support"))] + pub fn set_steer_ack_timeout_for_test(&self, ms: u64) { + self.steer_ack_timeout_ms.store(ms, Ordering::SeqCst); + } + /// Test-only convenience: spawn an inert (never-suspending, no-spawner) /// backend. Production opens via `open_session` → `spawn_with_wake` with a real /// wake recipe; only the `build_with_io` test seam uses this. @@ -1217,9 +1258,9 @@ impl CodexSessionBackend { let pending_tool_approvals = Arc::new(std::sync::Mutex::new(HashMap::new())); let current_model = Arc::new(Mutex::new(None)); let pending_sends = Arc::new(Mutex::new(HashMap::new())); - let pending_steers = Arc::new(Mutex::new(HashMap::new())); let pending_discovery = Arc::new(Mutex::new(HashMap::new())); let pending_set = Arc::new(Mutex::new(HashMap::new())); + let pending_steers = Arc::new(Mutex::new(HashMap::new())); let pending_resume = Arc::new(Mutex::new(None)); let resume_poison = Arc::new(Mutex::new(None)); let pending_fork = Arc::new(Mutex::new(None)); @@ -1242,9 +1283,9 @@ impl CodexSessionBackend { pending_auth_id: pending_auth_id.clone(), pending_tool_approvals: pending_tool_approvals.clone(), pending_sends: pending_sends.clone(), - pending_steers: pending_steers.clone(), pending_discovery: pending_discovery.clone(), pending_set: pending_set.clone(), + pending_steers: pending_steers.clone(), pending_resume: pending_resume.clone(), resume_poison: resume_poison.clone(), pending_fork: pending_fork.clone(), @@ -1301,13 +1342,14 @@ impl CodexSessionBackend { pending_tool_approvals, current_model, pending_sends, - pending_steers, pending_discovery, pending_set, + pending_steers, pending_resume, resume_poison, pending_fork, discovered, + steer_ack_timeout_ms: AtomicU64::new(STEER_ACK_TIMEOUT_MS), } } @@ -1555,9 +1597,9 @@ async fn reader_task( pending_auth_id: Arc>>, pending_tool_approvals: Arc>>, pending_sends: Arc>>, - pending_steers: PendingSteers, pending_discovery: Arc>>, pending_set: Arc>>, + pending_steers: Arc>>, pending_resume: Arc>>, resume_poison: Arc>>, pending_fork: Arc>>, @@ -1862,22 +1904,6 @@ async fn reader_task( .unwrap_or("request rejected (no error message)") .to_string() }); - if let Some(completion) = pending_steers.lock().await.remove(&rid) { - let result = match ( - frame - .get("result") - .and_then(|result| result.get("turnId")) - .and_then(Value::as_str), - frame.get("error"), - ) { - (Some(turn_id), _) => Ok(turn_id.to_owned()), - (_, Some(error)) if codex_steer_is_too_late(error) => Err("too_late".to_owned()), - (_, Some(error)) => Err(format!("steer_failed:{error}")), - _ => Err("steer_failed:missing turnId response".to_owned()), - }; - let _ = completion.send(result); - continue; - } // (ELECTRON-3Q0 fix A) Claim the thread/resume response. // An ERROR ("no rollout found for thread id …", verified: // samples/codex-cli/0.144.1/dead_resume.jsonl) means the @@ -1985,6 +2011,33 @@ async fn reader_task( } } } + // B5: claim an in-flight `turn/steer` ack and resolve the + // dispatcher's await. A result emits the synthetic + // MessageLifecycle{Completed} receipt (see PendingSteer docs); + // an error hands the raw message to dispatch for the + // message-text classification (§6甲.1). + if let Some(steer) = pending_steers.lock().await.remove(&rid) { + if frame.get("result").is_some() { + if let Some(client_msg_id) = steer.client_msg_id { + emit( + &event_tx, + &session_id, + turn_gen.load(Ordering::SeqCst), + SessionEvent::MessageLifecycle { + client_msg_id, + phase: crate::event::MessageLifecyclePhase::Completed, + }, + ); + } + let _ = steer.ack.send(None); + } else { + let msg = error_message + .clone() + .unwrap_or_else(|| "steer rejected (no error message)".into()); + let _ = steer.ack.send(Some(msg)); + } + continue; + } // B-CODEX-MODEL-LIST / O2: claim a discovery response. // model/list + collaborationMode/list fill the // `discovered` cache (capabilities() merges them); @@ -4068,57 +4121,120 @@ impl SessionBackend for CodexSessionBackend { turn_gen: self.turn_gen.load(Ordering::SeqCst), }) } - Command::Steer { - content, - client_user_message_id, - } => { - // REAL codex: `turn/steer{threadId, expectedTurnId, input}` — a SOFT - // injection (queued to the active turn's input, NOT a hard cancel; - // contrast turn/interrupt). The optimistic `expectedTurnId` is the - // gated-steering wire: codex rejects (activeTurnNotSteerable) if the - // turn already ended. NoTurn admission (no new turn_gen — folds into - // the live turn, b-side FSM never sees Steer). + Command::Steer { content, client_msg_id } => { + // REAL codex: `turn/steer{threadId, expectedTurnId, input, + // clientUserMessageId}` — a SOFT injection (queued to the active + // turn's input, NOT a hard cancel; contrast turn/interrupt). The + // optimistic `expectedTurnId` is the gated-steering wire; params + // verified against the official schema (samples/codex-cli/ + // 0.137.0/schema-full/ClientRequest.json TurnSteerParams, design + // spec §6甲.10 — `clientUserMessageId` is the client correlation + // id codex round-trips). NoTurn admission (no new turn_gen — + // folds into the live turn, b-side FSM never sees Steer). + // + // The RPC response is AWAITED (codex acks ~0ms, §6.2) so the + // caller learns a rejection synchronously and can fall back. let tid = self.bound_thread().await?; let Some(turn_id) = self.active_turn_id.lock().await.clone() else { - // No active turn to steer into. - return Err(BackendError::Transport("too_late".into())); + // No active turn to steer into. Same message as codex's own + // wire rejection so the caller classifies both uniformly. + return Err(BackendError::Transport("no active turn to steer".into())); }; - let id = self.next_rpc_id(); - let (completion_tx, completion_rx) = oneshot::channel(); - self.pending_steers.lock().await.insert(id, completion_tx); - let frame = json!({ - "jsonrpc": "2.0", "id": id, "method": "turn/steer", - "params": { + let mut expected_turn_id = turn_id; + let ack_timeout = std::time::Duration::from_millis(self.steer_ack_timeout_ms.load(Ordering::SeqCst)); + for attempt in 0..2u8 { + let id = self.next_rpc_id(); + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel(); + self.pending_steers.lock().await.insert( + id, + PendingSteer { + client_msg_id: client_msg_id.clone(), + ack: ack_tx, + }, + ); + let mut params = json!({ "threadId": tid, - "expectedTurnId": turn_id, + "expectedTurnId": expected_turn_id, "input": build_input(&content), - "clientUserMessageId": client_user_message_id, - } - }); - if let Err(error) = self.write_frame(frame).await { - self.pending_steers.lock().await.remove(&id); - return Err(error); - } - let response = tokio::time::timeout(std::time::Duration::from_secs(10), completion_rx).await; - match response { - Ok(Ok(Ok(response_turn_id))) if response_turn_id == turn_id => {} - Ok(Ok(Ok(response_turn_id))) => { - return Err(BackendError::Transport(format!( - "steer_failed:response turn mismatch ({response_turn_id})" - ))); + }); + if let Some(cmid) = client_msg_id.as_deref() { + params["clientUserMessageId"] = json!(cmid); } - Ok(Ok(Err(code))) => return Err(BackendError::Transport(code)), - Ok(Err(_)) => return Err(BackendError::Transport("steer_failed:response channel closed".into())), - Err(_) => { + let frame = json!({ "jsonrpc": "2.0", "id": id, "method": "turn/steer", "params": params }); + if let Err(e) = self.write_frame(frame).await { self.pending_steers.lock().await.remove(&id); - return Err(BackendError::Transport("steer_failed:response timeout".into())); + return Err(e); + } + match tokio::time::timeout(ack_timeout, ack_rx).await { + Ok(Ok(None)) => { + return Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }); + } + Ok(Ok(Some(msg))) => { + // codex returns a bare -32600 for both rejections; the + // message text is the ONLY discriminator (verified + // 0.144.6, design spec §6甲.1). Locked by test so a + // codex wording change fails here instead of silently + // degrading. + if msg.contains("no active turn to steer") { + // The turn ended between our read and the write → + // the caller opens a new turn (normal send path). + tracing::warn!( + conversation_id = %self.session_id, + classification = "turn_ended", + fallback = "caller opens a new turn", + "codex rejected turn/steer" + ); + return Err(BackendError::Transport("no active turn to steer".into())); + } + if msg.contains("expected active turn id") + && attempt == 0 + && let Some(found) = parse_found_turn_id(&msg) + { + // A DIFFERENT turn is active → retry steer against + // the id in the message (it names the live turn). + tracing::warn!( + conversation_id = %self.session_id, + classification = "different_turn_active", + fallback = "retry steer with the reported active turn id", + "codex rejected turn/steer" + ); + expected_turn_id = found; + continue; + } + tracing::warn!( + conversation_id = %self.session_id, + classification = "unrecognized", + fallback = "surface the rejection to the caller", + "codex rejected turn/steer" + ); + return Err(BackendError::Transport(format!("turn/steer rejected: {msg}"))); + } + // Ack lost (reader gone) or timed out: degrade to the old + // fire-and-forget contract — the frame is already written, + // and blocking the send path on a wedged pipe is worse. + Ok(Err(_)) | Err(_) => { + self.pending_steers.lock().await.remove(&id); + tracing::warn!( + conversation_id = %self.session_id, + timeout_ms = ack_timeout.as_millis() as u64, + "turn/steer ack not received; assuming delivered (fire-and-forget degradation)" + ); + return Ok(CommandReceipt { + accepted: true, + admission: Admission::NoTurn, + turn_gen: self.turn_gen.load(Ordering::SeqCst), + }); + } } } - Ok(CommandReceipt { - accepted: true, - admission: Admission::NoTurn, - turn_gen: self.turn_gen.load(Ordering::SeqCst), - }) + // Second rejection after the retry — surface it. + Err(BackendError::Transport( + "turn/steer rejected twice (active turn changed repeatedly)".into(), + )) } Command::SetMode { mode } => { // F-4: SetMode is a between-turn config write that can arrive while @@ -4600,6 +4716,17 @@ fn route_slash_command(content: &[ContentBlock]) -> Option { } } +/// Extract the LIVE turn id from codex's steer rejection message +/// ``expected active turn id `A` but found `B` `` (verified 0.144.6, design +/// spec §6甲.1 case 1b/1d): `B` names the currently-active turn, so a retry can +/// target it. Returns `None` when the message shape is unrecognized (the caller +/// then surfaces the rejection instead of retrying blind). +fn parse_found_turn_id(msg: &str) -> Option { + let after = msg.split("but found `").nth(1)?; + let id = after.split('`').next()?.trim(); + (!id.is_empty()).then(|| id.to_owned()) +} + fn build_input(content: &[ContentBlock]) -> Vec { content .iter() @@ -4664,6 +4791,13 @@ mod tests { use crate::testing::FakeAgentIo; use futures_util::StreamExt; + /// Verified backend matrix (task-1 brief): codex MUST advertise + /// `supports_midturn_delivery` so mid-turn UI can gate on it. + #[test] + fn capabilities_advertise_midturn_delivery() { + assert!(codex_capabilities().supports_midturn_delivery); + } + /// A retrying error must reach the user, not just tick the heartbeat. /// /// Frame captured live from codex 0.147.0, whose response stream kept @@ -7084,35 +7218,20 @@ mod tests { #[tokio::test] async fn dispatch_steer_writes_turn_steer_with_expected_turn_id() { - // R6 Steer → `turn/steer{threadId, expectedTurnId, input}` (soft injection; - // NoTurn admission — no new turn_gen). The expectedTurnId is the active turn. - let prefix = concat!( - r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-3"}}}"#, - "\n", - r#"{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"th-3","turn":{"id":"turn-X"}}}"#, - "\n" - ) - .as_bytes() - .to_vec(); - let tail = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"turnId\":\"turn-X\"}}\n".to_vec(); - let fake = FakeAgentIo::new(prefix, None).with_gated_tail(tail); - let release = fake.stdout_releaser(); + // R6/B5 Steer → `turn/steer{threadId, expectedTurnId, input, + // clientUserMessageId}` (soft injection; NoTurn admission — no new + // turn_gen). The expectedTurnId is the active turn; clientUserMessageId + // is the mid-turn correlation id (official schema, design spec §6甲.10). + // No response is scripted → the ack await degrades to fire-and-forget + // after the (shortened) timeout, still returning an accepted receipt. + let fake = fake_with_binding("th-3", Some("turn-X")); let captured = fake.captured_stdin(); let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; - let request_capture = Arc::clone(&captured); - tokio::spawn(async move { - for _ in 0..200 { - if String::from_utf8_lossy(&request_capture.lock().await).contains(r#""method":"turn/steer""#) { - release(); - return; - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; - } - }); + backend.set_steer_ack_timeout_for_test(100); let receipt = backend .dispatch(Command::Steer { content: vec![ContentBlock::Text("STEERED".into())], - client_user_message_id: Some("input-1".into()), + client_msg_id: Some("cmsg-7".into()), }) .await .expect("accepted"); @@ -7126,6 +7245,10 @@ mod tests { written.contains(r#""expectedTurnId":"turn-X""#), "gated by the active turn token, got: {written}" ); + assert!( + written.contains(r#""clientUserMessageId":"cmsg-7""#), + "carries the correlation id so codex round-trips it (§6甲.10), got: {written}" + ); assert!(written.contains("STEERED"), "carries the steer text, got: {written}"); assert!( written.contains(r#""clientUserMessageId":"input-1""#), @@ -7153,57 +7276,163 @@ mod tests { #[tokio::test] async fn dispatch_steer_without_active_turn_is_rejected() { - // No active turn → nothing to inject into → reject (matches codex's - // activeTurnNotSteerable; we pre-empt the wire roundtrip). + // No active turn → nothing to inject into → reject with the SAME message + // text codex's wire rejection uses (bare -32600 "no active turn to + // steer", verified 0.144.6 §6甲.1 — NOT a distinct error code), so the + // conversation layer classifies both uniformly. let fake = fake_with_binding("th-3", None); // bound thread but NO active turn let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; let err = backend .dispatch(Command::Steer { content: vec![ContentBlock::Text("late".into())], - client_user_message_id: None, + client_msg_id: None, }) .await .expect_err("steer with no active turn must be rejected"); - assert!(matches!(err, BackendError::Transport(m) if m == "too_late")); + assert!(matches!(err, BackendError::Transport(m) if m.contains("no active turn to steer"))); } + /// B5 §6甲.1 case 1a: codex rejects an in-flight steer with a bare -32600 + /// whose MESSAGE says the turn ended → the error must surface verbatim- + /// classifiable ("no active turn to steer") so the conversation layer opens + /// a new turn. Locked to the live 0.144.6 wording. #[tokio::test] - async fn dispatch_steer_maps_active_turn_rejection_to_too_late() { - let prefix = concat!( - r#"{"jsonrpc":"2.0","method":"thread/started","params":{"thread":{"id":"th-3"}}}"#, - "\n", - r#"{"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"th-3","turn":{"id":"turn-X"}}}"#, - "\n" - ) - .as_bytes() - .to_vec(); - let tail = - b"{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32602,\"message\":\"activeTurnNotSteerable\"}}\n" - .to_vec(); - let fake = FakeAgentIo::new(prefix, None).with_gated_tail(tail); - let release = fake.stdout_releaser(); + async fn steer_wire_rejection_turn_ended_surfaces_classifiable_error() { + let fake = fake_with_binding("th-9", Some("turn-X")).with_gated_tail( + format!( + "{}\n", + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"no active turn to steer"}}"# + ) + .into_bytes(), + ); let captured = fake.captured_stdin(); - let backend = CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await; - let request_capture = Arc::clone(&captured); - tokio::spawn(async move { - for _ in 0..200 { - if String::from_utf8_lossy(&request_capture.lock().await).contains(r#""method":"turn/steer""#) { - release(); - return; - } - tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let releaser = fake.stdout_releaser(); + let backend = Arc::new(CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await); + let dispatch = { + let backend = Arc::clone(&backend); + tokio::spawn(async move { + backend + .dispatch(Command::Steer { + content: vec![ContentBlock::Text("late".into())], + client_msg_id: Some("cmsg-1".into()), + }) + .await + }) + }; + // Wait until the steer frame is on the wire (pending registered), then + // release the scripted error response. + assert!(!captured_str(&captured).await.is_empty(), "steer frame must be written"); + releaser(); + let err = dispatch.await.unwrap().expect_err("wire rejection must surface"); + assert!( + matches!(&err, BackendError::Transport(m) if m.contains("no active turn to steer")), + "turn-ended rejection classifiable by message text, got {err:?}" + ); + } + + /// B5 §6甲.1 case 1b: ``expected active turn id `A` but found `B` `` names + /// the LIVE turn → dispatch retries ONCE against `B` and succeeds. + #[tokio::test] + async fn steer_wire_rejection_different_turn_retries_with_reported_id() { + let seg1 = format!( + "{}\n", + r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"expected active turn id `turn-A` but found `turn-B`"}}"# + ); + let seg2 = format!("{}\n", r#"{"jsonrpc":"2.0","id":2,"result":{"turnId":"turn-B"}}"#); + let fake = + fake_with_binding("th-9", Some("turn-A")).with_gated_segments(vec![seg1.into_bytes(), seg2.into_bytes()]); + let captured = fake.captured_stdin(); + let release = fake.segment_releaser(); + let backend = Arc::new(CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await); + let dispatch = { + let backend = Arc::clone(&backend); + tokio::spawn(async move { + backend + .dispatch(Command::Steer { + content: vec![ContentBlock::Text("mid".into())], + client_msg_id: Some("cmsg-2".into()), + }) + .await + }) + }; + // First steer on the wire → release the rejection naming turn-B. + assert!(!captured_str(&captured).await.is_empty(), "first steer written"); + release(); + // Wait for the RETRY frame targeting turn-B, then release its result. + for _ in 0..80 { + if captured_str(&captured).await.contains(r#""expectedTurnId":"turn-B""#) { + break; } - }); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + release(); + let receipt = dispatch.await.unwrap().expect("retry against turn-B must succeed"); + assert_eq!(receipt.admission, Admission::NoTurn); + let written = captured_str(&captured).await; + assert!( + written.contains(r#""expectedTurnId":"turn-B""#), + "retry targets the id parsed from the rejection message, got: {written}" + ); + } - let error = backend - .dispatch(Command::Steer { - content: vec![ContentBlock::Text("late".into())], - client_user_message_id: Some("input-late".into()), + /// B5: a successful steer ack emits the synthetic + /// `MessageLifecycle{Completed}` receipt for the correlation id (codex has + /// no command_lifecycle wire; the RPC result IS its acceptance — §6.2). NOT + /// `Started`: that would arm the conversation watcher's orphan-turn claim, + /// which exists for claude's follow-up-turn case only (§6甲.6). + #[tokio::test] + async fn steer_ack_emits_message_lifecycle_completed() { + let fake = fake_with_binding("th-9", Some("turn-X")) + .with_gated_tail(format!("{}\n", r#"{"jsonrpc":"2.0","id":1,"result":{"turnId":"turn-X"}}"#).into_bytes()); + let captured = fake.captured_stdin(); + let releaser = fake.stdout_releaser(); + let backend = Arc::new(CodexSessionBackend::build_with_io("codex-1", Box::new(fake)).await); + let mut events = backend.events(); + let dispatch = { + let backend = Arc::clone(&backend); + tokio::spawn(async move { + backend + .dispatch(Command::Steer { + content: vec![ContentBlock::Text("mid".into())], + client_msg_id: Some("cmsg-3".into()), + }) + .await }) - .await - .expect_err("the backend rejection must not be acknowledged"); + }; + assert!(!captured_str(&captured).await.is_empty(), "steer frame written"); + releaser(); + dispatch.await.unwrap().expect("ack accepted"); + let lifecycle = tokio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(env) = events.next().await { + if let SessionEvent::MessageLifecycle { client_msg_id, phase } = env.event { + return Some((client_msg_id, phase)); + } + } + None + }) + .await + .ok() + .flatten() + .expect("MessageLifecycle receipt must be emitted on the steer ack"); + assert_eq!(lifecycle.0, "cmsg-3"); + assert_eq!(lifecycle.1, crate::event::MessageLifecyclePhase::Completed); + } - assert!(matches!(error, BackendError::Transport(code) if code == "too_late")); + /// The §6甲.1 message-text parse is load-bearing (both rejections share the + /// same -32600 code) — lock the extraction so a codex wording change fails + /// HERE instead of silently degrading the retry path. + #[test] + fn parse_found_turn_id_extracts_the_live_turn() { + assert_eq!( + parse_found_turn_id("expected active turn id `turn-A` but found `turn-B`"), + Some("turn-B".into()) + ); + assert_eq!( + parse_found_turn_id("expected active turn id `00000000-0000-0000-0000-000000000000` but found `turn-B`"), + Some("turn-B".into()) + ); + assert_eq!(parse_found_turn_id("no active turn to steer"), None); + assert_eq!(parse_found_turn_id("expected active turn id but found ``"), None); } #[tokio::test] @@ -9032,11 +9261,11 @@ mod tests { let res = backend .dispatch(Command::Steer { content: vec![ContentBlock::Text("wait, also do X".into())], - client_user_message_id: None, + client_msg_id: None, }) .await; assert!( - matches!(&res, Err(BackendError::Transport(m)) if m == "too_late"), + matches!(&res, Err(BackendError::Transport(m)) if m.contains("no active turn to steer")), "R4: a Steer after the turn completed (active_turn_id cleared) is rejected with \ 'no active turn to steer' (got {res:?}) — the user's end-of-turn steer text \ vanishes as a failure. If a fix queues a just-missed steer as a fresh turn, \ diff --git a/crates/aionui-session/src/backend/types.rs b/crates/aionui-session/src/backend/types.rs index 7a1565ecf..58a2131ea 100644 --- a/crates/aionui-session/src/backend/types.rs +++ b/crates/aionui-session/src/backend/types.rs @@ -33,10 +33,14 @@ pub enum Command { Cancel { target: CancelTarget }, /// Inject content into the in-flight turn (adapter-private gated steering; /// the b-side FSM never sees Steer). Gated by `supported_commands.steer`. + /// + /// `client_msg_id` is the mid-turn correlation id (B5): claude stamps it as + /// the user frame's `uuid` (echoed back via `command_lifecycle`), codex + /// sends it as `turn/steer.clientUserMessageId` (official schema field, + /// design spec §6甲.10). `None` = no correlation (legacy callers / host steer). Steer { content: Vec, - /// Stable host input id forwarded to backends that support request correlation. - client_user_message_id: Option, + client_msg_id: Option, }, /// Answer a `Permission{request_id}` the backend raised. `decision` is the /// coarse allow/deny (sound for every generic tool approval). diff --git a/crates/aionui-session/src/capability.rs b/crates/aionui-session/src/capability.rs index 57cac7356..3e2b5c69d 100644 --- a/crates/aionui-session/src/capability.rs +++ b/crates/aionui-session/src/capability.rs @@ -87,6 +87,19 @@ pub struct Capabilities { /// it — a dead button (MX-QUEUE-3). `can_queue` MUST read this bit, never /// `caps.steer`. Default false; only claude sets it true. pub accepts_proactive_input: bool, + /// Whether a message written while a turn is IN FLIGHT reaches the agent + /// without waiting for that turn to end. + /// + /// ⚠️ DELIBERATELY SEPARATE from `accepts_proactive_input`, whose meaning + /// varies by backend: agy sets it true merely to keep the input box usable + /// (one process per turn — it ignores stdin mid-turn), while claude sets it + /// true because a mid-turn write is genuinely consumed. Gating any UI + /// affordance on `accepts_proactive_input` would light a mid-turn control on + /// agy that it cannot honour (the MX-QUEUE-3 dead button). + /// + /// This bit is the ONLY one the wire and the frontend may read. Default + /// false so a newly integrated backend behaves like ACP until proven. + pub supports_midturn_delivery: bool, /// When a mode switch this backend ACCEPTS starts governing tool approvals. /// /// Reported per call rather than statically, because the same backend answers @@ -240,3 +253,54 @@ pub struct SignalSet { /// the false branch has no fixture (recorded as a 04 residual). pub terminal_result: bool, } + +/// Backend-STATIC view of [`Capabilities::supports_midturn_delivery`], keyed by +/// the runtime backend identifier (the `extra.backend` / assistant +/// `runtime_backend` string the conversation layer persists and the session +/// factories dispatch on). +/// +/// The bit is a property of the backend TYPE, not of any live session: claude +/// and codex are the direct-CLI backends whose mid-turn write genuinely reaches +/// the agent; antigravity, aionrs, and every ACP agent are one-prompt-at-a-time. +/// Consumers use this when no live `SessionBackend` exists (fresh or dormant +/// conversations) so the reported capability cannot flap with agent liveness. +/// Unknown/empty identifiers are conservatively `false` (ACP-like until proven, +/// same default as `Capabilities`). Locked against the real capability +/// constructors by tests below and module-local asserts in each backend. +pub fn backend_supports_midturn_delivery(backend: &str) -> bool { + matches!(backend, "claude" | "codex") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn midturn_delivery_is_opt_in_and_antigravity_is_excluded() { + // Default MUST be false: a new backend is ACP-like until proven otherwise. + assert!(!Capabilities::default().supports_midturn_delivery); + } + + /// The static per-backend table must never drift from the bit the real + /// capability constructors declare. (claude has no pub constructor — its + /// lock lives module-local in `adapter::claude` tests.) + #[test] + fn static_backend_table_matches_capability_constructors() { + assert_eq!( + backend_supports_midturn_delivery("codex"), + crate::backend::codex_capabilities().supports_midturn_delivery, + ); + assert_eq!( + backend_supports_midturn_delivery("antigravity"), + crate::backend::antigravity_capabilities().supports_midturn_delivery, + ); + // Any ACP agent id (open set) falls through to the ACP connection's bit. + assert_eq!( + backend_supports_midturn_delivery("gemini"), + crate::backend::acp_capabilities().supports_midturn_delivery, + ); + // Unknown/empty backends are conservatively false. + assert!(!backend_supports_midturn_delivery("")); + assert!(!backend_supports_midturn_delivery("aionrs")); + } +} diff --git a/crates/aionui-session/src/event.rs b/crates/aionui-session/src/event.rs index 774d2ef3f..942b2d079 100644 --- a/crates/aionui-session/src/event.rs +++ b/crates/aionui-session/src/event.rs @@ -608,6 +608,19 @@ pub enum SessionEvent { /// pass-through, reducer no-op, never persisted as an event. Only codex /// emits it today; backends without a turn-anchored fork never do. BackendTurnBound { backend_turn_id: String }, + + /// Task 2 (mid-turn interjection observability): claude echoes back the + /// `uuid` WE minted on a user frame via a `command_lifecycle` frame + /// (`{"type":"command_lifecycle","command_uuid":"","state":"queued" + /// |"started"|"completed"|"cancelled"}`, verified 2.1.226, design spec + /// §6.1). `client_msg_id` is that echoed uuid — the SAME correlation key + /// the pending-queue already tracks — so consumers need no guessing. + /// Reducer no-op today (no consumer yet — Task 3); this is a pure + /// observation of "the user message was consumed by the agent". + MessageLifecycle { + client_msg_id: String, + phase: MessageLifecyclePhase, + }, } // ========================================================================== @@ -683,6 +696,22 @@ pub enum PermissionKind { Auth, } +/// Phase of a user message the CLI reports back. claude emits these as +/// `command_lifecycle` frames echoing the `uuid` WE minted on the user frame, +/// so correlation needs no guessing (verified 2.1.226; see the design spec +/// §6.1). Advertised as `msg_lifecycle_v1` in `system/init` capabilities. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum MessageLifecyclePhase { + /// Written to the CLI, not yet consumed into a turn. + Queued, + /// The agent has taken the message into a turn. + Started, + /// The turn that consumed it has finished. + Completed, + /// Dropped without being consumed. + Cancelled, +} + /// LC-8a: one entry in a [`SessionEvent::Plan`] to-do snapshot. Unified shape over /// codex `TurnPlanStep{step,status}` (priority absent) and ACP `PlanEntry{content, /// status,priority}`. The reducer never reads it (plan is FSM-orthogonal content); @@ -954,7 +983,8 @@ pub fn classify(event: &SessionEvent) -> EventClass { | MessageFinalized(..) | SessionInfo { .. } | SessionTitle { .. } - | CheckpointList { .. } => EventClass::BackendProduced, + | CheckpointList { .. } + | MessageLifecycle { .. } => EventClass::BackendProduced, } } @@ -991,6 +1021,9 @@ pub fn persist_tier(event: &SessionEvent) -> PersistTier { // the phase list is re-declared on the next run's first progress frame WorkflowPhase { .. } => PersistTier::Ephemeral, Plan { .. } => PersistTier::Ephemeral, // LC-8a: live to-do snapshot, full-replace + re-derivable (not history) + // Task 2: a pure liveness/correlation signal (queued→started→completed/ + // cancelled), re-derivable from the next turn's own frames — not history. + MessageLifecycle { .. } => PersistTier::Ephemeral, ToolCall { .. } | ToolResult { .. } | UsageDelta { .. } @@ -1442,16 +1475,26 @@ mod additive_tests { ), // ── backend-produced, State ── ("Rewound", SessionEvent::Rewound { to_turn: 1 }, BackendProduced, State), + // Task 2: a pure correlation/liveness signal, re-derivable — Ephemeral. + ( + "MessageLifecycle", + SessionEvent::MessageLifecycle { + client_msg_id: "u-1".into(), + phase: MessageLifecyclePhase::Queued, + }, + BackendProduced, + Ephemeral, + ), ]; - // Tripwire: every SessionEvent variant must appear. 33 variants today - // (7 orchestration-lowered + 26 backend-produced, incl. Notice + - // ToolOutputDelta + TurnDiffUpdated + SessionInfo + SessionTitle); - // AdapterSpecific appears twice for its raw-timing vs structured split - // → 34 rows. A new variant trips. + // Tripwire: every SessionEvent variant must appear. 34 variants today + // (7 orchestration-lowered + 27 backend-produced, incl. Notice + + // ToolOutputDelta + TurnDiffUpdated + SessionInfo + SessionTitle + + // MessageLifecycle); AdapterSpecific appears twice for its raw-timing + // vs structured split → 35 rows. A new variant trips. assert_eq!( table.len(), - 34, + 35, "every SessionEvent variant (+ the AdapterSpecific timing split) must be routed here" ); @@ -1670,6 +1713,10 @@ mod additive_tests { context_usage: None, cost_text: Some("Total cost: $0.1180".into()), }, + SessionEvent::MessageLifecycle { + client_msg_id: "u-1".into(), + phase: MessageLifecyclePhase::Started, + }, ]; for ev in events { let json = serde_json::to_string(&ev).expect("serialize"); diff --git a/crates/aionui-session/src/lib.rs b/crates/aionui-session/src/lib.rs index 01fa9dea3..3e2e381e4 100644 --- a/crates/aionui-session/src/lib.rs +++ b/crates/aionui-session/src/lib.rs @@ -66,15 +66,15 @@ pub use backend::{ }; pub use capability::{ BlockSet, Capabilities, CapabilityTier, CommandSet, ModeInfo, ModeSwitchEffect, ModelInfo, PromptAcceptedSource, - SignalSet, SlashCommandInfo, block_kind_name, + SignalSet, SlashCommandInfo, backend_supports_midturn_delivery, block_kind_name, }; pub use error::SessionError; pub use event::UsageBreakdown; pub use event::{ - CancelReason, CheckpointEntry, EventClass, ExitStatusLite, FinalizedMessage, ItemKind, NoticeLevel, Outcome, - PermissionKind, PersistTier, PlanEntry, PlanPriority, PlanStatus, ProvisioningPhase, SessionEvent, StopReason, - SubagentKind, SubagentStatus, SubagentTaskKind, ToolResultContent, TruncationInfo, TruncationKind, TurnOutcome, - classify, persist_tier, + CancelReason, CheckpointEntry, EventClass, ExitStatusLite, FinalizedMessage, ItemKind, MessageLifecyclePhase, + NoticeLevel, Outcome, PermissionKind, PersistTier, PlanEntry, PlanPriority, PlanStatus, ProvisioningPhase, + SessionEvent, StopReason, SubagentKind, SubagentStatus, SubagentTaskKind, ToolResultContent, TruncationInfo, + TruncationKind, TurnOutcome, classify, persist_tier, }; pub use reducer::{Transition, crash_outcome, step}; pub use state::{ diff --git a/crates/aionui-session/src/reducer.rs b/crates/aionui-session/src/reducer.rs index 6980aaa9d..4f9bee021 100644 --- a/crates/aionui-session/src/reducer.rs +++ b/crates/aionui-session/src/reducer.rs @@ -447,7 +447,10 @@ pub fn step(state: &SessionState, event: SessionEvent) -> (SessionState, Vec (state.clone(), Vec::new()), + | SessionEvent::BackendSuspended + // Task 2: a pure observability signal (queued/started/completed/cancelled + // echo of a user message) — no consumer yet (Task 3). Never a turn signal. + | SessionEvent::MessageLifecycle { .. } => (state.clone(), Vec::new()), // ⭐ SubagentUpdate: the ONE §6b b1 reducer READ. Upsert into // Running.subagents by `ref` (last-write-wins). This is the SOLE diff --git a/crates/aionui-session/src/state.rs b/crates/aionui-session/src/state.rs index 5e2685d15..4022c220b 100644 --- a/crates/aionui-session/src/state.rs +++ b/crates/aionui-session/src/state.rs @@ -244,17 +244,18 @@ pub fn can_send_message(state: &SessionState) -> bool { /// flight, not blocked on a permission/auth the user must answer first). /// `Starting`/`Error`/`Idle` never queue (Idle is `can_send`, not queue). /// (b) capability half — the backend `accepts_proactive_input` (claude's stdin -/// FIFO). ⚠️ NOT `caps.supported_commands.steer`: codex advertises steer but -/// the conv layer doesn't route it, so keying off steer would surface a dead -/// queue affordance (MX-QUEUE-3). ACP/aionrs lack the path → degrade to false. +/// FIFO; codex since B5 wired mid-turn Steer routing). ⚠️ NOT +/// `caps.supported_commands.steer`: a backend could advertise steer without +/// the conv layer routing it, surfacing a dead queue affordance +/// (MX-QUEUE-3). ACP/aionrs lack the path → degrade to false. /// /// Orthogonal to `can_send` (Idle): `can_send || can_queue` is the input-box gate. /// Truth table (× backend `accepts_proactive_input`): -/// | state | claude(true) | codex/acp/aionrs(false) | -/// |------------------------------------|--------------|-------------------------| -/// | Idle / Starting / Error | false | false | -/// | Running { requires_action empty } | true | false | -/// | Running { requires_action count>0 }| false | false | +/// | state | claude/codex(true) | acp/aionrs(false) | +/// |------------------------------------|--------------------|-------------------| +/// | Idle / Starting / Error | false | false | +/// | Running { requires_action empty } | true | false | +/// | Running { requires_action count>0 }| false | false | pub fn can_queue_message(state: &SessionState, accepts_proactive_input: bool) -> bool { accepts_proactive_input && matches!(state, SessionState::Running { .. }) && !is_requires_action(state) } @@ -459,10 +460,10 @@ mod tests { ), "Error cannot queue" ); - // codex/acp/aionrs (accepts_proactive_input=false): degrades to false in - // EVERY state — including Running no-RA where claude would queue. This is - // the MX-QUEUE-3 dead-button guard: the gate is the proactive-input bit, - // NOT supported_commands.steer (which codex sets true). + // acp/aionrs (accepts_proactive_input=false): degrades to false in + // EVERY state — including Running no-RA where claude/codex would queue. + // This is the MX-QUEUE-3 dead-button guard: the gate is the + // proactive-input bit, NOT supported_commands.steer. assert!( !can_queue_message(&ra(0), false), "no proactive-input path → never queue" diff --git a/crates/aionui-session/src/testing.rs b/crates/aionui-session/src/testing.rs index c41a4364d..d7c935f92 100644 --- a/crates/aionui-session/src/testing.rs +++ b/crates/aionui-session/src/testing.rs @@ -554,7 +554,7 @@ pub mod invariants { "steer", Command::Steer { content: Vec::new(), - client_user_message_id: None, + client_msg_id: None, }, |c| c.supported_commands.steer, ), From fd7e6faff227154a9258ff5c6f37a21d35cbac2e Mon Sep 17 00:00:00 2001 From: kaizhou-lab <1558390418@qq.com> Date: Wed, 19 Aug 2026 17:43:57 +0800 Subject: [PATCH 7/9] feat(session): distinguish Task subagents from background tasks (#890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Task subagent and a background bash render identically in the step list. Two changes converged on this: - #758 folded `local_agent` and `local_bash` into one `CardKind::BackgroundTask`, both headlined `bg task · `. - #870/#872 replaced the bare tool-name column (`Agent` vs `Bash` — the last visible difference) with description-derived labels. After that, a 20-minute foreground Task subagent showed as a wall of flat tool calls under a `bg task` chip, indistinguishable from a background shell (live 2026-08-19). The subagent's internal calls were also un-attributable: the adapter reads the frame-level `parent_tool_use_id` (009 H5), but the pump dropped it at the `ToolCallEventData` boundary, so neither the wire event nor the persisted row carried it. 1. **`SubagentTaskKind::AgentContainer`** — `sniff_task` maps `task_started.task_type: "local_agent"` to it (verified: `tests/fixtures/claude_2.1.169_single_tool_turn.ndjson`; the wire declares foreground and background Tasks the same way). `WorkflowContainer` alone still gates the Finish-suppression roster — turn-holding semantics are unchanged. 2. **`WorkflowCard::new_subagent`** — same card machinery as `new_background`, headline word `subagent` instead of `bg task`. The pump picks it for `AgentContainer` tasks. A Task row now reads `修复 AIONUI-151 桌面 401 恢复 · subagent ae859b22dc5afbdca · 22:25`; a background bash keeps `bg task`. 3. **`ToolCallEventData.parent_call_id`** — `translate_event` now carries the frame's `parent_tool_use_id` on ToolCall/ToolResult frames, and it persists into the `tool_call` row's content. This is the enabler for the frontend to group/indent a subagent's internal steps under its launching Task row (frontend change tracked separately). The field is `skip_serializing_if = None`, so the DB's `json_patch` upsert (`sqlite_conversation::upsert_message`) never erases stored attribution when a later parentless frame — the terminal `tool_result`, a card refresh, a turn-end close — merges into the same row. Deliberately NOT changed: a subagent-internal background bash still opens its own card. The card attaches to a real, visible row and is currently the only liveness signal inside a subagent stretch; suppressing it would also perturb the `live_background_tasks` count that - `subagent_card_says_subagent_not_bg_task` (card unit) and `task_subagent_card_is_labelled_subagent_and_children_carry_parent` (pump-level, 2.1.169-shaped script) — headline word + parent attribution on internal calls, `None` on the main-agent launching call. - `sniff_task_emits_subagent_update_lifecycle` extended with a `local_agent` frame → `AgentContainer`. - `parent_call_id` serialization asserted present-when-Some and absent-when-None (merge-patch safety). - Existing background-card, workflow, relay, and persistence suites pass unchanged: `cargo test -p aionui-session --lib sniff_task` (5), `-p aionui-ai-agent --lib background_/workflow/tool_call_event/subagent` (42), `-p aionui-conversation --lib background_stream` (13) + `--test stream_relay_tool_call/thinking_persistence/acp_tool_call_persistence` (7), `-p aionui-channel --test stream_relay_test` (8). `cargo clippy -p aionui-session -p aionui-ai-agent -p aionui-conversation -p aionui-channel --all-targets -- -D warnings` and `cargo fmt --all -- --check` clean. Co-authored-by: zk <> --- .../src/capability/backend_output_sink.rs | 2 + .../src/capability/backend_protocol_sink.rs | 1 + .../src/manager/acp/agent_session_flow.rs | 1 + .../src/protocol/events/mod.rs | 7 ++ .../src/protocol/events/tool_call.rs | 8 ++ crates/aionui-ai-agent/src/session_agent.rs | 100 +++++++++++++++++- .../aionui-ai-agent/src/workflow_progress.rs | 78 ++++++++++++-- crates/aionui-channel/src/message_service.rs | 1 + .../aionui-channel/tests/stream_relay_test.rs | 3 + .../src/background_stream.rs | 4 + .../aionui-conversation/src/service_test.rs | 1 + .../aionui-conversation/src/stream_relay.rs | 8 ++ .../tests/stream_relay_tool_call.rs | 6 ++ .../tests/thinking_persistence.rs | 1 + .../aionui-session/src/backend/claude_conn.rs | 42 +++++--- crates/aionui-session/src/event.rs | 12 ++- 16 files changed, 249 insertions(+), 26 deletions(-) diff --git a/crates/aionui-ai-agent/src/capability/backend_output_sink.rs b/crates/aionui-ai-agent/src/capability/backend_output_sink.rs index 992667241..f33a3c49d 100644 --- a/crates/aionui-ai-agent/src/capability/backend_output_sink.rs +++ b/crates/aionui-ai-agent/src/capability/backend_output_sink.rs @@ -76,6 +76,7 @@ impl OutputSink for BackendOutputSink { input: Some(parsed_input), output: None, description: None, + parent_call_id: None, })); } @@ -115,6 +116,7 @@ impl OutputSink for BackendOutputSink { Some(content.to_owned()) }, description: None, + parent_call_id: None, })); } diff --git a/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs b/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs index ca700bf88..946d14a92 100644 --- a/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs +++ b/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs @@ -219,6 +219,7 @@ impl ProtocolEmitter for BackendProtocolSink { }) .to_string(), ), + parent_call_id: None, })); } diff --git a/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs b/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs index 7997f8298..a38b1c8ca 100644 --- a/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs +++ b/crates/aionui-ai-agent/src/manager/acp/agent_session_flow.rs @@ -1353,6 +1353,7 @@ mod tests { input: None, output: None, description: None, + parent_call_id: None, })) .unwrap(); diff --git a/crates/aionui-ai-agent/src/protocol/events/mod.rs b/crates/aionui-ai-agent/src/protocol/events/mod.rs index 76ee7f3ff..24cf8b9cf 100644 --- a/crates/aionui-ai-agent/src/protocol/events/mod.rs +++ b/crates/aionui-ai-agent/src/protocol/events/mod.rs @@ -338,6 +338,7 @@ mod tests { input: None, output: None, description: None, + parent_call_id: None, }); let json = serde_json::to_value(&event).unwrap(); assert_eq!(json["type"], "tool_call"); @@ -355,12 +356,14 @@ mod tests { input: Some(json!({ "pattern": "**/*.rs" })), output: Some("src/main.rs\nsrc/lib.rs".into()), description: Some("Search for Rust files".into()), + parent_call_id: Some("toolu_task".into()), }); let json = serde_json::to_value(&event).unwrap(); assert_eq!(json["type"], "tool_call"); assert_eq!(json["data"]["input"]["pattern"], "**/*.rs"); assert_eq!(json["data"]["output"], "src/main.rs\nsrc/lib.rs"); assert_eq!(json["data"]["description"], "Search for Rust files"); + assert_eq!(json["data"]["parent_call_id"], "toolu_task"); } #[test] @@ -373,11 +376,15 @@ mod tests { input: None, output: None, description: None, + parent_call_id: None, }); let json = serde_json::to_value(&event).unwrap(); assert!(json["data"].get("input").is_none()); assert!(json["data"].get("output").is_none()); assert!(json["data"].get("description").is_none()); + // Absent, not null: a null would DELETE stored attribution under the + // DB's merge-patch upsert. + assert!(json["data"].get("parent_call_id").is_none()); } #[test] diff --git a/crates/aionui-ai-agent/src/protocol/events/tool_call.rs b/crates/aionui-ai-agent/src/protocol/events/tool_call.rs index 191d0e199..0d90dbaed 100644 --- a/crates/aionui-ai-agent/src/protocol/events/tool_call.rs +++ b/crates/aionui-ai-agent/src/protocol/events/tool_call.rs @@ -22,6 +22,14 @@ pub struct ToolCallEventData { pub output: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// The `call_id` of the Task/Agent call this call runs INSIDE (claude's + /// frame-level `parent_tool_use_id`); `None` = the main agent's own call. + /// Lets the frontend group/indent a subagent's internal steps under its + /// launching Task row. Skipped when `None` (merge-patch: the terminal + /// ToolResult frame legitimately repeats the call without it, and a null + /// would DELETE the stored attribution). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_call_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 4e78bf777..b3e97b0af 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -2974,6 +2974,7 @@ fn spawn_event_pump( input: None, output: Some(acc.clone()), description: None, + parent_call_id: None, })); continue; } @@ -3182,6 +3183,7 @@ fn spawn_event_pump( input: None, output: None, description: None, + parent_call_id: None, })); } tool_output.clear(); @@ -3314,6 +3316,7 @@ fn spawn_event_pump( input: None, output: None, description: None, + parent_call_id: None, })); } for (call_id, name) in kept_open { @@ -4013,11 +4016,19 @@ fn update_workflow_cards( .and_then(|v| v.as_str()) .or_else(|| args.get("command").and_then(|v| v.as_str())) .map(str::to_string); - let mut card = WorkflowCard::new_background(call_id.clone(), name, args, r#ref, desc, now_ms); + // A Task subagent (`local_agent`) gets the "subagent" headline; + // everything else (`local_bash`, unknown) stays "bg task". + let is_agent = matches!(kind, Some(SubagentTaskKind::AgentContainer)); + let mut card = if is_agent { + WorkflowCard::new_subagent(call_id.clone(), name, args, r#ref, desc, now_ms) + } else { + WorkflowCard::new_background(call_id.clone(), name, args, r#ref, desc, now_ms) + }; tracing::info!( conv_id = %conversation_id, task_id = %r#ref, %call_id, + subagent = is_agent, "session-pump: background task card opened" ); // No roster will ever arrive to trigger a first emission, so @@ -4070,6 +4081,7 @@ fn update_workflow_cards( input: None, output: None, description: None, + parent_call_id: None, }, agents: Vec::new(), settle_only: true, @@ -4339,6 +4351,7 @@ fn translate_event(event: SessionEvent, conversation_id: &str, terminal_result_s tool_use_id, name, input, + parent_tool_use_id, .. } => { let input = compact_stream_tool_payload(input); @@ -4350,12 +4363,16 @@ fn translate_event(event: SessionEvent, conversation_id: &str, terminal_result_s input: Some(input), output: None, description: None, + // Subagent attribution (009 H5): persisted onto the row so the + // frontend can group a subagent's steps under its Task call. + parent_call_id: parent_tool_use_id, })] } SessionEvent::ToolResult { tool_use_id, is_error, content, + parent_tool_use_id, .. } => { let output = tool_result_output(&content); @@ -4371,6 +4388,7 @@ fn translate_event(event: SessionEvent, conversation_id: &str, terminal_result_s input: None, output, description: None, + parent_call_id: parent_tool_use_id, })] } SessionEvent::TurnResult { @@ -5683,6 +5701,7 @@ mod translate_tests { input: None, output: None, description: None, + parent_call_id: None, }) } @@ -8585,6 +8604,85 @@ mod pump_tests { ); } + /// A Task subagent (`task_type: local_agent`, kind `AgentContainer`) rides + /// the same card machinery as a background bash but must be LABELLED as a + /// subagent — with both saying "bg task" the step list could not tell + /// delegated agent work from a background shell (live 2026-08-19). Its + /// internal tool calls also carry the launching call's id so the frontend + /// can group them under the Task row. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn task_subagent_card_is_labelled_subagent_and_children_carry_parent() { + use aionui_session::{SubagentStatus, SubagentTaskKind}; + let script = vec![ + // Shape mirrors claude_2.1.169_single_tool_turn.ndjson: Agent + // tool_use → task_started{local_agent} → the subagent's own tool_use + // frame carrying parent_tool_use_id. + env(SessionEvent::ToolCall { + tool_use_id: "toolu_task".into(), + name: "修复 AIONUI-151 桌面 401 恢复".into(), + subagent: aionui_session::SubagentKind::Inline, + input: serde_json::json!({ + "description": "修复 AIONUI-151 桌面 401 恢复", + "subagent_type": "claude", + "run_in_background": false + }), + parent_tool_use_id: None, + }), + env(SessionEvent::SubagentUpdate { + r#ref: "ae859b22dc5afbdca".into(), + label: Some("claude".into()), + status: SubagentStatus::Running, + parent_ref: Some("toolu_task".into()), + kind: Some(SubagentTaskKind::AgentContainer), + }), + env(SessionEvent::ToolCall { + tool_use_id: "toolu_inner".into(), + name: "Read httpBridge.ts".into(), + subagent: aionui_session::SubagentKind::Inline, + input: serde_json::json!({"file_path": "/tmp/httpBridge.ts"}), + parent_tool_use_id: Some("toolu_task".into()), + }), + env(SessionEvent::SubagentUpdate { + r#ref: "ae859b22dc5afbdca".into(), + label: None, + status: SubagentStatus::Completed, + parent_ref: Some("toolu_task".into()), + kind: None, + }), + ]; + let frames = drain_script(script).await; + + let progress = wf_frames(&frames); + assert!(!progress.is_empty(), "the subagent card must emit on open"); + let desc = progress[0].card.description.as_deref().unwrap_or_default(); + assert!( + desc.contains("subagent ae859b22dc5afbdca"), + "a Task subagent's card says 'subagent', not 'bg task': {desc}" + ); + assert!(!desc.contains("bg task"), "not a bg task: {desc}"); + + // Attribution: the subagent's INTERNAL call carries the Task call's id; + // the Task launch itself (a main-agent call) carries none. + let parent_of = |id: &str| { + frames.iter().find_map(|f| match f { + AgentStreamEvent::ToolCall(d) if d.call_id == id && d.status == ToolCallStatus::Running => { + Some(d.parent_call_id.clone()) + } + _ => None, + }) + }; + assert_eq!( + parent_of("toolu_inner"), + Some(Some("toolu_task".into())), + "a subagent-internal call must carry its Task call's id" + ); + assert_eq!( + parent_of("toolu_task"), + Some(None), + "the main-agent launching call carries no parent" + ); + } + /// A CANCELLED turn takes background-task cards down with it: the interrupt /// kills background tasks silently (no task frames follow — per the #732 /// capture), so waiting for a notification would strand the card spinning. diff --git a/crates/aionui-ai-agent/src/workflow_progress.rs b/crates/aionui-ai-agent/src/workflow_progress.rs index 0e8c118aa..58f17fa94 100644 --- a/crates/aionui-ai-agent/src/workflow_progress.rs +++ b/crates/aionui-ai-agent/src/workflow_progress.rs @@ -138,7 +138,10 @@ pub(crate) struct Rendered { /// - `local_workflow` → [`CardKind::Workflow`]: has a per-agent roster /// (`workflow_progress[]`) to render. /// - `local_bash` / `local_agent` → [`CardKind::BackgroundTask`]: no roster ever -/// arrives — the card is a single live row on the launching tool call. +/// arrives — the card is a single live row on the launching tool call. The +/// two differ only in the headline word ([`WorkflowCard::new_background`] says +/// "bg task", [`WorkflowCard::new_subagent`] says "subagent") so a Task +/// subagent is distinguishable from a background bash in the step list. /// /// (verified: local_agent + linkage in /// `claude_2.1.169_single_tool_turn.ndjson`; local_bash + linkage in @@ -203,9 +206,9 @@ impl WorkflowCard { } } - /// A rosterless background task (`local_bash` / `local_agent`). `desc` is the - /// launching call's own description of the work; `task_id` rides in the - /// headline so the user can name the task when asking to stop it. + /// A rosterless background task (`local_bash`). `desc` is the launching + /// call's own description of the work; `task_id` rides in the headline so + /// the user can name the task when asking to stop it. /// /// `desc` is DROPPED when it merely repeats `tool_name`. The step row draws /// the name and the description side by side and dedupes only on exact @@ -222,13 +225,41 @@ impl WorkflowCard { task_id: &str, desc: Option, now_ms: i64, + ) -> Self { + Self::new_task(call_id, tool_name, args, task_id, desc, now_ms, "bg task") + } + + /// A Task subagent's card (`local_agent`, foreground or background — the + /// wire declares both the same way). Identical to [`Self::new_background`] + /// except the headline word: a subagent is delegated agent work, and + /// labelling it "bg task" made it indistinguishable from a background bash + /// in the step list (live 2026-08-19). + pub fn new_subagent( + call_id: String, + tool_name: String, + args: serde_json::Value, + task_id: &str, + desc: Option, + now_ms: i64, + ) -> Self { + Self::new_task(call_id, tool_name, args, task_id, desc, now_ms, "subagent") + } + + fn new_task( + call_id: String, + tool_name: String, + args: serde_json::Value, + task_id: &str, + desc: Option, + now_ms: i64, + word: &str, ) -> Self { let mut card = Self::new(call_id, tool_name, args, now_ms); card.kind = CardKind::BackgroundTask; card.bg_headline = match desc { - Some(d) if d.trim() == card.tool_name.trim() => format!("bg task {task_id}"), - Some(d) => format!("{} · bg task {task_id}", elide_middle(&d, SUMMARY_MAX)), - None => format!("bg task {task_id}"), + Some(d) if d.trim() == card.tool_name.trim() => format!("{word} {task_id}"), + Some(d) => format!("{} · {word} {task_id}", elide_middle(&d, SUMMARY_MAX)), + None => format!("{word} {task_id}"), }; card } @@ -325,6 +356,9 @@ impl WorkflowCard { CardKind::BackgroundTask => None, }, description: Some(rendered.description.clone()), + // Cards ride a MAIN-STREAM launching call; absent (not null) so the + // merge-patch never touches attribution persisted by the call itself. + parent_call_id: None, }; let entries = rendered.entries.clone(); self.last_render = Some(rendered); @@ -666,6 +700,36 @@ mod tests { assert!(description.ends_with("00:09"), "lost the clock: {description:?}"); } + /// A Task subagent's card must say "subagent", not "bg task" — with both + /// labelled identically the step list could not distinguish delegated agent + /// work from a background shell (live 2026-08-19: a foreground Task and a + /// background bash rendered as the same "bg task · clock" row). + #[test] + fn subagent_card_says_subagent_not_bg_task() { + // Post-#870 shape: the Task call's step label IS its own description, + // so the dedupe drops it from the headline and only the word + id stay. + let label = "修复 AIONUI-151 桌面 401 恢复"; + let mut c = WorkflowCard::new_subagent( + "toolu_task".into(), + label.into(), + serde_json::json!({"description": label, "subagent_type": "claude"}), + "ae859b22dc5afbdca", + Some(label.to_string()), + 0, + ); + let (f, _) = c.take_emission(9_000, true).expect("task cards open immediately"); + let description = f.description.as_deref().unwrap(); + assert!( + description.contains("subagent ae859b22dc5afbdca"), + "headline must name the subagent and its id: {description:?}" + ); + assert!( + !description.contains("bg task"), + "a subagent is not a bg task: {description:?}" + ); + assert!(description.ends_with("00:09"), "lost the clock: {description:?}"); + } + /// The other direction: where the label is still a BARE tool name, the desc /// is the only thing saying what the task does, so it must be kept. #[test] diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 861ff038f..41c2ed5b1 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -569,6 +569,7 @@ mod tests { args: serde_json::Value::Null, status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, }); diff --git a/crates/aionui-channel/tests/stream_relay_test.rs b/crates/aionui-channel/tests/stream_relay_test.rs index bd8510939..506de89ad 100644 --- a/crates/aionui-channel/tests/stream_relay_test.rs +++ b/crates/aionui-channel/tests/stream_relay_test.rs @@ -138,6 +138,7 @@ async fn weixin_flushes_pending_text_before_tool_call() { args: serde_json::Value::Null, status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, })) @@ -191,6 +192,7 @@ async fn telegram_does_not_flush_text_before_tool_call() { args: serde_json::Value::Null, status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, })) @@ -229,6 +231,7 @@ async fn weixin_skips_flush_when_buffer_is_empty() { args: serde_json::Value::Null, status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, })) diff --git a/crates/aionui-conversation/src/background_stream.rs b/crates/aionui-conversation/src/background_stream.rs index f1cc2ec1e..5c7cd829c 100644 --- a/crates/aionui-conversation/src/background_stream.rs +++ b/crates/aionui-conversation/src/background_stream.rs @@ -626,6 +626,7 @@ mod tests { input: None, output: None, description: Some("sleep 30 · bg task b1 · 00:01".into()), + parent_call_id: None, }, agents: vec![], settle_only: false, @@ -642,6 +643,7 @@ mod tests { input: None, output: None, description: Some("sleep 30 · bg task b1 · 00:30".into()), + parent_call_id: None, }, agents: vec![ToolGroupEntry { call_id: "toolu_bg:1".into(), @@ -760,6 +762,7 @@ mod tests { input: None, output: None, description: None, + parent_call_id: None, }, agents: vec![], settle_only: true, @@ -776,6 +779,7 @@ mod tests { input: None, output: None, description: None, + parent_call_id: None, }, agents: vec![], settle_only: true, diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index 9448fe006..55f01b832 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -6503,6 +6503,7 @@ async fn send_message_does_not_auto_replay_after_tool_side_effect() { input: None, output: None, description: None, + parent_call_id: None, }), AgentStreamEvent::Error(ErrorEventData { message: "temporary provider failure".into(), diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index 398656463..f74e533fc 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -1898,6 +1898,7 @@ mod tests { input: None, output: Some("Phase 1 Run [0/1]".into()), description: Some("Run [0/1] · run:A · 1 agents".into()), + parent_call_id: None, }, agents: vec![ToolGroupEntry { call_id: "1".into(), @@ -1950,6 +1951,7 @@ mod tests { input: None, output: None, description: None, + parent_call_id: None, })) .await; assert_eq!( @@ -2030,6 +2032,7 @@ mod tests { args: json!({"path": "a.ts"}), status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, })) @@ -2388,6 +2391,7 @@ mod tests { input: None, output: None, description: None, + parent_call_id: None, })) .unwrap(); tx.send(AgentStreamEvent::Error(ErrorEventData { @@ -2646,6 +2650,7 @@ mod tests { args: json!({"path": "a.ts"}), status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, })) @@ -2712,6 +2717,7 @@ mod tests { args: json!({"path": "a.ts"}), status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, })) @@ -2979,6 +2985,7 @@ mod tests { input: Some(json!({"prompt": "a cat", "size": "1024x1024"})), output: None, description: Some("Generate image".into()), + parent_call_id: None, })) .unwrap(); // Second event: Completed with output but no input @@ -2990,6 +2997,7 @@ mod tests { input: None, output: Some("image.png".into()), description: None, + parent_call_id: None, })) .unwrap(); tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); diff --git a/crates/aionui-conversation/tests/stream_relay_tool_call.rs b/crates/aionui-conversation/tests/stream_relay_tool_call.rs index a331b6cf7..8059a07e7 100644 --- a/crates/aionui-conversation/tests/stream_relay_tool_call.rs +++ b/crates/aionui-conversation/tests/stream_relay_tool_call.rs @@ -86,6 +86,7 @@ async fn run_tool_call_with_empty_call_id_is_not_persisted() { input: Some(json!({"pattern": "*.rs"})), output: None, description: None, + parent_call_id: None, })) .unwrap(); tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); @@ -134,6 +135,7 @@ async fn run_tool_call_late_running_event_does_not_regress_completed_message() { input: None, output: Some("src/main.rs".into()), description: None, + parent_call_id: None, })) .unwrap(); tx.send(AgentStreamEvent::ToolCall(ToolCallEventData { @@ -144,6 +146,7 @@ async fn run_tool_call_late_running_event_does_not_regress_completed_message() { input: Some(json!({"pattern": "*.rs"})), output: None, description: Some("search files".into()), + parent_call_id: None, })) .unwrap(); tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); @@ -199,6 +202,7 @@ async fn run_tool_call_canceled_status_persists_as_terminal_finish() { input: Some(json!({"command": "sleep 60"})), output: None, description: None, + parent_call_id: None, })) .unwrap(); // The turn was interrupted: the fold layer closes the still-open call as @@ -211,6 +215,7 @@ async fn run_tool_call_canceled_status_persists_as_terminal_finish() { input: None, output: None, description: None, + parent_call_id: None, })) .unwrap(); tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap(); @@ -291,6 +296,7 @@ impl IAgentTask for ToolCallAgent { input: Some(json!({"pattern": "*.rs"})), output: None, description: None, + parent_call_id: None, })); let _ = self.event_tx.send(AgentStreamEvent::Finish(FinishEventData::default())); Ok(()) diff --git a/crates/aionui-conversation/tests/thinking_persistence.rs b/crates/aionui-conversation/tests/thinking_persistence.rs index 8dcbce698..bf67e07a4 100644 --- a/crates/aionui-conversation/tests/thinking_persistence.rs +++ b/crates/aionui-conversation/tests/thinking_persistence.rs @@ -89,6 +89,7 @@ fn tool_call(call_id: &str) -> AgentStreamEvent { args: json!({"path": "a.ts"}), status: ToolCallStatus::Running, description: None, + parent_call_id: None, input: None, output: None, }) diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index cf8e1a8e6..40cf76912 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -2939,13 +2939,17 @@ fn sniff_task( .map(str::to_string); let parent_ref = frame.get("tool_use_id").and_then(Value::as_str).map(str::to_string); // Container kind, declared ONLY on `task_started` (`task_type`: - // "local_workflow" for a Workflow container, "local_bash" for a background - // bash — verified: samples/claude-cli/2.1.176/workflow_*.ndjson + - // 2.1.220/_all_workflow_interrupt.jsonl; progress/updated/notification - // frames carry no task_type → None). The pump admits ONLY WorkflowContainer - // refs into its Finish-suppression roster. + // "local_workflow" for a Workflow container, "local_agent" for a Task + // subagent, "local_bash" for a background bash — verified: + // samples/claude-cli/2.1.176/workflow_*.ndjson + + // 2.1.220/_all_workflow_interrupt.jsonl + + // tests/fixtures/claude_2.1.169_single_tool_turn.ndjson; + // progress/updated/notification frames carry no task_type → None). The pump + // admits ONLY WorkflowContainer refs into its Finish-suppression roster; + // AgentContainer only changes the progress-card headline downstream. let kind = frame.get("task_type").and_then(Value::as_str).map(|t| match t { "local_workflow" => crate::event::SubagentTaskKind::WorkflowContainer, + "local_agent" => crate::event::SubagentTaskKind::AgentContainer, _ => crate::event::SubagentTaskKind::Other, }); let _ = event_tx.send(SessionEnvelope { @@ -6736,12 +6740,15 @@ mod tests { // Running; task_notification{status} → terminal. The reducer upserts these // into Running.subagents, which drives has_foreground_activity. // `kind` is learned ONLY from task_started.task_type: local_workflow → - // WorkflowContainer, local_bash (or any other value) → Other, absent - // (progress/notification frames) → None — the pump admits only - // WorkflowContainer refs into its Finish-suppression roster. + // WorkflowContainer, local_agent → AgentContainer, local_bash (or any + // other value) → Other, absent (progress/notification frames) → None — + // the pump admits only WorkflowContainer refs into its + // Finish-suppression roster; AgentContainer drives the "subagent" card + // headline. let frames = [ r#"{"type":"system","subtype":"task_started","task_id":"tk-1","tool_use_id":"toolu-9","subagent_type":"general-purpose","task_type":"local_workflow"}"#, r#"{"type":"system","subtype":"task_started","task_id":"tk-2","tool_use_id":"toolu-8","subagent_type":"bash","task_type":"local_bash"}"#, + r#"{"type":"system","subtype":"task_started","task_id":"tk-3","tool_use_id":"toolu-7","subagent_type":"general-purpose","task_type":"local_agent"}"#, r#"{"type":"system","subtype":"task_notification","task_id":"tk-1","tool_use_id":"toolu-9","status":"completed"}"#, ]; let bytes = format!("{}\n", frames.join("\n")).into_bytes(); @@ -6760,7 +6767,7 @@ mod tests { } = env.event { updates.push((r#ref, status, parent_ref, label, kind)); - if updates.len() == 3 { + if updates.len() == 4 { return; } } @@ -6770,8 +6777,8 @@ mod tests { assert_eq!( updates.len(), - 3, - "2 task_started + task_notification → 3 SubagentUpdate, got {updates:?}" + 4, + "3 task_started + task_notification → 4 SubagentUpdate, got {updates:?}" ); // started → Running, keyed by task_id, parent = tool_use_id, label = subagent_type. assert_eq!(updates[0].0, "tk-1", "ref = task_id"); @@ -6798,15 +6805,22 @@ mod tests { Some(crate::event::SubagentTaskKind::Other), "task_type=local_bash → Other" ); + // A Task subagent keeps its own kind, so the card layer can label it + // "subagent" instead of "bg task". + assert_eq!( + updates[2].4, + Some(crate::event::SubagentTaskKind::AgentContainer), + "task_type=local_agent → AgentContainer" + ); // notification completed → Completed, SAME ref (lifecycle upsert); the // frame carries no task_type → kind None. - assert_eq!(updates[2].0, "tk-1", "same ref across the lifecycle"); + assert_eq!(updates[3].0, "tk-1", "same ref across the lifecycle"); assert_eq!( - updates[2].1, + updates[3].1, crate::event::SubagentStatus::Completed, "status=completed → Completed" ); - assert_eq!(updates[2].4, None, "task_notification carries no task_type → kind None"); + assert_eq!(updates[3].4, None, "task_notification carries no task_type → kind None"); } /// sniff_mode: claude's AUTHORITATIVE mode signal is `permissionMode` on a diff --git a/crates/aionui-session/src/event.rs b/crates/aionui-session/src/event.rs index 942b2d079..0d3ee464f 100644 --- a/crates/aionui-session/src/event.rs +++ b/crates/aionui-session/src/event.rs @@ -676,13 +676,17 @@ pub enum SubagentKind { /// Container kind of a `SubagentUpdate` roster entry (see that variant's `kind` /// field). Normalized from the claude wire's `task_started.task_type`: -/// `"local_workflow"` → `WorkflowContainer`, any other declared value (e.g. -/// `"local_bash"`) → `Other`. Deliberately two-valued: the only consumer is the -/// pump's suppression-roster admission, which needs exactly the bit "may this -/// ref hold the turn open". +/// `"local_workflow"` → `WorkflowContainer`, `"local_agent"` (a Task subagent, +/// foreground or background — the wire declares both the same way, verified: +/// `claude_2.1.169_single_tool_turn.ndjson`) → `AgentContainer`, any other +/// declared value (e.g. `"local_bash"`) → `Other`. Two consumers: the pump's +/// suppression-roster admission (WorkflowContainer alone may hold a turn open) +/// and the background-card headline (AgentContainer renders "subagent", not +/// "bg task", so a Task subagent is distinguishable from a background bash). #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum SubagentTaskKind { WorkflowContainer, + AgentContainer, Other, } From 535df43a17f2197199d0ba5a41fdd10636f9fbe1 Mon Sep 17 00:00:00 2001 From: suoak <5143514+suoak@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:39:41 +0000 Subject: [PATCH 8/9] chore: bump 0.2.2 and finish midturn/#890 compile leftovers Version the workspace to 0.2.2 and fill parent_call_id, attachments, and steer mapping so the #836+#890 port compiles under tests. --- .release-please-manifest.json | 2 +- CHANGELOG.md | 17 ++++++ Cargo.lock | 54 +++++++++---------- Cargo.toml | 2 +- .../src/capability/backend_protocol_sink.rs | 2 + crates/aionui-ai-agent/src/session_agent.rs | 4 +- crates/aionui-conversation/src/routes.rs | 10 ++-- crates/aionui-conversation/src/service.rs | 1 + .../src/tool_event_pipeline.rs | 1 + .../aionui-session/src/backend/codex_conn.rs | 9 +--- 10 files changed, 57 insertions(+), 45 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 57e0617c3..96ab372f4 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"0.2.1"} +{".":"0.2.2"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dc66513f..4577ad544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.2.2](https://github.com/suoak/AionCore/compare/v0.2.1...v0.2.2) (2026-08-29) + +### Features + +* **conversation:** mid-turn interjection — deliver messages into a running claude/codex turn +* **session:** distinguish Task subagents from background tasks (`AgentContainer`) +* **skills:** slim auto-inject skill descriptions to the 200-char injection budget + +### Bug Fixes + +* **ai-agent:** wait for terminal output before asserting after process exit + +### Chores + +* **cli:** verify claude against 2.1.236 +* **acp:** bump codebuddy 2.141.0 and dimcode 0.3.22 registry pins + ## [0.2.1](https://github.com/suoak/AionCore/compare/v0.2.0...v0.2.1) (2026-08-29) ### Bug Fixes diff --git a/Cargo.lock b/Cargo.lock index 809a74b8d..31726724f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "aionui-ai-agent" -version = "0.2.1" +version = "0.2.2" dependencies = [ "agent-client-protocol", "aion-agent", @@ -394,7 +394,7 @@ dependencies = [ [[package]] name = "aionui-api-types" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-common", "serde", @@ -403,7 +403,7 @@ dependencies = [ [[package]] name = "aionui-app" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aion-config", "aionui-ai-agent", @@ -467,7 +467,7 @@ dependencies = [ [[package]] name = "aionui-assets" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-common", "axum", @@ -482,7 +482,7 @@ dependencies = [ [[package]] name = "aionui-assistant" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -508,7 +508,7 @@ dependencies = [ [[package]] name = "aionui-auth" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-common", @@ -533,7 +533,7 @@ dependencies = [ [[package]] name = "aionui-channel" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -565,7 +565,7 @@ dependencies = [ [[package]] name = "aionui-common" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aes-gcm", "async-trait", @@ -582,7 +582,7 @@ dependencies = [ [[package]] name = "aionui-conversation" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -617,7 +617,7 @@ dependencies = [ [[package]] name = "aionui-cron" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -646,7 +646,7 @@ dependencies = [ [[package]] name = "aionui-db" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-common", "async-trait", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "aionui-extension" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -696,7 +696,7 @@ dependencies = [ [[package]] name = "aionui-file" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -727,7 +727,7 @@ dependencies = [ [[package]] name = "aionui-mcp" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "aionui-office" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -783,7 +783,7 @@ dependencies = [ [[package]] name = "aionui-process" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-common", "aionui-runtime", @@ -803,7 +803,7 @@ dependencies = [ [[package]] name = "aionui-project" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -834,7 +834,7 @@ dependencies = [ [[package]] name = "aionui-realtime" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "axum", @@ -849,7 +849,7 @@ dependencies = [ [[package]] name = "aionui-runtime" -version = "0.2.1" +version = "0.2.2" dependencies = [ "dirs", "flate2", @@ -875,7 +875,7 @@ dependencies = [ [[package]] name = "aionui-session" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-common", "aionui-process", @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "aionui-session-message" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -919,7 +919,7 @@ dependencies = [ [[package]] name = "aionui-shell" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -952,7 +952,7 @@ dependencies = [ [[package]] name = "aionui-sidebar" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -977,7 +977,7 @@ dependencies = [ [[package]] name = "aionui-skill-runtime" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-ai-agent", "aionui-api-types", @@ -999,7 +999,7 @@ dependencies = [ [[package]] name = "aionui-system" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "aionui-auth", @@ -1029,7 +1029,7 @@ dependencies = [ [[package]] name = "aionui-team" -version = "0.2.1" +version = "0.2.2" dependencies = [ "agent-client-protocol", "aionui-ai-agent", @@ -1058,7 +1058,7 @@ dependencies = [ [[package]] name = "aionui-team-prompts" -version = "0.2.1" +version = "0.2.2" dependencies = [ "aionui-api-types", "serde", diff --git a/Cargo.toml b/Cargo.toml index efcf67846..96ea9c90a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ members = [ ] [workspace.package] -version = "0.2.1" +version = "0.2.2" edition = "2024" license = "MIT" diff --git a/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs b/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs index 946d14a92..4b2525785 100644 --- a/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs +++ b/crates/aionui-ai-agent/src/capability/backend_protocol_sink.rs @@ -158,6 +158,7 @@ impl ProtocolEmitter for BackendProtocolSink { input: None, output: None, description: Some(Self::execution_description(execution_id, "execute")), + parent_call_id: None, })); } @@ -189,6 +190,7 @@ impl ProtocolEmitter for BackendProtocolSink { error_code, truncation, )), + parent_call_id: None, })); } diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index b3e97b0af..ef49fda7d 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1298,9 +1298,7 @@ impl SessionAgentTask { .await .map(|_| ()) .map_err(|error| match &error { - BackendError::Transport(code) - if code == "too_late" || code.contains("no active turn to steer") => - { + BackendError::Transport(code) if code == "too_late" || code.contains("no active turn to steer") => { AgentError::bad_request("too_late") } _ => AgentError::bad_gateway(error.to_string()), diff --git a/crates/aionui-conversation/src/routes.rs b/crates/aionui-conversation/src/routes.rs index 9b228b6c5..f2ccfa6dd 100644 --- a/crates/aionui-conversation/src/routes.rs +++ b/crates/aionui-conversation/src/routes.rs @@ -10,11 +10,11 @@ use aionui_api_types::{ ActiveCountResponse, ApiResponse, ApprovalCheckQuery, ApprovalCheckResponse, CancelConversationRequest, CancelConversationResponse, CloneConversationRequest, ConfirmRequest, ConfirmationListResponse, ConversationArtifactListResponse, ConversationArtifactResponse, ConversationCapabilities, - ConversationInputListResponse, ConversationInputMode, ConversationInputReceipt, ConversationListResponse, - ConversationResponse, CreateConversationRequest, EnsureConversationRuntimeResponse, ForkConversationRequest, - ListConversationInputsQuery, ListConversationsQuery, ListMessagesQuery, MessageListResponse, MessageResponse, - MessageSearchResponse, SearchMessagesQuery, SendMessageRequest, SendMessageResponse, - SubmitConversationInputRequest, UpdateConversationArtifactRequest, UpdateConversationRequest, + ConversationInputListResponse, ConversationInputReceipt, ConversationListResponse, ConversationResponse, + CreateConversationRequest, EnsureConversationRuntimeResponse, ForkConversationRequest, ListConversationInputsQuery, + ListConversationsQuery, ListMessagesQuery, MessageListResponse, MessageResponse, MessageSearchResponse, + SearchMessagesQuery, SendMessageRequest, SendMessageResponse, SubmitConversationInputRequest, + UpdateConversationArtifactRequest, UpdateConversationRequest, }; use aionui_auth::CurrentUser; use aionui_common::ApiError; diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 3507853f3..fce579711 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -3904,6 +3904,7 @@ impl ConversationService { msg_id: user_msg_id.clone(), turn_id: Some(active_turn_id.clone()), files: resolved.files.clone(), + attachments: resolved.attachments.clone(), inject_skills, }; match agent.deliver_midturn(data).await { diff --git a/crates/aionui-conversation/src/tool_event_pipeline.rs b/crates/aionui-conversation/src/tool_event_pipeline.rs index 61947b2bd..57bc3ae9e 100644 --- a/crates/aionui-conversation/src/tool_event_pipeline.rs +++ b/crates/aionui-conversation/src/tool_event_pipeline.rs @@ -248,6 +248,7 @@ mod tests { input: None, output: Some(output.into()), description: None, + parent_call_id: None, }) } diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 1bd399f7d..493f80da1 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -28,7 +28,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use aionui_process::Spawner; use serde_json::{Value, json}; -use tokio::sync::{Mutex, broadcast, oneshot}; +use tokio::sync::{Mutex, broadcast}; #[cfg(any(test, feature = "test-support"))] use super::codex_title::NoTitleIo; @@ -969,13 +969,6 @@ struct Discovered { modes: Vec, } -fn codex_steer_is_too_late(error: &Value) -> bool { - let encoded = error.to_string(); - encoded.contains("activeTurnNotSteerable") - || encoded.contains("active turn not steerable") - || encoded.contains("turn is no longer active") -} - /// What `CodexSessionBackend::wake_handle` needs to re-spawn the codex app-server /// after an idle suspend and replay the resume handshake. `inert()` (no spawner) /// is used for test-built backends, which never suspend, so it is never consulted. From 1d308f1300fdce8c30482ec876fa32cf32c16bfa Mon Sep 17 00:00:00 2001 From: suoak <5143514+suoak@users.noreply.github.com> Date: Sat, 29 Aug 2026 02:06:59 +0000 Subject: [PATCH 9/9] test(session): drop leftover input-1 clientUserMessageId assert on turn/steer Official Codex turn/steer has a single clientUserMessageId (host mid-turn id). The stale input-1 check could not pass alongside cmsg-7. --- crates/aionui-session/src/backend/codex_conn.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index 493f80da1..66443967d 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -7243,10 +7243,6 @@ mod tests { "carries the correlation id so codex round-trips it (§6甲.10), got: {written}" ); assert!(written.contains("STEERED"), "carries the steer text, got: {written}"); - assert!( - written.contains(r#""clientUserMessageId":"input-1""#), - "carries the host input correlation id, got: {written}" - ); } #[test]