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
2 changes: 1 addition & 1 deletion desktop/electron/resources/agent_families.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -712,7 +712,7 @@
},
"prompt_pdf": {
"M1": true,
"M2": true,
"M2": false,
"M4": false
}
},
Expand Down
3 changes: 2 additions & 1 deletion desktop/electron/src/localagent/claudechild.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';

import { ClaudeChild, MAX_FRAME_BYTES, type DriverEvent, type SpawnedChild } from './claudechild.ts';
import { ClaudeChild, MAX_FRAME_BYTES, type SpawnedChild } from './claudechild.ts';
import type { DriverEvent } from './driver.ts';
import type { Family } from './families.ts';

class FakeChild extends EventEmitter {
Expand Down
21 changes: 6 additions & 15 deletions desktop/electron/src/localagent/claudechild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,16 @@
import { spawn as nodeSpawn } from 'node:child_process';
import { applyProfile } from '../frameprofile/translate.ts';
import type { EmittedEvent } from '../frameprofile/types.ts';
import type { Family } from './families.ts';
import {
buildInputFrame,
buildLaunchArgs,
ContextWindows,
DEFAULT_TOOL_POSTURE,
TurnClock,
type DriverEvent,
type InputKind,
type InputPayload,
type LocalDriver,
type ToolPosture,
} from './claudewire.ts';
} from './driver.ts';
import type { Family } from './families.ts';
import { buildInputFrame, buildLaunchArgs, ContextWindows, TurnClock } from './claudewire.ts';

/// The child's streams, narrowed to what this module uses. Structural so a
/// test can supply plain Node streams.
Expand All @@ -46,14 +45,6 @@ export type SpawnFn = (
opts: { cwd: string; env: NodeJS.ProcessEnv },
) => SpawnedChild;

/// An event on its way to the session log — kind/producer/payload, before the
/// log assigns it a `seq`.
export interface DriverEvent {
kind: string;
producer: string;
payload: Record<string, unknown>;
}

export interface ClaudeChildOptions {
family: Family;
cwd: string;
Expand Down Expand Up @@ -83,7 +74,7 @@ export interface ClaudeChildOptions {
/// growing main's heap without bound.
export const MAX_FRAME_BYTES = 1 << 20;

export class ClaudeChild {
export class ClaudeChild implements LocalDriver {
readonly #opts: ClaudeChildOptions;
readonly #turns = new TurnClock();
readonly #windows = new ContextWindows();
Expand Down
3 changes: 1 addition & 2 deletions desktop/electron/src/localagent/claudewire.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ import {
buildLaunchArgs,
ContextWindows,
CONFIG_HOME_ENV,
DEFAULT_TOOL_POSTURE,
isToolPosture,
resolveConfigHome,
toolArgs,
TurnClock,
} from './claudewire.ts';
import { DEFAULT_TOOL_POSTURE, isToolPosture } from './driver.ts';
import type { Family } from './families.ts';

const FAMILY: Family = {
Expand Down
44 changes: 6 additions & 38 deletions desktop/electron/src/localagent/claudewire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
/// state (a turn counter, a per-model window learned from an earlier frame).
/// L2's note says they "belong to whatever owns the session", and this is it.

import type { InputKind, InputPayload, ToolPosture } from './driver.ts';
import type { Family } from './families.ts';

// ── Config root ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -43,7 +44,7 @@ export function resolveConfigHome(

// ── Tool posture ─────────────────────────────────────────────────────────────

/// Which built-in tools a local child is launched with.
/// How claude keeps the posture contract (`driver.ts` `ToolPosture`).
///
/// **This is not the hub's `permission mode`, and the difference is not
/// cosmetic.** Permission mode gates whether a tool call is *approved*, and it
Expand All @@ -65,17 +66,12 @@ export function resolveConfigHome(
/// entirely. The model reports them as unavailable and calls nothing.
///
/// So the only lever that gates a non-interactive child is the tool list, and
/// this type is that list behind three named postures. An allowlist, not a
/// this table is that list behind the three named postures. An allowlist, not a
/// denylist: a denylist fails open for every tool claude adds after this file
/// was written, and "the engine grew a capability" must not silently widen what
/// a local session can do to the director's machine.
export type ToolPosture = 'converse' | 'read_local' | 'unrestricted';

/// The default. Reading the workdir is what makes a co-working Companion
/// useful; writing, executing and reaching the network are what make an
/// unattended one dangerous, and none of the three are here.
export const DEFAULT_TOOL_POSTURE: ToolPosture = 'read_local';

/// a local session can do to the director's machine. (codex keeps the same
/// contract by a different mechanism — an OS sandbox — see `codexPosture`.)
///
/// Tool names are claude's own, read off a live `system/init` frame's `tools`
/// array rather than from documentation.
const POSTURE_TOOLS: Record<ToolPosture, string[] | null> = {
Expand All @@ -90,10 +86,6 @@ const POSTURE_TOOLS: Record<ToolPosture, string[] | null> = {
unrestricted: null,
};

export function isToolPosture(v: unknown): v is ToolPosture {
return v === 'converse' || v === 'read_local' || v === 'unrestricted';
}

/// The `--tools` argv for a posture, or [] when the posture adds no flag.
export function toolArgs(posture: ToolPosture): string[] {
const tools = POSTURE_TOOLS[posture];
Expand Down Expand Up @@ -159,30 +151,6 @@ export function buildLaunchArgs(family: Family, opts: LaunchOptions): string[] {

// ── Input frames ─────────────────────────────────────────────────────────────

/// A binary attachment lowered onto an Anthropic content block.
export interface AttachmentInput {
mime: string;
/// base64, without a data: prefix.
data: string;
filename?: string;
}

export interface InputPayload {
body?: string;
images?: AttachmentInput[];
pdfs?: AttachmentInput[];
request_id?: string;
decision?: string;
note?: string;
reason?: string;
}

/// The input kinds the local claude driver accepts. A deliberate subset of the
/// Go driver's: `attention_reply` and `attach` are hub concepts (an attention
/// table, a document entity) that a local session has none of, so they are
/// absent rather than stubbed (D-4).
export type InputKind = 'text' | 'approval' | 'answer' | 'cancel';

/// Build the stream-json line for one user-side input — the port of
/// `buildStreamJSONInputFrame` (driver_stdio.go:707).
///
Expand Down
224 changes: 224 additions & 0 deletions desktop/electron/src/localagent/codexdriver.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/// End-to-end against a REAL codex app-server (vision-parity L4c).
///
/// The unit tests fake the channel, which proves the driver's bookkeeping and
/// proves nothing about the PROTOCOL: whether `thread/start` takes the params
/// we send it, whether `turn/start.input` accepts the blocks we build, whether
/// `thread/resume` finds the thread again. Every one of those is a claim about
/// a program we did not write, and this file is where they are measured.
///
/// It has already earned itself. The shapes the hub's Go driver has been
/// sending — `{type:"input_image", image_url}` and `{type:"input_file",
/// file_data}` — are answered by codex-cli 0.147.0 with `-32600 unknown
/// variant`, which fails the whole `turn/start`. A fake server accepts anything
/// and had pinned the wrong shape for as long as it shipped.
///
/// **Opt-in, because this one costs tokens** — it runs real model turns against
/// the operator's codex account:
///
/// TERMIPOD_CODEX_DRIVER_E2E=1 npm test
///
/// Skipped without the variable, and skipped with a clear reason when codex is
/// absent or unauthenticated (neither is a failure of this code).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

import { codexBinary, codexHome } from './codexattach.ts';
import { CodexDriver } from './codexdriver.ts';
import type { DriverEvent } from './driver.ts';
import { parseFamilies, familyByName, type Family } from './families.ts';

const ENABLED = process.env['TERMIPOD_CODEX_DRIVER_E2E'] === '1';
const HOME = os.homedir();

function codexPresent(): boolean {
const bin = codexBinary(HOME, process.env);
return path.isAbsolute(bin) && fs.existsSync(bin);
}

function authPresent(): boolean {
return fs.existsSync(path.join(codexHome(HOME, process.env), 'auth.json'));
}

/// The SHIPPED registry, not a fixture — the whole point is that the profile
/// the Companion translates codex frames with is the hub's own.
function codexFamily(): Family {
const artifact = fileURLToPath(new URL('../../resources/agent_families.generated.json', import.meta.url));
const fam = familyByName(parseFamilies(fs.readFileSync(artifact, 'utf-8')), 'codex');
assert.ok(fam !== undefined, 'the generated registry has no codex family');
return fam;
}

interface Live {
driver: CodexDriver;
events: DriverEvent[];
/// Resolve when an event satisfying `pred` arrives, or reject on timeout.
wait: (pred: (ev: DriverEvent) => boolean, what: string, ms?: number) => Promise<DriverEvent>;
}

function live(cwd: string, resumeThreadId?: string): Live {
const events: DriverEvent[] = [];
const waiters: Array<(ev: DriverEvent) => void> = [];
const driver = new CodexDriver({
family: codexFamily(),
cwd,
posture: 'read_local',
env: process.env,
homeDir: HOME,
...(resumeThreadId !== undefined ? { resumeThreadId } : {}),
onEvent: (ev) => {
events.push(ev);
for (const w of [...waiters]) w(ev);
},
});
return {
driver,
events,
wait: (pred, what, ms = 90_000) =>
new Promise<DriverEvent>((resolve, reject) => {
const hit = events.find(pred);
if (hit !== undefined) {
resolve(hit);
return;
}
const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}`)), ms);
waiters.push((ev) => {
if (!pred(ev)) return;
clearTimeout(timer);
resolve(ev);
});
}),
};
}

const skip = (): string | false => {
if (!ENABLED) return 'set TERMIPOD_CODEX_DRIVER_E2E=1 to run (spends tokens on a real turn)';
if (!codexPresent()) return 'no codex binary on this host';
if (!authPresent()) return 'codex is not authenticated on this host';
return false;
};

test('a real turn round-trips through the shipped frame profile', async (t) => {
const why = skip();
if (why !== false) {
t.skip(why);
return;
}
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'termipod-l4c-'));
const l = live(cwd);
try {
l.driver.start();
// `session.init` comes from the profile's `thread/started` rule — so this
// assertion covers the handshake AND the translation in one.
const init = await l.wait((e) => e.kind === 'session.init', 'session.init');
const threadId = String(init.payload.session_id);
assert.notEqual(threadId, '');

// Long enough to outlive the 200 ms flush window. A short reply
// legitimately produces NO partial — item/completed cancels the pending
// flush, which is the designed behaviour, so asserting a partial on a
// one-token answer would be asserting a race.
l.driver.input('text', {
body: 'Write four sentences about why streaming output matters, then end with exactly: L4C-OK',
});
const text = await l.wait(
(e) => e.kind === 'text' && e.payload.partial !== true && String(e.payload.text).includes('L4C-OK'),
'the final text event',
);
assert.match(String(text.payload.text), /L4C-OK/);

// Deltas arrived and were throttled into partials, which is E3's whole
// point: a unit test can prove the buffer, only this can prove codex
// actually sends them.
assert.ok(
l.events.some((e) => e.kind === 'text' && e.payload.partial === true),
'expected at least one streamed partial before the final',
);

// ── The measurement that matters most: resume finds the thread again ──
l.driver.stop();
const again = live(cwd, threadId);
try {
again.driver.start();
const reinit = await again.wait((e) => e.kind === 'session.init', 'session.init after resume');
assert.equal(reinit.payload.session_id, threadId);
assert.equal(reinit.payload.resumed, true);

again.driver.input('text', { body: 'What exact token did I ask you to reply with earlier? Answer with just that token.' });
const memory = await again.wait(
(e) => e.kind === 'text' && e.payload.partial !== true && String(e.payload.text).includes('L4C-OK'),
'the resumed turn to remember the token',
);
// The engine remembers across a fresh app-server process — the same
// property claude's `--resume` has, reached by a completely different
// mechanism.
assert.match(String(memory.payload.text), /L4C-OK/);
} finally {
again.driver.stop();
}
} finally {
l.driver.stop();
fs.rmSync(cwd, { recursive: true, force: true });
}
});

test('an image reaches the model in the variant this build accepts', async (t) => {
const why = skip();
if (why !== false) {
t.skip(why);
return;
}
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'termipod-l4c-img-'));
const l = live(cwd);
try {
l.driver.start();
await l.wait((e) => e.kind === 'session.init', 'session.init');
// A 1×1 png of one OPAQUE pure-green pixel (colour type 2, no alpha
// channel). Both halves of that are deliberate. Opaque, because the
// obvious sample to reach for — the widely-copied 1×1 "red dot" — is
// RGBA(255,0,0,127), and a half-transparent pixel gets described
// differently depending on what the reader composites it against: the
// same bytes drew "Light blue." from one turn and "red" from another.
// Green, because red and blue are what a model guesses when it cannot see
// the image at all, so an answer of "green" is evidence the bytes
// ARRIVED rather than evidence the model is agreeable.
l.driver.input('text', {
body: 'Name the single colour of this 1x1 image in one word.',
images: [
{
mime: 'image/png',
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNg+M8AAAICAQB7CYF4AAAAAElFTkSuQmCC',
},
],
});
// Wait for a completed text that NAMES the colour, not merely the first
// completed text: a turn is free to open with a preamble message ("i'll
// inspect the image…") before the answer, and asserting on whichever text
// lands first races that choice — the same class as the two assertions
// this e2e already had to unlearn. If no text ever says green, the wait
// times out and fails with every collected text in hand.
const answered = l.wait(
(e) => e.kind === 'text' && e.payload.partial !== true && /green/.test(String(e.payload.text).toLowerCase()),
'an answer naming the colour green',
);
await answered.catch((err: unknown) => {
const texts = l.events
.filter((e) => e.kind === 'text' && e.payload.partial !== true)
.map((e) => JSON.stringify(e.payload.text));
throw new Error(`${err instanceof Error ? err.message : String(err)} — completed texts: ${texts.join(' | ')}`);
});
// If the block shape were wrong, `turn/start` would have failed with
// `-32600 unknown variant` and this would be an `error` row instead.
assert.equal(
l.events.some((e) => e.kind === 'error' && /unknown variant/.test(String(e.payload.text))),
false,
'turn/start rejected the image block',
);
} finally {
l.driver.stop();
fs.rmSync(cwd, { recursive: true, force: true });
}
});
Loading
Loading