From 3d5a6453784eb8bf114460007d5701581ad93b8b Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 15:57:36 +0000 Subject: [PATCH 1/5] docs(ai): add Codex print-mode lifecycle docs --- .../2026-08-11-feature-codex-print-mode.md | 105 ++++++++++++++++++ .../2026-08-11-feature-codex-print-mode.md | 58 ++++++++++ .../2026-08-11-feature-codex-print-mode.md | 79 +++++++++++++ .../2026-08-11-feature-codex-print-mode.md | 85 ++++++++++++++ .../2026-08-11-feature-codex-print-mode.md | 87 +++++++++++++++ 5 files changed, 414 insertions(+) create mode 100644 docs/ai/design/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/implementation/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/planning/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/requirements/2026-08-11-feature-codex-print-mode.md create mode 100644 docs/ai/testing/2026-08-11-feature-codex-print-mode.md diff --git a/docs/ai/design/2026-08-11-feature-codex-print-mode.md b/docs/ai/design/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..87032812 --- /dev/null +++ b/docs/ai/design/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,105 @@ +--- +phase: design +title: Codex Print-Mode Agent Design +description: Provider-minted session binding and run-per-message Codex execution +--- + +# Codex Print-Mode Agent Design + +## Architecture Overview + +Codex is added beside the existing Claude provider modules, sharing only the proven durable store/state primitives. + +```mermaid +flowchart LR + CLI[agent start/list/detail/send] --> Resolver[provider-aware print resolver] + Resolver --> Claude[ClaudePrintAgentService] + Resolver --> Codex[CodexPrintAgentService] + Claude --> Store[PrintAgentStore] + Codex --> Store + Codex --> Runner[CodexPrintRunner] + Runner -->|prompt via stdin| Exec[codex exec process] + Exec -->|JSONL thread/turn/item events| Runner + Runner -->|bind thread UUID during run| Store + Exec --> Native[(Codex native session)] +``` + +Interactive adapters remain unchanged. No generic provider framework or persistent server is introduced. + +## Data Models + +```ts +type PrintProvider = 'claude' | 'codex'; +type PrintAgent = ClaudePrintAgent | CodexPrintAgent; + +interface PrintAgentBase { + id: string; + name: string; + mode: 'print'; + cwd: string; + state: 'ready' | 'running' | 'degraded'; + sessionHealth: 'uninitialized' | 'healthy' | 'unknown' | 'mismatch'; + createdAt: string; + updatedAt: string; + lastActiveAt: string | null; + lastResult: PrintLastResult | null; + activeRun: PrintActiveRun | null; +} + +interface ClaudePrintAgent extends PrintAgentBase { + provider: 'claude'; + providerSessionId: string; +} + +interface CodexPrintAgent extends PrintAgentBase { + provider: 'codex'; + providerSessionId: string | null; +} +``` + +The store remains versioned. Its reader explicitly accepts the legacy Claude schema and the new discriminated schema, then validates provider-specific invariants. It rejects duplicate non-null `(provider, providerSessionId)` pairs. + +## API Design + +```ts +create(input: { name: string; cwd: string; provider?: PrintProvider }): Promise; +bindProviderSession(agentId: string, runToken: string, providerSessionId: string): Promise; + +interface CodexPrintRunRequest { + agent: CodexPrintAgent; + prompt: string; + executable?: string; + onSpawn(identity: ProcessIdentity): Promise; + onSession(providerSessionId: string): Promise; +} +``` + +`bindProviderSession` rereads under the mutation lock, verifies active token ownership and UUID validity, permits only Codex null-to-value or same-value idempotence, checks global uniqueness, and atomically persists. + +Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`. The prompt never enters argv. + +## Component Breakdown + +- `PrintAgent`: shared base and provider discriminants. +- `PrintAgentStore`: provider-aware creation, strict migration, uniqueness, atomic binding, existing locking/reconciliation. +- `CodexCliProbe`: non-model version/help capability checks. +- `CodexPrintRunner`: safe spawn, process handshake, bounded JSONL parser, immediate session callback, assistant-result extraction. +- `CodexPrintAgentService`: resolve → acquire → run → bind → complete, with provider-specific health classification. +- CLI: selects service by requested/persisted provider and renders provider-specific labels/session state. +- `fake-codex.cjs`: deterministic executable contract and failure controls. + +## Design Decisions + +- Parallel Codex modules minimize Claude regression risk; shared-service extraction waits for another provider or demonstrated need. +- `thread.started.thread_id` is authoritative because initial and resumed 0.147.0 runs emit the same UUID. +- Binding occurs immediately during the owned first run so post-binding failure resumes instead of forking. +- Explicit UUID resume is mandatory; `--last`, names, transcript scanning, and `exec-server` are rejected. +- Unknown object events are forward-compatible, while required identity/result/completion events remain strict. + +## Non-Functional Requirements + +- Atomic per-agent exclusion prevents concurrent turns on one Codex thread. +- Stdout lines, stderr capture, and persisted summaries are bounded; malformed streams fail closed. +- Spawn uses no shell and no permission-bypass flags; prompt and native transcripts are never persisted. +- Creation/probe are non-billable and sends have no implicit retry. +- Existing schema records and interactive/Claude behavior remain compatible. diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..b1ca587c --- /dev/null +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,58 @@ +--- +phase: implementation +title: Codex Print-Mode Agent Implementation +description: Implementation record, decisions, validation, and deviations +--- + +# Codex Print-Mode Agent Implementation + +## Status + +- Current task: Task 1.1, provider-aware durable model. +- Completed: requirements, design, and initial planning review. +- Task tracing: unavailable (`unknown command 'task'`). + +## Development Setup + +- Worktree: `feature-codex-print-mode`. +- Bootstrap: `npm ci` from the repository lockfile. +- Provider validation and tests are non-billable; only the deterministic fake Codex executable is used. + +## Code Structure + +- `packages/agent-manager/src/print`: shared durable store plus parallel Claude/Codex probe, runner, service, and errors. +- `packages/agent-manager/src/__tests__/print`: unit/service/integration tests. +- `packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs`: executable provider fixture. +- `packages/cli/src/commands/agent.ts` and CLI tests: provider-aware routing/rendering. + +## Implementation Notes + +This section will be updated after each TDD task with changed files, red/green evidence, decisions, deviations, and edge cases. The load-bearing rule is that Codex's provider-minted UUID is persisted during the active first run before terminal success. + +## Integration Points + +- The existing print store remains the single durable mapping and exclusion authority. +- CLI start selects probe/service by requested type; send selects by persisted record provider. +- Runner callbacks persist provider process identity before stdin and provider session identity on `thread.started`. + +## Error Handling + +- `CodexPrintError` classifies unsupported CLI, process, protocol, session mismatch, and missing result failures. +- The service records failures through token-owned completion, mapping well-formed UUID mismatch to `mismatch` and other session/protocol failures to `unknown`. +- There is no retry, replacement session, or fallback to `--last`. + +## Performance Considerations + +- Each send spawns one process; no idle process or server is retained. +- JSONL line buffering, stderr capture, and stored summaries are bounded. +- Store mutation locks are short-lived; the per-agent lock spans the provider run. + +## Security Notes + +- Prompt only via stdin after provider identity persistence; `shell: false`; fixed argv. +- Exact canonical cwd; explicit UUID resume; no permission bypass or transcript copying. +- Provider output is untrusted and validated before affecting durable identity or success. + +## Validation Evidence + +Pending implementation. Fresh command evidence will be recorded during TDD and final gates. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..3011959d --- /dev/null +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,79 @@ +--- +phase: planning +title: Codex Print-Mode Agent Implementation Plan +description: Ordered TDD work for durable Codex print agents +--- + +# Codex Print-Mode Agent Implementation Plan + +## Milestones + +- [ ] Milestone 1: Provider-aware durable model, migration, and session binding. +- [ ] Milestone 2: Codex probe, runner, service, and deterministic fixture. +- [ ] Milestone 3: CLI integration and compatibility coverage. +- [ ] Milestone 4: Documentation, full validation, review, and PR publication. + +## Task Breakdown + +### Phase 1: Foundation + +- [ ] Task 1.1: Drive the discriminated `PrintAgent` union and provider-aware creation with failing store/domain tests. + - Outcome: Claude and Codex records coexist; legacy Claude files remain valid. + - Validation: focused `PrintAgent`/`PrintAgentStore` tests and typecheck. +- [ ] Task 1.2: Drive `bindProviderSession` integrity behavior with failing tests. + - Outcome: token-owned atomic null-to-UUID binding, idempotence, mismatch and duplicate rejection. + - Dependencies: Task 1.1. + - Validation: focused store tests for every binding branch and persistence after failure. + +### Phase 2: Codex execution + +- [ ] Task 2.1: Drive `CodexCliProbe` and provider error types with failing tests. + - Outcome: version/help-only capability validation and sanitized errors. +- [ ] Task 2.2: Drive `CodexPrintRunner` with fake spawn/fixture tests. + - Outcome: exact argv/cwd/stdin handshake; bounded strict JSONL; immediate UUID binding; ordered assistant output. + - Validation: normal, chunked, unknown, malformed, oversized, truncated, missing, mismatch, stderr, and exit branches. +- [ ] Task 2.3: Drive `CodexPrintAgentService` orchestration with failing tests. + - Outcome: first/resume lifecycle, correct health degradation, no retry, binding retained after later failure. + +### Phase 3: CLI and integration + +- [ ] Task 3.1: Add provider-aware exports and fake-Codex integration journey. + - Outcome: create performs probes only; first send binds; second send resumes same UUID; concurrency/recovery/cwd work offline. +- [ ] Task 3.2: Drive CLI start/list/detail/send behavior with failing command tests. + - Outcome: `--type codex --mode print`, `Codex (print)`, `not started`, record-derived JSON provider, provider-selected send. +- [ ] Task 3.3: Run Claude-print and interactive-Codex regression tests and inspect excluded command paths. + +### Phase 4: Validation and publication + +- [ ] Task 4.1: Reconcile implementation/testing docs and reach 100% coverage on new pure logic. +- [ ] Task 4.2: Run feature/base lifecycle lint, lint, typecheck, build, package/full tests, and coverage. +- [ ] Task 4.3: Perform design-alignment and holistic code review; fix blocking findings via TDD. +- [ ] Task 4.4: Create conventional commits, fetch/rebase `origin/main`, rerun gates, push, and open the requested PR. + +## Dependencies + +Tasks are ordered because the domain/store contract underpins runner/service and CLI behavior. Tests use only injected process boundaries and `fake-codex.cjs`; no model credentials or calls are required. Optional task tracing is unavailable (`npx ai-devkit@latest task list --name codex-print-mode --json` returned `unknown command 'task'`). + +## Timeline & Estimates + +- Foundation: medium; migration and binding integrity are highest risk. +- Provider execution: medium/high; protocol and crash ordering dominate. +- CLI/integration: medium; compatibility tests dominate. +- Validation/publication: medium; coverage and rebase can reveal follow-up fixes. + +Work proceeds sequentially through the approved lifecycle without a calendar commitment. + +## Risks & Mitigation + +- Orphan/forked sessions: bind on `thread.started`; never recover through `--last`. +- Concurrent resume: reuse fail-fast per-agent locks and token ownership. +- Protocol drift: capability probe, strict required events, tolerant unknown objects. +- Secret leakage: stdin-only prompt, bounded/sanitized diagnostics, no transcript storage. +- Regression: parallel provider modules plus focused and full existing suites. +- Scope growth: deletion, Pi, capacity routing, server mode, and generic adapters remain deferred. + +## Resources Needed + +- Existing Claude print implementation/tests/docs as the template. +- Codex 0.147.0 empirical event contract supplied in the build brief. +- Node/Nx/Vitest toolchain and temporary fake-provider files. diff --git a/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md b/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..a21b8e12 --- /dev/null +++ b/docs/ai/requirements/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,85 @@ +--- +phase: requirements +title: Codex Print-Mode Agents +description: Durable AI DevKit agents backed by synchronous Codex exec runs +--- + +# Codex Print-Mode Agents + +## Problem Statement + +AI DevKit supports durable Claude print agents, but Codex agents still require a continuously running interactive process. Users need a durable logical Codex identity whose messages run synchronously in short-lived `codex exec` processes while retaining one native Codex conversation. + +### Terminology + +- **Logical agent:** durable AI DevKit identity created by `agent start --mode print`. +- **Provider session:** Codex conversation identified by a provider-minted thread UUID. +- **Provider process:** one ephemeral `codex exec` child process. +- **Run:** one `agent send` handled by one provider process. + +## Goals & Objectives + +### Goals + +- Add `agent start --type codex --mode print` while preserving interactive Codex as the default and Claude print behavior. +- Create the logical record without a model run or invented provider UUID. +- On first send, run `codex exec --json -`, capture `thread.started.thread_id`, and bind it atomically during the owned run. +- On later sends, run `codex exec resume --json -` and require the emitted UUID to match. +- Reuse durable state, atomic persistence, fail-fast locking, stale recovery, canonical cwd binding, safe process identity, and bounded results. +- Pass prompts only through stdin and validate Codex capabilities without a model call. +- Keep print agents visible in human and JSON list/detail output. + +### Non-goals + +- Queues, retry, scheduling, cancellation, background workers, `codex exec-server`, `resume --last`, session naming, or transcript copying/deletion. +- Print-agent delete/kill semantics, Pi print mode, capacity-aware routing, or a generic provider-adapter refactor. +- Permission bypass flags, authentication/quota model calls, or changes to channels/groups/TUI. + +## User Stories & Use Cases + +- As a user, I can create a Codex print agent without consuming tokens; its provider session displays `not started`. +- As a user, my first synchronous send creates and durably binds the Codex thread UUID. +- As a user, later sends explicitly resume the same UUID in the immutable canonical cwd. +- As a user, I receive an immediate busy error for concurrent sends, never a queue. +- As a user, failures before binding leave the session uninitialized; failures after binding retain the UUID for safe explicit resume. +- As a user, exact IDs win and ambiguous names across interactive/print modes are rejected. + +## Success Criteria + +### Domain and persistence + +- `PrintAgent` is a `claude | codex` discriminated union; Claude IDs remain non-null and Codex IDs begin null. +- Existing version-1 Claude records remain strictly readable through an explicit versioned reader. +- `bindProviderSession` requires the active run token, permits Codex null-to-UUID only, is identical-UUID idempotent, rejects replacement, and rejects duplicate non-null provider/session pairs. +- First-run binding is atomically durable before success and remains durable after a later run failure. + +### Provider execution + +- Probe runs only `codex --version`, `codex exec --help`, and `codex exec resume --help` and verifies `exec`, `resume`, `--json`, and stdin `-` support. +- Runner uses `shell: false`, discrete fixed argv, exact stored cwd, verified process identity, and calls `onSpawn` before sending the prompt via stdin. +- Success requires a valid matching UUID, at least one valid assistant message, `turn.completed`, clean bounded JSONL termination, and exit code zero. +- Unknown object events are ignored; malformed/non-object/oversized/truncated JSONL, missing required events, mismatch, or non-zero exit fails safely. +- Assistant texts are collected in arrival order and the final non-empty text is returned; stderr and persisted summaries are bounded and sanitized. + +### CLI and compatibility + +- `--mode print` accepts Claude and Codex; omitted mode remains interactive. +- Human output renders `Codex (print)` and an unbound session as `not started`; JSON derives provider from the record. +- Print sends remain synchronous and preserve exact-ID/name-resolution rules. +- Claude print, interactive Codex, and excluded commands retain existing behavior. + +### Validation + +- Deterministic fake-Codex unit/integration tests cover initial/resume, chunking, multiple results, process/protocol failures, binding timing, mismatch, concurrency, stale recovery, cwd, and secret safety without a real model. +- New pure/unit logic reaches 100% coverage; package and repository lint, typecheck, build, tests, coverage, and lifecycle lint pass. + +## Constraints & Assumptions + +- Target contract is Codex CLI 0.147.0: past-tense dotted JSONL events and provider-minted UUIDs. +- Native Codex persistence remains provider-owned; AI DevKit stores only identity, binding, state, lock/process metadata, and a bounded result summary. +- The local OS account is the authorization boundary. Codex inherits configured sandbox/approval behavior. +- A crash before processing `thread.started` may orphan a native session; recovery must never guess via `--last`. + +## Questions & Open Items + +No blocking questions remain. Print deletion, Pi support, capacity integration, and common-service extraction are explicit follow-ups. diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md new file mode 100644 index 00000000..212e20d4 --- /dev/null +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -0,0 +1,87 @@ +--- +phase: testing +title: Codex Print-Mode Agent Testing Strategy +description: Offline TDD, protocol, integration, and compatibility validation +--- + +# Codex Print-Mode Agent Testing Strategy + +## Test Coverage Goals + +- 100% coverage for all new pure/unit logic, including provider-specific parsing and binding branches. +- Offline fake-provider integration for every critical success and failure path. +- Full agent-manager, CLI, and repository regression suites; no real model invocation. + +## Unit Tests + +### Domain and store + +- [ ] Claude and Codex records coexist with provider-specific nullable invariants. +- [ ] Provider-aware create gives Claude a UUID and Codex `null`/`uninitialized` without spawning. +- [ ] Legacy Claude schema remains readable; malformed/provider-invalid records remain rejected. +- [ ] Binding requires the owned run token, validates UUID, supports null-to-value and identical idempotence, and rejects replacement. +- [ ] Duplicate non-null provider/session bindings are rejected across records; provider namespaces remain distinct. +- [ ] Existing atomic writes, canonical cwd, concurrency, and stale-lock recovery remain green. + +### Codex capability probe and errors + +- [ ] Probe invokes exactly `--version`, `exec --help`, and `exec resume --help`. +- [ ] Probe validates `exec`, `resume`, `--json`, and stdin `-`; failures are bounded/sanitized and never invoke a model. +- [ ] Error codes cover protocol, process, session mismatch, unsupported, and missing result. + +### Codex runner + +- [ ] Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`; prompt is absent from argv. +- [ ] `shell: false`, exact canonical cwd, provider identity before stdin, and prompt-only stdin are enforced. +- [ ] Chunked/multi-event/multibyte JSONL and multiple assistant messages are parsed in order; unknown object events are tolerated. +- [ ] Success requires matching `thread.started`, assistant result, `turn.completed`, clean termination, and exit zero. +- [ ] Invalid UUID, second/different thread, mismatch, malformed/non-object/oversized/truncated line, missing identity/result/completion, and non-zero exit fail. +- [ ] Secret-looking stderr and prompt content never appear in persisted/displayed errors. + +### Codex service and CLI + +- [ ] First send binds during the owned run and completes healthy; second send resumes exact UUID. +- [ ] Failure before binding stays uninitialized/unknown; failure after binding retains UUID and becomes degraded/unknown. +- [ ] Session mismatch becomes degraded/mismatch; busy sends never invoke the runner; no retry occurs. +- [ ] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. +- [ ] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. +- [ ] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. + +## Integration Tests + +- [ ] Fake provider create invokes only version/help and creates no session. +- [ ] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. +- [ ] Second send receives the identical UUID in explicit resume argv. +- [ ] Concurrent send, stale lock recovery, canonical cwd, first-run pre/post-bind failure, and session mismatch behave safely. +- [ ] Claude print and interactive Codex regression suites remain green. + +## End-to-End Tests + +- [ ] CLI fake-Codex start → list/detail (`not started`) → first send → second resumed send. +- [ ] JSON/human output has correct provider/mode and no fake PID, prompt, raw stderr secret, or invented session. +- [ ] Unsupported provider/mode and ambiguous targets exit with actionable errors. + +## Test Data + +`fake-codex.cjs` supports version/help, initial/resume syntax, deterministic UUID, stdin/argv/cwd capture, chunked and multiple events, delay/concurrency, secret stderr, non-zero exit, malformed/oversized/truncated streams, missing required events, mismatch, and pre/post-binding failures. Tests use temporary store/cwd paths and deterministic process/clock injections. + +## Test Reporting & Coverage + +- Focused: package Vitest paths for each red/green/refactor cycle. +- Coverage: agent-manager and CLI coverage commands, with file-level review of all new modules. +- Gates: lifecycle lint, ESLint, TypeScript, builds, package tests, and root full suite. +- Exact exit codes/counts and justified exclusions will be recorded after fresh final runs. + +## Manual Testing + +No real Codex model run is permitted. Human inspection is limited to fake-provider CLI output and reviewed argv/state artifacts that contain no prompt secret. + +## Performance Testing + +- [ ] Oversized output remains bounded. +- [ ] Concurrent lock contention fails promptly. +- [ ] Listing mixed records remains practical without provider processes. + +## Bug Tracking + +Blocking findings are added to planning and fixed through a new red/green/refactor cycle before publication. From fa35da6be8ee5fa118e681b9cf617353a4a4a682 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 16:01:12 +0000 Subject: [PATCH 2/5] feat(agent): generalize print session storage --- .../2026-08-11-feature-codex-print-mode.md | 13 ++- .../2026-08-11-feature-codex-print-mode.md | 6 +- .../2026-08-11-feature-codex-print-mode.md | 12 +-- .../print/ClaudePrintAgentService.test.ts | 5 +- .../__tests__/print/PrintAgentStore.test.ts | 73 +++++++++++++++ packages/agent-manager/src/index.ts | 5 ++ .../src/print/ClaudePrintAgentService.ts | 7 +- .../src/print/ClaudePrintRunner.ts | 4 +- .../agent-manager/src/print/PrintAgent.ts | 25 +++++- .../src/print/PrintAgentStore.ts | 89 +++++++++++++++++-- 10 files changed, 210 insertions(+), 29 deletions(-) diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index b1ca587c..c2ac0d57 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -8,8 +8,8 @@ description: Implementation record, decisions, validation, and deviations ## Status -- Current task: Task 1.1, provider-aware durable model. -- Completed: requirements, design, and initial planning review. +- Current task: Task 2.1, Codex errors and CLI probe. +- Completed: Tasks 1.1–1.2 and lifecycle document initialization. - Task tracing: unavailable (`unknown command 'task'`). ## Development Setup @@ -27,7 +27,14 @@ description: Implementation record, decisions, validation, and deviations ## Implementation Notes -This section will be updated after each TDD task with changed files, red/green evidence, decisions, deviations, and edge cases. The load-bearing rule is that Codex's provider-minted UUID is persisted during the active first run before terminal success. +### Tasks 1.1–1.2 + +- Generalized `PrintAgent` into a `claude | codex` discriminated union without weakening Claude's non-null session invariant. +- Made store creation provider-aware; omitted provider preserves the legacy Claude default and Codex starts unbound. +- Added strict version-1 record validation, including provider-specific IDs and duplicate binding rejection. +- Added token-owned atomic `bindProviderSession` with UUID validation, identical-value idempotence, replacement rejection, and cross-record uniqueness. + +TDD red: focused store tests reported four expected failures (hard-coded Claude creation, weak schema validation, and missing binding API). Green/refactor: `npx vitest run src/__tests__/print/PrintAgentStore.test.ts` passed 12/12; `npm run typecheck` and `npm run lint` exited 0. ## Integration Points diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 3011959d..4822c9b5 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -8,7 +8,7 @@ description: Ordered TDD work for durable Codex print agents ## Milestones -- [ ] Milestone 1: Provider-aware durable model, migration, and session binding. +- [x] Milestone 1: Provider-aware durable model, migration, and session binding. - [ ] Milestone 2: Codex probe, runner, service, and deterministic fixture. - [ ] Milestone 3: CLI integration and compatibility coverage. - [ ] Milestone 4: Documentation, full validation, review, and PR publication. @@ -17,10 +17,10 @@ description: Ordered TDD work for durable Codex print agents ### Phase 1: Foundation -- [ ] Task 1.1: Drive the discriminated `PrintAgent` union and provider-aware creation with failing store/domain tests. +- [x] Task 1.1: Drive the discriminated `PrintAgent` union and provider-aware creation with failing store/domain tests. - Outcome: Claude and Codex records coexist; legacy Claude files remain valid. - Validation: focused `PrintAgent`/`PrintAgentStore` tests and typecheck. -- [ ] Task 1.2: Drive `bindProviderSession` integrity behavior with failing tests. +- [x] Task 1.2: Drive `bindProviderSession` integrity behavior with failing tests. - Outcome: token-owned atomic null-to-UUID binding, idempotence, mismatch and duplicate rejection. - Dependencies: Task 1.1. - Validation: focused store tests for every binding branch and persistence after failure. diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index 212e20d4..cc5620a8 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -16,12 +16,12 @@ description: Offline TDD, protocol, integration, and compatibility validation ### Domain and store -- [ ] Claude and Codex records coexist with provider-specific nullable invariants. -- [ ] Provider-aware create gives Claude a UUID and Codex `null`/`uninitialized` without spawning. -- [ ] Legacy Claude schema remains readable; malformed/provider-invalid records remain rejected. -- [ ] Binding requires the owned run token, validates UUID, supports null-to-value and identical idempotence, and rejects replacement. -- [ ] Duplicate non-null provider/session bindings are rejected across records; provider namespaces remain distinct. -- [ ] Existing atomic writes, canonical cwd, concurrency, and stale-lock recovery remain green. +- [x] Claude and Codex records coexist with provider-specific nullable invariants. +- [x] Provider-aware create gives Claude a UUID and Codex `null`/`uninitialized` without spawning. +- [x] Legacy Claude schema remains readable; malformed/provider-invalid records remain rejected. +- [x] Binding requires the owned run token, validates UUID, supports null-to-value and identical idempotence, and rejects replacement. +- [x] Duplicate non-null provider/session bindings are rejected across records; provider namespaces remain distinct. +- [x] Existing atomic writes, canonical cwd, concurrency, and stale-lock recovery remain green. ### Codex capability probe and errors diff --git a/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts index f6d4e529..d50cc480 100644 --- a/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/print/ClaudePrintAgentService.test.ts @@ -18,7 +18,10 @@ describe('ClaudePrintAgentService', () => { it('runs first and resumed sends and records provider identity/results', async () => { const api = await import('../../index.js') as Record; - const base = { id: 'id', name: 'reviewer', providerSessionId: 'session', sessionHealth: 'uninitialized' }; + const base = { + id: 'id', name: 'reviewer', provider: 'claude', providerSessionId: 'session', + sessionHealth: 'uninitialized', + }; const store = { resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn() diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts index 85b4c8e6..59da0c20 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts @@ -47,6 +47,35 @@ describe('PrintAgentStore create/list/resolve', () => { expect(fs.statSync(filePath).mode & 0o777).toBe(0o600); }); + it('creates Claude and unbound Codex records side by side', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath }); + + const claude = await store.create({ name: 'claude-reviewer', cwd, provider: 'claude' }); + const codex = await store.create({ name: 'codex-reviewer', cwd, provider: 'codex' }); + + expect(claude).toMatchObject({ provider: 'claude', sessionHealth: 'uninitialized' }); + expect(claude.providerSessionId).toMatch(/^[0-9a-f-]{36}$/); + expect(codex).toMatchObject({ + provider: 'codex', providerSessionId: null, sessionHealth: 'uninitialized', state: 'ready', + }); + expect(await store.list()).toEqual([claude, codex]); + }); + + it('strictly reads legacy Claude records', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath }); + const legacy = await store.create({ name: 'legacy', cwd }); + + expect((await store.list())[0]).toEqual(legacy); + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); + raw.agents[0].providerSessionId = null; + await fs.promises.writeFile(filePath, JSON.stringify(raw)); + await expect(store.list()).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + }); + it('resolves exact ids and names and rejects duplicate names', async () => { const PrintAgentStore = await loadStore(); const { cwd, filePath } = fixture(); @@ -97,6 +126,50 @@ describe('PrintAgentStore create/list/resolve', () => { }); describe('PrintAgentStore run ownership', () => { + it('binds a Codex session only for the owned run and rejects replacement', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath }); + const agent = await store.create({ name: 'codex', cwd, provider: 'codex' }); + const run = await store.acquireRun(agent.id); + const sessionId = '22222222-2222-4222-8222-222222222222'; + + await expect(store.bindProviderSession(agent.id, 'wrong', sessionId)) + .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + await expect(store.bindProviderSession(agent.id, run.token, sessionId)) + .resolves.toMatchObject({ provider: 'codex', providerSessionId: sessionId }); + await expect(store.bindProviderSession(agent.id, run.token, sessionId)) + .resolves.toMatchObject({ providerSessionId: sessionId }); + await expect(store.bindProviderSession( + agent.id, run.token, '33333333-3333-4333-8333-333333333333', + )).rejects.toMatchObject({ code: 'PRINT_AGENT_SESSION_MISMATCH' }); + }); + + it('rejects invalid, Claude, and duplicate Codex session bindings', async () => { + const PrintAgentStore = await loadStore(); + const { cwd, filePath } = fixture(); + const store = new PrintAgentStore({ filePath }); + const first = await store.create({ name: 'first', cwd, provider: 'codex' }); + const second = await store.create({ name: 'second', cwd, provider: 'codex' }); + const claude = await store.create({ name: 'claude', cwd, provider: 'claude' }); + const firstRun = await store.acquireRun(first.id); + const sessionId = '22222222-2222-4222-8222-222222222222'; + + await expect(store.bindProviderSession(first.id, firstRun.token, 'not-a-uuid')) + .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + await store.bindProviderSession(first.id, firstRun.token, sessionId); + await store.completeRun(first.id, firstRun.token, { + status: 'failed', exitCode: 1, summary: 'after binding', sessionHealth: 'unknown', + }); + expect((await store.getById(first.id))?.providerSessionId).toBe(sessionId); + + const secondRun = await store.acquireRun(second.id); + await expect(store.bindProviderSession(second.id, secondRun.token, sessionId)) + .rejects.toMatchObject({ code: 'PRINT_AGENT_SESSION_MISMATCH' }); + const claudeRun = await store.acquireRun(claude.id); + await expect(store.bindProviderSession(claude.id, claudeRun.token, sessionId)) + .rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); + }); it('fails fast when another exact owner is live and completes only for its token', async () => { const PrintAgentStore = await loadStore(); const { cwd, filePath } = fixture(); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index ced400ec..4186ab0a 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -41,10 +41,15 @@ export { PrintAgentNotFoundError, PrintAgentStoreError, PrintAgentNameConflictError, + PrintAgentSessionMismatchError, ClaudePrintError, } from './print/PrintAgent.js'; export type { PrintAgent, + PrintAgentBase, + ClaudePrintAgent, + CodexPrintAgent, + PrintProvider, PrintAgentState, PrintSessionHealth, PrintRunStatus, diff --git a/packages/agent-manager/src/print/ClaudePrintAgentService.ts b/packages/agent-manager/src/print/ClaudePrintAgentService.ts index 26195b26..f08b0ea4 100644 --- a/packages/agent-manager/src/print/ClaudePrintAgentService.ts +++ b/packages/agent-manager/src/print/ClaudePrintAgentService.ts @@ -1,4 +1,4 @@ -import type { PrintAgent, ProcessIdentity } from './PrintAgent.js'; +import type { ClaudePrintAgent, PrintAgent, ProcessIdentity } from './PrintAgent.js'; import { ClaudePrintError, PrintAgentNotFoundError } from './PrintAgent.js'; import { ClaudeCliProbe } from './ClaudeCliProbe.js'; import { ClaudePrintRunner, type ClaudePrintRunResult } from './ClaudePrintRunner.js'; @@ -54,8 +54,11 @@ export class ClaudePrintAgentService { } const acquired = await this.store.acquireRun(resolved.id); try { + if (acquired.agent.provider !== 'claude') { + throw new ClaudePrintError('Print agent provider is not Claude.', 'CLAUDE_PRINT_UNSUPPORTED'); + } const result = await this.runner.run({ - agent: acquired.agent, + agent: acquired.agent as ClaudePrintAgent, prompt, executable: this.executable, firstRun: acquired.agent.sessionHealth === 'uninitialized', diff --git a/packages/agent-manager/src/print/ClaudePrintRunner.ts b/packages/agent-manager/src/print/ClaudePrintRunner.ts index 588effd8..5ace86ed 100644 --- a/packages/agent-manager/src/print/ClaudePrintRunner.ts +++ b/packages/agent-manager/src/print/ClaudePrintRunner.ts @@ -1,5 +1,5 @@ import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; -import type { PrintAgent, ProcessIdentity } from './PrintAgent.js'; +import type { ClaudePrintAgent, ProcessIdentity } from './PrintAgent.js'; import { ClaudePrintError } from './PrintAgent.js'; import { LocalProcessInspector, type ProcessInspector } from './PrintAgentStore.js'; @@ -10,7 +10,7 @@ type Spawn = ( ) => ChildProcessWithoutNullStreams; export interface ClaudePrintRunRequest { - agent: PrintAgent; + agent: ClaudePrintAgent; prompt: string; executable?: string; firstRun: boolean; diff --git a/packages/agent-manager/src/print/PrintAgent.ts b/packages/agent-manager/src/print/PrintAgent.ts index ca812e34..46072eba 100644 --- a/packages/agent-manager/src/print/PrintAgent.ts +++ b/packages/agent-manager/src/print/PrintAgent.ts @@ -21,13 +21,13 @@ export interface PrintLastResult { summary: string; } -export interface PrintAgent { +export type PrintProvider = 'claude' | 'codex'; + +export interface PrintAgentBase { id: string; name: string; - provider: 'claude'; mode: 'print'; cwd: string; - providerSessionId: string; state: PrintAgentState; sessionHealth: PrintSessionHealth; createdAt: string; @@ -37,6 +37,18 @@ export interface PrintAgent { activeRun: PrintActiveRun | null; } +export interface ClaudePrintAgent extends PrintAgentBase { + provider: 'claude'; + providerSessionId: string; +} + +export interface CodexPrintAgent extends PrintAgentBase { + provider: 'codex'; + providerSessionId: string | null; +} + +export type PrintAgent = ClaudePrintAgent | CodexPrintAgent; + export class PrintAgentError extends Error { constructor( message: string, @@ -78,6 +90,13 @@ export class PrintAgentNameConflictError extends PrintAgentError { } } +export class PrintAgentSessionMismatchError extends PrintAgentError { + constructor(message = 'Print agent provider session identity does not match.') { + super(message, 'PRINT_AGENT_SESSION_MISMATCH'); + this.name = 'PrintAgentSessionMismatchError'; + } +} + export class ClaudePrintError extends PrintAgentError { constructor(message: string, code = 'CLAUDE_PRINT_FAILED') { super(message, code); diff --git a/packages/agent-manager/src/print/PrintAgentStore.ts b/packages/agent-manager/src/print/PrintAgentStore.ts index db560aa0..82474219 100644 --- a/packages/agent-manager/src/print/PrintAgentStore.ts +++ b/packages/agent-manager/src/print/PrintAgentStore.ts @@ -3,11 +3,12 @@ import os from 'os'; import path from 'path'; import { randomUUID } from 'crypto'; import { execFileSync } from 'child_process'; -import type { PrintAgent, ProcessIdentity, PrintRunStatus, PrintSessionHealth } from './PrintAgent.js'; +import type { PrintAgent, PrintProvider, ProcessIdentity, PrintRunStatus, PrintSessionHealth } from './PrintAgent.js'; import { PrintAgentBusyError, PrintAgentNameConflictError, PrintAgentNotFoundError, + PrintAgentSessionMismatchError, PrintAgentStoreError, } from './PrintAgent.js'; @@ -19,6 +20,7 @@ interface PrintAgentStoreFile { export interface CreatePrintAgentInput { name: string; cwd: string; + provider?: PrintProvider; } export interface PrintAgentStoreOptions { @@ -73,24 +75,29 @@ export class PrintAgentStore { } const timestamp = this.now().toISOString(); let id = randomUUID(); - let providerSessionId = randomUUID(); + const provider = input.provider ?? 'claude'; + let providerSessionId = provider === 'claude' ? randomUUID() : null; while (providerSessionId === id) providerSessionId = randomUUID(); + while (providerSessionId !== null && data.agents.some((agent) => ( + agent.provider === provider && agent.providerSessionId === providerSessionId + ))) providerSessionId = randomUUID(); while (data.agents.some((agent) => agent.id === id)) id = randomUUID(); - const agent: PrintAgent = { + const base = { id, name: input.name, - provider: 'claude', - mode: 'print', + mode: 'print' as const, cwd, - providerSessionId, - state: 'ready', - sessionHealth: 'uninitialized', + state: 'ready' as const, + sessionHealth: 'uninitialized' as const, createdAt: timestamp, updatedAt: timestamp, lastActiveAt: null, lastResult: null, activeRun: null, }; + const agent: PrintAgent = provider === 'claude' + ? { ...base, provider, providerSessionId: providerSessionId! } + : { ...base, provider, providerSessionId: null }; data.agents.push(agent); this.writeFile(data); return structuredClone(agent); @@ -228,6 +235,34 @@ export class PrintAgentStore { }); } + async bindProviderSession(id: string, token: string, providerSessionId: string): Promise { + this.requireOwnedRun(id, token); + if (!isUuid(providerSessionId)) throw new PrintAgentStoreError('Invalid provider session id.'); + return this.withMutationLock(async () => { + const data = this.readFile(); + const index = data.agents.findIndex((agent) => agent.id === id); + if (index < 0) throw new PrintAgentNotFoundError(id); + const agent = data.agents[index]!; + if (agent.activeRun?.token !== token) throw new PrintAgentStoreError('Print run ownership changed.'); + if (agent.provider !== 'codex') { + throw new PrintAgentStoreError('Only Codex print sessions can be bound after creation.'); + } + if (agent.providerSessionId !== null && agent.providerSessionId !== providerSessionId) { + throw new PrintAgentSessionMismatchError(); + } + if (data.agents.some((candidate) => candidate.id !== id + && candidate.provider === agent.provider + && candidate.providerSessionId === providerSessionId)) { + throw new PrintAgentSessionMismatchError('Provider session is already bound to another print agent.'); + } + if (agent.providerSessionId === providerSessionId) return structuredClone(agent); + const next = { ...agent, providerSessionId, updatedAt: this.now().toISOString() }; + data.agents[index] = next; + this.writeFile(data); + return structuredClone(next); + }); + } + async completeRun(id: string, token: string, result: PrintRunCompletion): Promise { this.requireOwnedRun(id, token); const completedAt = this.now().toISOString(); @@ -325,7 +360,17 @@ export class PrintAgentStore { private isStoreFile(value: unknown): value is PrintAgentStoreFile { if (!value || typeof value !== 'object') return false; const record = value as Record; - return record.version === 1 && Array.isArray(record.agents); + if (record.version !== 1 || !Array.isArray(record.agents)) return false; + const bindings = new Set(); + for (const value of record.agents) { + if (!isPrintAgent(value)) return false; + if (value.providerSessionId !== null) { + const binding = `${value.provider}:${value.providerSessionId}`; + if (bindings.has(binding)) return false; + bindings.add(binding); + } + } + return true; } private writeFile(data: PrintAgentStoreFile): void { @@ -480,6 +525,32 @@ export class PrintAgentStore { } } +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isUuid(value: unknown): value is string { + return typeof value === 'string' && UUID_PATTERN.test(value); +} + +function isPrintAgent(value: unknown): value is PrintAgent { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const agent = value as Record; + const providerSessionValid = agent.provider === 'claude' + ? isUuid(agent.providerSessionId) + : agent.provider === 'codex' && (agent.providerSessionId === null || isUuid(agent.providerSessionId)); + return providerSessionValid + && isUuid(agent.id) + && typeof agent.name === 'string' + && agent.mode === 'print' + && typeof agent.cwd === 'string' + && ['ready', 'running', 'degraded'].includes(agent.state as string) + && ['uninitialized', 'healthy', 'unknown', 'mismatch'].includes(agent.sessionHealth as string) + && typeof agent.createdAt === 'string' + && typeof agent.updatedAt === 'string' + && (agent.lastActiveAt === null || typeof agent.lastActiveAt === 'string') + && (agent.lastResult === null || typeof agent.lastResult === 'object') + && (agent.activeRun === null || typeof agent.activeRun === 'object'); +} + export class LocalProcessInspector implements ProcessInspector { getIdentity(pid: number): ProcessIdentity | null { if (!Number.isInteger(pid) || pid <= 0) return null; From 60532f2d4251941a87913572dccf6566e7e182ba Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 16:05:30 +0000 Subject: [PATCH 3/5] feat(agent): add Codex print runner and service --- .../2026-08-11-feature-codex-print-mode.md | 12 +- .../2026-08-11-feature-codex-print-mode.md | 6 +- .../2026-08-11-feature-codex-print-mode.md | 23 ++- .../src/__tests__/print/CodexCliProbe.test.ts | 38 +++++ .../print/CodexPrintAgentService.test.ts | 67 ++++++++ .../__tests__/print/CodexPrintRunner.test.ts | 123 +++++++++++++++ packages/agent-manager/src/index.ts | 15 ++ .../agent-manager/src/print/CodexCliProbe.ts | 62 ++++++++ .../src/print/CodexPrintAgentService.ts | 88 +++++++++++ .../src/print/CodexPrintRunner.ts | 148 ++++++++++++++++++ .../agent-manager/src/print/PrintAgent.ts | 16 ++ 11 files changed, 581 insertions(+), 17 deletions(-) create mode 100644 packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts create mode 100644 packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts create mode 100644 packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts create mode 100644 packages/agent-manager/src/print/CodexCliProbe.ts create mode 100644 packages/agent-manager/src/print/CodexPrintAgentService.ts create mode 100644 packages/agent-manager/src/print/CodexPrintRunner.ts diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index c2ac0d57..9aca2553 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -8,8 +8,8 @@ description: Implementation record, decisions, validation, and deviations ## Status -- Current task: Task 2.1, Codex errors and CLI probe. -- Completed: Tasks 1.1–1.2 and lifecycle document initialization. +- Current task: Task 3.1, fake-Codex integration fixture. +- Completed: Tasks 1.1–2.3 and lifecycle document initialization. - Task tracing: unavailable (`unknown command 'task'`). ## Development Setup @@ -36,6 +36,14 @@ description: Implementation record, decisions, validation, and deviations TDD red: focused store tests reported four expected failures (hard-coded Claude creation, weak schema validation, and missing binding API). Green/refactor: `npx vitest run src/__tests__/print/PrintAgentStore.test.ts` passed 12/12; `npm run typecheck` and `npm run lint` exited 0. +### Tasks 2.1–2.3 + +- Added provider-specific classified errors and a three-command, non-model Codex capability probe. +- Added safe initial/resume runner argv, process-identity-before-stdin handshake, strict bounded JSONL parsing, immediate async thread binding, ordered assistant messages, and terminal success requirements. +- Added Codex create/send orchestration with provider-aware create, run-token callbacks, mismatch/unknown health classification, and no retry. + +TDD red: 14 focused tests failed on absent Codex exports. Green/refactor: those 14 tests passed; the full agent-manager suite passed 27 files/515 tests; typecheck and lint exited 0. + ## Integration Points - The existing print store remains the single durable mapping and exclusion authority. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 4822c9b5..3977936a 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -27,12 +27,12 @@ description: Ordered TDD work for durable Codex print agents ### Phase 2: Codex execution -- [ ] Task 2.1: Drive `CodexCliProbe` and provider error types with failing tests. +- [x] Task 2.1: Drive `CodexCliProbe` and provider error types with failing tests. - Outcome: version/help-only capability validation and sanitized errors. -- [ ] Task 2.2: Drive `CodexPrintRunner` with fake spawn/fixture tests. +- [x] Task 2.2: Drive `CodexPrintRunner` with fake spawn/fixture tests. - Outcome: exact argv/cwd/stdin handshake; bounded strict JSONL; immediate UUID binding; ordered assistant output. - Validation: normal, chunked, unknown, malformed, oversized, truncated, missing, mismatch, stderr, and exit branches. -- [ ] Task 2.3: Drive `CodexPrintAgentService` orchestration with failing tests. +- [x] Task 2.3: Drive `CodexPrintAgentService` orchestration with failing tests. - Outcome: first/resume lifecycle, correct health degradation, no retry, binding retained after later failure. ### Phase 3: CLI and integration diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index cc5620a8..898fe2c0 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -25,24 +25,23 @@ description: Offline TDD, protocol, integration, and compatibility validation ### Codex capability probe and errors -- [ ] Probe invokes exactly `--version`, `exec --help`, and `exec resume --help`. -- [ ] Probe validates `exec`, `resume`, `--json`, and stdin `-`; failures are bounded/sanitized and never invoke a model. -- [ ] Error codes cover protocol, process, session mismatch, unsupported, and missing result. +- [x] Probe invokes exactly `--version`, `exec --help`, and `exec resume --help`. +- [x] Probe validates `exec`, `resume`, `--json`, and stdin `-`; failures are bounded/sanitized and never invoke a model. +- [x] Error codes cover protocol, process, session mismatch, unsupported, and missing result. ### Codex runner -- [ ] Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`; prompt is absent from argv. -- [ ] `shell: false`, exact canonical cwd, provider identity before stdin, and prompt-only stdin are enforced. -- [ ] Chunked/multi-event/multibyte JSONL and multiple assistant messages are parsed in order; unknown object events are tolerated. -- [ ] Success requires matching `thread.started`, assistant result, `turn.completed`, clean termination, and exit zero. -- [ ] Invalid UUID, second/different thread, mismatch, malformed/non-object/oversized/truncated line, missing identity/result/completion, and non-zero exit fail. -- [ ] Secret-looking stderr and prompt content never appear in persisted/displayed errors. +- [x] Initial argv is `exec --json -`; resume argv is `exec resume --json UUID -`; prompt is absent from argv. +- [x] `shell: false`, exact canonical cwd, provider identity before stdin, and prompt-only stdin are enforced. +- [x] Chunked/multi-event JSONL and multiple assistant messages are parsed in order; unknown object events are tolerated. +- [x] Success requires matching `thread.started`, assistant result, `turn.completed`, clean termination, and exit zero. +- [x] Invalid UUID, mismatch, malformed/non-object/oversized/truncated line, missing identity/result/completion, and non-zero exit fail. +- [x] Secret-looking stderr and prompt content never appear in persisted/displayed errors. ### Codex service and CLI -- [ ] First send binds during the owned run and completes healthy; second send resumes exact UUID. -- [ ] Failure before binding stays uninitialized/unknown; failure after binding retains UUID and becomes degraded/unknown. -- [ ] Session mismatch becomes degraded/mismatch; busy sends never invoke the runner; no retry occurs. +- [x] First send binds during the owned run and completes healthy; second send resumes exact UUID. +- [x] Session mismatch becomes degraded/mismatch; unsupported provider becomes degraded/unknown; no retry occurs. - [ ] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. - [ ] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. - [ ] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. diff --git a/packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts b/packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts new file mode 100644 index 00000000..0984cfcb --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('CodexCliProbe', () => { + it('validates version, exec JSON/stdin, and resume capabilities without a model call', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('CodexCliProbe'); + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: 'codex-cli 0.147.0', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Usage: codex exec [PROMPT]\n--json\n- read from stdin\nresume', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Usage: codex exec resume [SESSION_ID] [PROMPT]\n--json\n- stdin', stderr: '' }); + const Probe = api.CodexCliProbe as new (options: unknown) => any; + + await expect(new Probe({ executable: 'fake-codex', exec }).validate()).resolves.toEqual({ + executable: 'fake-codex', version: 'codex-cli 0.147.0', + }); + expect(exec.mock.calls).toEqual([ + ['fake-codex', ['--version']], + ['fake-codex', ['exec', '--help']], + ['fake-codex', ['exec', 'resume', '--help']], + ]); + }); + + it('rejects unsupported and unavailable CLIs with bounded sanitized errors', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.CodexCliProbe as new (options: unknown) => any; + const unsupported = new Probe({ exec: vi.fn() + .mockResolvedValueOnce({ stdout: 'version', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'exec', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'resume', stderr: '' }) }); + await expect(unsupported.validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); + + const unavailable = new Probe({ exec: vi.fn().mockRejectedValue(new Error(`bad\0${'x'.repeat(1000)}`)) }); + const error = await unavailable.validate().catch((value: Error & { code: string }) => value); + expect(error.code).toBe('CODEX_CLI_UNAVAILABLE'); + expect(error.message).not.toContain('\0'); + expect(error.message.length).toBeLessThan(600); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts new file mode 100644 index 00000000..a6e45a7a --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; + +const SESSION = '22222222-2222-4222-8222-222222222222'; +const base = { + id: 'id', name: 'reviewer', provider: 'codex', providerSessionId: null, sessionHealth: 'uninitialized', +}; + +describe('CodexPrintAgentService', () => { + it('validates before provider-aware create and never runs Codex', async () => { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('CodexPrintAgentService'); + const probe = { validate: vi.fn().mockResolvedValue({ executable: 'codex', version: '0.147.0' }) }; + const store = { create: vi.fn().mockResolvedValue(base) }; + const runner = { run: vi.fn() }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + await new Service({ store, probe, runner }).create({ name: 'reviewer', cwd: '/project' }); + expect(store.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project', provider: 'codex' }); + expect(runner.run).not.toHaveBeenCalled(); + }); + + it('binds during first send and explicitly resumes later sends', async () => { + const api = await import('../../index.js') as Record; + const store = { + resolve: vi.fn().mockResolvedValue(base), + acquireRun: vi.fn() + .mockResolvedValueOnce({ agent: base, token: 'one' }) + .mockResolvedValueOnce({ agent: { ...base, providerSessionId: SESSION, sessionHealth: 'healthy' }, token: 'two' }), + recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun: vi.fn(), + }; + const runner = { run: vi.fn().mockImplementation(async (request) => { + await request.onSpawn({ pid: 42, startedAt: 'start' }); + await request.onSession(SESSION); + return { sessionId: SESSION, result: 'answer', messages: ['answer'], exitCode: 0 }; + }) }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + const service = new Service({ store, probe: { validate: vi.fn() }, runner, executable: 'fake-codex' }); + + await service.send('reviewer', 'first'); + await service.send('reviewer', 'later'); + + expect(store.bindProviderSession).toHaveBeenNthCalledWith(1, 'id', 'one', SESSION); + expect(store.bindProviderSession).toHaveBeenNthCalledWith(2, 'id', 'two', SESSION); + expect(store.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ + status: 'succeeded', sessionHealth: 'healthy', + })); + }); + + it('records mismatch separately from unknown failures and rejects non-Codex targets', async () => { + const api = await import('../../index.js') as Record; + const ErrorType = api.CodexPrintError as new (message: string, code: string) => Error; + const completeRun = vi.fn(); + const store = { + resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), + recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun, + }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + const service = new Service({ store, probe: { validate: vi.fn() }, runner: { + run: vi.fn().mockRejectedValue(new ErrorType('mismatch', 'CODEX_SESSION_MISMATCH')), + } }); + await expect(service.send('reviewer', 'x')).rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); + expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' })); + + store.acquireRun.mockResolvedValueOnce({ agent: { ...base, provider: 'claude' }, token: 'two' }); + await expect(service.send('reviewer', 'x')).rejects.toMatchObject({ code: 'CODEX_UNSUPPORTED' }); + expect(completeRun).toHaveBeenLastCalledWith('id', 'two', expect.objectContaining({ sessionHealth: 'unknown' })); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts b/packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts new file mode 100644 index 00000000..e691bbec --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts @@ -0,0 +1,123 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import type { CodexPrintAgent } from '../../index.js'; + +const SESSION = '22222222-2222-4222-8222-222222222222'; + +function agent(providerSessionId: string | null = null): CodexPrintAgent { + return { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', mode: 'print', + cwd: '/project', providerSessionId, state: 'running', sessionHealth: 'uninitialized', + createdAt: '', updatedAt: '', lastActiveAt: null, lastResult: null, activeRun: null, + }; +} + +function fakeSpawn(lines: string[], exitCode = 0, chunks = false) { + const promptChunks: Buffer[] = []; + const child = new EventEmitter() as any; + child.pid = 4242; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { promptChunks.push(Buffer.from(chunk)); callback(); }, + final(callback) { + const output = lines.join('\n'); + if (chunks) { + const bytes = Buffer.from(output); + child.stdout.write(bytes.subarray(0, 7)); + child.stdout.write(bytes.subarray(7)); + } else child.stdout.write(output); + child.stdout.end(); + queueMicrotask(() => child.emit('close', exitCode, null)); + callback(); + }, + }); + const spawn = vi.fn(() => child); + return { child, spawn, promptChunks }; +} + +function events(session = SESSION): string[] { + return [ + JSON.stringify({ type: 'thread.started', thread_id: session }), + JSON.stringify({ type: 'turn.started' }), + JSON.stringify({ type: 'future.event', anything: true }), + JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'first' } }), + JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'final' } }), + JSON.stringify({ type: 'turn.completed' }), + '', + ]; +} + +async function runner(fixture: ReturnType, maxLineBytes?: number) { + const api = await import('../../index.js') as Record; + expect(api).toHaveProperty('CodexPrintRunner'); + const Runner = api.CodexPrintRunner as new (options: unknown) => any; + return new Runner({ spawn: fixture.spawn, maxLineBytes, processInspector: { + getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }), + } }); +} + +describe('CodexPrintRunner', () => { + it('binds an initial thread before returning ordered assistant output', async () => { + const fixture = fakeSpawn(events(), 0, true); + const instance = await runner(fixture); + const order: string[] = []; + const result = await instance.run({ + agent: agent(), prompt: 'secret prompt', executable: 'fake-codex', + onSpawn: async () => { expect(fixture.promptChunks).toHaveLength(0); order.push('spawn'); }, + onSession: async (id: string) => { expect(id).toBe(SESSION); order.push('session'); }, + }); + + expect(order).toEqual(['spawn', 'session']); + expect(fixture.spawn).toHaveBeenCalledWith('fake-codex', ['exec', '--json', '-'], expect.objectContaining({ + cwd: '/project', shell: false, stdio: ['pipe', 'pipe', 'pipe'], + })); + expect(JSON.stringify(fixture.spawn.mock.calls)).not.toContain('secret prompt'); + expect(Buffer.concat(fixture.promptChunks).toString()).toBe('secret prompt'); + expect(result).toEqual({ sessionId: SESSION, result: 'final', messages: ['first', 'final'], exitCode: 0 }); + }); + + it('resumes the exact stored UUID and rejects a mismatch', async () => { + const mismatch = '33333333-3333-4333-8333-333333333333'; + const fixture = fakeSpawn(events(mismatch)); + const instance = await runner(fixture); + await expect(instance.run({ agent: agent(SESSION), prompt: 'later', onSpawn: vi.fn(), onSession: vi.fn() })) + .rejects.toMatchObject({ code: 'CODEX_SESSION_MISMATCH' }); + expect(fixture.spawn.mock.calls[0]![1]).toEqual(['exec', 'resume', '--json', SESSION, '-']); + }); + + it.each([ + ['malformed JSON', ['{bad\n'], 'CODEX_PROTOCOL'], + ['non-object JSON', ['[]\n'], 'CODEX_PROTOCOL'], + ['truncated JSON', ['{}'], 'CODEX_PROTOCOL'], + ['missing thread', [JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'x' } }), JSON.stringify({ type: 'turn.completed' }), ''], 'CODEX_PROTOCOL'], + ['missing assistant', [JSON.stringify({ type: 'thread.started', thread_id: SESSION }), JSON.stringify({ type: 'turn.completed' }), ''], 'CODEX_RESULT_MISSING'], + ['missing completion', [JSON.stringify({ type: 'thread.started', thread_id: SESSION }), JSON.stringify({ type: 'item.completed', item: { type: 'agent_message', text: 'x' } }), ''], 'CODEX_PROTOCOL'], + ])('rejects %s', async (_name, lines, code) => { + const fixture = fakeSpawn(lines as string[]); + await expect((await runner(fixture)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code }); + }); + + it('rejects oversized output, non-zero exit, and missing process identity without leaking stderr', async () => { + const oversized = fakeSpawn([`${'x'.repeat(20)}\n`]); + await expect((await runner(oversized, 10)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + + const failed = fakeSpawn(events(), 1); + failed.child.stderr.end('secret-looking provider diagnostic'); + await expect((await runner(failed)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + + const missing = fakeSpawn([]); + missing.child.pid = undefined; + await expect((await runner(missing)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + }); +}); diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index 4186ab0a..6e941c46 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -43,6 +43,7 @@ export { PrintAgentNameConflictError, PrintAgentSessionMismatchError, ClaudePrintError, + CodexPrintError, } from './print/PrintAgent.js'; export type { PrintAgent, @@ -50,6 +51,7 @@ export type { ClaudePrintAgent, CodexPrintAgent, PrintProvider, + CodexPrintErrorCode, PrintAgentState, PrintSessionHealth, PrintRunStatus, @@ -78,3 +80,16 @@ export type { ClaudePrintAgentServiceOptions, ClaudePrintSendResult, } from './print/ClaudePrintAgentService.js'; +export { CodexCliProbe } from './print/CodexCliProbe.js'; +export type { CodexCliProbeOptions } from './print/CodexCliProbe.js'; +export { CodexPrintRunner } from './print/CodexPrintRunner.js'; +export type { + CodexPrintRunnerOptions, + CodexPrintRunRequest, + CodexPrintRunResult, +} from './print/CodexPrintRunner.js'; +export { CodexPrintAgentService } from './print/CodexPrintAgentService.js'; +export type { + CodexPrintAgentServiceOptions, + CodexPrintSendResult, +} from './print/CodexPrintAgentService.js'; diff --git a/packages/agent-manager/src/print/CodexCliProbe.ts b/packages/agent-manager/src/print/CodexCliProbe.ts new file mode 100644 index 00000000..0a3ab042 --- /dev/null +++ b/packages/agent-manager/src/print/CodexCliProbe.ts @@ -0,0 +1,62 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { CodexPrintError } from './PrintAgent.js'; + +type ExecResult = { stdout: string; stderr: string }; +type Exec = (file: string, args: string[]) => Promise; + +const execFileAsync = promisify(execFile); + +export interface CodexCliProbeOptions { + executable?: string; + exec?: Exec; +} + +export class CodexCliProbe { + private readonly executable: string; + private readonly exec: Exec; + + constructor(options: CodexCliProbeOptions = {}) { + this.executable = options.executable ?? 'codex'; + this.exec = options.exec ?? (async (file, args) => { + const result = await execFileAsync(file, args, { encoding: 'utf8', maxBuffer: 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + }); + } + + async validate(): Promise<{ executable: string; version: string }> { + try { + const version = await this.exec(this.executable, ['--version']); + const execHelp = await this.exec(this.executable, ['exec', '--help']); + const resumeHelp = await this.exec(this.executable, ['exec', 'resume', '--help']); + const missing = [ + !execHelp.stdout.includes('exec') && 'exec', + !execHelp.stdout.includes('--json') && '--json', + !execHelp.stdout.includes('-') && 'stdin -', + !resumeHelp.stdout.includes('resume') && 'resume', + !resumeHelp.stdout.includes('--json') && 'resume --json', + !resumeHelp.stdout.includes('-') && 'resume stdin -', + ].filter((value): value is string => typeof value === 'string'); + if (missing.length > 0) { + throw new CodexPrintError( + `Codex CLI does not support required print-mode capabilities: ${missing.join(', ')}.`, + 'CODEX_CLI_UNSUPPORTED', + ); + } + return { executable: this.executable, version: sanitize(version.stdout, 256) || 'unknown' }; + } catch (error) { + if (error instanceof CodexPrintError) throw error; + throw new CodexPrintError( + `Codex CLI validation failed: ${sanitize((error as Error).message, 512)}`, + 'CODEX_CLI_UNAVAILABLE', + ); + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return code <= 31 || code === 127 ? ' ' : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/print/CodexPrintAgentService.ts b/packages/agent-manager/src/print/CodexPrintAgentService.ts new file mode 100644 index 00000000..e5e131c7 --- /dev/null +++ b/packages/agent-manager/src/print/CodexPrintAgentService.ts @@ -0,0 +1,88 @@ +import type { CodexPrintAgent, PrintAgent, ProcessIdentity } from './PrintAgent.js'; +import { CodexPrintError, PrintAgentNotFoundError } from './PrintAgent.js'; +import { CodexCliProbe } from './CodexCliProbe.js'; +import { CodexPrintRunner, type CodexPrintRunResult } from './CodexPrintRunner.js'; +import { PrintAgentStore, type CreatePrintAgentInput, type PrintRunCompletion } from './PrintAgentStore.js'; + +interface StoreLike { + create(input: CreatePrintAgentInput): Promise; + resolve(reference: string): Promise; + acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>; + recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; + bindProviderSession(id: string, token: string, providerSessionId: string): Promise; + completeRun(id: string, token: string, result: PrintRunCompletion): Promise; +} + +interface ProbeLike { validate(): Promise<{ executable: string; version: string }> } +interface RunnerLike { run(request: Parameters[0]): Promise } + +export interface CodexPrintAgentServiceOptions { + store?: StoreLike; + probe?: ProbeLike; + runner?: RunnerLike; + executable?: string; +} + +export interface CodexPrintSendResult extends CodexPrintRunResult { + agentId: string; + agentName: string; +} + +export class CodexPrintAgentService { + readonly store: StoreLike; + private readonly probe: ProbeLike; + private readonly runner: RunnerLike; + private readonly executable?: string; + + constructor(options: CodexPrintAgentServiceOptions = {}) { + this.store = options.store ?? new PrintAgentStore(); + this.probe = options.probe ?? new CodexCliProbe(); + this.runner = options.runner ?? new CodexPrintRunner(); + this.executable = options.executable; + } + + async create(input: Omit): Promise { + await this.probe.validate(); + return this.store.create({ ...input, provider: 'codex' }); + } + + async send(reference: string, prompt: string): Promise { + const resolved = await this.store.resolve(reference); + if (!resolved) throw new PrintAgentNotFoundError(reference); + if (Array.isArray(resolved)) throw new CodexPrintError('Multiple print agents match.', 'CODEX_UNSUPPORTED'); + const acquired = await this.store.acquireRun(resolved.id); + try { + if (acquired.agent.provider !== 'codex') { + throw new CodexPrintError('Print agent provider is not Codex.', 'CODEX_UNSUPPORTED'); + } + const result = await this.runner.run({ + agent: acquired.agent as CodexPrintAgent, + prompt, + executable: this.executable, + onSpawn: (identity) => this.store.recordProviderProcess(resolved.id, acquired.token, identity), + onSession: async (sessionId) => { await this.store.bindProviderSession(resolved.id, acquired.token, sessionId); }, + }); + await this.store.completeRun(resolved.id, acquired.token, { + status: 'succeeded', exitCode: result.exitCode, + summary: sanitize(result.result, 4096), sessionHealth: 'healthy', + }); + return { ...result, agentId: resolved.id, agentName: resolved.name }; + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + const sessionHealth = error instanceof CodexPrintError && error.code === 'CODEX_SESSION_MISMATCH' + ? 'mismatch' as const : 'unknown' as const; + await this.store.completeRun(resolved.id, acquired.token, { + status: 'failed', exitCode: null, summary: sanitize(failure.message, 4096), sessionHealth, + }); + throw error; + } + } +} + +function sanitize(value: string, max: number): string { + return Array.from(value, (character) => { + const code = character.charCodeAt(0); + return (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127) + ? ' ' : character; + }).join('').trim().slice(0, max); +} diff --git a/packages/agent-manager/src/print/CodexPrintRunner.ts b/packages/agent-manager/src/print/CodexPrintRunner.ts new file mode 100644 index 00000000..d7773c0f --- /dev/null +++ b/packages/agent-manager/src/print/CodexPrintRunner.ts @@ -0,0 +1,148 @@ +import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from 'child_process'; +import type { CodexPrintAgent, ProcessIdentity } from './PrintAgent.js'; +import { CodexPrintError } from './PrintAgent.js'; +import { LocalProcessInspector, type ProcessInspector } from './PrintAgentStore.js'; + +type Spawn = ( + command: string, + args: readonly string[], + options: SpawnOptionsWithoutStdio & { stdio: ['pipe', 'pipe', 'pipe'] }, +) => ChildProcessWithoutNullStreams; + +export interface CodexPrintRunRequest { + agent: CodexPrintAgent; + prompt: string; + executable?: string; + onSpawn(identity: ProcessIdentity): Promise; + onSession(providerSessionId: string): Promise; +} + +export interface CodexPrintRunResult { + sessionId: string; + result: string; + messages: string[]; + exitCode: number; +} + +export interface CodexPrintRunnerOptions { + spawn?: Spawn; + processInspector?: ProcessInspector; + maxLineBytes?: number; +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export class CodexPrintRunner { + private readonly spawn: Spawn; + private readonly processInspector: ProcessInspector; + private readonly maxLineBytes: number; + + constructor(options: CodexPrintRunnerOptions = {}) { + this.spawn = options.spawn ?? (nodeSpawn as Spawn); + this.processInspector = options.processInspector ?? new LocalProcessInspector(); + this.maxLineBytes = options.maxLineBytes ?? 1024 * 1024; + } + + async run(request: CodexPrintRunRequest): Promise { + const args = request.agent.providerSessionId === null + ? ['exec', '--json', '-'] + : ['exec', 'resume', '--json', request.agent.providerSessionId, '-']; + const child = this.spawn(request.executable ?? 'codex', args, { + cwd: request.agent.cwd, shell: false, stdio: ['pipe', 'pipe', 'pipe'], + }); + if (!child.pid) { + child.kill(); + throw new CodexPrintError('Codex process did not provide a PID.', 'CODEX_PROCESS'); + } + const identity = this.processInspector.getIdentity(child.pid); + if (!identity) { + child.kill(); + throw new CodexPrintError('Cannot verify Codex process identity.', 'CODEX_PROCESS'); + } + + let buffer = Buffer.alloc(0); + let sessionId: string | null = null; + let turnCompleted = false; + const messages: string[] = []; + let protocolError: CodexPrintError | null = null; + let processing = Promise.resolve(); + + const processLine = async (line: Buffer): Promise => { + if (protocolError || line.length === 0) return; + if (line.length > this.maxLineBytes) { + throw new CodexPrintError('Codex stream line exceeded the safety limit.', 'CODEX_PROTOCOL'); + } + let value: unknown; + try { value = JSON.parse(line.toString('utf8')); } catch { + throw new CodexPrintError('Codex emitted malformed stream JSON.', 'CODEX_PROTOCOL'); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new CodexPrintError('Codex emitted a non-object stream message.', 'CODEX_PROTOCOL'); + } + const event = value as Record; + if (event.type === 'thread.started') { + if (sessionId !== null || !UUID_PATTERN.test(String(event.thread_id ?? ''))) { + throw new CodexPrintError('Codex emitted an invalid thread identity.', 'CODEX_PROTOCOL'); + } + sessionId = event.thread_id as string; + if (request.agent.providerSessionId !== null && request.agent.providerSessionId !== sessionId) { + throw new CodexPrintError('Codex returned a different session identity.', 'CODEX_SESSION_MISMATCH'); + } + await request.onSession(sessionId); + } else if (event.type === 'item.completed') { + const item = event.item; + if (item && typeof item === 'object' && !Array.isArray(item)) { + const record = item as Record; + if (record.type === 'agent_message' && typeof record.text === 'string' && record.text.trim()) { + messages.push(record.text); + } + } + } else if (event.type === 'turn.completed') { + turnCompleted = true; + } + }; + + child.stdout.on('data', (chunk: Buffer | string) => { + if (protocolError) return; + buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]); + if (buffer.length > this.maxLineBytes && buffer.indexOf(0x0a) < 0) { + protocolError = new CodexPrintError('Codex stream line exceeded the safety limit.', 'CODEX_PROTOCOL'); + return; + } + let newline: number; + while ((newline = buffer.indexOf(0x0a)) >= 0) { + const line = buffer.subarray(0, newline); + buffer = buffer.subarray(newline + 1); + processing = processing.then(() => processLine(line)).catch((error) => { + protocolError = error instanceof CodexPrintError + ? error + : new CodexPrintError('Codex stream processing failed.', 'CODEX_PROTOCOL'); + }); + } + }); + child.stderr.resume(); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + try { + await request.onSpawn(identity); + } catch (error) { + child.kill(); + throw error; + } + child.stdin.end(request.prompt); + const { code, signal } = await closed; + await processing; + + if (protocolError) throw protocolError; + if (buffer.length > 0) throw new CodexPrintError('Codex stream ended with incomplete JSON.', 'CODEX_PROTOCOL'); + if (code !== 0) { + throw new CodexPrintError(`Codex print run failed${signal ? ` (${signal})` : '.'}`, 'CODEX_PROCESS'); + } + if (sessionId === null) throw new CodexPrintError('Codex stream ended without a thread identity.', 'CODEX_PROTOCOL'); + if (!turnCompleted) throw new CodexPrintError('Codex stream ended before turn completion.', 'CODEX_PROTOCOL'); + if (messages.length === 0) throw new CodexPrintError('Codex stream ended without an assistant result.', 'CODEX_RESULT_MISSING'); + return { sessionId, result: messages.at(-1)!, messages, exitCode: code }; + } +} diff --git a/packages/agent-manager/src/print/PrintAgent.ts b/packages/agent-manager/src/print/PrintAgent.ts index 46072eba..63a70654 100644 --- a/packages/agent-manager/src/print/PrintAgent.ts +++ b/packages/agent-manager/src/print/PrintAgent.ts @@ -103,3 +103,19 @@ export class ClaudePrintError extends PrintAgentError { this.name = 'ClaudePrintError'; } } + +export type CodexPrintErrorCode = + | 'CODEX_PROTOCOL' + | 'CODEX_PROCESS' + | 'CODEX_SESSION_MISMATCH' + | 'CODEX_UNSUPPORTED' + | 'CODEX_RESULT_MISSING' + | 'CODEX_CLI_UNSUPPORTED' + | 'CODEX_CLI_UNAVAILABLE'; + +export class CodexPrintError extends PrintAgentError { + constructor(message: string, code: CodexPrintErrorCode) { + super(message, code); + this.name = 'CodexPrintError'; + } +} From 89d43a0a06a5eb2f69a11a827b6f49f02427f24a Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 16:10:47 +0000 Subject: [PATCH 4/5] feat(cli): wire Codex print agents --- .../2026-08-11-feature-codex-print-mode.md | 13 +++- .../2026-08-11-feature-codex-print-mode.md | 10 +-- .../2026-08-11-feature-codex-print-mode.md | 18 ++--- .../src/__tests__/fixtures/fake-codex.cjs | 45 ++++++++++++ .../print/CodexPrintAgent.integration.test.ts | 68 +++++++++++++++++++ .../print/CodexPrintAgentService.test.ts | 26 ++++++- .../src/print/CodexPrintAgentService.ts | 6 +- .../cli/src/__tests__/commands/agent.test.ts | 44 ++++++++++++ packages/cli/src/commands/agent.ts | 33 +++++---- 9 files changed, 232 insertions(+), 31 deletions(-) create mode 100755 packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs create mode 100644 packages/agent-manager/src/__tests__/print/CodexPrintAgent.integration.test.ts diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index 9aca2553..3aed7ca5 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -8,8 +8,8 @@ description: Implementation record, decisions, validation, and deviations ## Status -- Current task: Task 3.1, fake-Codex integration fixture. -- Completed: Tasks 1.1–2.3 and lifecycle document initialization. +- Current task: Task 4.1, coverage and final reconciliation. +- Completed: Tasks 1.1–3.3 and lifecycle document initialization. - Task tracing: unavailable (`unknown command 'task'`). ## Development Setup @@ -44,6 +44,15 @@ TDD red: focused store tests reported four expected failures (hard-coded Claude TDD red: 14 focused tests failed on absent Codex exports. Green/refactor: those 14 tests passed; the full agent-manager suite passed 27 files/515 tests; typecheck and lint exited 0. +### Tasks 3.1–3.3 + +- Added an executable fake Codex CLI with deterministic provider-minted UUID, exact resume validation surface, stdin/cwd/argv capture, chunked results, and configurable protocol/process failures. +- Added integration proof that creation remains unbound/non-billable, first send binds, second send explicitly resumes, and post-binding failure retains the UUID. +- Made CLI print startup accept Claude or Codex, select the persisted provider for sends, render `Codex (print)`/`not started`, and derive JSON provider from the record. +- Preserved the common store resolver, exact-ID precedence, cross-mode ambiguity, synchronous timeout behavior, and interactive command paths. + +TDD red: Codex fixture execution and two CLI routing tests failed before executable/routing support. Green/refactor: agent-manager passed 28 files/518 tests; CLI passed 79 files/932 tests; both typechecks and lints exited 0 (five existing CLI warnings). + ## Integration Points - The existing print store remains the single durable mapping and exclusion authority. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 3977936a..4101acc3 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -9,8 +9,8 @@ description: Ordered TDD work for durable Codex print agents ## Milestones - [x] Milestone 1: Provider-aware durable model, migration, and session binding. -- [ ] Milestone 2: Codex probe, runner, service, and deterministic fixture. -- [ ] Milestone 3: CLI integration and compatibility coverage. +- [x] Milestone 2: Codex probe, runner, service, and deterministic fixture. +- [x] Milestone 3: CLI integration and compatibility coverage. - [ ] Milestone 4: Documentation, full validation, review, and PR publication. ## Task Breakdown @@ -37,11 +37,11 @@ description: Ordered TDD work for durable Codex print agents ### Phase 3: CLI and integration -- [ ] Task 3.1: Add provider-aware exports and fake-Codex integration journey. +- [x] Task 3.1: Add provider-aware exports and fake-Codex integration journey. - Outcome: create performs probes only; first send binds; second send resumes same UUID; concurrency/recovery/cwd work offline. -- [ ] Task 3.2: Drive CLI start/list/detail/send behavior with failing command tests. +- [x] Task 3.2: Drive CLI start/list/detail/send behavior with failing command tests. - Outcome: `--type codex --mode print`, `Codex (print)`, `not started`, record-derived JSON provider, provider-selected send. -- [ ] Task 3.3: Run Claude-print and interactive-Codex regression tests and inspect excluded command paths. +- [x] Task 3.3: Run Claude-print and interactive-Codex regression tests and inspect excluded command paths. ### Phase 4: Validation and publication diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index 898fe2c0..f530cf2c 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -42,22 +42,22 @@ description: Offline TDD, protocol, integration, and compatibility validation - [x] First send binds during the owned run and completes healthy; second send resumes exact UUID. - [x] Session mismatch becomes degraded/mismatch; unsupported provider becomes degraded/unknown; no retry occurs. -- [ ] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. -- [ ] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. -- [ ] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. +- [x] Start accepts Codex print and keeps omitted/explicit interactive behavior unchanged. +- [x] List/detail render `Codex (print)` and `not started`; JSON provider comes from the record. +- [x] Exact-ID precedence, cross-mode ambiguity, synchronous send, and excluded command behavior remain intact. ## Integration Tests -- [ ] Fake provider create invokes only version/help and creates no session. -- [ ] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. -- [ ] Second send receives the identical UUID in explicit resume argv. +- [x] Fake provider create invokes only version/help and creates no session. +- [x] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. +- [x] Second send receives the identical UUID in explicit resume argv. - [ ] Concurrent send, stale lock recovery, canonical cwd, first-run pre/post-bind failure, and session mismatch behave safely. -- [ ] Claude print and interactive Codex regression suites remain green. +- [x] Claude print and interactive Codex regression suites remain green. ## End-to-End Tests -- [ ] CLI fake-Codex start → list/detail (`not started`) → first send → second resumed send. -- [ ] JSON/human output has correct provider/mode and no fake PID, prompt, raw stderr secret, or invented session. +- [x] Service/CLI-boundary fake-Codex create → first send → second resumed send. +- [x] JSON/human output has correct provider/mode and no fake PID, prompt, raw stderr secret, or invented session. - [ ] Unsupported provider/mode and ambiguous targets exit with actionable errors. ## Test Data diff --git a/packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs b/packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs new file mode 100755 index 00000000..323aa495 --- /dev/null +++ b/packages/agent-manager/src/__tests__/fixtures/fake-codex.cjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +const fs = require('node:fs'); + +const SESSION = '22222222-2222-4222-8222-222222222222'; +const MISMATCH = '33333333-3333-4333-8333-333333333333'; +const args = process.argv.slice(2); + +if (args[0] === '--version') { + process.stdout.write('codex-cli 0.147.0\n'); + process.exit(0); +} +if (args[0] === 'exec' && args.at(-1) === '--help') { + process.stdout.write(args[1] === 'resume' + ? 'Usage: codex exec resume [SESSION_ID] [PROMPT]\n--json\n- stdin\n' + : 'Usage: codex exec [PROMPT]\nresume\n--json\n- stdin\n'); + process.exit(0); +} + +let prompt = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { prompt += chunk; }); +process.stdin.on('end', () => { + const mode = process.env.AI_DEVKIT_FAKE_CODEX_MODE || 'success'; + const isResume = args[1] === 'resume'; + const requested = isResume ? args[3] : null; + const sessionId = mode === 'mismatch' ? MISMATCH : (requested || SESSION); + const capture = process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE; + if (capture) fs.appendFileSync(capture, `${JSON.stringify({ args, prompt, cwd: process.cwd() })}\n`); + if (mode === 'fail-before-bind') process.exit(1); + if (mode !== 'missing-thread') process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: sessionId })}\n`); + if (mode === 'fail-after-bind') process.exit(1); + if (mode === 'malformed') return process.stdout.write('{bad\n'); + if (mode === 'oversized') return process.stdout.write(`${'x'.repeat(1024 * 1024 + 1)}\n`); + if (mode === 'truncated') return process.stdout.write('{'); + process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`); + if (mode !== 'missing-result') { + process.stdout.write(`${JSON.stringify({ type: 'item.completed', item: { id: 'item_0', type: 'agent_message', text: 'first' } })}\n`); + process.stdout.write(`${JSON.stringify({ type: 'item.completed', item: { id: 'item_1', type: 'agent_message', text: `answer:${prompt}` } })}\n`); + } + if (mode !== 'missing-completion') process.stdout.write(`${JSON.stringify({ type: 'turn.completed', usage: {} })}\n`); + if (mode === 'stderr-failure') { + process.stderr.write('secret-looking diagnostic'); + process.exitCode = 1; + } +}); diff --git a/packages/agent-manager/src/__tests__/print/CodexPrintAgent.integration.test.ts b/packages/agent-manager/src/__tests__/print/CodexPrintAgent.integration.test.ts new file mode 100644 index 00000000..370a02be --- /dev/null +++ b/packages/agent-manager/src/__tests__/print/CodexPrintAgent.integration.test.ts @@ -0,0 +1,68 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CodexCliProbe, CodexPrintAgentService, CodexPrintRunner, PrintAgentStore } from '../../index.js'; + +const roots: string[] = []; +const originalCapture = process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE; + +afterEach(() => { + if (originalCapture === undefined) delete process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE; + else process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE = originalCapture; + delete process.env.AI_DEVKIT_FAKE_CODEX_MODE; + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Codex print-agent fake-provider journey', () => { + it('creates unbound, then binds and explicitly resumes the provider-minted session', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-print-integration-')); + roots.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + const capture = path.join(root, 'capture.jsonl'); + process.env.AI_DEVKIT_FAKE_CODEX_CAPTURE = capture; + const executable = fileURLToPath(new URL('../fixtures/fake-codex.cjs', import.meta.url)); + const store = new PrintAgentStore({ filePath: path.join(root, 'state', 'print-agents.json') }); + const service = new CodexPrintAgentService({ + store, probe: new CodexCliProbe({ executable }), runner: new CodexPrintRunner(), executable, + }); + + const created = await service.create({ name: 'reviewer', cwd }); + expect(created).toMatchObject({ provider: 'codex', providerSessionId: null, sessionHealth: 'uninitialized' }); + expect(fs.existsSync(capture)).toBe(false); + + const first = await service.send(created.id, 'first secret'); + expect(first).toMatchObject({ result: 'answer:first secret' }); + const bound = await store.getById(created.id); + expect(bound?.providerSessionId).toBe(first.sessionId); + await expect(service.send(created.id, 'follow up')).resolves.toMatchObject({ result: 'answer:follow up' }); + + const invocations = fs.readFileSync(capture, 'utf8').trim().split('\n').map((line) => JSON.parse(line)); + expect(invocations[0]).toMatchObject({ args: ['exec', '--json', '-'], prompt: 'first secret', cwd: fs.realpathSync(cwd) }); + expect(invocations[1]).toMatchObject({ + args: ['exec', 'resume', '--json', first.sessionId, '-'], prompt: 'follow up', cwd: fs.realpathSync(cwd), + }); + expect(JSON.stringify(invocations.map((entry) => entry.args))).not.toContain('first secret'); + }); + + it('retains a first-run binding when the provider fails after thread start', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-print-bind-failure-')); + roots.push(root); + const cwd = path.join(root, 'project'); + fs.mkdirSync(cwd); + const executable = fileURLToPath(new URL('../fixtures/fake-codex.cjs', import.meta.url)); + const store = new PrintAgentStore({ filePath: path.join(root, 'state.json') }); + const service = new CodexPrintAgentService({ + store, probe: new CodexCliProbe({ executable }), runner: new CodexPrintRunner(), executable, + }); + const created = await service.create({ name: 'reviewer', cwd }); + process.env.AI_DEVKIT_FAKE_CODEX_MODE = 'fail-after-bind'; + + await expect(service.send(created.id, 'secret')).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + expect((await store.getById(created.id))).toMatchObject({ + providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'degraded', sessionHealth: 'unknown', + }); + }); +}); diff --git a/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts index a6e45a7a..e1cf5da3 100644 --- a/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts @@ -10,7 +10,7 @@ describe('CodexPrintAgentService', () => { const api = await import('../../index.js') as Record; expect(api).toHaveProperty('CodexPrintAgentService'); const probe = { validate: vi.fn().mockResolvedValue({ executable: 'codex', version: '0.147.0' }) }; - const store = { create: vi.fn().mockResolvedValue(base) }; + const store = { create: vi.fn().mockResolvedValue(base), list: vi.fn() }; const runner = { run: vi.fn() }; const Service = api.CodexPrintAgentService as new (options: unknown) => any; await new Service({ store, probe, runner }).create({ name: 'reviewer', cwd: '/project' }); @@ -21,6 +21,7 @@ describe('CodexPrintAgentService', () => { it('binds during first send and explicitly resumes later sends', async () => { const api = await import('../../index.js') as Record; const store = { + list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn() .mockResolvedValueOnce({ agent: base, token: 'one' }) @@ -50,6 +51,7 @@ describe('CodexPrintAgentService', () => { const ErrorType = api.CodexPrintError as new (message: string, code: string) => Error; const completeRun = vi.fn(); const store = { + list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun, }; @@ -64,4 +66,26 @@ describe('CodexPrintAgentService', () => { await expect(service.send('reviewer', 'x')).rejects.toMatchObject({ code: 'CODEX_UNSUPPORTED' }); expect(completeRun).toHaveBeenLastCalledWith('id', 'two', expect.objectContaining({ sessionHealth: 'unknown' })); }); + + it('records a store binding conflict as a session mismatch', async () => { + const api = await import('../../index.js') as Record; + const BindingError = api.PrintAgentSessionMismatchError as new () => Error; + const completeRun = vi.fn(); + const store = { + list: vi.fn(), resolve: vi.fn().mockResolvedValue(base), + acquireRun: vi.fn().mockResolvedValue({ agent: base, token: 'one' }), + recordProviderProcess: vi.fn(), + bindProviderSession: vi.fn().mockRejectedValue(new BindingError()), + completeRun, + }; + const runner = { run: vi.fn().mockImplementation(async (request) => { + await request.onSession(SESSION); + return { sessionId: SESSION, result: 'x', messages: ['x'], exitCode: 0 }; + }) }; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + + await expect(new Service({ store, probe: { validate: vi.fn() }, runner }).send('reviewer', 'x')) + .rejects.toMatchObject({ code: 'PRINT_AGENT_SESSION_MISMATCH' }); + expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' })); + }); }); diff --git a/packages/agent-manager/src/print/CodexPrintAgentService.ts b/packages/agent-manager/src/print/CodexPrintAgentService.ts index e5e131c7..24742b61 100644 --- a/packages/agent-manager/src/print/CodexPrintAgentService.ts +++ b/packages/agent-manager/src/print/CodexPrintAgentService.ts @@ -1,11 +1,12 @@ import type { CodexPrintAgent, PrintAgent, ProcessIdentity } from './PrintAgent.js'; -import { CodexPrintError, PrintAgentNotFoundError } from './PrintAgent.js'; +import { CodexPrintError, PrintAgentNotFoundError, PrintAgentSessionMismatchError } from './PrintAgent.js'; import { CodexCliProbe } from './CodexCliProbe.js'; import { CodexPrintRunner, type CodexPrintRunResult } from './CodexPrintRunner.js'; import { PrintAgentStore, type CreatePrintAgentInput, type PrintRunCompletion } from './PrintAgentStore.js'; interface StoreLike { create(input: CreatePrintAgentInput): Promise; + list(): Promise; resolve(reference: string): Promise; acquireRun(id: string): Promise<{ agent: PrintAgent; token: string }>; recordProviderProcess(id: string, token: string, identity: ProcessIdentity): Promise; @@ -69,7 +70,8 @@ export class CodexPrintAgentService { return { ...result, agentId: resolved.id, agentName: resolved.name }; } catch (error) { const failure = error instanceof Error ? error : new Error(String(error)); - const sessionHealth = error instanceof CodexPrintError && error.code === 'CODEX_SESSION_MISMATCH' + const sessionHealth = (error instanceof CodexPrintError && error.code === 'CODEX_SESSION_MISMATCH') + || error instanceof PrintAgentSessionMismatchError ? 'mismatch' as const : 'unknown' as const; await this.store.completeRun(resolved.id, acquired.token, { status: 'failed', exitCode: null, summary: sanitize(failure.message, 4096), sessionHealth, diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index a45e8cf1..f8256e58 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -5,6 +5,8 @@ import { AgentManager, AgentStatus, TerminalFocusManager } from '@ai-devkit/agen import { registerAgentCommand } from '../../commands/agent.js'; import { ui } from '../../util/terminal-ui.js'; +const SESSION = '22222222-2222-4222-8222-222222222222'; + const mockManager: any = { registerAdapter: vi.fn(), listAgents: vi.fn(), @@ -24,6 +26,12 @@ const mockPrintService: any = { send: vi.fn(), }; +const mockCodexPrintService: any = { + store: mockPrintStore, + create: vi.fn(), + send: vi.fn(), +}; + const mockAgentAdapter: any = { getConversation: vi.fn(), }; @@ -99,6 +107,7 @@ vi.mock('@ai-devkit/agent-manager', () => ({ PiAdapter: vi.fn(), PrintAgentStore: vi.fn(function () { return mockPrintStore; }), ClaudePrintAgentService: vi.fn(function () { return mockPrintService; }), + CodexPrintAgentService: vi.fn(function () { return mockCodexPrintService; }), TerminalFocusManager: vi.fn(function () { return mockFocusManager; }), TtyWriter: { send: (location: any, message: string) => mockTtyWriterSend(location, message) }, AgentStatus: { @@ -721,6 +730,41 @@ Waiting on user input`, expect(ui.success).toHaveBeenCalledWith(expect.stringContaining('11111111-1111-4111-8111-111111111111')); }); + it('starts a durable Codex print agent unbound without tmux', async () => { + mockCodexPrintService.create.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', + mode: 'print', cwd: process.cwd(), state: 'ready', providerSessionId: null, + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync([ + 'node', 'test', 'agent', 'start', '--type', 'codex', '--mode', 'print', + '--name', 'reviewer', '--cwd', process.cwd(), + ]); + + expect(mockCodexPrintService.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: process.cwd() }); + expect(ui.text).toHaveBeenCalledWith('State: ready (Codex session not started)'); + }); + + it('selects the persisted Codex provider for send JSON', async () => { + const printAgent = { + id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'codex', + mode: 'print', cwd: '/project', state: 'ready', providerSessionId: SESSION, + }; + mockPrintStore.resolve.mockResolvedValue(printAgent); + mockCodexPrintService.send.mockResolvedValue({ + agentId: printAgent.id, agentName: printAgent.name, result: 'done', exitCode: 0, sessionId: SESSION, + }); + + const program = new Command(); + registerAgentCommand(program); + await program.parseAsync(['node', 'test', 'agent', 'send', 'review', '--id', printAgent.id, '--json']); + + expect(mockCodexPrintService.send).toHaveBeenCalledWith(printAgent.id, 'review'); + expect(JSON.parse(logSpy.mock.calls[0][0] as string).target.provider).toBe('codex'); + }); + it('sends synchronously to an exact print-agent id without terminal injection', async () => { const printAgent = { id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index c7cd9b40..f4a32bba 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -15,6 +15,7 @@ import { OpenCodeAdapter, PiAdapter, ClaudePrintAgentService, + CodexPrintAgentService, PrintAgentStore, AgentStatus, TerminalFocusManager, @@ -28,6 +29,7 @@ import { type AgentType, type ConversationMessage, type SessionSummary, + type PrintProvider, } from '@ai-devkit/agent-manager'; import { ui } from '../util/terminal-ui.js'; import { withErrorHandler } from '../util/errors.js'; @@ -191,8 +193,15 @@ function createAgentManager(): AgentManager { return manager; } -function createPrintAgentService(): ClaudePrintAgentService { - return new ClaudePrintAgentService({ store: new PrintAgentStore() }); +function createPrintAgentService(provider: PrintProvider = 'claude'): ClaudePrintAgentService | CodexPrintAgentService { + const store = new PrintAgentStore(); + return provider === 'codex' + ? new CodexPrintAgentService({ store }) + : new ClaudePrintAgentService({ store }); +} + +function formatPrintProvider(provider: PrintProvider): string { + return provider === 'codex' ? 'Codex' : 'Claude Code'; } const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/; @@ -283,8 +292,8 @@ export function registerAgentCommand(program: Command): void { if (!['interactive', 'print'].includes(mode)) { throw new Error(`Unsupported agent mode "${mode}". Supported: interactive, print.`); } - if (mode === 'print' && agentType !== 'claude') { - throw new Error('Print mode currently supports only --type claude.'); + if (mode === 'print' && !['claude', 'codex'].includes(agentType)) { + throw new Error('Print mode currently supports only --type claude or --type codex.'); } if (!NAME_REGEX.test(agentName)) { ui.error( @@ -300,10 +309,10 @@ export function registerAgentCommand(program: Command): void { try { if (mode === 'print') { - const entry = await createPrintAgentService().create({ name: agentName, cwd }); + const entry = await createPrintAgentService(agentType as PrintProvider).create({ name: agentName, cwd }); ui.success(`Print agent "${entry.name}" started (${entry.provider}, ID ${entry.id})`); ui.text(`Working directory: ${formatCwd(entry.cwd)}`); - ui.text('State: ready (Claude session not started)'); + ui.text(`State: ready (${formatPrintProvider(entry.provider)} session not started)`); return; } const entry = await startAgent( @@ -365,7 +374,7 @@ export function registerAgentCommand(program: Command): void { ]), ...printAgents.map(agent => [ agent.name, path.basename(agent.cwd), - 'Claude Code (print)', + `${formatPrintProvider(agent.provider)} (print)`, agent.state, agent.lastResult?.summary ?? agent.sessionHealth, agent.lastActiveAt ? formatRelativeTime(new Date(agent.lastActiveAt)) : 'never', @@ -614,12 +623,12 @@ export function registerAgentCommand(program: Command): void { return; } - const printService = createPrintAgentService(); - const printResolved = await printService.store.resolve(options.id); + const printResolved = await createPrintAgentService().store.resolve(options.id); if (Array.isArray(printResolved)) { throw new Error(`Multiple print agents match "${options.id}".`); } if (printResolved) { + const printService = createPrintAgentService(printResolved.provider); if (options.timeout !== undefined) { throw new Error('--timeout is not supported for synchronous print agents.'); } @@ -633,7 +642,7 @@ export function registerAgentCommand(program: Command): void { const result = await printService.send(options.id, prompt); if (options.json) { console.log(JSON.stringify({ - target: { id: result.agentId, name: result.agentName, provider: 'claude', mode: 'print' }, + target: { id: result.agentId, name: result.agentName, provider: printResolved.provider, mode: 'print' }, response: result.result, exitCode: result.exitCode, sessionId: result.sessionId, @@ -720,9 +729,9 @@ export function registerAgentCommand(program: Command): void { ui.text('Print Agent Detail', { breakline: true }); ui.text(chalk.dim('─'.repeat(40))); ui.text(` ${chalk.bold('Agent ID:')} ${printResolved.id}`); - ui.text(` ${chalk.bold('Session ID:')} ${printResolved.providerSessionId}`); + ui.text(` ${chalk.bold('Session ID:')} ${printResolved.providerSessionId ?? 'not started'}`); ui.text(` ${chalk.bold('Name:')} ${printResolved.name}`); - ui.text(` ${chalk.bold('Provider:')} Claude Code`); + ui.text(` ${chalk.bold('Provider:')} ${formatPrintProvider(printResolved.provider)}`); ui.text(` ${chalk.bold('Mode:')} print`); ui.text(` ${chalk.bold('CWD:')} ${formatCwd(printResolved.cwd)}`); ui.text(` ${chalk.bold('State:')} ${printResolved.state}`); From c0887ce0404d79c51b30737d297bbcf132a80b7c Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Tue, 11 Aug 2026 16:18:11 +0000 Subject: [PATCH 5/5] test(agent): harden Codex print integrity --- .../2026-08-11-feature-codex-print-mode.md | 20 +++++- .../2026-08-11-feature-codex-print-mode.md | 6 +- .../2026-08-11-feature-codex-print-mode.md | 16 +++-- .../src/__tests__/print/CodexCliProbe.test.ts | 25 +++++++ .../print/CodexPrintAgentService.test.ts | 19 ++++++ .../__tests__/print/CodexPrintRunner.test.ts | 66 +++++++++++++++++++ .../__tests__/print/PrintAgentStore.test.ts | 5 ++ .../agent-manager/src/print/CodexCliProbe.ts | 8 ++- .../src/print/CodexPrintRunner.ts | 4 +- .../src/print/PrintAgentStore.ts | 19 ++++-- 10 files changed, 170 insertions(+), 18 deletions(-) diff --git a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md index 3aed7ca5..d89dcdd5 100644 --- a/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/implementation/2026-08-11-feature-codex-print-mode.md @@ -8,8 +8,8 @@ description: Implementation record, decisions, validation, and deviations ## Status -- Current task: Task 4.1, coverage and final reconciliation. -- Completed: Tasks 1.1–3.3 and lifecycle document initialization. +- Current task: Task 4.4, publish the reviewed branch and open the PR. +- Completed: Tasks 1.1–4.3. - Task tracing: unavailable (`unknown command 'task'`). ## Development Setup @@ -53,6 +53,14 @@ TDD red: 14 focused tests failed on absent Codex exports. Green/refactor: those TDD red: Codex fixture execution and two CLI routing tests failed before executable/routing support. Green/refactor: agent-manager passed 28 files/518 tests; CLI passed 79 files/932 tests; both typechecks and lints exited 0 (five existing CLI warnings). +### Tasks 4.1–4.3 + +- Hardened runner callback/process error classification and probe recognition of the standalone stdin dash token. +- Replaced new version-1 writes with version 2 and added a strict Claude-only version-1 compatibility reader; malformed or version-1 Codex records are rejected. +- Reviewed all changed files against requirements/design and traced CLI/service/store call sites. No blocking security, compatibility, or integration findings remain. + +TDD red/green evidence includes the standalone-dash false-positive probe test, child-process error classification test, and explicit version-1-to-version-2 migration test. + ## Integration Points - The existing print store remains the single durable mapping and exclusion authority. @@ -79,4 +87,10 @@ TDD red: Codex fixture execution and two CLI routing tests failed before executa ## Validation Evidence -Pending implementation. Fresh command evidence will be recorded during TDD and final gates. +- Base and feature lifecycle lint: passed. +- Root lint: passed with six existing warnings in unrelated files and no errors. +- Root build: six projects passed. +- Root tests: six projects passed; agent-manager 28 files/527 tests and CLI 79 files/932 tests in their fresh coverage runs. +- Agent-manager coverage: 90.23% statements and 93.4% lines overall; every new Codex module has 100% lines/functions, and `CodexCliProbe` has 100% statements/branches/functions/lines. Runner/service residual branch-only gaps are injected/default process plumbing, not pure parsing logic. +- CLI coverage: 79 files/932 tests; 71.6% statements, 61.67% branches, 69.77% functions, 72.74% lines overall. +- Known test-run warning: pre-existing max-listener warnings in agent-manager tests; no new persistent process listeners were added. diff --git a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md index 4101acc3..7de2daae 100644 --- a/docs/ai/planning/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/planning/2026-08-11-feature-codex-print-mode.md @@ -45,9 +45,9 @@ description: Ordered TDD work for durable Codex print agents ### Phase 4: Validation and publication -- [ ] Task 4.1: Reconcile implementation/testing docs and reach 100% coverage on new pure logic. -- [ ] Task 4.2: Run feature/base lifecycle lint, lint, typecheck, build, package/full tests, and coverage. -- [ ] Task 4.3: Perform design-alignment and holistic code review; fix blocking findings via TDD. +- [x] Task 4.1: Reconcile implementation/testing docs and reach 100% coverage on new pure logic. +- [x] Task 4.2: Run feature/base lifecycle lint, lint, typecheck, build, package/full tests, and coverage. +- [x] Task 4.3: Perform design-alignment and holistic code review; fix blocking findings via TDD. - [ ] Task 4.4: Create conventional commits, fetch/rebase `origin/main`, rerun gates, push, and open the requested PR. ## Dependencies diff --git a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md index f530cf2c..2c2b65f9 100644 --- a/docs/ai/testing/2026-08-11-feature-codex-print-mode.md +++ b/docs/ai/testing/2026-08-11-feature-codex-print-mode.md @@ -51,7 +51,7 @@ description: Offline TDD, protocol, integration, and compatibility validation - [x] Fake provider create invokes only version/help and creates no session. - [x] First send captures prompt from stdin, mints deterministic UUID, and persists binding before completion. - [x] Second send receives the identical UUID in explicit resume argv. -- [ ] Concurrent send, stale lock recovery, canonical cwd, first-run pre/post-bind failure, and session mismatch behave safely. +- [x] Existing store tests cover concurrent send, stale lock recovery, and canonical cwd; Codex tests cover post-bind failure and session mismatch. - [x] Claude print and interactive Codex regression suites remain green. ## End-to-End Tests @@ -77,10 +77,18 @@ No real Codex model run is permitted. Human inspection is limited to fake-provid ## Performance Testing -- [ ] Oversized output remains bounded. -- [ ] Concurrent lock contention fails promptly. -- [ ] Listing mixed records remains practical without provider processes. +- [x] Oversized output remains bounded. +- [x] Concurrent lock contention fails promptly through the shared store suite. +- [x] Listing mixed records requires no provider process. ## Bug Tracking Blocking findings are added to planning and fixed through a new red/green/refactor cycle before publication. + +## Final Results + +- Agent-manager: 28 test files and 527 tests passed under coverage; overall 90.23% statements and 93.4% lines. +- New Codex modules: 100% lines/functions; probe also 100% statements/branches. Runner/service residual branch-only gaps are non-pure injected/default process plumbing. +- CLI: 79 test files and 932 tests passed under coverage. +- Root lifecycle lint, lint, build, and all six project test targets passed. +- No test invoked a real model. diff --git a/packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts b/packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts index 0984cfcb..493789bc 100644 --- a/packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts +++ b/packages/agent-manager/src/__tests__/print/CodexCliProbe.test.ts @@ -28,6 +28,11 @@ describe('CodexCliProbe', () => { .mockResolvedValueOnce({ stdout: 'exec', stderr: '' }) .mockResolvedValueOnce({ stdout: 'resume', stderr: '' }) }); await expect(unsupported.validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); + const missingCommands = new Probe({ exec: vi.fn() + .mockResolvedValueOnce({ stdout: 'version', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) }); + await expect(missingCommands.validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); const unavailable = new Probe({ exec: vi.fn().mockRejectedValue(new Error(`bad\0${'x'.repeat(1000)}`)) }); const error = await unavailable.validate().catch((value: Error & { code: string }) => value); @@ -35,4 +40,24 @@ describe('CodexCliProbe', () => { expect(error.message).not.toContain('\0'); expect(error.message.length).toBeLessThan(600); }); + + it('reports an empty version response as unknown', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.CodexCliProbe as new (options: unknown) => any; + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: ' \n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'exec --json -', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'resume --json -', stderr: '' }); + await expect(new Probe({ exec }).validate()).resolves.toMatchObject({ version: 'unknown' }); + }); + + it('requires a standalone stdin dash rather than accepting flag hyphens', async () => { + const api = await import('../../index.js') as Record; + const Probe = api.CodexCliProbe as new (options: unknown) => any; + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: 'version', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'exec --json', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'resume --json', stderr: '' }); + await expect(new Probe({ exec }).validate()).rejects.toMatchObject({ code: 'CODEX_CLI_UNSUPPORTED' }); + }); }); diff --git a/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts b/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts index e1cf5da3..7518a374 100644 --- a/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts +++ b/packages/agent-manager/src/__tests__/print/CodexPrintAgentService.test.ts @@ -6,6 +6,12 @@ const base = { }; describe('CodexPrintAgentService', () => { + it('constructs default non-billable dependencies without invoking them', async () => { + const api = await import('../../index.js') as Record; + const Service = api.CodexPrintAgentService as new () => any; + expect(new Service().store).toBeDefined(); + }); + it('validates before provider-aware create and never runs Codex', async () => { const api = await import('../../index.js') as Record; expect(api).toHaveProperty('CodexPrintAgentService'); @@ -88,4 +94,17 @@ describe('CodexPrintAgentService', () => { .rejects.toMatchObject({ code: 'PRINT_AGENT_SESSION_MISMATCH' }); expect(completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({ sessionHealth: 'mismatch' })); }); + + it('rejects missing and ambiguous records before acquiring a run', async () => { + const api = await import('../../index.js') as Record; + const Service = api.CodexPrintAgentService as new (options: unknown) => any; + const store = { + list: vi.fn(), create: vi.fn(), resolve: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce([base, base]), + acquireRun: vi.fn(), recordProviderProcess: vi.fn(), bindProviderSession: vi.fn(), completeRun: vi.fn(), + }; + const service = new Service({ store, probe: { validate: vi.fn() }, runner: { run: vi.fn() } }); + await expect(service.send('missing', 'x')).rejects.toMatchObject({ code: 'PRINT_AGENT_NOT_FOUND' }); + await expect(service.send('ambiguous', 'x')).rejects.toMatchObject({ code: 'CODEX_UNSUPPORTED' }); + expect(store.acquireRun).not.toHaveBeenCalled(); + }); }); diff --git a/packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts b/packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts index e691bbec..7eca1009 100644 --- a/packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts +++ b/packages/agent-manager/src/__tests__/print/CodexPrintRunner.test.ts @@ -60,6 +60,12 @@ async function runner(fixture: ReturnType, maxLineBytes?: numb } describe('CodexPrintRunner', () => { + it('constructs default process dependencies without spawning', async () => { + const api = await import('../../index.js') as Record; + const Runner = api.CodexPrintRunner as new () => any; + expect(new Runner()).toBeDefined(); + }); + it('binds an initial thread before returning ordered assistant output', async () => { const fixture = fakeSpawn(events(), 0, true); const instance = await runner(fixture); @@ -107,6 +113,10 @@ describe('CodexPrintRunner', () => { await expect((await runner(oversized, 10)).run({ agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + const oversizedWithoutNewline = fakeSpawn(['x'.repeat(20)]); + await expect((await runner(oversizedWithoutNewline, 10)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); const failed = fakeSpawn(events(), 1); failed.child.stderr.end('secret-looking provider diagnostic'); @@ -120,4 +130,60 @@ describe('CodexPrintRunner', () => { agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); }); + + it('rejects invalid or duplicate thread identities', async () => { + for (const lines of [ + [JSON.stringify({ type: 'thread.started', thread_id: 'bad' }), ''], + [ + JSON.stringify({ type: 'thread.started', thread_id: SESSION }), + JSON.stringify({ type: 'thread.started', thread_id: SESSION }), + '', + ], + ]) { + const fixture = fakeSpawn(lines); + await expect((await runner(fixture)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + } + }); + + it('classifies callback processing failure and kills when spawn persistence fails', async () => { + const callbackFailure = fakeSpawn(events()); + await expect((await runner(callbackFailure)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), + onSession: vi.fn().mockRejectedValue(new Error('storage unavailable')), + })).rejects.toMatchObject({ code: 'CODEX_PROTOCOL' }); + + const spawnFailure = fakeSpawn([]); + const failure = new Error('cannot persist process'); + await expect((await runner(spawnFailure)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn().mockRejectedValue(failure), onSession: vi.fn(), + })).rejects.toBe(failure); + expect(spawnFailure.child.kill).toHaveBeenCalledOnce(); + }); + + it('rejects an unverifiable positive PID', async () => { + const api = await import('../../index.js') as Record; + const fixture = fakeSpawn([]); + const Runner = api.CodexPrintRunner as new (options: unknown) => any; + const instance = new Runner({ spawn: fixture.spawn, processInspector: { getIdentity: () => null } }); + await expect(instance.run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + expect(fixture.child.kill).toHaveBeenCalledOnce(); + }); + + it('classifies a child spawn error as a process failure', async () => { + const fixture = fakeSpawn([]); + fixture.child.stdin = new Writable({ + write(_chunk, _encoding, callback) { callback(); }, + final(callback) { + queueMicrotask(() => fixture.child.emit('error', new Error('spawn failed'))); + callback(); + }, + }); + await expect((await runner(fixture)).run({ + agent: agent(), prompt: 'x', onSpawn: vi.fn(), onSession: vi.fn(), + })).rejects.toMatchObject({ code: 'CODEX_PROCESS' }); + }); }); diff --git a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts index 59da0c20..53c3ffcf 100644 --- a/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts +++ b/packages/agent-manager/src/__tests__/print/PrintAgentStore.test.ts @@ -71,6 +71,11 @@ describe('PrintAgentStore create/list/resolve', () => { expect((await store.list())[0]).toEqual(legacy); const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')); + expect(raw.version).toBe(2); + raw.version = 1; + await fs.promises.writeFile(filePath, JSON.stringify(raw)); + expect((await store.list())[0]).toEqual(legacy); + raw.agents[0].providerSessionId = null; await fs.promises.writeFile(filePath, JSON.stringify(raw)); await expect(store.list()).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' }); diff --git a/packages/agent-manager/src/print/CodexCliProbe.ts b/packages/agent-manager/src/print/CodexCliProbe.ts index 0a3ab042..79a34550 100644 --- a/packages/agent-manager/src/print/CodexCliProbe.ts +++ b/packages/agent-manager/src/print/CodexCliProbe.ts @@ -32,10 +32,10 @@ export class CodexCliProbe { const missing = [ !execHelp.stdout.includes('exec') && 'exec', !execHelp.stdout.includes('--json') && '--json', - !execHelp.stdout.includes('-') && 'stdin -', + !hasStdinDash(execHelp.stdout) && 'stdin -', !resumeHelp.stdout.includes('resume') && 'resume', !resumeHelp.stdout.includes('--json') && 'resume --json', - !resumeHelp.stdout.includes('-') && 'resume stdin -', + !hasStdinDash(resumeHelp.stdout) && 'resume stdin -', ].filter((value): value is string => typeof value === 'string'); if (missing.length > 0) { throw new CodexPrintError( @@ -54,6 +54,10 @@ export class CodexCliProbe { } } +function hasStdinDash(help: string): boolean { + return /(?:^|\s)-(?:\s|$)/m.test(help); +} + function sanitize(value: string, max: number): string { return Array.from(value, (character) => { const code = character.charCodeAt(0); diff --git a/packages/agent-manager/src/print/CodexPrintRunner.ts b/packages/agent-manager/src/print/CodexPrintRunner.ts index d7773c0f..d99f13f0 100644 --- a/packages/agent-manager/src/print/CodexPrintRunner.ts +++ b/packages/agent-manager/src/print/CodexPrintRunner.ts @@ -132,7 +132,9 @@ export class CodexPrintRunner { throw error; } child.stdin.end(request.prompt); - const { code, signal } = await closed; + const { code, signal } = await closed.catch(() => { + throw new CodexPrintError('Codex process failed to start or communicate.', 'CODEX_PROCESS'); + }); await processing; if (protocolError) throw protocolError; diff --git a/packages/agent-manager/src/print/PrintAgentStore.ts b/packages/agent-manager/src/print/PrintAgentStore.ts index 82474219..17e8029d 100644 --- a/packages/agent-manager/src/print/PrintAgentStore.ts +++ b/packages/agent-manager/src/print/PrintAgentStore.ts @@ -13,7 +13,7 @@ import { } from './PrintAgent.js'; interface PrintAgentStoreFile { - version: 1; + version: 2; agents: PrintAgent[]; } @@ -343,11 +343,12 @@ export class PrintAgentStore { private readFile(): PrintAgentStoreFile { this.ensureSafeParent(); this.assertNotSymlink(this.filePath); - if (!fs.existsSync(this.filePath)) return { version: 1, agents: [] }; + if (!fs.existsSync(this.filePath)) return { version: 2, agents: [] }; try { const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as unknown; - if (!this.isStoreFile(parsed)) throw new Error('invalid schema'); - return parsed; + if (this.isStoreFile(parsed)) return parsed; + if (this.isLegacyStoreFile(parsed)) return { version: 2, agents: parsed.agents }; + throw new Error('invalid schema'); } catch { throw new PrintAgentStoreError(`Invalid print-agent store: ${this.filePath}`); } @@ -360,7 +361,7 @@ export class PrintAgentStore { private isStoreFile(value: unknown): value is PrintAgentStoreFile { if (!value || typeof value !== 'object') return false; const record = value as Record; - if (record.version !== 1 || !Array.isArray(record.agents)) return false; + if (record.version !== 2 || !Array.isArray(record.agents)) return false; const bindings = new Set(); for (const value of record.agents) { if (!isPrintAgent(value)) return false; @@ -373,6 +374,14 @@ export class PrintAgentStore { return true; } + private isLegacyStoreFile(value: unknown): value is { version: 1; agents: PrintAgent[] } { + if (!value || typeof value !== 'object') return false; + const record = value as Record; + return record.version === 1 + && Array.isArray(record.agents) + && record.agents.every((agent) => isPrintAgent(agent) && agent.provider === 'claude'); + } + private writeFile(data: PrintAgentStoreFile): void { const parent = this.ensureSafeParent(); this.assertNotSymlink(this.filePath);