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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Each of these breaks the product, a release, or the build with **no loud error**.

1. **Wire-protocol versions are lockstep.** `WIRE_PROTOCOL_VERSION` (currently 41) is a `z.literal` in `packages/foundation/schema/src/wire`; any change to any wire variant must bump it. Mismatched peers still complete the socket ("connected") but every frame fails validation and is **silently discarded** — zero messages, a hang-like failure. Rebuild and restart the daemon and every client together.
1. **Wire-protocol versions are lockstep.** `WIRE_PROTOCOL_VERSION` (currently 44) is a `z.literal` in `packages/foundation/schema/src/wire`; any change to any wire variant must bump it. Mismatched peers still complete the socket ("connected") but every frame fails validation and is **silently discarded** — zero messages, a hang-like failure. Rebuild and restart the daemon and every client together.
2. **`foxts/once` prewarms by default.** `once(fn)` runs `fn` immediately at construction and caches the result; call-at-most-once semantics need `once(fn, false)`. The default has already shipped a daemon that ran its shutdown at boot and transports whose close-callback fired at construction. Read any foxts helper's `.d.ts`/source before adopting it — the lodash-alike name lies.
3. **Native deps must be allow-listed.** pnpm blocks install scripts by default; a native dep (e.g. `better-sqlite3`) missing from `allowBuilds:` in `pnpm-workspace.yaml` installs fine but fails at `require()` time with missing bindings.
4. **`check:ci` does not run `pnpm test`.** CI runs vitest as a separate TypeScript-job step, while `check:ci` remains format/lint/typecheck only. Run both commands before every commit; passing either one alone is not the complete JavaScript gate.
Expand Down
5 changes: 2 additions & 3 deletions packages/foundation/schema/src/model/agent/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,8 @@ export const AgentEventSchema = z.discriminatedUnion('type', [
* learned and on every provider-side change, full-replace semantics. */
z.object({ type: z.literal('available-commands-update'), commands: z.array(AgentCommandSchema) }),
/** The model catalog the session accepts via `AgentInput.set-model` — same full-replace contract
* as the command catalog. Only adapters whose model set is install-dependent emit it (opencode);
* agents with a curated static catalog (claude-code/codex) never do, and clients fall back to
* their static tables. */
* as the command catalog. Adapter catalogs may also carry per-model effort capabilities; clients
* fall back to curated static tables when no catalog is advertised. */
z.object({ type: z.literal('available-models-update'), models: z.array(AgentModelOptionSchema) }),

z.object({ type: z.literal('status'), status: SessionStatusSchema }),
Expand Down
23 changes: 16 additions & 7 deletions packages/foundation/schema/src/model/agent/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,18 @@ export const McpServerSchema = z.discriminatedUnion('type', [
]);
export type McpServer = z.infer<typeof McpServerSchema>;

/** Reasoning-effort levels. low–xhigh switch live; `max` only applies at process startup, so
* adapters honor it by restarting the underlying process and resuming in place. `ultracode` is
* claude-code's xhigh-plus-standing-orchestration mode, modeled as a level; it switches live. */
export const EffortLevelSchema = z.enum(['low', 'medium', 'high', 'xhigh', 'max', 'ultracode']);
/** Normalized reasoning-effort levels. Availability is provider- and model-specific: `max` is
* startup-only for claude-code but a normal per-turn Codex value; Codex `ultra` enables proactive
* multi-agent behavior, while claude-code's distinct `ultracode` mode switches live. */
export const EffortLevelSchema = z.enum([
'low',
'medium',
'high',
'xhigh',
'max',
'ultra',
'ultracode',
]);
export type EffortLevel = z.infer<typeof EffortLevelSchema>;

/** Parameters required to start an agent session. */
Expand Down Expand Up @@ -83,15 +91,16 @@ export function agentCommandMatches(command: AgentCommand, name: string): boolea
return command.name === name || (command.aliases?.includes(name) ?? false);
}

/** A model a live session accepts via `AgentInput.set-model`, advertised by adapters whose model
* set is install-dependent (opencode: whatever providers the user's local install has connected)
* rather than a fixed vendor list. `id` is the exact value to send back on `set-model`. */
/** A model an adapter accepts via `AgentInput.set-model`, advertised before session start and/or
* on a live session. `id` is the exact value to send back on `set-model`. */
export const AgentModelOptionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
/** Absent means unknown; an empty list means this model has no effort axis. */
effortLevels: z.array(EffortLevelSchema).optional(),
/** Provider default for this model, when the catalog advertises one. */
defaultEffort: EffortLevelSchema.optional(),
});
export type AgentModelOption = z.infer<typeof AgentModelOptionSchema>;

Expand Down
3 changes: 2 additions & 1 deletion packages/foundation/schema/src/wire/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { WirePayloadSchema } from './payload';
// schema it does not speak.
// 43 combines 42's agent.catalog/agent.cataloged with CODE-316's parallel 42 bump for
// file.host/file.hosted, keeping every distinct schema on a distinct protocol version.
export const WIRE_PROTOCOL_VERSION = 43 as const;
// 44 adds provider/model-specific reasoning-effort metadata and Codex's distinct `ultra` level.
export const WIRE_PROTOCOL_VERSION = 44 as const;

/** Complete wire message: version + unique id + timestamp + payload. */
export const WireMessageSchema = z.object({
Expand Down
33 changes: 33 additions & 0 deletions packages/foundation/schema/tests/contract/wire/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ describe('agent wire variants', () => {
outcome: { outcome: 'cancelled' },
source: 'session',
},
{ type: 'effort-update', effort: 'ultra' },
{
type: 'available-models-update',
models: [
{
id: 'gpt-5.6-sol',
label: 'GPT-5.6-Sol',
effortLevels: ['low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
defaultEffort: 'low',
},
],
},
])('accepts $type through the complete wire envelope', (event) => {
expect(
WireMessageSchema.safeParse({
Expand All @@ -30,4 +42,25 @@ describe('agent wire variants', () => {
}).success,
).toBe(true);
});

it('accepts Codex ultra as input but rejects provider values outside the normalized vocabulary', () => {
const message = {
v: WIRE_PROTOCOL_VERSION,
id: 'message-1',
ts: 0,
payload: {
kind: 'agent.input',
clientReqId: 'request-1',
sessionId: 'session-1',
input: { type: 'set-effort', effort: 'ultra' },
},
};
expect(WireMessageSchema.safeParse(message).success).toBe(true);
expect(
WireMessageSchema.safeParse({
...message,
payload: { ...message.payload, input: { type: 'set-effort', effort: 'minimal' } },
}).success,
).toBe(false);
});
});
2 changes: 1 addition & 1 deletion packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they
- **Approvals**: server→client requests `item/commandExecution|fileChange/requestApproval` answer through the shared `requestPermission` round-trip — Allow→`accept`, Always allow→`acceptForSession` (real session-scoped grant), Reject→`decline`, teardown/cancel→`cancel`. The `declined` item status folds into `failed`, like claude-code's denied tool_result.
- **Approval-policy axis** (three tiers; claude-only ids like `plan`/`auto` reject): `default`→untrusted + workspace-write, `acceptEdits` (initial)→on-request + workspace-write, `bypassPermissions`→never + danger-full-access. Applied per `turn/start` — next-turn semantics, same channel as model/effort; nothing can alter an in-flight turn.
- **config.toml contract**: `codexConfiguredSandbox()` (`codex/config.ts`; exercised via a throwaway `CODEX_HOME` in `codex-config.test.ts`) reads the active profile's or top-level `sandbox_mode`. Until the user EXPLICITLY picks a tier, a configured sandbox is never overridden — thread/turn requests omit their sandbox override so codex's own resolution wins (NEVER silently loosen a stricter read-only); the preset's approval posture still rides, which is safe now that approvals are answerable. An explicit pick applies the preset exactly, config.toml notwithstanding. Honoring config.toml `approval_policy` stays an unfinished follow-up.
- **set-model / set-effort** are stored and sent as `turn/start` overrides (they stick for subsequent turns). Effort accepts `low`–`xhigh`; `max`/`ultracode` are claude-only and reject. The picker entries are the static UI tables (`agent-models.ts` / `agent-efforts.ts`), each verified live via `turn_context` readback. At startup, the adapter reflects the `thread/start`/`thread/resume` response's model and configured effort; a null effort resolves through that model's `model/list.defaultReasoningEffort` without pinning it as an override. `thread/settings/updated` is the ongoing effective model/effort authority. The rest of the dynamic catalog remains deliberately unused (the CODE-104 attempt was cancelled).
- **set-model / set-effort** are stored and sent as `turn/start` overrides (they stick for subsequent turns). Effort availability comes from each `model/list` entry's `supportedReasoningEfforts`: Sol/Terra advertise `low|medium|high|xhigh|max|ultra`, while Luna omits `ultra`; `minimal` is outside LinkCode's normalized vocabulary and Claude's distinct `ultracode` always rejects. The adapter publishes that per-model catalog both before session start and on the live event stream, and validates startup/live selections against the selected model. If optional `model/list` discovery is unavailable, validation defers to Codex instead of blocking session start. At startup, it reflects the `thread/start`/`thread/resume` response's model and configured effort; a null effort resolves through that model's `model/list.defaultReasoningEffort` without pinning it as an override. `thread/settings/updated` reflects effective provider state unless it conflicts with a newer pending next-turn pick. Switches apply from the next turn, not mid-turn.
- **File changes surface structurally**: `fileChange.changes[].diff` (unified) → per-hunk `{type:'diff', path, oldText, newText}` via `diffContentFromUnified`; a rename rides `kind.move_path` and is cited by destination. Command output arrives only at `item/completed` (`aggregatedOutput`). Tool kinds: `fileChange`→edit, `commandExecution`→execute, `webSearch`→fetch, `mcpToolCall`→other, `turn/plan/updated`→ a distinct `{type:'plan'}` event.
- **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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ async function waitIdle(events: AgentEvent[]): Promise<void> {
}

describe('ClaudeCodeAdapter effort switching', () => {
it('rejects Codex ultra instead of treating it as a Claude flag setting', async () => {
const adapter = new ClaudeCodeAdapter();
await expect(
adapter.start({ kind: 'claude-code', cwd: '/tmp/repo', effort: 'ultra' }),
).rejects.toThrow("claude-code: effort 'ultra' is not supported");
expect(queries).toHaveLength(0);
});

it('applies initial effort while constructing the first Query', async () => {
const { events } = await makeAdapter('high');
const q0 = queries[0];
Expand Down
Loading