Skip to content

feat: real local ACP engine + per-harness model override - #1

Closed
sanil-23 wants to merge 23 commits into
fix/1244-acp-default-harness-kind-gapfrom
feat/1245-acp-local-engine-model
Closed

feat: real local ACP engine + per-harness model override#1
sanil-23 wants to merge 23 commits into
fix/1244-acp-default-harness-kind-gapfrom
feat/1245-acp-local-engine-model

Conversation

@sanil-23

Copy link
Copy Markdown
Owner

Summary

Stacked on tinyhumansai#1247 (targets tinyhumansai/opencompany) — this PR is opened against my own fork's fix/1244-acp-default-harness-kind-gap branch, since tinyhumansai#1245's work depends on that fix and the base branch doesn't exist upstream yet. Once tinyhumansai#1247 merges to tinyhumansai/opencompany:main, I'll retarget this one there.

Named harnesses (tinyhumansai#993) let a teammate bind to an acp harness, but no engine existed to actually run that turn — lanes::build unconditionally recorded every acp harness unavailable. This wires a real one for transport = "local", plus a model field so a power user can pin a specific model on it — mirroring the pattern block/buzz already ships: a teammate carries harness + model as independent settings, and the host injects the model into the harness's own startup lever.

Changes

  • AcpHarness.model: Option<String> (src/company/types.rs) + validation (manifest.rs) — a plain string hint forwarded to the agent's own lever, not a credential, so it does not join [harness.inference]'s prohibition on acp harnesses. Rejected on transport = "runner" (no wire protocol for it yet) and when empty.
  • The AcpAgent/AcpAgentFactory/AcpTurn/AcpUpdate port moved from harness::acp::run_turn (behind the openhuman feature) to src/ports/acp.rs, ungated. Found this the hard way: the desktop shell — the only implementation this crate does not itself provide — does not enable openhuman on its opencompany dependency at all (deliberately, to avoid pulling the embedded-engine dependency tree into every desktop build), so the port had to live somewhere it could actually see. harness::acp::run_turn re-exports the types and keeps AcpRunTurn/fold, which genuinely do need openhuman's TurnStep/RunTurn.
  • lanes::build resolves a real engine for transport = "local" when given a factory (Option<&dyn AcpAgentFactory>, #[cfg(feature = "acp")] with an uninhabited-type fallback so openhuman-without-acp builds still compile). transport = "runner" still resolves unavailable — its own, larger piece of work (a socket wire protocol).
  • LocalAcpAgent/LocalAcpAgentFactory (src-tauri/src/acp/local_agent.rs): spawns the harness's CLI via the existing AcpClient, demultiplexes ACP's single global session/update stream by session id (one subprocess serves every teammate bound to the harness), and injects the model via a per-CLI env var — confirmed live against the real adapter, not guessed: ANTHROPIC_MODEL for claude, GOOSE_MODEL for goose. codex has no confirmed startup-model lever yet — model validates but isn't injected for it, rather than guessing wrong and silently spawning a process that ignores the setting.
  • V1 fails closed on ACP permission requests (session/request_permission) rather than routing them through the company's ApprovalRequestQueue — flagged explicitly in the module docs and harnesses.md as a known gap, not the intended end state. Safe direction to be wrong in: a refused edit is visible and actionable; a silent auto-approval would not be.
  • AppState::with_acp_agents (src/app/types.rs) threads the factory to desktop::register, mirroring with_rebuilder's exact existing pattern; wired for real in src-tauri/src/embedded.rs.
  • Found and fixed a real bug via live testing: discovery.rs's catalog still named the legacy claude-code-acp binary. The current package (@agentclientprotocol/claude-agent-acp) installs a binary literally named claude-agent-acp. Would have silently failed every spawn on a current install.

Live testing

Validated against a real, authenticated claude-agent-acp (not just the scripted fixture acp_client.rs already covers):

  • a real prompt/response round trip
  • session/new actually advertising a model-category configOptions entry
  • ANTHROPIC_MODEL set on the spawned process actually steering the reported current model (ran it twice under two different values, confirmed they differ)
  • the full LocalAcpAgent path, through the AcpAgent trait — the same seam lanes::build calls in production — including per-agent workspace directory creation

See src-tauri/tests/acp_live_smoke.rs. #[ignore]d and never selected by CI (needs real credentials, costs real usage); run explicitly:

cargo test -p opencompany-desktop --test acp_live_smoke -- --ignored --nocapture

API Or Behavior Changes

New, additive manifest field ([harness.acp].model); new, additive AppState builder method. No behavior change for any company that doesn't set model or isn't running on the desktop app. Desktop companies bound to a lone/default acp harness now actually run turns on it (previously always unavailable — or, before tinyhumansai#1244's fix, silently ran the embedded engine instead).

Tests

  • cargo fmt --all -- --check (both crates)
  • cargo clippy --features acp --all-targets / cargo clippy --all-targets (desktop) — clean
  • cargo test --features acp --lib — 4691 passed
  • cargo test --features openhuman --lib — 4657 passed
  • cargo test --lib (desktop, fixture-based) — clean, no regressions
  • Live suite (acp_live_smoke.rs, --ignored) — 4/4 passed against real claude-agent-acp

New tests: manifest validation table test for model; the live suite above.

Documentation

docs/spec/runtime/harnesses.md updated: new "Model" section, corrected the RunnerDispatch/AcpAgent claim (it doesn't actually implement the port — that was aspirational even before this PR), updated "A harness with no engine fails the turn" and "What a harness does not decide" (the permission-request gap), and the implementation map.

graycyrus and others added 23 commits August 20, 2026 16:30
GET .../workflows/runs now returns a WorkflowRunsPage envelope
({ runs, hasMore }) instead of a bare array, ordered newest-first by
the run's actual settle time (seq/atMillis), with beforeSeq-based
cursor pagination so a page can be fetched further back without an
unbounded read. RunHistoryPanel gains a "Load older" affordance gated
on hasMore.

Every existing call site and test that read the runs endpoint as a
bare array needed updating to the new envelope shape.

Closes tinyhumansai#1012
…i#1245)

Named harnesses (tinyhumansai#993) let a teammate bind to an `acp` harness, but no
engine existed to actually run that turn -- lanes::build unconditionally
recorded every acp harness `unavailable`. This wires a real one for
`transport = "local"`, plus a `model` field so a power user can pin a
specific model on it, mirroring the pattern block/buzz already ships
(a teammate carries harness + model as independent settings, and the
host injects the model into the harness's own startup lever).

- `AcpHarness.model: Option<String>` (src/company/types.rs) + validation
  (manifest.rs): a plain string hint forwarded to the agent's own lever,
  not a credential, so it does not join `[harness.inference]`'s
  prohibition on acp harnesses. Rejected on `transport = "runner"` (no
  wire protocol for it yet) and when empty.

- The `AcpAgent`/`AcpAgentFactory`/`AcpTurn`/`AcpUpdate` port moved from
  `harness::acp::run_turn` (behind the `openhuman` feature) to
  `src/ports/acp.rs`, ungated. The desktop shell -- the only implementation
  this crate does not itself provide -- does not enable `openhuman` on its
  `opencompany` dependency at all, so the port had to live somewhere it
  could actually see without pulling in the whole embedded-engine
  dependency tree. `harness::acp::run_turn` re-exports the types and keeps
  `AcpRunTurn`/`fold`, which do need `openhuman`'s `TurnStep`/`RunTurn`.

- `lanes::build` resolves a real engine for `transport = "local"` when
  given a factory (`Option<&dyn AcpAgentFactory>`, `#[cfg(feature = "acp")]`
  with an uninhabited-type fallback for `openhuman`-without-`acp` builds);
  `transport = "runner"` still resolves `unavailable` (its own, larger
  piece of work).

- `LocalAcpAgent`/`LocalAcpAgentFactory` (src-tauri/src/acp/local_agent.rs):
  spawns the harness's CLI via the existing `AcpClient`, demultiplexes
  ACP's single global `session/update` stream by session id (one
  subprocess serves every teammate on the harness), and injects the model
  via a per-CLI env var confirmed live against the real adapter --
  `ANTHROPIC_MODEL` for claude, `GOOSE_MODEL` for goose. `codex` has no
  confirmed lever yet (validated but not injected, rather than guessed).
  V1 fails closed on ACP permission requests rather than routing them
  through the company's approval-policy gate -- a known, documented gap,
  not the intended end state.

- `AppState::with_acp_agents` (src/app/types.rs) threads the factory to
  `desktop::register`, mirroring `with_rebuilder`'s exact pattern; wired
  for real in src-tauri/src/embedded.rs.

- Found and fixed a real bug via live testing: discovery.rs's catalog
  still named the legacy `claude-code-acp` binary; the current package
  installs `claude-agent-acp`. Would have silently failed every spawn on
  a current install.

Live-tested against a real, authenticated claude-agent-acp (not just the
scripted fixture): a real prompt/response round trip, `session/new`
advertising a model config option, `ANTHROPIC_MODEL` actually steering
the reported current model, and the full `LocalAcpAgent` path through the
`AcpAgent` trait -- see src-tauri/tests/acp_live_smoke.rs (`#[ignore]`d,
costs real usage, never runs in CI).

Co-Authored-By: Claude <noreply@anthropic.com>
tinyhumansai#1196)

Resolves the tinyhumansai#1106 park-and-ask for the specific tie tinyhumansai#1196 reports: a global
baseline teammate (globals/agents/) plausibly fits alongside a role the
company staffed itself. The company has already expressed a preference by
staffing that role, so the baseline candidate is dropped and the card
dispatches instead of parking. A tie between two baseline teammates, or
between two company teammates, is untouched — tinyhumansai#1106's park-and-ask stands.

Carries Agent::global into TeammateBrief so the planner's roster prompt also
distinguishes a baseline teammate ("— from the shared baseline").

Co-Authored-By: Claude <noreply@anthropic.com>
…nyhumansai#1196)

Addresses CodeRabbit review on tinyhumansai#1246: direct coverage that a global
teammate's prompt line carries "— from the shared baseline" and a
company-authored teammate's does not.

Co-Authored-By: Claude <noreply@anthropic.com>
…yhumansai#1245)

Verified live rather than left as a documented gap. Tried four candidate
startup env vars against a real codex-acp (OPENAI_MODEL, CODEX_MODEL,
MODEL, OPENAI_DEFAULT_MODEL) -- none moved the reported current model off
its default. codex-acp does advertise a real configOptions entry with
category "model" though, and session/set_config_option against it, right
after session/new, does work -- confirmed live, and that it's per-session
state (a second, independent session reverts to the adapter's default).

LocalAcpAgent::session_for now tries that fallback whenever this build
has no known startup env var for the agent (model_env_var returned None)
and a model was requested -- not codex-specific in the code, so any
future agent in the same position gets it for free. Guarded so it never
fires redundantly when an env var already carried the model at spawn.

New: a deterministic (non-live, runs in CI) test pinning the configId
lookup against codex-acp's real captured response shape, plus a live
test proving the full LocalAcpAgent path completes through the fallback
without error against the real adapter.

Co-Authored-By: Claude <noreply@anthropic.com>
…inyhumansai#1260)

A server can require OAuth and still be undrivable from this console. Slack's
MCP endpoint answers 401 with a proper resource-metadata challenge and
advertises no `registration_endpoint`, so `begin` has no client to mint and
refuses -- correctly, but only once the operator has pressed a button that
could never work, and the refusal is the only place the real remedy appears.

The probe could not say so because `oauth_required` covered both states. It
answers one question, "did this server ask for OAuth", and the console reads
it as the answer to a different one, "can we complete a sign-in". Those come
apart exactly when dynamic client registration is missing.

`select_auth_server` already decides this -- authorize + token + registration +
authorization_code -- inside `begin`, at click time. `supports_console_oauth`
reuses it rather than restating the rule, so the question the probe asks and
the one `begin` enforces cannot drift.

The refinement sits in `probe_server`, not in `classify_mcp_error`, which is
pure and synchronous by design and holds only the resource-metadata URL, never
the fetched authorization-server document. It runs on the `OauthRequired` arm
alone, so a healthy server pays nothing: by then this server has already
answered 401. Bounded at 5s, because a metadata endpoint that accepts a
connection and then hangs would otherwise stall the probe.

Every uncertain answer is `true`. Discovery is a live call that fails for
reasons that say nothing about capability, and answering `false` on a timeout
would replace a working Sign in button with "paste a token" on a server that
signs in perfectly well -- worse than the behaviour being fixed. Unsure means
leave it as it was.

Both credential messages also stop naming Connections, which carries no MCP
server row at all. An instruction that cannot be followed reads as a broken
feature rather than a missing token.

Gated on `mcp` with a no-op fallback: a build without it has no `oauth/start`
route, so there is no button to withdraw.

Part of tinyhumansai#1260
…ansai#1260)

Which control a row offers is now a function of the host's hint and nothing
else, stated once in `credentialAffordance` rather than as two inline
conditions. `sign_in` for `oauth_required`; `add_token` for the new
`static_token_required` and for a plain `credential_required`, which wanted the
same field and simply never had a button to withdraw.

The inline field is the half that had to come with it. Following the old error
was impossible: the only Token input on the page belongs to the *add* form, so
an operator told to paste a token for an existing server would have created a
second copy of it. The host has accepted a credential rotation on
`PUT .../mcp/servers/{name}` all along -- `token`, `authKind`, `headerName`,
`paramName` -- and the control was simply never built. Per-row, write-only,
Enter to save and Escape to close.

It re-tests on success rather than trusting the write, because the operator
cannot know whether the token was the right one until the server answers, and a
silent save would leave the amber badge sitting there with no way to tell a
wrong token from an unsaved one.

Verified against a live host: a junk token saves, re-probes, and stays
`needs_config` rather than turning green, and the value appears nowhere in the
server list response. A no-auth server grows no credential control at all --
the easy way to get this wrong is to sprout a token prompt on every row.

Closes tinyhumansai#1260
…y-pagination

# Conflicts:
#	src/server/ops/workflows.rs
The console's "ask for a reason before closing" guard never fired on
any declared ledger: the server sent StatusSpec::needs_reason as
snake_case while the frontend's LedgerStatus.needsReason expected
camelCase, so it always read undefined.

StatusSpec itself must stay snake_case in both directions — it's also
what a declared ledger round-trips through in every store backend, so
an asymmetric rename there would silently drop the flag back to false
on save-then-reload (verified this against
closing_without_a_reason_is_a_400_that_says_so, which broke under that
approach). Instead, add a wire-only LedgerStatusDto in
src/server/ops/ledgers.rs with #[serde(rename_all = "camelCase")], and
have LedgerSummary.statuses build through it.

Closes tinyhumansai#1266
The tinyhumansai#1189 stranded-approvals tests merged in from upstream still indexed
the run history response as a bare array (body[0][...]), a leftover from
before tinyhumansai#1012 wrapped it in { runs, hasMore }. Point them at body["runs"][0]
so they assert against the actual response shape.
The `openhuman,tinycortex` CI lane builds without the `mcp` feature, so
refine_oauth_capability's real body (the only place that constructs this
variant) compiles out in favor of its no-op stub. Match the existing
cfg_attr(not(feature = "..."), allow(dead_code)) idiom used elsewhere in
this file's siblings (paypal.rs, rpc.rs, workflows.rs) rather than
widening the feature gate.
…mansai#1196)

Addresses CodeRabbit review: prefer_company_over_baseline classified any
non-baseline candidate id as company-side, so a desk (which resolves to
neither) could count as "company" and wrongly trigger dropping a genuine
baseline teammate from a tie the company never actually resolved. Replaced the
boolean classifier with a three-way Provenance (Baseline / Company / neither)
so a desk stays neutral on both sides — it neither triggers the drop nor is
dropped by it.

Co-Authored-By: Claude <noreply@anthropic.com>
The route stub for GET …/workflows/runs still fulfilled with a bare
JSON array. Since tinyhumansai#1012 wrapped that response as { runs, hasMore }, the
console's fetch never found `data.runs` and the list never rendered,
timing out both geometry specs in this file (CI: Console E2E and
Console E2E (live brain)).
…dcr-hint

fix(mcp): stop offering a Sign in button where OAuth cannot complete (tinyhumansai#1260)
…ason-case-mismatch

fix: send ledger status needsReason as camelCase on the wire
…tiebreak-precedence

fix(harness): baseline teammate steps aside for a company-authored tie
…-agent (tinyhumansai#1245)

LocalAcpAgent previously failed closed on every session/request_permission
call it wasn't explicitly configured to allow. Replace that with
AutoApprovingFiles, mirroring buzz-agent's handle_permission_request: pick
the allow_once-kind option the CLI offered, falling back to reject_once/
reject_always, never a hardcoded optionId. The CLI's own permission mode is
the trust boundary, same as running it interactively.

Co-Authored-By: Claude <noreply@anthropic.com>
…ory-pagination

workflows: run history ordered by finish time, cursor pagination, bounded read
…lt-harness-kind-gap

fix: a lone default acp harness no longer silently runs on the embedded engine
)

Not yet wired into generate_handler! or called by the frontend — this is
the read-only probe (acp::discovery::survey) on its own IPC surface, ready
for the UI work that consumes it.

Co-Authored-By: Claude <noreply@anthropic.com>
@sanil-23

Copy link
Copy Markdown
Owner Author

Superseded — fix/1244 merged upstream (tinyhumansai#1247), so this now targets main directly: tinyhumansai#1280

@sanil-23 sanil-23 closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants