Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,11 @@ See [readme-dev.md](readme-dev.md) for local client configuration, binary packag

### Subagent sessions

Subagent sessions follow the draft [ACP subagent RFD](https://github.com/agentclientprotocol/agent-client-protocol/pull/1992) and are enabled only after bilateral capability negotiation during `initialize`. Without native negotiation, the subagent lifecycle stays an ordinary ACP tool call.
Codex subagents are reported as ACP child sessions that follow the ACP [Subagent Sessions RFD](https://agentclientprotocol.com/rfds/subagents). A v1 client opts in with `clientCapabilities.subagents: {}` during `initialize`. A v2 client gets them without negotiation. The adapter announces each child with `subagent_update`, reports delegated tasks as `session_message`, and references children from collaboration tool calls with `session` content. A client can cancel a child's current work with `session/cancel`.

See [docs/subagent-sessions.md](docs/subagent-sessions.md) for the negotiation, lifecycle events, `session/load` reconstruction, and legacy fallback details.
JetBrains AIR clients keep an older private contract instead. Without either contract, the subagent lifecycle stays an ordinary ACP tool call.

See [docs/subagent-sessions.md](docs/subagent-sessions.md) for both contracts, `session/load` replay, and the fallback.

### Background terminal tasks

Expand Down
13 changes: 6 additions & 7 deletions docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1005,15 +1005,14 @@ Recovery never removes the transcript record.

## Native subagent sessions

The adapter implements the draft [ACP subagent RFD](https://github.com/agentclientprotocol/agent-client-protocol/pull/1992).
[Subagent sessions](subagent-sessions.md) describes the lifecycle.
AIR keeps the private subagent contract that predates the ACP Subagent Sessions RFD.
[Subagent sessions](subagent-sessions.md#air-legacy-contract) describes its lifecycle and the standard contract of other clients.
This section covers only the AIR bridge.

- The canonical client field is `clientCapabilities.subagents: {}`.
- Released ACP SDKs can strip that draft field.
AIR can instead declare `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`.
- Either signal enables native subagent sessions. New clients must prefer the canonical field.
- The agent always advertises `agentCapabilities.sessionCapabilities.subagents`. It advertises `nativeSubagentSessions` to AIR.
- AIR declares `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`, or on v1 `clientCapabilities.subagents: {}`.
- Either signal enables native subagent sessions for AIR.
- The agent advertises `agentCapabilities.sessionCapabilities.subagents` and `nativeSubagentSessions` to AIR only.
A client that is not AIR gets neither, because the RFD defines no Agent-side capability.
- Without either signal, a Codex subagent stays an ordinary tool call.
AIR gets `_meta.jetbrains.air.subagent: true` on a spawn. Another client gets the tool call without `_meta`.

Expand Down
83 changes: 63 additions & 20 deletions docs/subagent-sessions.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,79 @@
# Native subagent sessions

This document describes the draft [ACP subagent RFD](https://github.com/agentclientprotocol/agent-client-protocol/pull/1992) as implemented by `codex-acp`. The implementation is in [codex-acp PR #419](https://github.com/agentclientprotocol/codex-acp/pull/419). The Claude reference is [claude-agent-acp PR #1017](https://github.com/agentclientprotocol/claude-agent-acp/pull/1017).
`codex-acp` reports Codex subagents as ACP child sessions through one of two contracts. A connection gets one contract, never both.

## Capability negotiation
- The **standard contract** follows the ACP [Subagent Sessions RFD](https://agentclientprotocol.com/rfds/subagents): `subagent_update`, `session_message`, and `session` tool call content.
- The **AIR legacy contract** is the older private contract for JetBrains AIR clients: `subagent_spawned` and `subagent_state_update`.

Subagents require bilateral capability negotiation during `initialize`.
Without either contract, a subagent stays an ordinary tool call on the root session. See [Fallback](#fallback).

- The canonical client field is `clientCapabilities.subagents: {}`.
- The agent returns `agentCapabilities.sessionCapabilities.subagents: {}`.
- Because released SDKs may strip the draft field, AIR clients can instead advertise `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; this adapter advertises that key to AIR in its initialize response. See [AIR extensions](air-extensions.md#native-subagent-sessions).
- New clients and agents must prefer the canonical field.
## Standard contract

## Lifecycle events
### Capability negotiation

- The adapter sends `subagent_spawned` before any child output.
- It uses the child session ID for later messages, thoughts, plans, tools, permissions, and elicitations.
- It sends one `subagent_state_update` on the immediate parent.
- The adapter advertises an empty child capability object. It does not support targeted child cancel or close operations.
- Child permission and elicitation controls remain visible through the root session.
- A v1 Client opts in with `clientCapabilities.subagents: {}`. Without it, the adapter sends none of the updates below.
- A v2 Client needs no capability. Subagent updates are part of the v2 session model.
- There is no Agent-side capability. The `initialize` response to a standard Client does not contain `agentCapabilities.sessionCapabilities.subagents`.

### Association updates

- The adapter sends `subagent_update` on the immediate parent before any update, permission request, or elicitation that bears the child's session ID. Ancestors are announced before descendants.
- A child keeps one session ID across assignments, idle periods, and replay.
- `title` comes from the Codex agent path or thread name.
- `description` is the role that Codex assigned to the child (`agentRole`). It is omitted when Codex reports no role. It never holds the task prompt.
- `capabilities.cancel` is advertised only while the child's current turn can be interrupted. `session/cancel` with the child's ID interrupts that work and its observed descendants. Every other Client request for a child session is rejected.
- `state` reports `running`, `requires_action`, `idle` with a `stopReason`, or `unknown` when observability is lost. A v2 Client also gets the same `state_update` on the child session. There is no terminal child state.
- `cwd` is reported as described in [Directories](#directories).

### Delegated tasks

## Session load
The task a session gives a child is reported as `session_message`, not as a user message and not in `description`.

`session/load` reconstructs the child tree from Codex history. It reports an orphan as `disconnected` when the history does not prove an outcome. Live timeout, `shutdown`, and `notFound` fallbacks currently use `failed`; this differs from the draft rule for an unknown outcome.
- **Incoming view.** A `userMessage` item in the child's Codex thread is reported on the child session, before the child's response. `messageId` is the Codex item ID. `recipientSessionId` is the child.
- **Outgoing view.** A completed `spawnAgent`, `sendInput`, `sendMessage`, or `followupTask` collaboration call with a prompt is reported on the sender's session. `messageId` is `<item id>:to:<receiver thread id>`. A failed or interrupted call reports no message.
- **Linking.** The two views are linked with `senderMessageId` and `recipientMessageId` when the same text was observed for the same recipient on both sides. Whichever view is observed second carries the reference, and a metadata-only `session_message` adds the reference to the first.
- The incoming view names `senderSessionId` only when it is linked. Codex does not record the sender on the child's item, so an unlinked incoming view names only its recipient. Subagents recorded only as `subAgentActivity` items have no prompt on the parent side, so their tasks have no outgoing view and no sender.
- Neither view is derived from the other. A view is reported only when its Codex item was observed.
- Message IDs are the same live and on replay.

## Standard subagent directories
### Collaboration tool calls

On standard subagent connections, directory information is part of `subagent_update` on the immediate parent. This applies to both ACP versions. It does not change AIR's legacy subagent events.
- The `spawnAgent` call is not shown. The announcement and the task message report the delegation.
- Other collaboration calls (`wait`, `sendInput`, `closeAgent`, and so on) stay visible on the sender's session. Their `content` holds one `{ "type": "session", "sessionId": ... }` entry for each receiver that is already announced. `rawInput` still holds the Codex thread IDs.

A verified loaded-child read reports its absolute `cwd`, including a worktree directory. Known child settings changes update that value. Announcements do not reset directory fields while readback is pending. Omission preserves a retained override; `null` would explicitly restore inheritance from the immediate parent, not report an unknown directory. Readback does not block child output, and a superseded read cannot replace newer settings.
### Session load

`session/load`, and v2 `session/resume` with `replayFrom`, replay each recorded child conversation once, before the response. Replay reports the same announcements, session messages, and session references as the live path. It reports the latest recorded turn outcome as a historical `idle` state. A missing or incomplete child transcript adds one advisory thought on the root session. Replay grants no cancel capability.

### Directories

Directory information is part of `subagent_update` on the immediate parent, on both ACP versions.

A verified loaded-child read reports its absolute `cwd`, including a worktree directory. Known child settings changes update that value. Announcements do not reset directory fields while readback is pending. Omission preserves a retained override. `null` would explicitly restore inheritance from the immediate parent, not report an unknown directory. Readback does not block child output, and a superseded read cannot replace newer settings.

The adapter verifies the child's ID, parent, and loaded state before accepting readback. It does not report relative paths or infer directories from provider IDs. Codex does not expose an authoritative ACP additional-directory list for children, so the adapter omits that field. It does not copy parent roots or treat writable sandbox paths as workspace roots. `[]` would mean explicitly no additional directories, not unknown directories.

## Legacy fallback
## AIR legacy contract

This contract applies only to a Client that declares `_meta.jetbrains.air`. It does not use `subagent_update`, `session_message`, or `session` tool call content. See [AIR extensions](air-extensions.md#native-subagent-sessions).

### Capability negotiation

- AIR opts in with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`, or on v1 with `clientCapabilities.subagents: {}`.
- The adapter answers an AIR Client with `agentCapabilities.sessionCapabilities.subagents: {}` on v1 and lists `nativeSubagentSessions` in its own `_meta.jetbrains.air.capabilities`.

### Lifecycle events

- The adapter sends `subagent_spawned` before any child output. Its `task` field holds the prompt.
- It uses the child session ID for later messages, thoughts, plans, tools, permissions, and elicitations.
- It sends one `subagent_state_update` on the immediate parent, with `completed`, `failed`, `cancelled`, or `disconnected`.
- The adapter advertises an empty child capability object. It does not support targeted child cancel or close operations.
- On v2 both updates are sent as `_subagent_update`.

### Session load

`session/load` reconstructs the child tree from Codex history. It reports an orphan as `disconnected` when the history does not prove an outcome. Live timeout, `shutdown`, and `notFound` fallbacks use `failed`.

## Fallback

Without native negotiation, subagent lifecycle stays an ordinary ACP tool call. Child permission and elicitation requests stay on the root session.
Without either contract, the subagent lifecycle stays an ordinary ACP tool call. Child permission and elicitation requests stay on the root session.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"vitest": "^5.0.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "~2.5.0",
"@agentclientprotocol/sdk": "~2.6.0",
"@openai/codex": "^0.166.0",
"cross-spawn": "^7.0.6",
"diff": "^9.0.0",
Expand Down
9 changes: 6 additions & 3 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,10 @@ schema alone:

Non-AIR v1 clients opt in with `clientCapabilities.subagents: {}`. Non-AIR v2 clients use
standard `subagent_update` associations automatically. AIR keeps its existing opt-in, updates,
and generation-based identities; clients do not receive both contracts.
and generation-based identities; clients do not receive both contracts. Only AIR gets the
Agent-side `sessionCapabilities.subagents` in the `initialize` response.
See [docs/subagent-sessions.md](docs/subagent-sessions.md) for delegated task messages and session
references on collaboration tool calls.

A standard child keeps one opaque conversation ID across assignments and parent handler changes.
Its ancestors are announced before its output, tool updates, or interactions. Permissions and
Expand Down Expand Up @@ -294,8 +297,8 @@ including nested children, using legacy or paginated history. Announcements and
precede the response. Completed snapshots reconcile tool and rendering state before duplicate live
payloads are suppressed; live bookkeeping still runs. In-progress commands retain classification without
resetting streamed output. Incomplete text has no safe delta cursor; it and other records without a safe
partial representation are omitted rather than replayed over newer content. Agent-owned input is not
rendered as human input. Missing, omitted, or incomplete child history produces a parent advisory;
partial representation are omitted rather than replayed over newer content. Agent-owned input is replayed as
`session_message`, not as human input. Missing, omitted, or incomplete child history produces a parent advisory;
an empty transcript alone does not invent a gap.

Replay reports the latest recorded child turn's known success, cancellation, or failure as historical
Expand Down
6 changes: 2 additions & 4 deletions src/AcpSessionExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import type {StandardSubagentUpdate, SubagentSpawnedUpdate, SubagentStateUpdate}

/** Updates the adapter emits, including its existing AIR subagent contract. */
export type AcpSessionUpdate =
| Exclude<SessionNotification["update"], {
sessionUpdate: "subagent_update" | "session_message" | "session_message_chunk";
}>
| Exclude<SessionNotification["update"], {sessionUpdate: "subagent_update"}>
| SubagentSpawnedUpdate
| SubagentStateUpdate
| StandardSubagentUpdate
Expand All @@ -16,7 +14,7 @@ export type AcpSessionNotification = Omit<SessionNotification, "update"> & {
update: AcpSessionUpdate;
};

/** The only cast needed until the ACP SDK publishes the extension updates. */
/** The only cast needed for AIR's private updates, which the ACP SDK does not type. */
export function asSdkSessionNotification(
notification: AcpSessionNotification,
): SessionNotification {
Expand Down
4 changes: 4 additions & 0 deletions src/AcpV2SessionUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export function toV2SessionUpdate(update: AcpSessionUpdate): acpV2.SessionUpdate
case "notice":
case "compaction_update":
case "compaction_summary_chunk":
case "session_message":
case "session_message_chunk":
return update;
case "config_option_update":
return {...update, configOptions: toV2ConfigOptions(update.configOptions)};
Expand Down Expand Up @@ -163,6 +165,8 @@ export function toV2ToolCallContent(content: acp.ToolCallContent): acpV2.ToolCal
// Same shape on v2; the terminal itself is sent by `toV2SessionUpdates`.
case "terminal":
return content;
case "session":
return content;
case "diff":
return {type: "diff", ...toV2Diff(content)};
}
Expand Down
Loading
Loading