Skip to content
Draft
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
23 changes: 22 additions & 1 deletion packages/adapters/claude-code/src/adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,29 @@ describe('Claude Agent SDK query lifecycle', () => {
});
});

it('routes interrupt through query.interrupt and completes the active turn', async () => {
// Requirement: an attached session with padded thinking normalizes onto the
// query options instead of reaching the engine raw. Non-redundant: the only
// attached-path normalization proof for claude-code.
it('normalizes padded thinking from an attached session onto the query', async () => {
const records: MockQueryRecord[] = [];
const factory = queryFactory(async function* (input) {
for await (const _user of input.prompt) {
yield init();
yield result('normalized');
}
}, records);
const adapter = new ClaudeCodeAdapter({ queryFactory: factory });
const session = tracked(adapter, adapter.attach(SESSION_ID));
session.cwd = process.cwd();
session.thinking = ' HIGH ' as 'high';

const events = await collect(adapter.deliver(session, 'hi'));

expect(events.at(-1)).toMatchObject({ final_text: 'normalized' });
expect(records[0]!.input.options).toMatchObject({ effort: 'high', resume: SESSION_ID });
});

it('routes interrupt through query.interrupt and completes the active turn', async () => { const records: MockQueryRecord[] = [];
let release: (() => void) | undefined;
const stopped = new Promise<void>((resolve) => {
release = resolve;
Expand Down
21 changes: 13 additions & 8 deletions packages/adapters/claude-code/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type {
SpawnOpts,
WireEvent,
} from '@codor/protocol';
import { PolicySchema, ThinkingLevelSchema } from '@codor/protocol';
import { PolicySchema, normalizeThinkingLevel, ThinkingLevelSchema } from '@codor/protocol';

import { probeClaudeLimits } from './limits-probe.js';
import { peekClaudeContextUsage } from './peek.js';
Expand Down Expand Up @@ -94,9 +94,11 @@ function claudeThinkingOptions(
thinking: Session['thinking'],
): Pick<ClaudeOptions, 'thinking' | 'effort' | 'settings'> {
if (thinking === undefined) return {};
ThinkingLevelSchema.parse(thinking);
assertThinkingLevel(thinking);
if (thinking === 'ultracode') {
// Normalized here as well as in spawn: rebuilt sessions reach this path directly.
const normalized = normalizeThinkingLevel(thinking);
ThinkingLevelSchema.parse(normalized);
assertThinkingLevel(normalized);
if (normalized === 'ultracode') {
return {
thinking: { type: 'adaptive' },
effort: 'xhigh',
Expand All @@ -105,7 +107,7 @@ function claudeThinkingOptions(
}
return {
thinking: { type: 'adaptive' },
effort: thinking as Exclude<Session['thinking'], 'ultra' | 'ultracode' | undefined>,
effort: normalized as unknown as Exclude<(typeof CLAUDE_THINKING_LEVELS)[number], 'ultracode'>,
};
}
// harn:end canonical-spawn-controls-enforced
Expand Down Expand Up @@ -427,16 +429,19 @@ export class ClaudeCodeAdapter implements HarnessAdapter {

spawn(opts: SpawnOpts): Session {
claudePermissionMode(opts.policy);
let thinking: Session['thinking'];
if (opts.thinking !== undefined) {
ThinkingLevelSchema.parse(opts.thinking);
assertThinkingLevel(opts.thinking);
const normalized = normalizeThinkingLevel(opts.thinking);
ThinkingLevelSchema.parse(normalized);
assertThinkingLevel(normalized);
thinking = normalized;
}
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
thinking: opts.thinking,
thinking,
};
}

Expand Down
25 changes: 25 additions & 0 deletions packages/adapters/codex/src/adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,31 @@ describe('persistent Codex app-server lifecycle', () => {
error: 'codex missing',
});
});

// Requirement: a rebuilt session carrying an unlisted thinking value fails
// the turn before any app-server request, and a padded recognized value is
// normalized onto the turn. Non-redundant: the only deliver-path thinking
// validation proof for codex.
it('revalidates thinking on rebuilt sessions before starting a turn', async () => {
const server = createFakeCodexAppServer();
const { adapter } = fixtureAdapter(server);
const rebuilt: Session = { harness: 'codex', cwd: '/work', thinking: 'extreme' as 'high' };
const events = await collect(adapter, rebuilt, 'stale');
expect(events.at(-1)).toMatchObject({
type: 'run.completed',
status: 'failed',
error: expect.stringContaining('extreme'),
});
expect(server.messages.some((message) => message.method === 'turn/start')).toBe(false);

const padded: Session = { harness: 'codex', cwd: '/work', thinking: ' HIGH ' as 'high' };
const run = collect(adapter, padded, 'normalized');
await server.waitForRequest('turn/start');
const start = server.messages.find((message) => message.method === 'turn/start');
expect((start?.params as { effort?: string }).effort).toBe('high');
completeTurn(server, 'turn-1');
expect((await run).at(-1)).toMatchObject({ status: 'completed' });
});
});
// harn:end codex-app-server-is-the-member-runtime

Expand Down
32 changes: 22 additions & 10 deletions packages/adapters/codex/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
ThinkingLevel,
WireEvent,
} from '@codor/protocol';
import { PolicySchema, ThinkingLevelSchema } from '@codor/protocol';
import { PolicySchema, normalizeThinkingLevel, ThinkingLevelSchema } from '@codor/protocol';

import {
CodexAppServerClient,
Expand Down Expand Up @@ -97,10 +97,12 @@ export function codexPolicyOptions(policy: string | undefined): CodexPolicyOptio
};
}

function validateThinking(thinking: ThinkingLevel | undefined): void {
if (thinking === undefined) return;
ThinkingLevelSchema.parse(thinking);
assertThinkingLevel(thinking);
function validateThinking(thinking: ThinkingLevel | undefined): ThinkingLevel | undefined {
if (thinking === undefined) return undefined;
const normalized = normalizeThinkingLevel(thinking) as ThinkingLevel;
ThinkingLevelSchema.parse(normalized);
assertThinkingLevel(normalized);
return normalized;
}
// harn:end canonical-spawn-controls-enforced

Expand Down Expand Up @@ -466,13 +468,13 @@ export class CodexAdapter implements HarnessAdapter {

spawn(opts: SpawnOpts): Session {
codexPolicyOptions(opts.policy);
validateThinking(opts.thinking);
const thinking = validateThinking(opts.thinking);
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
thinking: opts.thinking,
thinking,
};
}

Expand Down Expand Up @@ -507,6 +509,16 @@ export class CodexAdapter implements HarnessAdapter {
): AsyncIterable<WireEvent> {
const runtime = this.runtimeFor(session);
if (runtime.active !== null) throw new Error('a Codex turn is already in flight for this member');
// Revalidated before touching the runtime: rebuilt sessions bypass spawn,
// so a stored unlisted value must fail here rather than reach the app server.
let thinking: ThinkingLevel | undefined;
try {
thinking = validateThinking(session.thinking);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
yield { type: 'run.completed', status: 'failed', error: detail };
return;
}
await this.prepareRuntime(runtime, session);

runtime.context.latestResolvedModel = runtime.threadModel;
Expand All @@ -533,7 +545,7 @@ export class CodexAdapter implements HarnessAdapter {
threadId: runtime.threadId,
input: [{ type: 'text', text: payload, text_elements: [] }],
cwd: session.cwd,
...this.turnOptions(session),
...this.turnOptions(session, thinking),
});
turn.turnId ??= responseId(response, 'turn');
} catch (error) {
Expand Down Expand Up @@ -703,13 +715,13 @@ export class CodexAdapter implements HarnessAdapter {
};
}

private turnOptions(session: Session): Record<string, unknown> {
private turnOptions(session: Session, thinking: ThinkingLevel | undefined): Record<string, unknown> {
const policy = codexPolicyOptions(session.policy);
return {
approvalPolicy: policy.approvalPolicy,
sandboxPolicy: policy.sandboxPolicy,
...(session.model !== undefined && { model: session.model }),
...(session.thinking !== undefined && { effort: session.thinking }),
...(thinking !== undefined && { effort: thinking }),
};
}

Expand Down
37 changes: 36 additions & 1 deletion packages/adapters/grok/src/adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -84,6 +84,41 @@ console.log(JSON.stringify({type:'response.completed',status:'completed',usage:{
expect(events.at(-1)).toMatchObject({ type: 'run.completed', status: 'failed' });
});

// Requirement: the attach/rebuild path revalidates thinking at argv build,
// so a stored unlisted value fails the turn instead of reaching the CLI.
// Non-redundant: the only deliver-path thinking validation proof for grok.
it('revalidates thinking on the attach path and never spawns for an unlisted level', async () => {
const dir = mkdtempSync(join(tmpdir(), 'codor-grok-attach-'));
dirs.push(dir);
const marker = join(dir, 'spawned');
const argvLog = join(dir, 'argv.json');
const command = executable(`
const fs = require('node:fs');
fs.writeFileSync(${JSON.stringify(marker)}, 'spawned');
fs.writeFileSync(${JSON.stringify(argvLog)}, JSON.stringify(process.argv.slice(2)));
process.stderr.write('boom\\n');
process.exit(7);
`);
const adapter = new GrokAdapter(command);
const attached = adapter.attach('ses_attach');
attached.cwd = process.cwd();
attached.thinking = 'extreme' as 'high';
const events: WireEvent[] = [];
for await (const event of adapter.deliver(attached, 'hi')) events.push(event);
expect(events.at(-1)).toMatchObject({
type: 'run.completed', status: 'failed', final_text: expect.stringContaining('extreme'),
});
expect(() => readFileSync(marker)).toThrow();

const normalized = adapter.attach('ses_normalized');
normalized.cwd = process.cwd();
normalized.thinking = ' HIGH ' as 'high';
const runEvents: WireEvent[] = [];
for await (const event of adapter.deliver(normalized, 'hi')) runEvents.push(event);
expect(runEvents.at(-1)).toMatchObject({ type: 'run.completed', status: 'failed', final_text: 'boom' });
expect(JSON.parse(readFileSync(argvLog, 'utf8'))).toContain('high');
});

it('keeps supervision when a completed child still has an active delivery', async () => {
const adapter = new GrokAdapter();
const session = adapter.spawn({ cwd: process.cwd() });
Expand Down
49 changes: 38 additions & 11 deletions packages/adapters/grok/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
SpawnOpts,
WireEvent,
} from '@codor/protocol';
import { PolicySchema } from '@codor/protocol';
import { normalizeThinkingLevel, PolicySchema, ThinkingLevelSchema } from '@codor/protocol';

import { createTurnTranslator } from './translate.js';

Expand All @@ -27,10 +27,24 @@ export function grokApprovalArgs(policy: string | undefined): string[] {
}

// harn:assume canonical-spawn-controls-enforced ref=grok-spawn-control-mapping
export const GROK_THINKING_LEVELS = ['low', 'medium', 'high'] as const;

export function grokArgs(session: Session, payload: string): string[] {
const args = ['-p', payload, '--output-format', 'streaming-json', '--no-auto-update'];
if (session.model !== undefined) args.push('--model', session.model);
if (session.thinking !== undefined) args.push('--effort', session.thinking);
if (session.thinking !== undefined) {
// Revalidated at every argv build: the attach/rebuild path never passes
// through spawn, so a stored unlisted value must fail here, not at the CLI.
const thinking = normalizeThinkingLevel(session.thinking);
if (!ThinkingLevelSchema.safeParse(thinking).success ||
!(GROK_THINKING_LEVELS as readonly string[]).includes(thinking)) {
throw new Error(
`adapter 'grok' does not support thinking level '${session.thinking}'; ` +
`valid levels: ${GROK_THINKING_LEVELS.join(', ')}`,
);
}
args.push('--effort', thinking);
}
args.push(...grokApprovalArgs(session.policy));
if (session.session_ref !== undefined) args.push('--resume', session.session_ref);
return args;
Expand All @@ -51,7 +65,7 @@ export class GrokAdapter implements HarnessAdapter {
extensions: false,
// harn:assume harness-declares-supported-thinking-levels ref=grok-thinking-level-declaration
thinking: true,
thinking_levels: ['low', 'medium', 'high'] as const,
thinking_levels: GROK_THINKING_LEVELS,
// harn:end harness-declares-supported-thinking-levels
live_inbox: false,
// Grok exposes --always-approve, but its CLI does not document a native
Expand All @@ -74,19 +88,24 @@ export class GrokAdapter implements HarnessAdapter {

spawn(opts: SpawnOpts): Session {
grokApprovalArgs(opts.policy);
if (opts.thinking !== undefined &&
!(this.capabilities.thinking_levels as readonly string[]).includes(opts.thinking)) {
throw new Error(
`adapter 'grok' does not support thinking level '${opts.thinking}'; ` +
`valid levels: ${this.capabilities.thinking_levels.join(', ')}`,
);
let thinking: Session['thinking'];
if (opts.thinking !== undefined) {
const normalized = normalizeThinkingLevel(opts.thinking);
if (!ThinkingLevelSchema.safeParse(normalized).success ||
!(this.capabilities.thinking_levels as readonly string[]).includes(normalized)) {
throw new Error(
`adapter 'grok' does not support thinking level '${opts.thinking}'; ` +
`valid levels: ${this.capabilities.thinking_levels.join(', ')}`,
);
}
thinking = normalized;
}
return {
harness: this.id,
cwd: opts.cwd,
model: opts.model,
policy: opts.policy,
thinking: opts.thinking,
thinking,
};
}

Expand All @@ -106,7 +125,15 @@ export class GrokAdapter implements HarnessAdapter {
payload: string,
hooks: AdapterTurnHooks = {},
): AsyncIterable<WireEvent> {
const args = grokArgs(session, payload);
let args: string[];
try {
args = grokArgs(session, payload);
} catch (error) {
const translator = createTurnTranslator(session.session_ref);
const detail = error instanceof Error ? error.message : String(error);
yield* translator.end({ status: 'failed', final_text: detail });
return;
}
// harn:assume grok-cli-resolves-windows-command-shims ref=grok-windows-cli-spawn-provider
// harn:assume adapter-children-inherit-session-env ref=grok-child-environment
const child = spawn(this.command, args, {
Expand Down
Loading