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
4 changes: 2 additions & 2 deletions packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they
- **Compaction (CODE-142)**: a `contextCompaction` item (id only — no tokens or summary on the app-server protocol) emits `{type:'compaction'}` with `status:'in_progress'` at item/started and `'completed'` at item/completed (clients merge by `compactionId`; the status field is codex-only — claude-code's boundary events stay status-less = completed). The adapter's `teardown()` override settles a compaction whose item/completed was lost to an interrupt or server death. History replays rollout `compacted` rows (`window_id` → `compactionId`); `payload.message` carries the plain summary ONLY for local compaction — remote compaction (the ChatGPT-account path; verified on real 0.144 rollouts) writes an empty `message` and ships the summary as `{type:'compaction', encrypted_content}` inside `replacement_history`, unrecoverable like reasoning, so those markers replay summary-less. Live item ids and rollout window ids do NOT converge, same as tools.
- **Lifecycle races are handled explicitly** (`codex/adapter.ts` field docs are the reference): `turnStartsInFlight` (a COUNT — `turn/completed` can precede the `turn/start` reply, so a drained queue prompt overlaps the settled frame, whose cleanup must not drop the newer frame's guard) gates the send→turn-id window, a cancel inside it is armed and fired the moment the id lands; `lastCompletedTurnId` stops a late `turn/start` response from re-activating a settled turn; an unexpected app-server exit finalizes the turn, re-arms `resumeFrom`, and the next prompt respawns + `thread/resume`s in place.
- **Session-ref is DEFERRED for fresh threads** until the first turn is accepted — announcing at `thread/start` triggers the client's transcript seed against an empty rollout and the seed's uptoSeq cut swallows the first prompt. Resumed threads announce immediately.
- **Auth (CODE-174)**: the app-server caches credentials for its whole process lifetime — an `auth.json` written after spawn is invisible to `getAuthStatus` AND the request path (verified live on 0.144.1). A signed-out turn 401s through a ~27 s retry storm (5× websocket then 5× https); the structured status rides only the mid-retry `error` notifications (`codexErrorInfo.responseStreamDisconnected.httpStatusCode`), while the final no-retry error degrades to `codexErrorInfo:"other"` with the 401 left in prose — `isCodexAuthError` matches both. The adapter latches the FIRST 401 into one non-recoverable `authentication_failed` error (the code the daemon's login re-probe keys on), quietly retires the server (deliberate `close()` suppresses the exit alarm) and arms `resumeFrom`, so the next prompt respawns + `thread/resume`s with fresh on-disk credentials — retry-after-login works via respawn, never in-place.
- **Auth (CODE-174)**: the app-server caches credentials for its whole process lifetime — an `auth.json` written after spawn is invisible to `getAuthStatus` AND the request path (verified live on 0.144.1). A signed-out turn 401s through a ~27 s retry storm; the structured status rides only the mid-retry `error` notifications, while the final no-retry error leaves the 401 in prose — `isCodexAuthError` matches both. The adapter latches the FIRST 401 and retires the server, then the next prompt respawns + `thread/resume`s with fresh credentials. A CLI-backed 401 emits non-recoverable `authentication_failed` so the daemon re-probes login. An account-backed endpoint instead runs through a private runtime `model_provider` (`base_url`, Responses wire, `CODEX_API_KEY`, WebSocket off) and reports a 401 as a provider error without asking for ChatGPT login. The provider override is load-bearing on Codex 0.144.6: `OPENAI_BASE_URL` does not configure the built-in provider, whose WebSocket path otherwise still targets `api.openai.com`.
Comment thread
lucas77778 marked this conversation as resolved.
- **Usage**: `thread/tokenUsage/updated` fires once per model call; emit the thread-cumulative `total`, not `last` (consumers replace usage wholesale). No cost data on any codex surface.
- **History** stays on direct rollout-JSONL reads (`sessions/` + `archived_sessions/` + `session_index.jsonl`, filtered by cwd), skipping corrupt lines, independent of the live process. History reads carry the project cwd so `CODEX_HOME` resolves through the same login-shell/`direnv` environment as the live session; project-scoped list lookups preserve their resolving cwd for subsequent import reads. Machine-injected user-role rows are filtered from replay and title previews per content part — codex 0.144 dropped the `<user_instructions>` wrapper and glues a `# AGENTS.md instructions …` prose part and the `<environment_context>` part into ONE user row (marker list in `history.ts`, all verbatim in the 0.144.1 binary; CODE-235). 0.144.6 additionally injects a `<skill>` row (the invoked SKILL.md) beside the typed `$name args` prompt, plus `<recommended_plugins>` / `<codex_internal_context` rows (CODE-576). A marker-matched row is rescued only when every marker-bearing part is echoed by an `event_msg`/`user_message` row — real prompts always are (TUI- and app-server-written alike), injected rows never; rollouts without event_msg rows degrade to marker-only; `turn_context.summary` is a reasoning-summary mode, NOT a title. Read pages are cut by aggregate serialized history-event bytes as well as event count (`sliceHistoryEventPage`, budget `MAX_ATTACHMENT_TOTAL_BASE64_LENGTH`) — one `history.read.result` is a single logical transport message and the tunnel silently drops what its reassembly buffer can't hold, so payload-heavy transcripts fan across cursor pages. Reasoning cannot replay from rollouts (`encrypted_content` only). MCP calls replay from `event_msg mcp_tool_call_end` rows — for nested code-mode calls the ONLY persisted record — and their `call_id` IS the live item id, so replayed MCP cards converge with live events (CODE-576); code-mode `exec` script rows replay as generic execute rows with the `Script …/Wall time/Output:` envelope unwrapped (failed on a failed/terminated script), while the nested `exec_command` runs the script made leave no durable rollout record at all.
- Known provider limits (recorded on CODE-97): `turn/steer` unused (queueing is turn-boundary by design); app-server writes `trust_level = "trusted"` for every thread cwd into `~/.codex/config.toml`; enterprise `clientInfo` registration with OpenAI.
Expand Down Expand Up @@ -114,7 +114,7 @@ levels (Claude `max`) and live-switchable levels share validation and reflection
engine caches the emitted effort and replays it when the newly created session attaches.

- **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one), and that is its **only** per-account state: there is no default account and no default model. A session that names neither — automation, schedules, IM threads, mobile — resolves to the head of `enabledAccountModels` (pool order × the account's own model order), the same entry the composer displays for an untouched draft, so the two sides cannot disagree about what "unpicked" means. Sessions started from a picker carry `StartOptions.accountId`; that field is a *request* — resolution consumes it and reports the account that actually backed the run, so an id naming a deleted account falls back to that same head instead of starting a session with no credential. An enabled account the agent cannot speak to is skipped rather than fatal (it never reaches the model menu either); only an account the request *names* fails the start loudly. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records what the thread is *set to* — account, model, effort, approval tier — and a relaunch replays it, so a thread keeps its own picks even after the head of the agent's list moves. Only accepted picks are recorded (`SessionLifecycleService.applyInput`): a model an adapter resolved for itself is reflected to the client but never pinned, or every thread would be stuck on its first launch and a change to the agent's list could never reach it again. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct.
- **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` in the app-server env (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env.
- **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` plus the per-thread account provider described above (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env.
- **The two provider-routed agents need a provider id, and the model string is not a reliable source.** An explicit account-bound Pi model resolves as `config.knownProvider` + the complete endpoint-owned model id, even when that id contains `/`; an unbound Pi model uses its `provider/modelId` ref. A same-provider Pi-qualified pin is unwrapped only when the opaque account form has no registry match and the qualified form does. With no explicit model, Pi precedence is the resumed session's last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in Pi's catalog, from `@linkcode/providers`) → Pi's first available provider. This keeps a credential-only resume on the provider that actually owns the transcript while ensuring an explicit current-account model is not overridden by stale transcript routing.
- **pi's credential injection cannot change a provider's wire, and must not pretend to.** `registerProvider` with no `models` takes `applyProviderConfig`'s override-only branch (verified in the installed `dist/core/model-registry.js`), which rewrites `baseUrl` and leaves each model's `api` untouched. `config.api` is read in exactly two places — the `config.streamSimple` branch and the `config.models` branch — so on a baseUrl-only call it is **silently discarded**, despite `ProviderConfigInput` declaring `api?: Api`. Passing it typechecks and does nothing; an earlier revision of this adapter did exactly that, and mocked-`registerProvider` tests asserted the call shape and never noticed. This is why injection is only correct when the target provider's *built-in* wire already matches the endpoint — which is the case that matters, since pi ships correct metadata for every provider it knows. Aiming a provider at a differently-shaped endpoint needs a `models`-carrying call (`@linkcode/providers` AGENTS.md records why that is not built).
- **Interactive login** (`login.ts` dispatcher `startAgentCliLogin`, kinds in `AGENT_LOGIN_KINDS`): claude-code drives `claude auth login --claudeai` (remote callback page; the user pastes the code back via stdin); codex drives `account/login/start {type:'chatgpt'}` on a short-lived app-server (`native/codex/login.ts`) whose OWN localhost callback completes the flow — no code hand-back (`submitCode` is a no-op), settle = `account/login/completed {success, error?}`. Auth probing: claude `auth status --json` (stdout, structured); codex `login status` (TEXT only — signed-out rides STDERR + exit 1, parse both streams; `parseCodexLoginStatus` fails open on rewording).
Expand Down
25 changes: 23 additions & 2 deletions packages/host/agent-adapter/src/__tests__/codex-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,11 @@ const RETRY_401 = {
},
};

async function promptedAdapter() {
async function promptedAdapter(options: StartOptions = start) {
const adapter = new TestCodex();
const events: AgentEvent[] = [];
adapter.onEvent((e) => events.push(e));
await adapter.start(start);
await adapter.start(options);
await adapter.send({ type: 'prompt', content: [{ type: 'text', text: 'hi' }] });
const server = adapter.fakeServers[0];
server.notify('turn/started', { turn: { id: 'turn-1' } });
Expand Down Expand Up @@ -108,6 +108,27 @@ describe('CodexAdapter auth failure (CODE-174)', () => {
expect(server.closed).toBe(true);
});

it('reports a Gateway 401 as provider auth failure without requesting ChatGPT login', async () => {
const { events, server } = await promptedAdapter({
...start,
model: 'openai/gpt-5.6',
config: {
authToken: 'gateway-token',
baseUrl: 'https://gateway.linkcode.ai/v1',
},
});
server.notify('error', RETRY_401);

const errors = errorEvents(events);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({
message: 'Codex provider authentication failed',
recoverable: true,
});
expect(errors[0].code).toBeUndefined();
expect(server.closed).toBe(true);
});

it('matches the final no-retry error whose 401 survives only in prose', async () => {
const { events, server } = await promptedAdapter();
server.notify('error', {
Expand Down
31 changes: 31 additions & 0 deletions packages/host/agent-adapter/src/__tests__/codex-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,37 @@ describe('CodexAdapter shell-command passthrough', () => {
expect(overridden.configuredSandboxEnvironment).toEqual(overridden.fakeServers[0].opts.env);
});

it('routes account credentials through a non-WebSocket Responses provider', async () => {
const adapter = new TestCodex();
await adapter.start({
...start,
model: 'openai/gpt-5.6',
config: {
authToken: 'gateway-token',
baseUrl: 'https://gateway.linkcode.ai/v1',
},
});

expect(adapter.fakeServers[0].opts.env).toMatchObject({
CODEX_API_KEY: 'gateway-token',
OPENAI_BASE_URL: 'https://gateway.linkcode.ai/v1',
});
expect(adapter.fakeServers[0].requests).toContainEqual({
method: 'thread/start',
params: expect.objectContaining({
model: 'openai/gpt-5.6',
modelProvider: 'linkcode-account',
config: expect.objectContaining({
'model_providers.linkcode-account.base_url': 'https://gateway.linkcode.ai/v1',
'model_providers.linkcode-account.wire_api': 'responses',
'model_providers.linkcode-account.env_key': 'CODEX_API_KEY',
'model_providers.linkcode-account.supports_websockets': false,
'model_providers.linkcode-account.requires_openai_auth': false,
}),
}),
});
});

it('announces the gated command and requests permission by subject reference', async () => {
const adapter = new TestCodex();
const events: AgentEvent[] = [];
Expand Down
Loading
Loading