diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4c39906 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,136 @@ +name: Build Binaries (Reusable) + +on: + workflow_call: + inputs: + version_name: + description: Version identifier used in archive names + required: true + type: string + is_nightly: + description: Whether this is a nightly build + type: boolean + default: false + revision: + description: Exact source revision to build (defaults to the caller's commit) + type: string + default: '' + pull_request: + paths: + - '.github/workflows/build.yml' + - '.github/workflows/release.yml' + - '.github/workflows/nightly.yml' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'build.rs' + - 'src/**' + - 'docs/**' + - 'README.md' + - 'LICENSE' + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + - os: macos-14 + target: x86_64-apple-darwin + - os: macos-14 + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + rust_flags: '-C target-feature=+crt-static' + - os: windows-latest + target: aarch64-pc-windows-msvc + rust_flags: '-C target-feature=+crt-static' + - os: ubuntu-latest + target: x86_64-unknown-freebsd + toolchain_target: x86_64-unknown-freebsd@14 + steps: + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.revision || github.sha }} + persist-credentials: false + + - name: Verify release version + if: github.ref_type == 'tag' && !inputs.is_nightly + shell: bash + run: | + VERSION=$(sed -nE 's/^version = "([^"]+)"/\1/p' Cargo.toml) + test "$GITHUB_REF_NAME" = "v$VERSION" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compilation toolchain + if: runner.os == 'Linux' + uses: taiki-e/setup-cross-toolchain-action@12b7ad4acfa95a1476779d6c06699b96ec1691f8 # v1.42.0 + with: + target: ${{ matrix.toolchain_target || matrix.target }} + + - name: Cache Cargo dependencies + uses: swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: ${{ matrix.target }} + + - name: Build release binary + env: + RUSTFLAGS: ${{ matrix.rust_flags || '' }} + run: cargo build --locked --release --bin qmcp --target ${{ matrix.target }} + + - name: Package archive (Linux, macOS and FreeBSD) + if: runner.os != 'Windows' + shell: bash + env: + VERSION_NAME: ${{ inputs.version_name || github.ref_name }} + TARGET: ${{ matrix.target }} + run: | + PKG="qmcp-${VERSION_NAME//\//-}-${TARGET}" + STAGING="dist/${PKG}" + mkdir -p "$STAGING" + cp "target/${TARGET}/release/qmcp" README.md LICENSE "$STAGING/" + cp -R docs "$STAGING/" + tar -czf "dist/${PKG}.tar.gz" -C dist "$PKG" + echo "ASSET_PATH=dist/${PKG}.tar.gz" >> "$GITHUB_ENV" + + - name: Package archive (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + VERSION_NAME: ${{ inputs.version_name || github.ref_name }} + TARGET: ${{ matrix.target }} + run: | + $PKG = "qmcp-$($env:VERSION_NAME.Replace('/', '-'))-$($env:TARGET)" + $STAGING = Join-Path 'dist' $PKG + New-Item -ItemType Directory -Force -Path $STAGING | Out-Null + Copy-Item "target/$($env:TARGET)/release/qmcp.exe" -Destination $STAGING + Copy-Item README.md, LICENSE -Destination $STAGING + Copy-Item docs -Destination $STAGING -Recurse + $ZIP_PATH = "dist/${PKG}.zip" + Compress-Archive -Path (Join-Path $STAGING '*') -DestinationPath $ZIP_PATH -Force + "ASSET_PATH=${ZIP_PATH}" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Upload artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: qmcp-${{ matrix.target }} + path: ${{ env.ASSET_PATH }} + if-no-files-found: error diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..e472616 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,88 @@ +name: Nightly + +on: + schedule: + - cron: '0 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +# Serialize publication so runs cannot race while replacing the nightly release. +concurrency: + group: nightly + cancel-in-progress: false + +jobs: + check-changes: + name: Check for changes since last nightly + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + has_changes: ${{ steps.check.outputs.has_changes }} + revision: ${{ steps.check.outputs.revision }} + version_name: ${{ steps.check.outputs.version_name }} + steps: + - name: Checkout main + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: main + persist-credentials: false + + - name: Check for changes + id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + LAST_NIGHTLY_SHA=$(gh release view nightly --repo "$GITHUB_REPOSITORY" --json targetCommitish --jq '.targetCommitish' 2>/dev/null || true) + CURRENT_SHA=$(git rev-parse HEAD) + if [ "$LAST_NIGHTLY_SHA" != "$CURRENT_SHA" ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + echo "revision=$CURRENT_SHA" >> "$GITHUB_OUTPUT" + echo "version_name=nightly-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + build: + name: Build Nightly Binaries + needs: check-changes + if: needs.check-changes.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch' + uses: ./.github/workflows/build.yml + with: + version_name: ${{ needs.check-changes.outputs.version_name }} + is_nightly: true + revision: ${{ needs.check-changes.outputs.revision }} + + release: + name: Create Nightly GitHub Release + needs: [check-changes, build] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: artifacts/ + pattern: 'qmcp-*' + merge-multiple: true + + - name: Replace nightly release + env: + GH_TOKEN: ${{ github.token }} + REVISION: ${{ needs.check-changes.outputs.revision }} + run: | + gh release delete nightly --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag || true + gh release create nightly \ + --repo "$GITHUB_REPOSITORY" \ + --title "Nightly Build" \ + --notes "**This is a pre-release build from the main branch.** + + It is not intended for production use. + + Commit: \`$REVISION\`" \ + --prerelease \ + --target "$REVISION" \ + artifacts/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f6a3b6..32e1dca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,94 +2,26 @@ name: Release on: push: - tags: ['v*.*.*'] - pull_request: - paths: ['.github/workflows/release.yml'] + tags: + - 'v*.*.*' workflow_dispatch: permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -env: - CARGO_TERM_COLOR: always - jobs: build: - name: Release build ${{ matrix.target }} - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-musl - - os: ubuntu-latest - target: aarch64-unknown-linux-musl - - os: macos-14 - target: x86_64-apple-darwin - - os: macos-14 - target: aarch64-apple-darwin - steps: - - name: Checkout code - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - - name: Verify release version - if: github.ref_type == 'tag' - env: - RELEASE_TAG: ${{ github.ref_name }} - run: | - VERSION=$(sed -nE 's/^version = "([^"]+)"/\1/p' Cargo.toml | head -n 1) - test "$RELEASE_TAG" = "v$VERSION" - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable - with: - targets: ${{ matrix.target }} - - - name: Install cross-compilation toolchain - if: runner.os == 'Linux' - uses: taiki-e/setup-cross-toolchain-action@12b7ad4acfa95a1476779d6c06699b96ec1691f8 # v1.42.0 - with: - target: ${{ matrix.target }} - - - name: Cache Cargo dependencies - uses: swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 - with: - key: ${{ matrix.target }} - - - name: Build release binary - run: cargo build --locked --release --bin qmcp --target ${{ matrix.target }} - - - name: Package archive - env: - RELEASE_REF: ${{ github.ref_name }} - TARGET: ${{ matrix.target }} - run: | - PKG="qmcp-${RELEASE_REF//\//-}-${TARGET}" - STAGING="dist/${PKG}" - mkdir -p "$STAGING" - cp "target/${TARGET}/release/qmcp" README.md LICENSE "$STAGING/" - cp -R docs "$STAGING/" - tar -czf "dist/${PKG}.tar.gz" -C dist "$PKG" - echo "ASSET_PATH=dist/${PKG}.tar.gz" >> "$GITHUB_ENV" - - - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: qmcp-${{ matrix.target }} - path: ${{ env.ASSET_PATH }} - if-no-files-found: error + name: Build Release Binaries + uses: ./.github/workflows/build.yml + with: + version_name: ${{ github.ref_name }} release: name: GitHub Release - if: github.ref_type == 'tag' needs: [build] runs-on: ubuntu-latest timeout-minutes: 10 @@ -106,8 +38,10 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: + tag_name: ${{ github.ref_name }} + target_commitish: ${{ github.sha }} files: artifacts/* - fail_on_unmatched_files: true generate_release_notes: true - draft: false - prerelease: false + fail_on_unmatched_files: true + draft: ${{ github.ref_type != 'tag' }} + prerelease: ${{ github.ref_type != 'tag' }} diff --git a/AGENTS.md b/AGENTS.md index 22f0357..451fe1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,70 @@ # AGENTS.md -- Keep code, comments, documentation, and diagnostics in English. -- The project, crate, and binary are named `qmcp` (lowercase). -- Support only stdio and Streamable HTTP. -- Require static Bearer or external-provider OAuth authentication for non-loopback HTTP binds; preserve Host/Origin validation and never log credentials. -- Keep OAuth minimal: resource discovery and token introspection only; login, PKCE, consent and token issuance belong to the provider. -- Keep stdout exclusively for MCP JSON-RPC in stdio mode; diagnostics use stderr. -- Use the flake devShell on NixOS (`nix develop -c ...`) and the declared Rust toolchain. -- Run `cargo check`, formatting checks, Clippy, and relevant tests before finishing. -- Keep tests isolated: use mock ACP endpoints and never access the user's session database or restart existing QueryMT services. -- Timeout, disconnect, and MCP cancellation must not implicitly close sessions or cancel remote work. -- Preserve qmacp's MIT attribution when reusing its ACP implementation. +## Working Rules + +- Keep changes minimal and focused; avoid drive-by refactors and unrelated cleanup. +- Preserve staged, unstaged and untracked work. Do not stage, unstage, commit or + push unless the user explicitly requests that action. Editing an already-staged + file does not authorize updating the index. +- Keep this file limited to durable, repository-wide guidance, not task-specific + corrections. Keep code, comments, documentation and diagnostics in English. +- Never access the user's session database or stop/restart existing QueryMT + services. Use mock ACP endpoints or isolated instances with fresh test databases; + stop only processes created for the current test. + +## Repository Overview + +- The project, crate and binary are named `qmcp` (lowercase). +- `src/main.rs` configures the server; `server.rs` exposes MCP tools and + `commands.rs` implements their ACP operations using the qmacp client library. +- `transport.rs` handles stdio/Streamable HTTP; `oauth.rs` handles provider OAuth; + `output.rs` assembles tool results, bounded events and recovery information. +- `tests/bridge.rs` exercises the MCP-to-ACP bridge with mocks. `README.md` and + `docs/how-to-connect.md` cover user setup; avoid duplicating their content. + +## Development Environment + +- Detect Nix first. When available, use the project devShell (`nix develop` or + `nix develop -c `); an active project direnv shell is sufficient. +- Without Nix, use the supported native toolchain and dependencies. Check + `flake.nix` and `rust-toolchain.toml`; diagnose setup failures rather than + silently switching environments or toolchains. +- Run validation from the repository root inside the selected environment. + +## Validation + +- After Rust edits, run `cargo check -p qmcp` (include `--tests` for test edits). + Run `cargo fmt` only after that check passes. +- Use focused red/green tests where applicable. Each added case must protect a + distinct required behavior or real regression; prefer existing tests/fixtures. +- Do not test deleted functionality, mirror implementation constants/logic, or + multiply equivalent input combinations. Keep scaffolding proportional; moving + excess into another file is not simplification. +- Start with affected checks, then expand to consumers/features as needed. Consult + `.github/workflows/ci.yml` for final CI commands: + ```sh + cargo check -p qmcp --tests + cargo test -p qmcp --test bridge + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + ``` +- Tests must not connect to the user's running server. Load/resume can restore + runtime state; do not treat them as harmless history reads. +- Report checks actually run, failures and remaining gaps. Documentation-only + edits do not require a Rust build. + +## Protocol and Code Conventions + +- Support only stdio and Streamable HTTP. Keep stdout exclusively for MCP JSON-RPC + in stdio mode; use stderr for diagnostics and never log credentials. +- Require static Bearer or external-provider OAuth authentication for non-loopback + HTTP binds; preserve Host/Origin validation. +- Keep OAuth minimal: resource discovery and token introspection only. Login, + PKCE, consent and token issuance belong to the provider. +- Timeout, disconnect and MCP cancellation must not implicitly close sessions or + cancel remote work. Retain submission/recovery information on failure. +- Follow surrounding code, rustfmt and existing error conventions. +- Do not add speculative compatibility layers. Flag API/protocol changes and + affected consumers; ask before making compatibility decisions. +- Keep transport/client fixes in qmacp and MCP adaptation in qmcp. Do not expand + work into QueryMT or `querymt-desktop` without authorization. diff --git a/Cargo.lock b/Cargo.lock index 27b2027..36280b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1857,8 +1857,8 @@ dependencies = [ [[package]] name = "qmacp" -version = "0.1.1" -source = "git+https://github.com/querymt/qmacp?tag=v0.1.1#5a8371446f49a559b57f106b2ca22c8d0fc75463" +version = "0.1.2" +source = "git+https://github.com/querymt/qmacp?tag=v0.1.2#7b019b104017b7f30926c8ce18689b7aa8d142b8" dependencies = [ "agent-client-protocol", "anyhow", @@ -1876,7 +1876,7 @@ dependencies = [ [[package]] name = "qmcp" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 56ff32a..36380e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "qmcp" description = "MCP server for controlling a running QueryMT ACP WebSocket server." authors = ["Ivan Zatevakhin "] -version = "0.1.0" +version = "0.1.1" edition = "2024" license = "MIT" readme = "README.md" @@ -15,7 +15,7 @@ path = "src/main.rs" anyhow = "1" axum = "0.8" clap = { version = "4", features = ["derive", "env"] } -qmacp = { git = "https://github.com/querymt/qmacp", tag = "v0.1.1" } +qmacp = { git = "https://github.com/querymt/qmacp", tag = "v0.1.2" } reqwest = { version = "0.13", default-features = false, features = ["json", "form", "rustls"] } rmcp = { version = "3.6", default-features = false, features = ["server", "transport-io", "transport-streamable-http-server"] } schemars = "1" diff --git a/README.md b/README.md index 1c641b4..0d466d3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ return provider data under `result`, separate from bridge status and event metad notifications; disconnecting is not a guarantee that the operation stopped. - Failed calls may include `recovery` details. Check them and current runtime before retrying: prior effects are not rolled back, including in `exec` batches. +- Queue/steer delivery skips session history. On `prompt`, `until_idle: false` + returns after submission when either is selected; idle prompt delivery still waits. +- A receipt confirms acceptance, not task success. Check `observationState`; + invalid receipts fail and keep submission recovery unconfirmed. - Events and assistant text are returned with the result, not streamed live. Check `droppedEvents` and `textTruncated` for incomplete output. - `find` scans at most 20 pages. Check `truncated` and `scanLimitReached` before diff --git a/src/commands.rs b/src/commands.rs index 880a525..130e243 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -168,6 +168,7 @@ pub enum Command { timeout: u64, #[serde(default)] delivery: Delivery, + /// Wait for idle after submission, not proof the submitted task succeeded. #[serde(default = "default_true")] until_idle: bool, }, @@ -192,7 +193,7 @@ pub enum Command { #[serde(default = "default_true")] idle: bool, }, - /// Steer an in-flight turn. Omit run_id to discover the active run. + /// Steer an in-flight turn. Omit run_id to discover it; a nonempty active run is required. Steer { session_id: String, run_id: Option, @@ -1142,23 +1143,33 @@ async fn prompt( let session_id = session_id .ok_or_else(|| CliError::Usage("prompt requires session_id or new=true".into()))?; state.session(&session_id, &cwd, false); - open_session(client, session_id.clone(), cwd.clone()).await?; session_id }; // Keep the session id observable even if configuration or the prompt later fails. client .emit(json!({ "type": "session", "sessionId": session_id, "cwd": cwd })) .await; - apply_config(client, &session_id, mode, model, effort).await?; + state.recovery.insert("stage".into(), json!("configure")); + state + .recovery + .insert("deliveryState".into(), json!("not_submitted")); let chosen = if new { Delivery::Prompt } else { choose_delivery(client, &session_id, delivery).await? }; + if !new && chosen == Delivery::Prompt { + open_session(client, session_id.clone(), cwd.clone()).await?; + } + apply_config(client, &session_id, mode, model, effort).await?; client.clear_assistant_text().await; let mut submitted = None; let stop_reason = match chosen { Delivery::Prompt => { + state.recovery.insert("stage".into(), json!("submit")); + state + .recovery + .insert("deliveryState".into(), json!("unknown")); let response = client .prompt(session_id.clone(), text, Some(Duration::from_secs(86_400))) .await @@ -1181,6 +1192,10 @@ async fn prompt( } Delivery::Auto => unreachable!("auto is resolved before send"), }; + state + .recovery + .insert("deliveryState".into(), json!("accepted")); + state.recovery.insert("stage".into(), json!("observe")); if until_idle { wait_until_idle(client, &session_id, Duration::from_secs(u32::MAX.into())).await?; } @@ -1192,6 +1207,8 @@ async fn prompt( "stopReason": stop_reason, "text": text, "submitted": submitted, + "deliveryState": "accepted", + "observationState": if until_idle { "idle" } else if chosen == Delivery::Prompt { "prompt_returned" } else { "not_waited" }, "loadSession": initialized.agent_capabilities.load_session, })) } @@ -1217,11 +1234,16 @@ async fn choose_delivery( let phase = state .pointer("/runtime/phase") .and_then(Value::as_str) - .unwrap_or("idle"); + .filter(|phase| !phase.is_empty()) + .ok_or_else(|| { + CliError::rpc("runtime response has no valid phase; input was not submitted") + })?; let steerable = state .pointer("/runtime/steerable") .and_then(Value::as_bool) - .unwrap_or(false); + .ok_or_else(|| { + CliError::rpc("runtime response has no valid steerable flag; input was not submitted") + })?; Ok(delivery_from_runtime(phase, steerable)) } @@ -1352,46 +1374,72 @@ async fn submit_input( run_id: Option, text: String, ) -> Result { + state.recovery.insert("sessionId".into(), json!(session_id)); + state.recovery.insert("stage".into(), json!("submit")); + state + .recovery + .insert("deliveryState".into(), json!("not_submitted")); client.initialize().await.map_err(CliError::from_request)?; - let run_id = if method.ends_with("/steer") && run_id.is_none() { - runtime(client, session_id.clone()) - .await? - .pointer("/runtime/active_run_id") - .and_then(Value::as_str) - .map(str::to_string) + let delivery = if method.ends_with("/steer") { + "steer" + } else { + "queue" + }; + let run_id = if delivery == "steer" { + let run_id = match run_id { + Some(id) => Some(id), + None => runtime(client, session_id.clone()) + .await? + .pointer("/runtime/active_run_id") + .and_then(Value::as_str) + .map(str::to_string), + }; + Some(run_id.filter(|id| !id.trim().is_empty()).ok_or_else(|| { + CliError::rpc( + "steer requires an active run or a nonempty run_id; input was not submitted", + ) + })?) } else { run_id }; let client_input_id = uuid::Uuid::new_v4().to_string(); - state.submission_started( - &session_id, - &client_input_id, - if method.ends_with("/steer") { - "steer" - } else { - "queue" - }, - ); + state.submission_started(&session_id, &client_input_id, delivery); let mut params = json!({ "session_id": session_id, "client_input_id": client_input_id, "prompt": [{ "type": "text", "text": text }], }); if let Some(run_id) = run_id { - params - .as_object_mut() - .expect("params object") - .insert("expected_run_id".to_string(), json!(run_id)); + params["expected_run_id"] = json!(run_id); } let response = client .extension(method, params) .await .map_err(CliError::from_request)?; let acknowledgement = extension_payload(&response); + if acknowledgement.get("input_id").and_then(Value::as_str) != Some(client_input_id.as_str()) + || !matches!( + response.get("status").and_then(Value::as_str), + Some("queued" | "started" | "steered") + ) + || response.get("ok") == Some(&Value::Bool(false)) + || acknowledgement.get("ok") == Some(&Value::Bool(false)) + { + return Err(CliError::rpc( + "invalid submission receipt; acceptance is unknown; inspect before retrying", + )); + } state.submission_acknowledged(&acknowledgement); + client + .emit(json!({ + "type": "submitted", "sessionId": session_id, "clientInputId": client_input_id, + "delivery": delivery, "deliveryState": "accepted", "result": acknowledgement, + })) + .await; Ok(json!({ "sessionId": session_id, "clientInputId": client_input_id, + "deliveryState": "accepted", "result": acknowledgement, })) } diff --git a/src/output.rs b/src/output.rs index 620debe..e8b046c 100644 --- a/src/output.rs +++ b/src/output.rs @@ -82,6 +82,8 @@ impl OperationState { pub fn submission_started(&mut self, session_id: &str, input_id: &str, delivery: &str) { self.recovery.insert("sessionId".into(), json!(session_id)); + self.recovery + .insert("deliveryState".into(), json!("unknown")); self.recovery.insert( "submission".into(), json!({ @@ -91,6 +93,8 @@ impl OperationState { } pub fn submission_acknowledged(&mut self, result: &Value) { + self.recovery + .insert("deliveryState".into(), json!("accepted")); let submission = self .recovery .get_mut("submission") diff --git a/src/server.rs b/src/server.rs index b1661f1..605d8e5 100644 --- a/src/server.rs +++ b/src/server.rs @@ -283,7 +283,7 @@ mod tests { } "_querymt/session/queue" => { input_id = request["params"]["client_input_id"].clone(); - json!({"accepted": true}) + json!({"status": "queued", "data": {"input_id": input_id, "position": 1}}) } "session/cancel" | "session/close" => { panic!("MCP cancellation must not cancel or close remote work") @@ -323,16 +323,17 @@ mod tests { assert_eq!(result["results"].as_array().unwrap().len(), 1); assert_eq!(result["results"][0]["result"]["ok"], true); assert_eq!(result["recovery"]["submission"]["state"], "acknowledged"); - assert_eq!( - result["recovery"]["submission"]["acknowledgement"]["accepted"], - true - ); + assert_eq!(result["recovery"]["deliveryState"], "accepted"); let input_id = tokio::time::timeout(Duration::from_secs(1), peer) .await .expect("the cancelled call must drop its ACP client") .unwrap(); assert!(input_id.is_string()); assert_eq!(result["recovery"]["submission"]["clientInputId"], input_id); + assert_eq!( + result["recovery"]["submission"]["acknowledgement"]["input_id"], + input_id + ); } #[test] diff --git a/tests/bridge.rs b/tests/bridge.rs index 0cd74d7..c3eb81a 100644 --- a/tests/bridge.rs +++ b/tests/bridge.rs @@ -34,6 +34,9 @@ enum SubmissionBehavior { PollError, Busy, NoReply, + HistoryUnavailable, + MissingReceiptStatus, + WrongReceiptId, } impl MockAcp { @@ -168,6 +171,7 @@ async fn serve_acp( | "_querymt/session/getLifecycle" | "_querymt/session/setLifecycle" | "_querymt/session/setPinned" + | "_querymt/session/runtimeState" ) && let Some(payload) = &extension_payload { send( @@ -187,6 +191,10 @@ async fn serve_acp( "_querymt/profiles" => json!({"profiles": [{"id": "fixture-profile"}]}), "session/new" => json!({"sessionId": "fixture-session"}), "session/resume" | "session/load" => { + if matches!(behavior, SubmissionBehavior::HistoryUnavailable) { + send(&mut socket, json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32000, "message": "history replay unavailable"}})).await; + continue; + } json!({"configOptions": [{"id": "model", "name": "Model", "type": "select", "currentValue": "fixture-model", "options": [{"value": "fixture-model", "name": "Fixture"}]}]}) } "session/list" => { @@ -231,7 +239,19 @@ async fn serve_acp( if matches!(behavior, SubmissionBehavior::NoReply) { continue; } - json!({"accepted": true}) + let input_id = ¶ms["client_input_id"]; + match behavior { + SubmissionBehavior::MissingReceiptStatus => { + json!({"data": {"input_id": input_id}}) + } + SubmissionBehavior::WrongReceiptId => { + json!({"status": "queued", "data": {"input_id": "wrong-input"}}) + } + _ => json!({ + "status": if method.ends_with("/steer") { "steered" } else { "queued" }, + "data": {"input_id": input_id, "run_id": "fixture-run", "position": 1}, + }), + } } "_querymt/session/discardQueuedInput" => json!({"accepted": true}), "session/set_mode" if params["modeId"] == "flood-config" => { @@ -446,6 +466,7 @@ async fn exercise_bridge(http: bool) { assert_eq!(prompt["isError"], false, "{prompt}"); assert_eq!(prompt["structuredContent"]["sessionId"], "fixture-session"); assert_eq!(prompt["structuredContent"]["text"], "reply:-"); + assert_eq!(prompt["structuredContent"]["observationState"], "idle"); assert!( prompt["structuredContent"]["events"] .as_array() @@ -456,10 +477,14 @@ async fn exercise_bridge(http: bool) { let continued = mcp .tool( "prompt", - json!({"session_id": "fixture-session", "cwd": cwd, "text": "continue"}), + json!({"session_id": "fixture-session", "cwd": cwd, "text": "continue", "until_idle": false}), ) .await; assert_eq!(continued["structuredContent"]["text"], "reply:continue"); + assert_eq!( + continued["structuredContent"]["observationState"], + "prompt_returned" + ); let listed = mcp .tool( @@ -505,6 +530,7 @@ async fn exercise_bridge(http: bool) { .as_str() .is_some() ); + assert_eq!(queued["structuredContent"]["deliveryState"], "accepted"); let cancelled = mcp .tool("cancel", json!({"session_id": "fixture-session"})) .await; @@ -707,8 +733,17 @@ async fn submission_identity_survives_poll_failures_and_timeouts_without_input_e assert!(submission.get("acknowledgement").is_none()); } else { assert_eq!(submission["state"], "acknowledged"); - assert_eq!(submission["acknowledgement"]["accepted"], true); + assert_eq!( + submission["acknowledgement"]["input_id"], + submission["clientInputId"] + ); } + assert!(requests.iter().all(|request| { + !matches!( + request["method"].as_str(), + Some("session/load" | "session/resume") + ) + })); backend.wait_disconnected().await; assert_no_implicit_cancel(&backend); } @@ -716,6 +751,111 @@ async fn submission_identity_survives_poll_failures_and_timeouts_without_input_e } } +#[tokio::test] +async fn nonwaiting_delivery_skips_unavailable_history_and_returns_the_receipt() { + for delivery in ["queue", "auto"] { + let backend = MockAcp::with_submission(SubmissionBehavior::HistoryUnavailable).await; + let mut mcp = Mcp::start(&backend.url, false).await; + let response = mcp + .tool( + "prompt", + json!({ + "session_id": "fixture-session", "text": "callback", "delivery": delivery, + "until_idle": false, + }), + ) + .await; + assert_eq!(response["isError"], false, "{response}"); + let result = &response["structuredContent"]; + assert_eq!( + result["delivery"], + if delivery == "auto" { "steer" } else { "queue" } + ); + assert_eq!(result["observationState"], "not_waited"); + assert_eq!(result["text"], ""); + assert_eq!(result["submitted"]["deliveryState"], "accepted"); + assert_eq!( + result["submitted"]["result"]["input_id"], + result["submitted"]["clientInputId"] + ); + assert!(result["events"].as_array().unwrap().iter().any(|event| { + event["type"] == "submitted" + && event["clientInputId"] == result["submitted"]["clientInputId"] + })); + assert!(backend.recorded().iter().all(|request| { + !matches!( + request["method"].as_str(), + Some("session/load" | "session/resume" | "session/prompt") + ) + })); + } +} + +#[tokio::test] +async fn invalid_receipts_stay_unconfirmed_and_stop_a_batch() { + for behavior in [ + SubmissionBehavior::MissingReceiptStatus, + SubmissionBehavior::WrongReceiptId, + ] { + let backend = MockAcp::with_submission(behavior).await; + let mut mcp = Mcp::start(&backend.url, false).await; + let response = mcp.tool("exec", json!({"calls": [ + {"name": "queue", "arguments": {"session_id": "fixture-session", "text": "callback"}}, + {"name": "close", "arguments": {"session_id": "fixture-session"}}, + ]})).await; + assert_eq!(response["isError"], true, "{response}"); + let result = &response["structuredContent"]; + assert_eq!(result["failedCall"], "queue"); + assert_eq!(result["recovery"]["submission"]["state"], "unconfirmed"); + assert!( + result["recovery"]["submission"] + .get("acknowledgement") + .is_none() + ); + assert!( + !result["events"] + .as_array() + .unwrap() + .iter() + .any(|event| event["type"] == "submitted") + ); + let requests = backend.recorded(); + let sent = requests + .iter() + .find(|request| request["method"] == "_querymt/session/queue") + .unwrap(); + assert_eq!( + result["recovery"]["submission"]["clientInputId"], + sent["params"]["client_input_id"] + ); + assert_no_implicit_cancel(&backend); + } +} + +#[tokio::test] +async fn invalid_runtime_and_missing_active_run_never_submit_work() { + for (tool, runtime) in [ + ("prompt", json!({})), + ("prompt", json!({"phase": "tools"})), + ("steer", json!({"phase": "idle", "steerable": false})), + ] { + let backend = MockAcp::with_extension_payload(runtime).await; + let mut mcp = Mcp::start(&backend.url, false).await; + let response = mcp + .tool( + tool, + json!({"session_id": "fixture-session", "text": "callback"}), + ) + .await; + assert_eq!(response["isError"], true, "{response}"); + assert_eq!(response["structuredContent"]["code"], 3); + assert!(backend.recorded().iter().all(|request| matches!( + request["method"].as_str(), + Some("initialize" | "_querymt/session/runtimeState") + ))); + } +} + #[tokio::test] async fn batch_keeps_each_steps_truncation_status_after_a_later_short_answer() { for http in [false, true] {