feat: add agent runtime info tracking with model detection from rollout files
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
# Codex SDK Binary Investigation Report
|
||||
|
||||
**Date:** 2025-12-20
|
||||
**SDK Version:** @openai/codex-sdk@0.76.0
|
||||
**Status:** ✅ System binary usage is fully supported
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The `@openai/codex-sdk` library **DOES support using a system-installed Codex binary** instead of the embedded one. This is accomplished via the `codexPathOverride` configuration option in the `CodexOptions` interface.
|
||||
|
||||
---
|
||||
|
||||
## How the SDK Currently Locates the Codex Binary
|
||||
|
||||
### Default Behavior (Embedded Binary)
|
||||
|
||||
When no override is provided, the SDK uses the `findCodexPath()` function to locate the embedded binary:
|
||||
|
||||
**Source:** `dist/index.js:278-330`
|
||||
|
||||
```typescript
|
||||
function findCodexPath() {
|
||||
const { platform, arch } = process;
|
||||
let targetTriple = null;
|
||||
|
||||
// Determines platform-specific binary path
|
||||
// e.g., "aarch64-apple-darwin" for Apple Silicon Macs
|
||||
|
||||
const vendorRoot = path2.join(scriptDirName, "..", "vendor");
|
||||
const archRoot = path2.join(vendorRoot, targetTriple);
|
||||
const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex";
|
||||
const binaryPath = path2.join(archRoot, "codex", codexBinaryName);
|
||||
return binaryPath;
|
||||
}
|
||||
```
|
||||
|
||||
### Binary Location Structure
|
||||
|
||||
The SDK ships with pre-compiled binaries in the `vendor/` directory:
|
||||
|
||||
```
|
||||
node_modules/@openai/codex-sdk/
|
||||
├── vendor/
|
||||
│ ├── aarch64-apple-darwin/codex/codex (40MB - Apple Silicon)
|
||||
│ ├── x86_64-apple-darwin/codex/codex (Intel Mac)
|
||||
│ ├── aarch64-unknown-linux-musl/codex/codex (ARM Linux)
|
||||
│ ├── x86_64-unknown-linux-musl/codex/codex (x86_64 Linux)
|
||||
│ ├── aarch64-pc-windows-msvc/codex/codex.exe (ARM Windows)
|
||||
│ └── x86_64-pc-windows-msvc/codex/codex.exe (x86_64 Windows)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Options Available
|
||||
|
||||
### `codexPathOverride` Option
|
||||
|
||||
**Type Definition:** `dist/index.d.ts:209-218`
|
||||
|
||||
```typescript
|
||||
type CodexOptions = {
|
||||
codexPathOverride?: string; // ← THIS IS THE KEY OPTION
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
```
|
||||
|
||||
**Implementation:** `dist/index.js:336-338`
|
||||
|
||||
```typescript
|
||||
constructor(options = {}) {
|
||||
this.exec = new CodexExec(options.codexPathOverride, options.env);
|
||||
this.options = options;
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:** `dist/index.js:145-147`
|
||||
|
||||
```typescript
|
||||
constructor(executablePath = null, env) {
|
||||
this.executablePath = executablePath || findCodexPath();
|
||||
this.envOverride = env;
|
||||
}
|
||||
```
|
||||
|
||||
The `codexPathOverride` is passed as `executablePath`. If provided, it bypasses the `findCodexPath()` function entirely.
|
||||
|
||||
---
|
||||
|
||||
## How to Use a System Codex Binary
|
||||
|
||||
### Method 1: Direct Path Specification
|
||||
|
||||
```typescript
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
const codex = new Codex({
|
||||
codexPathOverride: '/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex'
|
||||
});
|
||||
```
|
||||
|
||||
### Method 2: Dynamic PATH Resolution
|
||||
|
||||
```typescript
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const systemCodexPath = execSync('which codex', { encoding: 'utf8' }).trim();
|
||||
|
||||
const codex = new Codex({
|
||||
codexPathOverride: systemCodexPath
|
||||
});
|
||||
```
|
||||
|
||||
### Method 3: Environment Variable Based (Custom Implementation)
|
||||
|
||||
```typescript
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
const codex = new Codex({
|
||||
codexPathOverride: process.env.CODEX_BINARY_PATH || undefined
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** The SDK does NOT have a built-in environment variable for this purpose. The `CODEX_BINARY_PATH` above is a custom convention you'd need to implement in your application.
|
||||
|
||||
---
|
||||
|
||||
## Verification Test Results
|
||||
|
||||
### Test Configuration
|
||||
|
||||
- **System Codex:** `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex`
|
||||
- **Embedded Codex:** `node_modules/@openai/codex-sdk/vendor/aarch64-apple-darwin/codex/codex`
|
||||
- **Versions:** Both are `codex-cli 0.76.0`
|
||||
|
||||
### Test Results
|
||||
|
||||
```bash
|
||||
$ node test-codex-path-override.mjs
|
||||
Testing codexPathOverride option...
|
||||
|
||||
Test 1: Using default embedded binary
|
||||
✓ Created Codex instance with default settings
|
||||
|
||||
Test 2: Using system binary via codexPathOverride
|
||||
✓ Created Codex instance with codexPathOverride
|
||||
|
||||
Test 3: Using which codex result
|
||||
System codex location: /Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex
|
||||
✓ Created Codex instance with system codex from PATH
|
||||
|
||||
✅ All tests passed!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of Using System Codex
|
||||
|
||||
### 1. **Version Control**
|
||||
- Update codex independently via package manager (npm, brew, asdf, etc.)
|
||||
- No need to wait for SDK release to get latest codex features
|
||||
- Easier to test beta/development versions
|
||||
|
||||
### 2. **Disk Space Savings**
|
||||
- Embedded binary: ~40MB per architecture
|
||||
- In a monorepo with multiple node_modules: Saves 40MB × number of installations
|
||||
- System binary: Single installation regardless of projects
|
||||
|
||||
### 3. **Consistency**
|
||||
- Same codex version across all tools and SDKs
|
||||
- Easier debugging when CLI and SDK use identical binaries
|
||||
- Simplified version management
|
||||
|
||||
### 4. **Development Workflow**
|
||||
- Test local codex builds without reinstalling SDK
|
||||
- Point to custom-compiled binaries for debugging
|
||||
- A/B test different codex versions easily
|
||||
|
||||
---
|
||||
|
||||
## Potential Considerations
|
||||
|
||||
### 1. **Version Compatibility**
|
||||
- SDK expects specific codex CLI behavior
|
||||
- Using mismatched versions might cause issues
|
||||
- SDK is at v0.76.0, ensure system codex matches or is compatible
|
||||
|
||||
### 2. **PATH Availability**
|
||||
- System binary must be accessible when Node.js process runs
|
||||
- Consider environment differences (development vs production)
|
||||
- Absolute paths are safer than relying on PATH resolution
|
||||
|
||||
### 3. **No Fallback**
|
||||
- If codexPathOverride points to invalid/missing binary, execution will fail
|
||||
- SDK doesn't automatically fall back to embedded binary
|
||||
- Implement error handling if using dynamic resolution
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables (Built-in)
|
||||
|
||||
The SDK does NOT provide a built-in environment variable for binary path override, but it does use these:
|
||||
|
||||
### Used by SDK
|
||||
|
||||
1. **`CODEX_INTERNAL_ORIGINATOR_OVERRIDE`** (Internal)
|
||||
- Source: `dist/index.js:140-141`
|
||||
- Set to `"codex_sdk_ts"` to identify SDK usage
|
||||
- Not for user configuration
|
||||
|
||||
2. **`OPENAI_BASE_URL`** (Configurable)
|
||||
- Override via `CodexOptions.baseUrl`
|
||||
- Also injectable via env
|
||||
|
||||
3. **`CODEX_API_KEY`** (Configurable)
|
||||
- Override via `CodexOptions.apiKey`
|
||||
- Also injectable via env
|
||||
|
||||
### NOT Provided
|
||||
|
||||
- ❌ `CODEX_BINARY_PATH` - Not a built-in feature
|
||||
- ❌ `CODEX_PATH` - Not a built-in feature
|
||||
- ❌ Auto-discovery via system PATH - Not implemented
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Pattern
|
||||
|
||||
For maximum flexibility, consider this pattern:
|
||||
|
||||
```typescript
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
function createCodexInstance(options = {}) {
|
||||
// Priority order:
|
||||
// 1. Explicit override in options
|
||||
// 2. Environment variable
|
||||
// 3. System PATH
|
||||
// 4. Embedded binary (SDK default)
|
||||
|
||||
let codexPath = options.codexPathOverride;
|
||||
|
||||
if (!codexPath && process.env.CODEX_BINARY_PATH) {
|
||||
codexPath = process.env.CODEX_BINARY_PATH;
|
||||
}
|
||||
|
||||
if (!codexPath) {
|
||||
try {
|
||||
codexPath = execSync('which codex', { encoding: 'utf8' }).trim();
|
||||
if (!existsSync(codexPath)) {
|
||||
codexPath = undefined;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to embedded binary
|
||||
codexPath = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return new Codex({
|
||||
...options,
|
||||
codexPathOverride: codexPath
|
||||
});
|
||||
}
|
||||
|
||||
// Usage
|
||||
const codex = createCodexInstance();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| **Can SDK use system codex?** | ✅ Yes, via `codexPathOverride` |
|
||||
| **Is configuration option available?** | ✅ Yes, in `CodexOptions` interface |
|
||||
| **Built-in env variable support?** | ❌ No, must implement custom logic |
|
||||
| **Automatic PATH resolution?** | ❌ No, must provide explicit path |
|
||||
| **Fallback to embedded?** | ❌ No, fails if override is invalid |
|
||||
| **Version tested** | 0.76.0 (both system and embedded) |
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The `@openai/codex-sdk` library provides full support for using a system-installed Codex binary through the `codexPathOverride` configuration option. This feature is well-designed and allows developers to choose between the convenience of embedded binaries and the flexibility of system-managed installations.
|
||||
|
||||
To use a system binary:
|
||||
1. Install codex globally (via npm, brew, asdf, etc.)
|
||||
2. Pass the path via `codexPathOverride` when instantiating `Codex`
|
||||
3. Optionally implement PATH resolution logic for automatic discovery
|
||||
|
||||
The embedded binary remains the default, ensuring the SDK works out-of-the-box without requiring system-wide codex installation.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Model Fetching Investigation Report
|
||||
|
||||
## Summary
|
||||
|
||||
Investigated the model fetching system to determine what models are actually available for both Claude and Codex agents.
|
||||
|
||||
## Code Location
|
||||
|
||||
The model fetching logic is located in:
|
||||
- **File**: `packages/server/src/server/agent/model-catalog.ts` (lines 22-101)
|
||||
- **Main Function**: `fetchProviderModelCatalog(provider, options)`
|
||||
- **Claude Function**: `fetchClaudeModelCatalog(options)` (lines 35-66)
|
||||
- **Codex Function**: `fetchCodexModelCatalog()` (lines 68-101)
|
||||
|
||||
## How It Works
|
||||
|
||||
### Claude Model Fetching
|
||||
- Uses the Claude Agent SDK's `query.supportedModels()` method
|
||||
- Creates a query instance with the given `cwd` (working directory)
|
||||
- Returns models from the SDK with their display names and descriptions
|
||||
|
||||
### Codex Model Fetching
|
||||
- Spawns the Codex binary (`codex app-server`) as a subprocess
|
||||
- Communicates via JSON-RPC protocol
|
||||
- Sends `initialize` followed by `model/list` requests
|
||||
- Returns models with metadata including reasoning efforts
|
||||
|
||||
## Actual Models Returned
|
||||
|
||||
### Claude Models (3 total)
|
||||
|
||||
1. **Default (recommended)** (`default`)
|
||||
- Description: "Sonnet 4.5 · Smartest model for daily use"
|
||||
- Is Default: false
|
||||
|
||||
2. **Opus** (`opus`)
|
||||
- Description: "Legacy: Opus 4.1 · Reaches usage limits faster"
|
||||
- Is Default: false
|
||||
|
||||
3. **Haiku** (`haiku`)
|
||||
- Description: "Haiku 4.5 · Fastest model for simple tasks"
|
||||
- Is Default: false
|
||||
|
||||
### Codex Models (3 total)
|
||||
|
||||
1. **gpt-5.1-codex** (`gpt-5.1-codex`) ⭐ DEFAULT
|
||||
- Description: "Optimized for codex."
|
||||
- Is Default: true
|
||||
- Model: gpt-5.1-codex
|
||||
- Default Reasoning Effort: medium
|
||||
|
||||
2. **gpt-5.1-codex-mini** (`gpt-5.1-codex-mini`)
|
||||
- Description: "Optimized for codex. Cheaper, faster, but less capable."
|
||||
- Is Default: false
|
||||
- Model: gpt-5.1-codex-mini
|
||||
- Default Reasoning Effort: medium
|
||||
|
||||
3. **gpt-5.1** (`gpt-5.1`)
|
||||
- Description: "Broad world knowledge with strong general reasoning."
|
||||
- Is Default: false
|
||||
- Model: gpt-5.1
|
||||
- Default Reasoning Effort: medium
|
||||
|
||||
## Key Observations
|
||||
|
||||
1. **Claude models**: The model IDs returned are simplified (`default`, `opus`, `haiku`) rather than full model identifiers like `claude-sonnet-4-5-20250929`.
|
||||
|
||||
2. **Codex models**: All three models are available and properly marked with `isDefault` flag on `gpt-5.1-codex`.
|
||||
|
||||
3. **No Sonnet variant**: Interestingly, Claude returns a "default" model (which is Sonnet 4.5 based on the description) but doesn't expose a model ID called "sonnet" - only "default", "opus", and "haiku".
|
||||
|
||||
4. **Model resolution**: The `model-resolver.ts` file uses these catalogs to find the default model when none is specified, preferring models marked with `isDefault: true`.
|
||||
|
||||
## Testing Method
|
||||
|
||||
Created and ran a test script (`test-models.ts`) that directly imports and calls:
|
||||
- `fetchClaudeModelCatalog()`
|
||||
- `fetchCodexModelCatalog()`
|
||||
|
||||
Executed with: `npx tsx test-models.ts`
|
||||
|
||||
## Related Files
|
||||
|
||||
- `src/server/agent/model-catalog.ts` - Model fetching implementation
|
||||
- `src/server/agent/model-resolver.ts` - Default model resolution
|
||||
- `src/server/agent/model-catalog.e2e.test.ts` - End-to-end tests
|
||||
- `src/server/session.ts:1589` - Usage in session handling (list_provider_models_response)
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
HOME_NAVIGATION_KEY,
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import { extractAgentModel } from "@/utils/extract-agent-model";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
@@ -44,47 +45,6 @@ type GitRepoInfoResponseMessage = Extract<
|
||||
|
||||
type BranchStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
function extractAgentModel(agent?: Agent | null): string | null {
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directModel = typeof agent.model === "string" ? agent.model.trim() : "";
|
||||
if (directModel.length > 0) {
|
||||
return directModel;
|
||||
}
|
||||
|
||||
const metadata = agent.persistence?.metadata;
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persistedModel = (metadata as Record<string, unknown>).model;
|
||||
if (typeof persistedModel === "string" && persistedModel.trim().length > 0) {
|
||||
return persistedModel.trim();
|
||||
}
|
||||
|
||||
const extra = (metadata as Record<string, unknown>).extra;
|
||||
if (!extra || typeof extra !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getModelFrom = (source: unknown) => {
|
||||
if (!source || typeof source !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = (source as Record<string, unknown>).model;
|
||||
return typeof candidate === "string" && candidate.trim().length > 0
|
||||
? candidate.trim()
|
||||
: null;
|
||||
};
|
||||
|
||||
return (
|
||||
getModelFrom((extra as Record<string, unknown>).codex) ??
|
||||
getModelFrom((extra as Record<string, unknown>).claude)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentScreen() {
|
||||
const router = useRouter();
|
||||
const { serverId, agentId } = useLocalSearchParams<{ serverId: string; agentId: string }>();
|
||||
|
||||
@@ -130,6 +130,7 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string
|
||||
availableModes: snapshot.availableModes ?? [],
|
||||
pendingPermissions: snapshot.pendingPermissions ?? [],
|
||||
persistence: snapshot.persistence ?? null,
|
||||
runtimeInfo: snapshot.runtimeInfo,
|
||||
lastUsage: snapshot.lastUsage,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
|
||||
@@ -74,6 +74,14 @@ export type ProviderModelState = {
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export interface AgentRuntimeInfo {
|
||||
provider: AgentProvider;
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
modeId?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
@@ -89,6 +97,7 @@ export interface Agent {
|
||||
availableModes: AgentMode[];
|
||||
pendingPermissions: any[];
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string | null;
|
||||
title: string | null;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { extractAgentModel } from "./extract-agent-model";
|
||||
|
||||
describe("extractAgentModel", () => {
|
||||
it("returns runtime model when present", () => {
|
||||
const agent = {
|
||||
runtimeInfo: { model: "gpt-5.2-codex" },
|
||||
} as Partial<Agent> as Agent;
|
||||
|
||||
expect(extractAgentModel(agent)).toBe("gpt-5.2-codex");
|
||||
});
|
||||
|
||||
it("returns null when runtime model missing even if config model is set", () => {
|
||||
const agent = {
|
||||
model: "gpt-5.1-codex",
|
||||
runtimeInfo: {},
|
||||
} as Partial<Agent> as Agent;
|
||||
|
||||
expect(extractAgentModel(agent)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
export function extractAgentModel(agent?: Agent | null): string | null {
|
||||
if (!agent) return null;
|
||||
const runtimeModel = agent.runtimeInfo?.model;
|
||||
return typeof runtimeModel === "string" && runtimeModel.trim().length > 0
|
||||
? runtimeModel.trim()
|
||||
: null;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class TestAgentSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
private runtimeModel: string | null = null;
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {}
|
||||
|
||||
@@ -54,10 +55,23 @@ class TestAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
async *stream(): AsyncGenerator<AgentStreamEvent> {}
|
||||
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
yield { type: "turn_completed", provider: this.provider };
|
||||
this.runtimeModel = "gpt-5.2-codex";
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.runtimeModel ?? this.config.model ?? null,
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
@@ -84,6 +98,26 @@ class TestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
describe("AgentManager", () => {
|
||||
test("normalizeConfig does not inject default model when omitted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
idFactory: () => "agent-without-model",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
expect(snapshot.model).toBeUndefined();
|
||||
});
|
||||
|
||||
test("createAgent persists provided title before returning", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
@@ -109,4 +143,53 @@ describe("AgentManager", () => {
|
||||
expect(persisted?.title).toBe("Fix Login Bug");
|
||||
expect(persisted?.id).toBe("agent-with-title");
|
||||
});
|
||||
|
||||
test("createAgent populates runtimeInfo after session creation", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
idFactory: () => "agent-with-runtime-info",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "gpt-5.2-codex",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
expect(snapshot.runtimeInfo).toBeDefined();
|
||||
expect(snapshot.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
||||
expect(snapshot.runtimeInfo?.sessionId).toBe(snapshot.sessionId);
|
||||
});
|
||||
|
||||
test("runAgent refreshes runtimeInfo after completion", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const registryPath = join(workdir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry,
|
||||
idFactory: () => "agent-with-run-runtime",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
expect(snapshot.runtimeInfo?.model ?? null).toBeNull();
|
||||
|
||||
await manager.runAgent(snapshot.id, "hello");
|
||||
|
||||
const refreshed = manager.getAgent(snapshot.id);
|
||||
expect(refreshed?.runtimeInfo?.model).toBe("gpt-5.2-codex");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,10 +17,10 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
AgentRuntimeInfo,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { resolveAgentModel } from "./model-resolver.js";
|
||||
import type { AgentRegistry } from "./agent-registry.js";
|
||||
|
||||
export const AGENT_LIFECYCLE_STATUSES = [
|
||||
@@ -80,6 +80,7 @@ type ManagedAgentBase = {
|
||||
cwd: string;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
availableModes: AgentMode[];
|
||||
@@ -479,21 +480,21 @@ export class AgentManager {
|
||||
const self = this;
|
||||
|
||||
return (async function* streamForwarder() {
|
||||
let finalizeError: string | undefined;
|
||||
try {
|
||||
for await (const event of iterator) {
|
||||
self.handleStreamEvent(agent, event);
|
||||
yield event;
|
||||
}
|
||||
finalize();
|
||||
} catch (error) {
|
||||
const message =
|
||||
finalizeError =
|
||||
error instanceof Error ? error.message : "Agent stream failed";
|
||||
finalize(message);
|
||||
throw error;
|
||||
} finally {
|
||||
await self.refreshRuntimeInfo(agent);
|
||||
// Ensure we always clear the pending run and emit state when the stream is
|
||||
// cancelled early (e.g., via .return()) so the UI can exit the cancelling state.
|
||||
finalize();
|
||||
finalize(finalizeError);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -712,6 +713,7 @@ export class AgentManager {
|
||||
sessionId: session.id,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
runtimeInfo: undefined,
|
||||
lifecycle: "initializing",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -729,6 +731,7 @@ export class AgentManager {
|
||||
this.agents.set(agentId, managed);
|
||||
// Initialize previousStatus to track transitions
|
||||
this.previousStatuses.set(agentId, managed.lifecycle);
|
||||
await this.refreshRuntimeInfo(managed);
|
||||
await this.persistSnapshot(managed, {
|
||||
title: config.title ?? null,
|
||||
});
|
||||
@@ -773,6 +776,16 @@ export class AgentManager {
|
||||
} catch {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
}
|
||||
|
||||
private async refreshRuntimeInfo(agent: ActiveManagedAgent): Promise<void> {
|
||||
try {
|
||||
agent.runtimeInfo = await agent.session.getRuntimeInfo();
|
||||
} catch {
|
||||
// Keep existing runtimeInfo if refresh fails.
|
||||
}
|
||||
}
|
||||
|
||||
private async primeHistory(agent: ActiveManagedAgent): Promise<void> {
|
||||
@@ -813,6 +826,7 @@ export class AgentManager {
|
||||
case "turn_completed":
|
||||
agent.lastUsage = event.usage;
|
||||
agent.lastError = undefined;
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
break;
|
||||
case "turn_failed":
|
||||
agent.lastError = event.error;
|
||||
@@ -949,15 +963,7 @@ export class AgentManager {
|
||||
normalized.cwd = resolve(normalized.cwd);
|
||||
}
|
||||
|
||||
const resolvedModel = await resolveAgentModel({
|
||||
provider: normalized.provider,
|
||||
requestedModel: normalized.model,
|
||||
cwd: normalized.cwd,
|
||||
});
|
||||
|
||||
if (resolvedModel) {
|
||||
normalized.model = resolvedModel;
|
||||
} else if (typeof normalized.model === "string") {
|
||||
if (typeof normalized.model === "string") {
|
||||
const trimmed = normalized.model.trim();
|
||||
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
@@ -83,11 +83,18 @@ function createManagedAgent(
|
||||
pendingPermissionsOverride ?? new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun: pendingRunValue as ManagedAgent["pendingRun"],
|
||||
timeline: [],
|
||||
runtimeInfo: {
|
||||
provider: "claude",
|
||||
sessionId: "session-123",
|
||||
model: "claude-3.5-sonnet",
|
||||
modeId: "plan",
|
||||
},
|
||||
persistence: { ...basePersistence },
|
||||
lastUsage: undefined,
|
||||
lastError: lastErrorValue,
|
||||
historyPrimed: true,
|
||||
lastUserMessageAt: now,
|
||||
attention: { requiresAttention: false },
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -149,6 +156,12 @@ describe("toStoredAgentRecord", () => {
|
||||
note: "warm",
|
||||
},
|
||||
});
|
||||
expect(record.runtimeInfo).toEqual({
|
||||
provider: "claude",
|
||||
sessionId: "session-123",
|
||||
model: "claude-3.5-sonnet",
|
||||
modeId: "plan",
|
||||
});
|
||||
expect(record.config).toEqual({
|
||||
modeId: agent.config.modeId,
|
||||
model: agent.config.model,
|
||||
@@ -232,6 +245,8 @@ describe("toAgentPayload", () => {
|
||||
requestedAt: "2025-02-02T00:00:00.000Z",
|
||||
extra: { flag: true },
|
||||
});
|
||||
expect(payload.runtimeInfo).toEqual(agent.runtimeInfo);
|
||||
expect(payload.runtimeInfo).not.toBe(agent.runtimeInfo);
|
||||
expect(payload.availableModes).not.toBe(agent.availableModes);
|
||||
expect(payload.availableModes).toEqual(agent.availableModes);
|
||||
expect(payload.capabilities).not.toBe(agent.capabilities);
|
||||
|
||||
@@ -27,6 +27,7 @@ export function toStoredAgentRecord(
|
||||
const createdAt = options?.createdAt ?? agent.createdAt.toISOString();
|
||||
const config = buildSerializableConfig(agent.config);
|
||||
const persistence = sanitizePersistenceHandle(agent.persistence);
|
||||
const runtimeInfo = sanitizeOptionalJsonValue(agent.runtimeInfo);
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
@@ -42,6 +43,7 @@ export function toStoredAgentRecord(
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
runtimeInfo,
|
||||
persistence,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
attentionReason: agent.attention.requiresAttention
|
||||
@@ -57,11 +59,14 @@ export function toAgentPayload(
|
||||
agent: ManagedAgent,
|
||||
options?: ProjectionOptions
|
||||
): AgentSnapshotPayload {
|
||||
const runtimeInfo = sanitizeOptionalJsonValue(agent.runtimeInfo);
|
||||
|
||||
const payload: AgentSnapshotPayload = {
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
model: agent.config.model ?? null,
|
||||
runtimeInfo,
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
lastUserMessageAt: agent.lastUserMessageAt
|
||||
|
||||
@@ -19,6 +19,8 @@ type ManagedAgentOverrides = Omit<
|
||||
pendingPermissions?: Map<string, AgentPermissionRequest>;
|
||||
session?: AgentSession | null;
|
||||
pendingRun?: ManagedAgent["pendingRun"];
|
||||
runtimeInfo?: ManagedAgent["runtimeInfo"];
|
||||
attention?: ManagedAgent["attention"];
|
||||
};
|
||||
|
||||
function createManagedAgent(
|
||||
@@ -71,6 +73,14 @@ function createManagedAgent(
|
||||
new Map<string, AgentPermissionRequest>(),
|
||||
pendingRun,
|
||||
timeline: overrides.timeline ?? [],
|
||||
attention: overrides.attention ?? { requiresAttention: false },
|
||||
runtimeInfo:
|
||||
overrides.runtimeInfo ?? {
|
||||
provider,
|
||||
sessionId: overrides.sessionId ?? "session-123",
|
||||
model: config.model ?? null,
|
||||
modeId: config.modeId ?? null,
|
||||
},
|
||||
persistence: overrides.persistence ?? null,
|
||||
historyPrimed: overrides.historyPrimed ?? true,
|
||||
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
|
||||
|
||||
@@ -40,6 +40,15 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||
runtimeInfo: z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
sessionId: z.string().nullable(),
|
||||
model: z.string().nullable().optional(),
|
||||
modeId: z.string().nullable().optional(),
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
|
||||
@@ -123,6 +123,14 @@ export type AgentRunResult = {
|
||||
timeline: AgentTimelineItem[];
|
||||
};
|
||||
|
||||
export type AgentRuntimeInfo = {
|
||||
provider: AgentProvider;
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
modeId?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ListPersistedAgentsOptions = {
|
||||
limit?: number;
|
||||
};
|
||||
@@ -162,6 +170,7 @@ export interface AgentSession {
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator<AgentStreamEvent>;
|
||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
AgentRuntimeInfo,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -386,6 +387,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private streamedAssistantTextThisTurn = false;
|
||||
private streamedReasoningThisTurn = false;
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private lastOptionsModel: string | null = null;
|
||||
|
||||
constructor(
|
||||
config: ClaudeAgentConfig,
|
||||
@@ -417,6 +420,20 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return this.claudeSessionId;
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
if (this.cachedRuntimeInfo) {
|
||||
return { ...this.cachedRuntimeInfo };
|
||||
}
|
||||
const info: AgentRuntimeInfo = {
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null,
|
||||
model: this.lastOptionsModel,
|
||||
modeId: this.currentMode ?? null,
|
||||
};
|
||||
this.cachedRuntimeInfo = info;
|
||||
return { ...info };
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
const events = this.stream(prompt, options);
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
@@ -436,6 +453,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedRuntimeInfo = {
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId ?? this.pendingLocalId ?? null,
|
||||
model: this.lastOptionsModel,
|
||||
modeId: this.currentMode ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
sessionId: this.claudeSessionId ?? this.pendingLocalId,
|
||||
finalText,
|
||||
@@ -608,11 +632,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!this.claudeSessionId) {
|
||||
return null;
|
||||
}
|
||||
const { model: _ignoredModel, ...restConfig } = this.config;
|
||||
this.persistence = {
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
nativeHandle: this.claudeSessionId,
|
||||
metadata: this.config,
|
||||
metadata: restConfig,
|
||||
};
|
||||
return this.persistence;
|
||||
}
|
||||
@@ -684,6 +709,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.config.model) {
|
||||
base.model = this.config.model;
|
||||
}
|
||||
this.lastOptionsModel = base.model ?? null;
|
||||
if (this.claudeSessionId) {
|
||||
base.resume = this.claudeSessionId;
|
||||
base.continue = true;
|
||||
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
AgentRuntimeInfo,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
@@ -66,19 +67,21 @@ const CODEX_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "full-access",
|
||||
label: "Full Access",
|
||||
description: "Codex can edit files, run commands, and access the network without additional prompts.",
|
||||
description:
|
||||
"Codex can edit files, run commands, and access the network without additional prompts.",
|
||||
},
|
||||
];
|
||||
|
||||
const VALID_CODEX_MODES = new Set(
|
||||
CODEX_MODES.map((mode) => mode.id)
|
||||
);
|
||||
const VALID_CODEX_MODES = new Set(CODEX_MODES.map((mode) => mode.id));
|
||||
|
||||
const DEFAULT_CODEX_MODE_ID = "auto";
|
||||
|
||||
const MODE_PRESETS: Record<
|
||||
string,
|
||||
Partial<ThreadOptions> & { networkAccessEnabled?: boolean; webSearchEnabled?: boolean }
|
||||
Partial<ThreadOptions> & {
|
||||
networkAccessEnabled?: boolean;
|
||||
webSearchEnabled?: boolean;
|
||||
}
|
||||
> = {
|
||||
"read-only": {
|
||||
sandboxMode: "read-only",
|
||||
@@ -100,7 +103,9 @@ const MODE_PRESETS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
|
||||
type CodexOptionsOverrides = Partial<ThreadOptions> & {
|
||||
skipGitRepoCheck?: boolean;
|
||||
};
|
||||
|
||||
const MAX_ROLLOUT_SEARCH_DEPTH = 5;
|
||||
const PERSISTED_TIMELINE_LIMIT = 20;
|
||||
@@ -142,7 +147,10 @@ function normalizeCallId(value?: string | null): string | undefined {
|
||||
|
||||
function extractThreadItemCallId(item: ThreadItem): string | undefined {
|
||||
const withCallId = item as ThreadItemWithOptionalCallId;
|
||||
return normalizeCallId(withCallId.call_id) ?? normalizeCallId((item as { id?: string }).id);
|
||||
return (
|
||||
normalizeCallId(withCallId.call_id) ??
|
||||
normalizeCallId((item as { id?: string }).id)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCommandDisplayName(command?: unknown): string {
|
||||
@@ -155,7 +163,9 @@ function buildCommandDisplayName(command?: unknown): string {
|
||||
return "Command";
|
||||
}
|
||||
|
||||
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
|
||||
function buildFileChangeSummary(
|
||||
files: { path: string; kind: string }[]
|
||||
): string {
|
||||
if (!files.length) {
|
||||
return "File change";
|
||||
}
|
||||
@@ -167,7 +177,11 @@ function buildFileChangeSummary(files: { path: string; kind: string }[]): string
|
||||
|
||||
function coerceSandboxMode(value?: string): SandboxMode | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value === "read-only" || value === "workspace-write" || value === "danger-full-access") {
|
||||
if (
|
||||
value === "read-only" ||
|
||||
value === "workspace-write" ||
|
||||
value === "danger-full-access"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
@@ -175,21 +189,35 @@ function coerceSandboxMode(value?: string): SandboxMode | undefined {
|
||||
|
||||
function coerceApprovalMode(value?: string): ApprovalMode | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value === "never" || value === "on-request" || value === "on-failure" || value === "untrusted") {
|
||||
if (
|
||||
value === "never" ||
|
||||
value === "on-request" ||
|
||||
value === "on-failure" ||
|
||||
value === "untrusted"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function coerceReasoningEffort(value?: string): ModelReasoningEffort | undefined {
|
||||
function coerceReasoningEffort(
|
||||
value?: string
|
||||
): ModelReasoningEffort | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value === "minimal" || value === "low" || value === "medium" || value === "high") {
|
||||
if (
|
||||
value === "minimal" ||
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeExtraOptions(extra?: Record<string, unknown>): CodexOptionsOverrides | undefined {
|
||||
function normalizeExtraOptions(
|
||||
extra?: Record<string, unknown>
|
||||
): CodexOptionsOverrides | undefined {
|
||||
if (!extra) return undefined;
|
||||
const allowedKeys = new Set([
|
||||
"model",
|
||||
@@ -211,7 +239,10 @@ function normalizeExtraOptions(extra?: Record<string, unknown>): CodexOptionsOve
|
||||
|
||||
function detectSystemCodexPath(): string | undefined {
|
||||
try {
|
||||
const codexPath = execSync("which codex", { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }).trim();
|
||||
const codexPath = execSync("which codex", {
|
||||
encoding: "utf8",
|
||||
stdio: ["pipe", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (codexPath && codexPath.length > 0) {
|
||||
return codexPath;
|
||||
}
|
||||
@@ -236,7 +267,9 @@ export class CodexAgentClient implements AgentClient {
|
||||
codexOptions.codexPathOverride = systemCodexPath;
|
||||
console.log(`[Codex] Using system binary: ${systemCodexPath}`);
|
||||
} else {
|
||||
console.log("[Codex] Using embedded binary (no system codex found in PATH)");
|
||||
console.log(
|
||||
"[Codex] Using embedded binary (no system codex found in PATH)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,18 +281,25 @@ export class CodexAgentClient implements AgentClient {
|
||||
return CodexAgentSession.create(this.codex, codexConfig);
|
||||
}
|
||||
|
||||
async resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>
|
||||
): Promise<AgentSession> {
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const merged = { ...metadata, ...overrides } as Partial<AgentSessionConfig>;
|
||||
if (!merged.cwd) {
|
||||
throw new Error("Codex resume requires the original working directory in metadata");
|
||||
throw new Error(
|
||||
"Codex resume requires the original working directory in metadata"
|
||||
);
|
||||
}
|
||||
const mergedConfig = { ...merged, provider: "codex" } as AgentSessionConfig;
|
||||
const codexConfig = this.assertConfig(mergedConfig);
|
||||
return CodexAgentSession.create(this.codex, codexConfig, handle);
|
||||
}
|
||||
|
||||
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
|
||||
async listPersistedAgents(
|
||||
options?: ListPersistedAgentsOptions
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
const sessionRoot = resolveCodexSessionRoot();
|
||||
if (!sessionRoot) {
|
||||
return [];
|
||||
@@ -271,7 +311,11 @@ export class CodexAgentClient implements AgentClient {
|
||||
const candidates = await collectRecentRolloutFiles(sessionRoot, limit * 3);
|
||||
const descriptors: PersistedAgentDescriptor[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const descriptor = await parseRolloutDescriptor(candidate.path, candidate.mtime, sessionRoot);
|
||||
const descriptor = await parseRolloutDescriptor(
|
||||
candidate.path,
|
||||
candidate.mtime,
|
||||
sessionRoot
|
||||
);
|
||||
if (descriptor) {
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
@@ -284,14 +328,20 @@ export class CodexAgentClient implements AgentClient {
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): CodexAgentConfig {
|
||||
if (config.provider !== "codex") {
|
||||
throw new Error(`CodexAgentClient received config for provider '${config.provider}'`);
|
||||
throw new Error(
|
||||
`CodexAgentClient received config for provider '${config.provider}'`
|
||||
);
|
||||
}
|
||||
return config as CodexAgentConfig;
|
||||
}
|
||||
}
|
||||
|
||||
class CodexAgentSession implements AgentSession {
|
||||
static async create(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle): Promise<CodexAgentSession> {
|
||||
static async create(
|
||||
codex: Codex,
|
||||
config: CodexAgentConfig,
|
||||
handle?: AgentPersistenceHandle
|
||||
): Promise<CodexAgentSession> {
|
||||
const session = new CodexAgentSession(codex, config, handle);
|
||||
if (handle) {
|
||||
await session.loadReplayHistory(handle);
|
||||
@@ -316,8 +366,13 @@ class CodexAgentSession implements AgentSession {
|
||||
private historyEvents: AgentStreamEvent[] = [];
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private cancelCurrentTurn: (() => void) | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
|
||||
constructor(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle) {
|
||||
constructor(
|
||||
codex: Codex,
|
||||
config: CodexAgentConfig,
|
||||
handle?: AgentPersistenceHandle
|
||||
) {
|
||||
this.codex = codex;
|
||||
this.config = { ...config };
|
||||
|
||||
@@ -336,6 +391,7 @@ class CodexAgentSession implements AgentSession {
|
||||
this.threadId = handle?.sessionId ?? handle?.nativeHandle ?? null;
|
||||
this.pendingLocalId = this.threadId ?? `codex-${randomUUID()}`;
|
||||
this.persistence = handle ?? null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
if (handle && !this.threadId) {
|
||||
throw new Error("Codex resume requires a thread id");
|
||||
}
|
||||
@@ -348,7 +404,31 @@ class CodexAgentSession implements AgentSession {
|
||||
return this.threadId;
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
if (!this.cachedRuntimeInfo) {
|
||||
await this.refreshRuntimeInfoFromRollout();
|
||||
}
|
||||
if (this.cachedRuntimeInfo) {
|
||||
return { ...this.cachedRuntimeInfo };
|
||||
}
|
||||
const modelFromRollout = await this.readRuntimeModelFromRollout();
|
||||
const info: AgentRuntimeInfo = {
|
||||
provider: "codex",
|
||||
sessionId: this.threadId ?? this.pendingLocalId ?? null,
|
||||
model:
|
||||
modelFromRollout ??
|
||||
this.threadOptions.model ??
|
||||
null,
|
||||
modeId: this.currentMode ?? null,
|
||||
};
|
||||
this.cachedRuntimeInfo = info;
|
||||
return { ...info };
|
||||
}
|
||||
|
||||
async run(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions
|
||||
): Promise<AgentRunResult> {
|
||||
const events = this.stream(prompt, options);
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
let finalText = "";
|
||||
@@ -367,6 +447,9 @@ class CodexAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
// Update runtime info cache after the turn completes
|
||||
await this.refreshRuntimeInfoFromRollout();
|
||||
|
||||
return {
|
||||
sessionId: this.threadId ?? this.pendingLocalId,
|
||||
finalText,
|
||||
@@ -375,7 +458,10 @@ class CodexAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
async *stream(prompt: AgentPromptInput, options?: AgentRunOptions): AsyncGenerator<AgentStreamEvent> {
|
||||
async *stream(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const thread = this.thread;
|
||||
if (!thread) {
|
||||
throw new Error("Codex session is closed");
|
||||
@@ -405,7 +491,10 @@ class CodexAgentSession implements AgentSession {
|
||||
type: "turn.completed",
|
||||
} as ThreadEvent);
|
||||
void cancellation.catch((error) => {
|
||||
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
||||
console.warn(
|
||||
"[CodexAgentSession] Failed to stop Codex stream:",
|
||||
error
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
|
||||
@@ -433,6 +522,7 @@ class CodexAgentSession implements AgentSession {
|
||||
if (this.cancelCurrentTurn === requestCancel) {
|
||||
this.cancelCurrentTurn = null;
|
||||
}
|
||||
await this.refreshRuntimeInfoFromRollout();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,10 +574,15 @@ class CodexAgentSession implements AgentSession {
|
||||
return Array.from(this.pendingPermissions.values());
|
||||
}
|
||||
|
||||
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> {
|
||||
async respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse
|
||||
): Promise<void> {
|
||||
const request = this.pendingPermissions.get(requestId);
|
||||
if (!request) {
|
||||
throw new Error(`No pending Codex permission request with id '${requestId}'`);
|
||||
throw new Error(
|
||||
`No pending Codex permission request with id '${requestId}'`
|
||||
);
|
||||
}
|
||||
|
||||
this.pendingPermissions.delete(requestId);
|
||||
@@ -522,12 +617,13 @@ class CodexAgentSession implements AgentSession {
|
||||
if (!this.threadId) {
|
||||
return null;
|
||||
}
|
||||
const { model: _ignoredModel, ...restConfig } = this.config;
|
||||
this.persistence = {
|
||||
provider: "codex",
|
||||
sessionId: this.threadId,
|
||||
nativeHandle: this.threadId,
|
||||
metadata: {
|
||||
...this.config,
|
||||
...restConfig,
|
||||
codexSessionDir: this.codexSessionDir ?? undefined,
|
||||
codexRolloutPath: this.rolloutPath ?? undefined,
|
||||
},
|
||||
@@ -568,10 +664,13 @@ class CodexAgentSession implements AgentSession {
|
||||
options.model = this.config.model;
|
||||
}
|
||||
if (this.config.sandboxMode) {
|
||||
options.sandboxMode = coerceSandboxMode(this.config.sandboxMode) ?? options.sandboxMode;
|
||||
options.sandboxMode =
|
||||
coerceSandboxMode(this.config.sandboxMode) ?? options.sandboxMode;
|
||||
}
|
||||
if (this.config.approvalPolicy) {
|
||||
options.approvalPolicy = coerceApprovalMode(this.config.approvalPolicy) ?? options.approvalPolicy;
|
||||
options.approvalPolicy =
|
||||
coerceApprovalMode(this.config.approvalPolicy) ??
|
||||
options.approvalPolicy;
|
||||
}
|
||||
if (typeof this.config.networkAccess === "boolean") {
|
||||
options.networkAccessEnabled = this.config.networkAccess;
|
||||
@@ -581,10 +680,13 @@ class CodexAgentSession implements AgentSession {
|
||||
}
|
||||
if (this.config.reasoningEffort) {
|
||||
options.modelReasoningEffort =
|
||||
coerceReasoningEffort(this.config.reasoningEffort) ?? options.modelReasoningEffort;
|
||||
coerceReasoningEffort(this.config.reasoningEffort) ??
|
||||
options.modelReasoningEffort;
|
||||
}
|
||||
|
||||
const extra = normalizeExtraOptions(this.config.extra?.codex as Record<string, unknown> | undefined);
|
||||
const extra = normalizeExtraOptions(
|
||||
this.config.extra?.codex as Record<string, unknown> | undefined
|
||||
);
|
||||
if (extra) {
|
||||
Object.assign(options, extra);
|
||||
}
|
||||
@@ -592,7 +694,9 @@ class CodexAgentSession implements AgentSession {
|
||||
return options;
|
||||
}
|
||||
|
||||
private async loadReplayHistory(handle: AgentPersistenceHandle): Promise<void> {
|
||||
private async loadReplayHistory(
|
||||
handle: AgentPersistenceHandle
|
||||
): Promise<void> {
|
||||
const threadIdentifier = handle.sessionId ?? handle.nativeHandle;
|
||||
if (!threadIdentifier || !this.codexSessionDir) {
|
||||
return;
|
||||
@@ -604,7 +708,10 @@ class CodexAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (!rolloutFile) {
|
||||
rolloutFile = await findRolloutFile(threadIdentifier, this.codexSessionDir);
|
||||
rolloutFile = await findRolloutFile(
|
||||
threadIdentifier,
|
||||
this.codexSessionDir
|
||||
);
|
||||
}
|
||||
|
||||
if (!rolloutFile) {
|
||||
@@ -612,6 +719,7 @@ class CodexAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
this.rolloutPath = rolloutFile;
|
||||
await this.refreshRuntimeInfoFromRollout();
|
||||
|
||||
try {
|
||||
const events = await parseRolloutFile(rolloutFile, threadIdentifier);
|
||||
@@ -619,11 +727,16 @@ class CodexAgentSession implements AgentSession {
|
||||
this.historyEvents = events;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[CodexAgentSession] Failed to parse rollout ${rolloutFile}:`, error);
|
||||
console.warn(
|
||||
`[CodexAgentSession] Failed to parse rollout ${rolloutFile}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveCodexSessionDir(handle?: AgentPersistenceHandle): string | null {
|
||||
private resolveCodexSessionDir(
|
||||
handle?: AgentPersistenceHandle
|
||||
): string | null {
|
||||
const metadataDir = readMetadataString(handle, "codexSessionDir");
|
||||
if (metadataDir) {
|
||||
return metadataDir;
|
||||
@@ -639,7 +752,8 @@ class CodexAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const extraHome = readCodexExtraString(this.config, "home");
|
||||
const codexHome = extraHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
const codexHome =
|
||||
extraHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
return path.join(codexHome, "sessions");
|
||||
}
|
||||
|
||||
@@ -652,6 +766,46 @@ class CodexAgentSession implements AgentSession {
|
||||
return extraRollout ?? null;
|
||||
}
|
||||
|
||||
private async refreshRuntimeInfoFromRollout(): Promise<void> {
|
||||
if (!this.rolloutPath) {
|
||||
const threadIdentifier = this.threadId;
|
||||
if (threadIdentifier && this.codexSessionDir) {
|
||||
const rolloutFile = await findRolloutFile(
|
||||
threadIdentifier,
|
||||
this.codexSessionDir
|
||||
);
|
||||
if (rolloutFile) {
|
||||
this.rolloutPath = rolloutFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
const modelFromRollout = await this.readRuntimeModelFromRollout();
|
||||
if (!modelFromRollout && this.cachedRuntimeInfo) {
|
||||
return;
|
||||
}
|
||||
this.cachedRuntimeInfo = {
|
||||
provider: "codex",
|
||||
sessionId: this.threadId ?? this.pendingLocalId ?? null,
|
||||
model:
|
||||
modelFromRollout ??
|
||||
this.threadOptions.model ??
|
||||
null,
|
||||
modeId: this.currentMode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async readRuntimeModelFromRollout(): Promise<string | null> {
|
||||
const rollout = this.rolloutPath;
|
||||
if (!rollout) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await readLatestTurnContextModel(rollout);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildTurnOptions(options?: AgentRunOptions): TurnOptions {
|
||||
if (!options?.outputSchema) {
|
||||
return {};
|
||||
@@ -680,7 +834,11 @@ class CodexAgentSession implements AgentSession {
|
||||
this.threadId = event.thread_id;
|
||||
this.pendingLocalId = event.thread_id;
|
||||
this.persistence = null;
|
||||
yield { type: "thread_started", provider: "codex", sessionId: event.thread_id };
|
||||
yield {
|
||||
type: "thread_started",
|
||||
provider: "codex",
|
||||
sessionId: event.thread_id,
|
||||
};
|
||||
break;
|
||||
case "turn.started":
|
||||
yield { type: "turn_started", provider: "codex" };
|
||||
@@ -724,7 +882,10 @@ class CodexAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handlePermissionEvent(event: ThreadEvent): AgentStreamEvent[] | null {
|
||||
const eventType = typeof (event as { type?: string }).type === "string" ? (event as { type?: string }).type : null;
|
||||
const eventType =
|
||||
typeof (event as { type?: string }).type === "string"
|
||||
? (event as { type?: string }).type
|
||||
: null;
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
@@ -742,7 +903,9 @@ class CodexAgentSession implements AgentSession {
|
||||
return null;
|
||||
}
|
||||
|
||||
private enqueuePermissionRequest(request: AgentPermissionRequest | null): AgentStreamEvent[] | null {
|
||||
private enqueuePermissionRequest(
|
||||
request: AgentPermissionRequest | null
|
||||
): AgentStreamEvent[] | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
@@ -769,7 +932,9 @@ class CodexAgentSession implements AgentSession {
|
||||
return events;
|
||||
}
|
||||
|
||||
private buildExecPermissionRequest(rawInput: unknown): AgentPermissionRequest | null {
|
||||
private buildExecPermissionRequest(
|
||||
rawInput: unknown
|
||||
): AgentPermissionRequest | null {
|
||||
if (!rawInput || typeof rawInput !== "object") {
|
||||
return null;
|
||||
}
|
||||
@@ -778,14 +943,24 @@ class CodexAgentSession implements AgentSession {
|
||||
const commandParts: string[] = Array.isArray(raw.command)
|
||||
? raw.command.filter((entry: unknown) => typeof entry === "string")
|
||||
: typeof raw.command === "string"
|
||||
? [raw.command]
|
||||
: [];
|
||||
? [raw.command]
|
||||
: [];
|
||||
const commandText = commandParts.join(" ").trim();
|
||||
const cwd = typeof raw.cwd === "string" && raw.cwd.length ? raw.cwd : undefined;
|
||||
const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID();
|
||||
const reason = typeof raw.reason === "string" && raw.reason.length ? raw.reason : undefined;
|
||||
const risk = raw.risk && typeof raw.risk === "object" ? raw.risk : undefined;
|
||||
const parsedCommand = Array.isArray(raw.parsed_cmd) ? raw.parsed_cmd : undefined;
|
||||
const cwd =
|
||||
typeof raw.cwd === "string" && raw.cwd.length ? raw.cwd : undefined;
|
||||
const requestId =
|
||||
typeof raw.call_id === "string" && raw.call_id.length
|
||||
? raw.call_id
|
||||
: randomUUID();
|
||||
const reason =
|
||||
typeof raw.reason === "string" && raw.reason.length
|
||||
? raw.reason
|
||||
: undefined;
|
||||
const risk =
|
||||
raw.risk && typeof raw.risk === "object" ? raw.risk : undefined;
|
||||
const parsedCommand = Array.isArray(raw.parsed_cmd)
|
||||
? raw.parsed_cmd
|
||||
: undefined;
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
callId: raw.call_id,
|
||||
@@ -822,21 +997,35 @@ class CodexAgentSession implements AgentSession {
|
||||
return request;
|
||||
}
|
||||
|
||||
private buildPatchPermissionRequest(rawInput: unknown): AgentPermissionRequest | null {
|
||||
private buildPatchPermissionRequest(
|
||||
rawInput: unknown
|
||||
): AgentPermissionRequest | null {
|
||||
if (!rawInput || typeof rawInput !== "object") {
|
||||
return null;
|
||||
}
|
||||
const raw = rawInput as PatchPermissionRequestPayload;
|
||||
|
||||
const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID();
|
||||
const changes = raw.changes && typeof raw.changes === "object" ? raw.changes : undefined;
|
||||
const requestId =
|
||||
typeof raw.call_id === "string" && raw.call_id.length
|
||||
? raw.call_id
|
||||
: randomUUID();
|
||||
const changes =
|
||||
raw.changes && typeof raw.changes === "object" ? raw.changes : undefined;
|
||||
const filePaths = changes ? Object.keys(changes) : [];
|
||||
const grantRoot = typeof raw.grant_root === "string" && raw.grant_root.length ? raw.grant_root : undefined;
|
||||
const reason = typeof raw.reason === "string" && raw.reason.length ? raw.reason : undefined;
|
||||
const grantRoot =
|
||||
typeof raw.grant_root === "string" && raw.grant_root.length
|
||||
? raw.grant_root
|
||||
: undefined;
|
||||
const reason =
|
||||
typeof raw.reason === "string" && raw.reason.length
|
||||
? raw.reason
|
||||
: undefined;
|
||||
|
||||
const title =
|
||||
filePaths.length > 0
|
||||
? `Apply patch to ${filePaths.length} file${filePaths.length === 1 ? "" : "s"}`
|
||||
? `Apply patch to ${filePaths.length} file${
|
||||
filePaths.length === 1 ? "" : "s"
|
||||
}`
|
||||
: "Apply patch";
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
@@ -881,17 +1070,24 @@ class CodexAgentSession implements AgentSession {
|
||||
const exitCode = (item as any)?.exit_code;
|
||||
const cwd = (item as any)?.cwd;
|
||||
const commandValue = item.command;
|
||||
const command = typeof commandValue === "string" ? commandValue :
|
||||
Array.isArray(commandValue) ? (commandValue as string[]).join(" ") : "command";
|
||||
const command =
|
||||
typeof commandValue === "string"
|
||||
? commandValue
|
||||
: Array.isArray(commandValue)
|
||||
? (commandValue as string[]).join(" ")
|
||||
: "command";
|
||||
|
||||
// Build structured command result matching StructuredToolResult type
|
||||
const structuredOutput = typeof aggregatedOutput === "string" ? {
|
||||
type: "command" as const,
|
||||
command,
|
||||
output: aggregatedOutput,
|
||||
exitCode: typeof exitCode === "number" ? exitCode : undefined,
|
||||
cwd,
|
||||
} : undefined;
|
||||
const structuredOutput =
|
||||
typeof aggregatedOutput === "string"
|
||||
? {
|
||||
type: "command" as const,
|
||||
command,
|
||||
output: aggregatedOutput,
|
||||
exitCode: typeof exitCode === "number" ? exitCode : undefined,
|
||||
cwd,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return createToolCallTimelineItem({
|
||||
server: "command",
|
||||
@@ -906,7 +1102,10 @@ class CodexAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
case "file_change": {
|
||||
const files = item.changes.map((change) => ({ path: change.path, kind: change.kind }));
|
||||
const files = item.changes.map((change) => ({
|
||||
path: change.path,
|
||||
kind: change.kind,
|
||||
}));
|
||||
return createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
@@ -968,7 +1167,10 @@ async function fileExists(filePath: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function findRolloutFile(threadId: string, root: string): Promise<string | null> {
|
||||
async function findRolloutFile(
|
||||
threadId: string,
|
||||
root: string
|
||||
): Promise<string | null> {
|
||||
const stack: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
|
||||
while (stack.length > 0) {
|
||||
const { dir, depth } = stack.pop()!;
|
||||
@@ -983,7 +1185,8 @@ async function findRolloutFile(threadId: string, root: string): Promise<string |
|
||||
if (entry.isFile()) {
|
||||
const matchesThread = entry.name.includes(threadId);
|
||||
const matchesPrefix = entry.name.startsWith("rollout-");
|
||||
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||
const matchesExtension =
|
||||
entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||
if (matchesThread && matchesPrefix && matchesExtension) {
|
||||
return entryPath;
|
||||
}
|
||||
@@ -995,10 +1198,18 @@ async function findRolloutFile(threadId: string, root: string): Promise<string |
|
||||
return null;
|
||||
}
|
||||
|
||||
async function parseRolloutFile(filePath: string, threadId: string): Promise<AgentStreamEvent[]> {
|
||||
async function parseRolloutFile(
|
||||
filePath: string,
|
||||
threadId: string
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const events: AgentStreamEvent[] = [{ type: "thread_started", provider: "codex", sessionId: threadId }];
|
||||
const lines = content
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const events: AgentStreamEvent[] = [
|
||||
{ type: "thread_started", provider: "codex", sessionId: threadId },
|
||||
];
|
||||
const commandCalls = new Map<string, { command: string; cwd?: string }>();
|
||||
|
||||
for (const line of lines) {
|
||||
@@ -1015,6 +1226,40 @@ async function parseRolloutFile(filePath: string, threadId: string): Promise<Age
|
||||
return events;
|
||||
}
|
||||
|
||||
export async function readLatestTurnContextModel(
|
||||
filePath: string
|
||||
): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const lines = content.split(/\r?\n/).filter(Boolean);
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
const line = lines[i];
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
(parsed as { type?: string }).type === "turn_context"
|
||||
) {
|
||||
const payload = (parsed as { payload?: unknown }).payload;
|
||||
if (payload && typeof payload === "object") {
|
||||
const model = (payload as Record<string, unknown>).model;
|
||||
if (typeof model === "string" && model.trim().length > 0) {
|
||||
return model.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRolloutEntry(line: string): RolloutEntry | null {
|
||||
if (!line) {
|
||||
return null;
|
||||
@@ -1024,7 +1269,11 @@ function parseRolloutEntry(line: string): RolloutEntry | null {
|
||||
if (isRolloutEntry(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
if (parsed && typeof parsed === "object" && typeof (parsed as { output?: unknown }).output === "string") {
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
typeof (parsed as { output?: unknown }).output === "string"
|
||||
) {
|
||||
return parseRolloutEntry((parsed as { output: string }).output);
|
||||
}
|
||||
} catch {
|
||||
@@ -1042,7 +1291,11 @@ function isRolloutEntry(value: unknown): value is RolloutEntry {
|
||||
}
|
||||
|
||||
function isSessionMetaEntry(value: unknown): value is SessionMetaEntry {
|
||||
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "session_meta");
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as { type?: unknown }).type === "session_meta"
|
||||
);
|
||||
}
|
||||
|
||||
function handleRolloutResponseItem(
|
||||
@@ -1059,12 +1312,20 @@ function handleRolloutResponseItem(
|
||||
const text = extractMessageText(payload.content);
|
||||
if (text) {
|
||||
if (payload.role === "assistant") {
|
||||
events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text } });
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text },
|
||||
});
|
||||
} else if (payload.role === "user") {
|
||||
if (isSyntheticRolloutUserMessage(text)) {
|
||||
break;
|
||||
}
|
||||
events.push({ type: "timeline", provider: "codex", item: { type: "user_message", text } });
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "user_message", text },
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -1072,7 +1333,11 @@ function handleRolloutResponseItem(
|
||||
case "reasoning": {
|
||||
const text = extractReasoningText(payload);
|
||||
if (text) {
|
||||
events.push({ type: "timeline", provider: "codex", item: { type: "reasoning", text } });
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "reasoning", text },
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1093,12 +1358,19 @@ function handleRolloutResponseItem(
|
||||
}
|
||||
}
|
||||
|
||||
function handleRolloutEventMessage(payload: RolloutEventPayload | undefined, events: AgentStreamEvent[]): void {
|
||||
function handleRolloutEventMessage(
|
||||
payload: RolloutEventPayload | undefined,
|
||||
events: AgentStreamEvent[]
|
||||
): void {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
if (payload.type === "agent_reasoning" && typeof payload.text === "string") {
|
||||
events.push({ type: "timeline", provider: "codex", item: { type: "reasoning", text: payload.text } });
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "reasoning", text: payload.text },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,9 +1387,12 @@ function handleRolloutFunctionCall(
|
||||
if (SHELL_FUNCTION_NAMES.has(name)) {
|
||||
const args = safeJsonParse<Record<string, unknown>>(payload.arguments);
|
||||
const command = formatCommand(args);
|
||||
const cwd = args && typeof args === "object" && typeof (args as { workdir?: unknown }).workdir === "string"
|
||||
? ((args as { workdir?: unknown }).workdir as string)
|
||||
: undefined;
|
||||
const cwd =
|
||||
args &&
|
||||
typeof args === "object" &&
|
||||
typeof (args as { workdir?: unknown }).workdir === "string"
|
||||
? ((args as { workdir?: unknown }).workdir as string)
|
||||
: undefined;
|
||||
if (command && typeof payload.call_id === "string") {
|
||||
commandCalls.set(payload.call_id, { command, cwd });
|
||||
events.push({
|
||||
@@ -1141,7 +1416,11 @@ function handleRolloutFunctionCall(
|
||||
const args = safeJsonParse<{ plan?: unknown }>(payload.arguments);
|
||||
const planItems = parsePlanItems(args);
|
||||
if (planItems.length) {
|
||||
events.push({ type: "timeline", provider: "codex", item: { type: "todo", items: planItems } });
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "todo", items: planItems },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1175,16 +1454,19 @@ function finalizeRolloutFunctionCall(
|
||||
}
|
||||
const result = safeJsonParse<CommandExecutionResult>(payload.output);
|
||||
const exitCode = result?.metadata?.exit_code;
|
||||
const status = exitCode === undefined || exitCode === 0 ? "completed" : "failed";
|
||||
const status =
|
||||
exitCode === undefined || exitCode === 0 ? "completed" : "failed";
|
||||
|
||||
// Build structured command output
|
||||
const output = result?.stdout ? {
|
||||
type: "command" as const,
|
||||
command: command.command,
|
||||
output: result.stdout,
|
||||
exitCode,
|
||||
cwd: command.cwd,
|
||||
} : result;
|
||||
const output = result?.stdout
|
||||
? {
|
||||
type: "command" as const,
|
||||
command: command.command,
|
||||
output: result.stdout,
|
||||
exitCode,
|
||||
cwd: command.cwd,
|
||||
}
|
||||
: result;
|
||||
|
||||
events.push({
|
||||
type: "timeline",
|
||||
@@ -1203,7 +1485,10 @@ function finalizeRolloutFunctionCall(
|
||||
commandCalls.delete(payload.call_id);
|
||||
}
|
||||
|
||||
function handleRolloutCustomToolCall(payload: RolloutCustomToolCallPayload, events: AgentStreamEvent[]): void {
|
||||
function handleRolloutCustomToolCall(
|
||||
payload: RolloutCustomToolCallPayload,
|
||||
events: AgentStreamEvent[]
|
||||
): void {
|
||||
if (payload?.name === "apply_patch" && typeof payload.input === "string") {
|
||||
const patchText = payload.input;
|
||||
const files = parsePatchFiles(patchText);
|
||||
@@ -1339,7 +1624,10 @@ type SessionMetaEntry = {
|
||||
payload?: SessionMetaPayload;
|
||||
};
|
||||
|
||||
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
|
||||
async function collectRecentRolloutFiles(
|
||||
rootDir: string,
|
||||
limit: number
|
||||
): Promise<RolloutCandidate[]> {
|
||||
const candidates: RolloutCandidate[] = [];
|
||||
async function traverse(dir: string): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
@@ -1402,8 +1690,12 @@ async function parseRolloutDescriptor(
|
||||
continue;
|
||||
}
|
||||
if (!sessionId && isSessionMetaEntry(entry) && entry.payload) {
|
||||
sessionId = typeof entry.payload.id === "string" ? entry.payload.id : null;
|
||||
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
|
||||
sessionId =
|
||||
typeof entry.payload.id === "string" ? entry.payload.id : null;
|
||||
if (
|
||||
typeof entry.payload.cwd === "string" &&
|
||||
entry.payload.cwd.length > 0
|
||||
) {
|
||||
cwd = entry.payload.cwd;
|
||||
}
|
||||
}
|
||||
@@ -1467,7 +1759,8 @@ function extractMessageText(content: unknown): string {
|
||||
parts.push(text.trim());
|
||||
continue;
|
||||
}
|
||||
const message = typeof record.message === "string" ? record.message : undefined;
|
||||
const message =
|
||||
typeof record.message === "string" ? record.message : undefined;
|
||||
if (message && message.trim()) {
|
||||
parts.push(message.trim());
|
||||
}
|
||||
@@ -1482,7 +1775,10 @@ export function isSyntheticRolloutUserMessage(text: string): boolean {
|
||||
}
|
||||
|
||||
const lower = normalized.toLowerCase();
|
||||
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
|
||||
if (
|
||||
lower.startsWith("# agents.md instructions for") &&
|
||||
lower.includes("<instructions>")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1510,7 +1806,10 @@ function extractReasoningText(payload: RolloutReasoningPayload): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function loadCodexPersistedTimeline(filePath: string, threadId: string): Promise<AgentTimelineItem[]> {
|
||||
async function loadCodexPersistedTimeline(
|
||||
filePath: string,
|
||||
threadId: string
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
try {
|
||||
const events = await parseRolloutFile(filePath, threadId);
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
@@ -1525,13 +1824,19 @@ async function loadCodexPersistedTimeline(filePath: string, threadId: string): P
|
||||
}
|
||||
return timeline;
|
||||
} catch (error) {
|
||||
console.warn(`[CodexAgentSession] Failed to load persisted timeline for ${threadId}:`, error);
|
||||
console.warn(
|
||||
`[CodexAgentSession] Failed to load persisted timeline for ${threadId}:`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parsePlanItems(args: unknown): { text: string; completed: boolean }[] {
|
||||
const planValue = typeof args === "object" && args ? (args as { plan?: unknown }).plan : undefined;
|
||||
const planValue =
|
||||
typeof args === "object" && args
|
||||
? (args as { plan?: unknown }).plan
|
||||
: undefined;
|
||||
const plan = Array.isArray(planValue) ? planValue : [];
|
||||
const items: { text: string; completed: boolean }[] = [];
|
||||
for (const step of plan) {
|
||||
@@ -1539,7 +1844,12 @@ function parsePlanItems(args: unknown): { text: string; completed: boolean }[] {
|
||||
continue;
|
||||
}
|
||||
const record = step as { step?: unknown; text?: unknown; status?: unknown };
|
||||
const text = typeof record.step === "string" ? record.step : typeof record.text === "string" ? record.text : "";
|
||||
const text =
|
||||
typeof record.step === "string"
|
||||
? record.step
|
||||
: typeof record.text === "string"
|
||||
? record.text
|
||||
: "";
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
@@ -1600,7 +1910,10 @@ function formatCommand(args: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readCodexExtraString(config: CodexAgentConfig, key: string): string | undefined {
|
||||
function readCodexExtraString(
|
||||
config: CodexAgentConfig,
|
||||
key: string
|
||||
): string | undefined {
|
||||
const extras = config.extra?.codex;
|
||||
if (!extras || typeof extras !== "object") {
|
||||
return undefined;
|
||||
@@ -1609,7 +1922,10 @@ function readCodexExtraString(config: CodexAgentConfig, key: string): string | u
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readMetadataString(handle: AgentPersistenceHandle | undefined, key: string): string | undefined {
|
||||
function readMetadataString(
|
||||
handle: AgentPersistenceHandle | undefined,
|
||||
key: string
|
||||
): string | undefined {
|
||||
if (!handle?.metadata || typeof handle.metadata !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1617,8 +1933,12 @@ function readMetadataString(handle: AgentPersistenceHandle | undefined, key: str
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function sanitizeMetadata(metadata: Record<string, unknown>): Record<string, unknown> | undefined {
|
||||
const entries = Object.entries(metadata).filter(([_, value]) => value !== undefined && value !== null);
|
||||
function sanitizeMetadata(
|
||||
metadata: Record<string, unknown>
|
||||
): Record<string, unknown> | undefined {
|
||||
const entries = Object.entries(metadata).filter(
|
||||
([_, value]) => value !== undefined && value !== null
|
||||
);
|
||||
if (!entries.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import { readLatestTurnContextModel } from "./codex-agent.js";
|
||||
|
||||
describe("readLatestTurnContextModel", () => {
|
||||
it("returns the most recent turn_context model from rollout", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-rollout-"));
|
||||
const file = path.join(dir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session_meta", payload: { id: "thread-1" } }),
|
||||
JSON.stringify({
|
||||
type: "turn_context",
|
||||
payload: { model: "gpt-5.1-codex", cwd: "/tmp" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "turn_context",
|
||||
payload: { model: "gpt-5.2-codex", cwd: "/tmp" },
|
||||
}),
|
||||
];
|
||||
await fs.writeFile(file, lines.join("\n"), "utf8");
|
||||
|
||||
const model = await readLatestTurnContextModel(file);
|
||||
expect(model).toBe("gpt-5.2-codex");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentRuntimeInfo,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
@@ -214,6 +215,14 @@ const AgentPersistenceHandleSchema: z.ZodType<AgentPersistenceHandle | null> =
|
||||
})
|
||||
.nullable();
|
||||
|
||||
const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
sessionId: z.string().nullable(),
|
||||
model: z.string().nullable().optional(),
|
||||
modeId: z.string().nullable().optional(),
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const AgentSnapshotPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
provider: AgentProviderSchema,
|
||||
@@ -229,6 +238,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
availableModes: z.array(AgentModeSchema),
|
||||
pendingPermissions: z.array(AgentPermissionRequestPayloadSchema),
|
||||
persistence: AgentPersistenceHandleSchema.nullable(),
|
||||
runtimeInfo: AgentRuntimeInfoSchema.optional(),
|
||||
lastUsage: AgentUsageSchema.optional(),
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CodexAgentClient } from "@paseo/server/src/server/agent/providers/codex-agent.js";
|
||||
|
||||
async function test() {
|
||||
console.log("Creating CodexAgentClient...");
|
||||
const client = new CodexAgentClient();
|
||||
|
||||
console.log("Creating session...");
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
console.log("Session created:", session.id);
|
||||
console.log("\nNow check ps to see which codex binary is running!\n");
|
||||
|
||||
// Run a simple prompt using stream to see all events
|
||||
console.log("Running test prompt...");
|
||||
console.log("Sleeping 30s - check ps for the args now!");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30000));
|
||||
|
||||
const events = session.stream('echo "test"');
|
||||
|
||||
for await (const event of events) {
|
||||
if (event.type === "turn_completed") {
|
||||
console.log("Turn completed");
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
console.log("Session closed");
|
||||
}
|
||||
|
||||
test().catch(console.error);
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
|
||||
console.log('Testing codexPathOverride option...\n');
|
||||
|
||||
// Test 1: Default (embedded binary)
|
||||
console.log('Test 1: Using default embedded binary');
|
||||
const defaultCodex = new Codex();
|
||||
console.log('✓ Created Codex instance with default settings');
|
||||
|
||||
// Test 2: System binary via codexPathOverride
|
||||
console.log('\nTest 2: Using system binary via codexPathOverride');
|
||||
const systemCodex = new Codex({
|
||||
codexPathOverride: '/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/codex'
|
||||
});
|
||||
console.log('✓ Created Codex instance with codexPathOverride');
|
||||
|
||||
// Test 3: Using which codex
|
||||
console.log('\nTest 3: Using which codex result');
|
||||
import { execSync } from 'child_process';
|
||||
const whichCodex = execSync('which codex', { encoding: 'utf8' }).trim();
|
||||
console.log('System codex location:', whichCodex);
|
||||
|
||||
const whichCodexInstance = new Codex({
|
||||
codexPathOverride: whichCodex
|
||||
});
|
||||
console.log('✓ Created Codex instance with system codex from PATH');
|
||||
|
||||
console.log('\n✅ All tests passed! codexPathOverride option is available and working.');
|
||||
console.log('\nConclusion: You can use the system-installed codex by passing:');
|
||||
console.log(' new Codex({ codexPathOverride: "/path/to/system/codex" })');
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
fetchClaudeModelCatalog,
|
||||
fetchCodexModelCatalog,
|
||||
} from "./packages/server/dist/server/agent/model-catalog.js";
|
||||
|
||||
console.log("=== Testing Claude Model Catalog ===\n");
|
||||
try {
|
||||
const claudeModels = await fetchClaudeModelCatalog();
|
||||
console.log(`Found ${claudeModels.length} Claude models:\n`);
|
||||
claudeModels.forEach((model, idx) => {
|
||||
console.log(`${idx + 1}. ${model.label} (${model.id})`);
|
||||
console.log(` Description: ${model.description || "N/A"}`);
|
||||
console.log(` Is Default: ${model.isDefault || false}`);
|
||||
console.log("");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching Claude models:", error.message);
|
||||
}
|
||||
|
||||
console.log("\n=== Testing Codex Model Catalog ===\n");
|
||||
try {
|
||||
const codexModels = await fetchCodexModelCatalog();
|
||||
console.log(`Found ${codexModels.length} Codex models:\n`);
|
||||
codexModels.forEach((model, idx) => {
|
||||
console.log(`${idx + 1}. ${model.label} (${model.id})`);
|
||||
console.log(` Description: ${model.description || "N/A"}`);
|
||||
console.log(` Is Default: ${model.isDefault || false}`);
|
||||
if (model.metadata) {
|
||||
console.log(` Model: ${model.metadata.model || "N/A"}`);
|
||||
console.log(` Default Reasoning Effort: ${model.metadata.defaultReasoningEffort || "N/A"}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching Codex models:", error.message);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
fetchClaudeModelCatalog,
|
||||
fetchCodexModelCatalog,
|
||||
} from "./packages/server/src/server/agent/model-catalog.js";
|
||||
|
||||
async function main() {
|
||||
console.log("=== Testing Claude Model Catalog ===\n");
|
||||
try {
|
||||
const claudeModels = await fetchClaudeModelCatalog();
|
||||
console.log(`Found ${claudeModels.length} Claude models:\n`);
|
||||
claudeModels.forEach((model, idx) => {
|
||||
console.log(`${idx + 1}. ${model.label} (${model.id})`);
|
||||
console.log(` Description: ${model.description || "N/A"}`);
|
||||
console.log(` Is Default: ${model.isDefault || false}`);
|
||||
console.log("");
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching Claude models:", error.message);
|
||||
}
|
||||
|
||||
console.log("\n=== Testing Codex Model Catalog ===\n");
|
||||
try {
|
||||
const codexModels = await fetchCodexModelCatalog();
|
||||
console.log(`Found ${codexModels.length} Codex models:\n`);
|
||||
codexModels.forEach((model, idx) => {
|
||||
console.log(`${idx + 1}. ${model.label} (${model.id})`);
|
||||
console.log(` Description: ${model.description || "N/A"}`);
|
||||
console.log(` Is Default: ${model.isDefault || false}`);
|
||||
if (model.metadata) {
|
||||
console.log(` Model: ${model.metadata.model || "N/A"}`);
|
||||
console.log(` Default Reasoning Effort: ${model.metadata.defaultReasoningEffort || "N/A"}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching Codex models:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CodexAgentClient } from "@paseo/server/src/server/agent/providers/codex-agent.js";
|
||||
|
||||
async function test() {
|
||||
console.log("Creating CodexAgentClient...");
|
||||
const client = new CodexAgentClient();
|
||||
|
||||
console.log("Creating session...");
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
console.log("Session created:", session.id);
|
||||
console.log("\nNow check ps to see which codex binary is running!\n");
|
||||
|
||||
// Run a simple prompt using stream to see all events
|
||||
console.log("Running test prompt...");
|
||||
console.log("Sleeping 30s - check ps for the args now!");
|
||||
await new Promise((resolve) => setTimeout(resolve, 30000));
|
||||
|
||||
const events = session.stream('echo "test"');
|
||||
|
||||
for await (const event of events) {
|
||||
if (event.type === "turn_completed") {
|
||||
console.log("Turn completed");
|
||||
}
|
||||
}
|
||||
|
||||
await session.close();
|
||||
console.log("Session closed");
|
||||
}
|
||||
|
||||
test().catch(console.error);
|
||||
Reference in New Issue
Block a user