Compare commits

...
7 changed files with 183 additions and 57 deletions
+33 -6
View File
@@ -1,5 +1,5 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus //! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! one ignored test against the real `claude-code-acp`. //! ignored smoke tests against each maintained adapter.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -268,7 +268,7 @@ async fn advertised_settings_are_normalized_and_mutable() {
async fn terminate_sends_cancel_then_sigterm() { async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path())) let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
.await .await
.expect("spawn"); .expect("spawn");
let pid = session.pid(); let pid = session.pid();
@@ -309,7 +309,7 @@ async fn terminate_is_satisfied_by_an_exit_on_cancel() {
async fn handshake_error_fails_the_spawn_and_reaps_the_child() { async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()); let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into()); config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails"); let err = maestro_acp::spawn(config).await.expect_err("init fails");
@@ -404,7 +404,10 @@ fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
async fn load_session_resumes_the_stored_id_and_drops_the_replay() { async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into()); config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn"); let session = maestro_acp::spawn(config).await.expect("spawn");
@@ -437,7 +440,10 @@ async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
async fn load_session_requires_the_load_capability() { async fn load_session_requires_the_load_capability() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into()); config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config) let err = maestro_acp::spawn(config)
@@ -452,7 +458,10 @@ async fn load_session_requires_the_load_capability() {
async fn load_session_error_fails_the_spawn() { async fn load_session_error_fails_the_spawn() {
let fake = Fake::new(); let fake = Fake::new();
let cwd = TempDir::new().unwrap(); let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old"); let mut config = fake
.config(cwd.path())
.adapter(AcpAdapter::Codex)
.load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into()); config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails"); let err = maestro_acp::spawn(config).await.expect_err("load fails");
@@ -476,3 +485,21 @@ async fn real_adapter_handshake() {
.await; .await;
assert!(session.has_exited()); assert!(session.has_exited());
} }
/// End to end against the maintained Codex adapter. It needs a locally
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
#[tokio::test]
#[ignore]
async fn real_codex_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session =
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
.await
.expect("real codex-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
+52
View File
@@ -573,6 +573,58 @@ mod tests {
); );
} }
#[test]
fn projects_the_codex_adapter_fixture_without_provider_specific_rows() {
let fixture = include_str!("../tests/fixtures/codex-acp-session.jsonl");
let events = fixture
.lines()
.map(serde_json::from_str::<SessionNotification>)
.collect::<Result<Vec<_>, _>>()
.expect("Codex ACP fixture follows the supported schema");
let mut projection = Projection::default();
for (seq, event) in events.iter().enumerate() {
projection.apply((seq + 1) as u64, event);
}
let rows = projection.snapshot(events.len() as u64).rows;
assert_eq!(
rows.iter().map(|row| row.kind).collect::<Vec<_>>(),
[
TranscriptRowKind::User,
TranscriptRowKind::AgentThought,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolDiff,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::ToolCall,
TranscriptRowKind::Plan,
TranscriptRowKind::Notice,
TranscriptRowKind::Notice,
TranscriptRowKind::AgentText,
]
);
assert_eq!(rows[2].data["rawInput"]["command"], "rg TODO");
assert_eq!(rows[2].data["rawOutput"]["exit_code"], 0);
assert_eq!(rows[4].data["_meta"]["is_mcp_tool_call"], true);
assert_eq!(rows[5].data["rawInput"]["query"], "Agent Client Protocol");
assert_eq!(
rows[7].data["content"][0]["content"]["mimeType"],
"image/png"
);
let state = projection.state();
assert_eq!(state.model.as_deref(), Some("gpt-5.3-codex"));
assert_eq!(state.mode.as_deref(), Some("agent"));
assert_eq!(state.usage.as_ref().unwrap()["used"], 1200);
assert_eq!(state.context.as_ref().unwrap()["size"], 258400);
assert_eq!(
state.config_options.as_ref().unwrap()["configOptions"][2]["currentValue"],
"high"
);
}
#[test] #[test]
fn coalesces_anonymous_stream_chunks_without_joining_turns() { fn coalesces_anonymous_stream_chunks_without_joining_turns() {
let mut projection = Projection::default(); let mut projection = Projection::default();
@@ -0,0 +1,16 @@
{"sessionId":"codex-session","update":{"sessionUpdate":"user_message_chunk","messageId":"user-1","content":{"type":"text","text":"Inspect the repository"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_thought_chunk","messageId":"thought-1","content":{"type":"text","text":"I should inspect the files first."}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"command-1","kind":"execute","title":"rg TODO","status":"in_progress","rawInput":{"command":"rg TODO","cwd":"/workspace"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call_update","toolCallId":"command-1","status":"completed","rawOutput":{"formatted_output":"src/lib.rs: TODO","exit_code":0}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"change-1","kind":"edit","title":"Editing files","status":"completed","content":[{"type":"diff","path":"src/lib.rs","oldText":"old","newText":"new"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"mcp-1","kind":"execute","title":"mcp.github.search","status":"completed","rawInput":{"server":"github","tool":"search","arguments":{"query":"ACP"}},"rawOutput":{"result":"found"},"_meta":{"is_mcp_tool_call":true}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"web-1","kind":"search","title":"Search the web for ACP","status":"completed","rawInput":{"type":"webSearch","query":"Agent Client Protocol"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-view-1","kind":"read","title":"View Image /workspace/diagram.png","status":"completed","content":[{"type":"content","content":{"type":"resource_link","name":"/workspace/diagram.png","uri":"/workspace/diagram.png"}}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"tool_call","toolCallId":"image-gen-1","title":"Image generation","status":"completed","content":[{"type":"content","content":{"type":"image","data":"aW1hZ2U=","mimeType":"image/png"}}],"rawOutput":{"path":"/workspace/generated.png"}}}
{"sessionId":"codex-session","update":{"sessionUpdate":"plan","entries":[{"content":"Implement compatibility fixture","priority":"medium","status":"in_progress"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"usage_update","used":1200,"size":258400}}
{"sessionId":"codex-session","update":{"sessionUpdate":"current_mode_update","currentModeId":"agent"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"config_option_update","configOptions":[{"id":"agent-mode","name":"Agent mode","category":"mode","type":"select","currentValue":"agent","options":[{"value":"read-only","name":"Read-only"},{"value":"agent","name":"Agent"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-5.3-codex","options":[{"value":"gpt-5.3-codex","name":"GPT-5.3-Codex"}]},{"id":"reasoning_effort","name":"Reasoning effort","category":"thought_level","type":"select","currentValue":"high","options":[{"value":"high","name":"High"}]},{"id":"fast-mode","name":"Fast mode","category":"model_config","type":"select","currentValue":"off","options":[{"value":"off","name":"Off"}]}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"status","description":"Show session status"}]}}
{"sessionId":"codex-session","update":{"sessionUpdate":"session_info_update","title":"Codex session"}}
{"sessionId":"codex-session","update":{"sessionUpdate":"agent_message_chunk","messageId":"assistant-1","content":{"type":"text","text":"Done."}}}
+33 -29
View File
@@ -16,10 +16,10 @@ does not exist yet, and nothing here describes it.
A terminal session's agent runs inside a tmux pane; a structured session's A terminal session's agent runs inside a tmux pane; a structured session's
agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The agent runs as a subprocess of the daemon itself, speaking ACP over stdio. The
daemon is the ACP **client**; the adapter process, `claude-code-acp`, is the daemon is the ACP **client**; the provider adapter process is the ACP
ACP **agent** — it wraps Claude Code's own SDK and translates the model's **agent**. It translates the provider's events into ACP `session/update`
tool calls into ACP `session/update` notifications instead of drawing them to notifications instead of drawing them to a pane. `crates/maestro-acp` owns the
a pane. `crates/maestro-acp` owns the daemon's side of that connection; daemon's side of that connection;
`crates/maestro-daemon/src/projection.rs` folds what it receives into the two `crates/maestro-daemon/src/projection.rs` folds what it receives into the two
projections below. projections below.
@@ -37,30 +37,26 @@ ACP handle instead of a tmux pane).
## The adapter ## The adapter
`ADAPTER_BIN` (`claude-code-acp`) is resolved by walking `PATH` — the The resolved structured command selects a provider adapter. Its binary is
session's own `PATH` env var if the spawn request set one, the daemon's resolved by walking the session's `PATH` when the spawn request sets one, or
otherwise — and taking the first executable file named `claude-code-acp` on the daemon's `PATH` otherwise, and taking the first executable with that name.
it. Install it once per host:
```sh | Command | Adapter binary | Install command | Provider detail |
npm install -g --prefix ~/.local @zed-industries/claude-code-acp | --- | --- | --- | --- |
``` | `claude` | `claude-code-acp` | `npm install -g --prefix ~/.local @zed-industries/claude-code-acp` | Needs a logged-in Claude Code. |
| `codex` | `codex-acp` | `npm install -g --prefix ~/.local @agentclientprotocol/codex-acp` | Uses Codex CLI or adapter-configured authentication; it advertises model, reasoning-effort, agent-mode, and optional fast-mode configuration. |
That is `maestro_acp::INSTALL_COMMAND`, and it is not just documentation: it The selected install command is baked into `AcpError::AdapterMissing`. When an
is baked into `AcpError::AdapterMissing`'s message. When the adapter cannot adapter cannot be found, spawn still creates a **Dead** session with that
be found, spawn still creates the session — **Dead**, with that install command as `activity_detail` (see `docs/INSTALL.md` for the systemd `PATH`
command as its `activity_detail` — so the sidebar names the fix instead of caveat). Provider authentication failures surface through the adapter's stderr
the session silently never appearing (see `docs/INSTALL.md` for the full tail in the same field.
install writeup, including the systemd `PATH` caveat). The adapter also needs
a logged-in Claude Code (`claude /login`); a missing login surfaces the same
way, via the adapter's own stderr tail in the failure message.
The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a The handshake is bounded by `HANDSHAKE_TIMEOUT` (60s) — long enough for a
slow machine to boot a node process and the Claude Code SDK behind it, never slow machine to boot an adapter and its provider process, never long enough
long enough to hang a spawn indefinitely. Any handshake failure — missing to hang a spawn indefinitely. Any handshake failure — missing adapter, early
adapter, early exit, a protocol error, or the timeout — leaves no orphaned exit, a protocol error, or the timeout — leaves no orphaned process: `spawn`
process: `spawn` closes and reaps the child itself before returning the closes and reaps the child itself before returning the error.
error.
## The ACP handshake ## The ACP handshake
@@ -69,17 +65,17 @@ declares, deliberately, almost nothing:
| Capability | Declared | Why | | Capability | Declared | Why |
| --- | --- | --- | | --- | --- | --- |
| `fs.readTextFile` | `false` | The adapter uses Claude Code's own Read tool instead of asking the daemon to read a file on its behalf — the same tool a terminal-view Claude Code session uses. | | `fs.readTextFile` | `false` | Providers read the session workspace through their own tool surface. |
| `fs.writeTextFile` | `false` | Same reasoning, for Write. | | `fs.writeTextFile` | `false` | Providers write the session workspace through their own tool surface. |
| `terminal` | `false` | The adapter runs Claude Code's own Bash tool rather than asking the daemon to open a terminal for it. | | `terminal` | `false` | Providers run commands through their own tool surface. |
Declining all three keeps the daemon out of the file/terminal surface Declining all three keeps the daemon out of the file/terminal surface
entirely — the adapter already has direct access to the session's `cwd`, and entirely — the adapter already has direct access to the session's `cwd`, and
routing every read, write, and shell command through the daemon would only routing every read, write, and shell command through the daemon would only
add a hop with no new capability. Every other capability namespace ACP add a hop with no new capability. Every other capability namespace ACP
defines (session extensions, auth, elicitation) is simply left at its defines (session extensions, auth, elicitation) is simply left at its
default; the daemon does not advertise support because `claude-code-acp` has default; the daemon does not advertise support because neither supported
never asked for it, not because it was weighed and refused. adapter needs it, not because it was weighed and refused.
The one client method that *is* implemented for real is The one client method that *is* implemented for real is
`session/request_permission` (#139) — see [Approvals](#approvals) below. `session/request_permission` (#139) — see [Approvals](#approvals) below.
@@ -96,6 +92,14 @@ flight is dropped on arrival, deliberately: the daemon's own event log
already holds that history, and forwarding it too would duplicate every row already holds that history, and forwarding it too would duplicate every row
in the projections below. in the projections below.
Codex and Claude differ in what they emit, not in this lifecycle. Codex maps
shell commands, file changes, MCP calls, web search, image generation/view,
reasoning, plans, and token usage into standard ACP chunks and tool calls;
those fold into the same eight rows. Codex's `available_commands_update` and
`session_info_update`, like equivalent Claude updates, remain opaque
`Notice` rows. Its `session/load` capability is required for daemon recovery
just as it is for Claude.
## The eight transcript row kinds ## The eight transcript row kinds
`TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured `TranscriptRowKind` (`maestro-protocol`) is the alphabet a structured
+15 -9
View File
@@ -26,15 +26,21 @@ describe("buildSpawnRequest", () => {
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } }); expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
}); });
it("requests structured view only for Claude Code", () => { it.each(["claude", "FOO=1 /usr/local/bin/codex", "env -i codex"])(
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({ "requests structured view for resolved %s commands",
req: { (command) => {
cwd: base.cwd, expect(buildSpawnRequest({ ...base, command, structuredView: true }, null)).toEqual({
command: "claude", req: {
metadata: { name: "amber-fox" }, cwd: base.cwd,
view: "Structured", command,
}, metadata: { name: "amber-fox" },
}); view: "Structured",
},
});
},
);
it("does not request structured view for unsupported commands", () => {
expect( expect(
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null), buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
).toEqual({ ).toEqual({
+9 -2
View File
@@ -15,6 +15,13 @@ import { splitCommand } from "./toolIcon.js";
import { abbreviateCwd } from "./types.js"; import { abbreviateCwd } from "./types.js";
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js"; import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
const STRUCTURED_VIEW_TOOLS = new Set(["claude", "codex"]);
function supportsStructuredView(command: string): boolean {
const tool = splitCommand(command).tool;
return tool !== null && STRUCTURED_VIEW_TOOLS.has(tool);
}
/** Where a worktree's branch comes from: freshly created (default) or picked /** Where a worktree's branch comes from: freshly created (default) or picked
* from the repository's existing branches (#61). */ * from the repository's existing branches (#61). */
export type WorktreeBranchMode = "new" | "existing"; export type WorktreeBranchMode = "new" | "existing";
@@ -68,7 +75,7 @@ export function buildSpawnRequest(
metadata, metadata,
}; };
if (values.structuredView && splitCommand(values.command).tool === "claude") { if (values.structuredView && supportsStructuredView(values.command)) {
req.view = "Structured"; req.view = "Structured";
} }
@@ -369,7 +376,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
let commandTouched = false; let commandTouched = false;
function updateStructuredAvailability(): void { function updateStructuredAvailability(): void {
const supported = splitCommand(command).tool === "claude"; const supported = supportsStructuredView(command);
structuredWrap.hidden = !supported; structuredWrap.hidden = !supported;
if (!supported) structuredView.checked = false; if (!supported) structuredView.checked = false;
} }
+25 -11
View File
@@ -44,7 +44,7 @@ describe("spawn form structured view", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("is default-off and shown only for the resolved Claude Code command", async () => { it("is default-off and shown for resolved Claude Code and Codex commands", async () => {
stubApi(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
@@ -59,26 +59,33 @@ describe("spawn form structured view", () => {
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(false);
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click(); const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
expect(wrap.hidden).toBe(true); custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click(); command.value = "FOO=1 /usr/local/bin/codex";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(false); expect(wrap.hidden).toBe(false);
command.value = "opencode";
command.dispatchEvent(new Event("input"));
expect(wrap.hidden).toBe(true);
}); });
it("clears a checked value when the command changes away from Claude Code", async () => { it("clears a checked value when Codex changes to an unsupported command", async () => {
stubApi(); stubApi();
handle = createSpawnForm({ onSuccess() {} }); handle = createSpawnForm({ onSuccess() {} });
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
await drain(); await drain();
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "env -i codex";
command.dispatchEvent(new Event("input"));
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!; const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
checkbox.click(); checkbox.click();
expect(checkbox.checked).toBe(true); expect(checkbox.checked).toBe(true);
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
custom.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "opencode"; command.value = "opencode";
command.dispatchEvent(new Event("input")); command.dispatchEvent(new Event("input"));
@@ -86,7 +93,7 @@ describe("spawn form structured view", () => {
expect(checkbox.checked).toBe(false); expect(checkbox.checked).toBe(false);
}); });
it("emits Structured in the submitted request when selected", async () => { it.each(["claude", "codex"])("emits Structured for a selected %s command", async (tool) => {
const requests = stubApi(); const requests = stubApi();
handle = createSpawnForm({ handle = createSpawnForm({
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false }, seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
@@ -95,13 +102,20 @@ describe("spawn form structured view", () => {
document.body.appendChild(handle.el); document.body.appendChild(handle.el);
await drain(); await drain();
if (tool === "codex") {
handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!.click();
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
command.value = "codex";
command.dispatchEvent(new Event("input"));
}
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click(); handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
handle.el.requestSubmit(); handle.el.requestSubmit();
await drain(); await drain();
expect(requests).toHaveLength(1); expect(requests).toHaveLength(1);
expect(JSON.parse(String(requests[0].body))).toMatchObject({ expect(JSON.parse(String(requests[0].body))).toMatchObject({
command: "claude", command: tool,
view: "Structured", view: "Structured",
}); });
}); });