Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol]
- [Async Codex questions](docs/async-questions.md) as non-blocking form elicitations, with answers steered into the turn that asked them.
- [Background terminal tasks](docs/air-extensions.md#async-tasks) in AIR, with task status and targeted stop support after capability negotiation.
- Session-scoped long-running goals in AIR through the [goal extension](docs/air-extensions.md#goal).
- In-place message editing in AIR through the [session rewind extension](docs/air-extensions.md#session-rewind), without a provider fork.
- Typed warnings and errors through the opt-in [AIR session failure extension](docs/air-extensions.md#session-failure).
- All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md).
- A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation.
Expand Down
58 changes: 57 additions & 1 deletion docs/air-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ It describes only this adapter.
- [Session index](#session-index)
- [Context compaction](#context-compaction)
- [Session fork point](#session-fork-point)
- [Session rewind](#session-rewind)
- [Presentation hints](#presentation-hints)
- [Predicted follow-up prompt](#predicted-follow-up-prompt)
- [Removed keys](#removed-keys)
Expand Down Expand Up @@ -137,7 +138,8 @@ The `initialize` response carries the agent side of the extension only when the
"recommendedValue",
"rawInputRendering",
"planContentDelta",
"codexHooks"
"codexHooks",
"sessionRewind"
]
}
}
Expand Down Expand Up @@ -180,6 +182,8 @@ Five agent capabilities come with `sessionIndex` and are listed exactly when it

The goal extension has no client capability.
The agent advertises the `goal` object, and the client uses the control method when it wants to.
The session rewind extension has no client capability either.
The agent lists `sessionRewind`, and the client sends `_session/rewind` when it wants to; see [Session rewind](#session-rewind).

### Hook trust

Expand Down Expand Up @@ -1406,6 +1410,58 @@ It adds this object to the `session/fork` request:
- When no message matches, the request fails with `invalidParams`.
- The fork keeps the history up to the turn that holds the message.

## Session rewind

Standard ACP forks a session, which gives a second thread with another id.
AIR edits a message in the session the user is looking at, so it removes a history suffix from the same Codex thread
instead, through `_session/rewind`.

The adapter advertises `sessionRewind` in the AIR capability list of `initialize`.
A client sends the method only when the capability is there.

```json
{
"sessionId": "thread-1",
"beforeMessage": { "messageId": "item-12", "messageFingerprint": "sha256:<64 hex>", "messageOccurrence": 1 },
"resumeAtMessage": { "messageId": "item-11", "messageFingerprint": "sha256:<64 hex>", "messageOccurrence": 1 }
}
```

- `beforeMessage` is the first user message to remove, and it is excluded from the retained history.
- `resumeAtMessage` is the last agent message to retain. It is absent when the client rewinds the first user turn.
The turn boundary already fixes the cut, so the adapter does not need it.
- A history point is the ACP message id, the `sha256:` digest of the message text, and the one-based occurrence of
that digest among messages of the same role.
The adapter matches the id first, and the fingerprint occurrence when restored Codex history carries other ids.
The id is the one that history replay sends: the Codex item id on v1, and the client message id on v2.
An id with a `:segment:<n>` suffix also matches the message without the suffix.
- The text of a user message is its text blocks as history replay sends them, joined without a separator.
A file mention of a prompt is a text block such as `[@foo.txt](file:///work/foo.txt)`, so it is part of the text.
A `resource_link` block that replay sends for a native attachment is not.
- The occurrence counts the user messages of the session itself.
A Codex subagent runs in its own thread, so its messages are not counted, as in the Claude adapter.
- The response is `{"rewound": true}` only after Codex accepted the rewind.
- `{"rewound": false}` means the conversation was left as it was, and the client then keeps its own transcript too.
The adapter answers it while a turn is running or a prompt waits to start one: that turn writes the history the
rewind would replace.
- A `session/prompt` sent while a rewind runs starts its turn after the rewind, on the retained history.
- When no message matches, the request fails with `invalidParams`.
- A message that is a steer inside an existing turn fails with `invalidParams`:
Codex removes whole turns, not a suffix of one.
- A session created with legacy thread history fails with `invalidRequest` and
`data: {sessionId, historyMode: "legacy"}`. Only threads with paginated history can be rewound,
and a client that gets this error can offer the fork instead.

Codex mapping:

- The adapter reads the thread metadata, refuses legacy history, then reads the turns and resolves `beforeMessage`
to the turn it starts.
- Paginated history calls `thread/revert` with that turn as the exclusive boundary.
Legacy history had only the deprecated `thread/rollback`, which Codex has since removed, so it gets the
`invalidRequest` above.
- The Codex thread id stays the ACP session id. No `thread/fork`, no new thread, no session-list entry.
- `thread/revert` changes the persisted conversation only. It does not revert file changes.

## Presentation hints

The adapter sends these keys only to AIR:
Expand Down
19 changes: 19 additions & 0 deletions src/AcpAgentRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
SESSION_STEERING_METHOD,
} from "./AcpExtensions";
import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension";
import {SESSION_REWIND_METHOD} from "./SessionRewind";
import {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} from "./CodexHookTrust";
import {
SESSION_ARCHIVE_METHOD,
Expand Down Expand Up @@ -57,6 +58,18 @@ const asyncTaskStopParamsParser = z.object({
asyncTaskId: z.string().trim().min(1),
}).passthrough();

const sessionHistoryPointParser = z.object({
messageId: z.string().trim().min(1),
messageFingerprint: z.string().regex(/^sha256:[0-9a-f]{64}$/),
messageOccurrence: z.number().int().positive(),
});

const sessionRewindParamsParser = z.object({
sessionId: z.string().trim().min(1),
beforeMessage: sessionHistoryPointParser,
resumeAtMessage: sessionHistoryPointParser.optional(),
}).passthrough();

const hooksListParamsParser = z.object({cwd: z.string().trim().min(1)});
const hooksTrustParamsParser = z.object({
cwd: z.string().trim().min(1),
Expand Down Expand Up @@ -200,6 +213,9 @@ function createRouter(
.onRequest(acp.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params))
.onRequest(acp.methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params))
.onRequest(acp.methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params))
// Before `session/prompt`: the SDK tries the handlers in this order, so a prompt sent right after a
// rewind reaches the agent after it, and waits for it.
.onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent(ctx.params).extMethod(SESSION_REWIND_METHOD, ctx.params))
.onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent(ctx.params).prompt(ctx.params, ctx.signal))
.onNotification(acp.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params))
.onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params))
Expand Down Expand Up @@ -233,6 +249,9 @@ function createRouter(
// The v1-only `authentication/status|logout` extensions are not registered on v2.
.onRequest(acpV2.methods.agent.auth.login, (ctx) => getAgent().authenticateV2(ctx.params, ctx.requestId, ctx.signal))
.onRequest(acpV2.methods.agent.auth.logout, (ctx) => getAgent().logoutV2(ctx.params))
// Before `session/prompt`: the SDK tries the handlers in this order, so a prompt sent right after a
// rewind reaches the agent after it, and waits for it.
.onRequest(SESSION_REWIND_METHOD, sessionRewindParamsParser, (ctx) => getAgent(ctx.params).extMethod(SESSION_REWIND_METHOD, ctx.params))
.onRequest(acpV2.methods.agent.session.prompt, (ctx) => getAgent(ctx.params).promptV2(ctx.params, ctx.signal))
.onNotification(acpV2.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params))
.onRequest(acpV2.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params))
Expand Down
15 changes: 14 additions & 1 deletion src/AcpExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
ASYNC_TASK_STOP_METHOD,
type AsyncTaskStopExtRequest,
} from "./async-tasks/AsyncTaskExtension";
import {
SESSION_REWIND_METHOD,
type SessionRewindRequest,
} from "./SessionRewind";

export {
AUTH_STATUS_META_KEY,
Expand Down Expand Up @@ -79,6 +83,7 @@ export type ExtMethodRequest =
| SessionSteeringExtRequest
| GoalControlExtRequest
| AsyncTaskStopExtRequest
| SessionRewindExtRequest

export function isExtMethodRequest(request: { method: string, params: Record<string, unknown> }): request is ExtMethodRequest {
return request.method === "authentication/status"
Expand All @@ -87,7 +92,8 @@ export function isExtMethodRequest(request: { method: string, params: Record<str
|| request.method === GOAL_CONTROL_METHOD
|| request.method === LEGACY_GOAL_CONTROL_METHOD
|| request.method === SESSION_STEERING_METHOD
|| request.method === ASYNC_TASK_STOP_METHOD;
|| request.method === ASYNC_TASK_STOP_METHOD
|| request.method === SESSION_REWIND_METHOD;
}

/**
Expand Down Expand Up @@ -139,6 +145,13 @@ export type SessionSteeringExtRequest = {
params: SessionSteerRequest;
}

export type SessionRewindExtRequest = {
method: typeof SESSION_REWIND_METHOD;
params: SessionRewindRequest;
}

export {SESSION_REWIND_METHOD, type SessionRewindRequest, type SessionRewindResponse} from "./SessionRewind";

export async function steerSessionWithFallback(
connection: Pick<ClientContext, "request">,
params: SessionSteerRequest,
Expand Down
1 change: 1 addition & 0 deletions src/AirExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const AIR_CUSTOM_INSTRUCTIONS_KEY = "customInstructions";
export const AIR_NATIVE_SUBAGENT_SESSIONS_KEY = "nativeSubagentSessions";
export const AIR_ASYNC_TASKS_KEY = "asyncTasks";
export const AIR_RECOMMENDED_CONFIG_VALUE_KEY = "recommendedValue";
export const AIR_SESSION_REWIND_KEY = "sessionRewind";
export const AIR_ASYNC_TASKS_BACKGROUNDED_KEY = "backgrounded";
export const AIR_AGENT_FILE_CHANGE_REPORT_REQUEST_KEY = "agentFileChangeReportRequest";
export const AIR_MESSAGE_PHASE_KEY = "phase";
Expand Down
7 changes: 7 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import type {ModeKind} from "./app-server/ModeKind";
import {arePathBasenamesEqual, arePathsEqual, isAbsolutePathLike, isAbsoluteProtocolPath} from "./PathUtils";
import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions";
import {forkSession as runForkSession} from "./SessionFork";
import {rewindSession as runRewindSession, type SessionRewindRequest, type SessionRewindResponse} from "./SessionRewind";
import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
import {runAccountLogin} from "./CodexAccountLogin";
import {throwIfRequestCancelled, withRequestCancellation} from "./RequestCancellation";
Expand Down Expand Up @@ -747,6 +748,12 @@ export class CodexAcpClient {
}, instructions);
}

async rewindSession(request: SessionRewindRequest): Promise<SessionRewindResponse> {
const response = await runRewindSession(request, this.codexClient);
await this.waitForSessionNotifications(request.sessionId);
return response;
}

async loadSession(
request: WithAcpMcpServers<acp.LoadSessionRequest>,
onSubscribed?: () => void,
Expand Down
Loading
Loading