feat: Add Task tool & MCP awareness with ETag-based prompt verification

This release enhances the bridge prompt system with awareness of OpenCode's
advanced capabilities and implements cache-based verification for more robust
prompt filtering.

**New Features:**
- Task tool awareness: Bridge prompt now documents sub-agent system
- MCP tool awareness: Documents Model Context Protocol tool conventions
- OpenCode prompt verification: ETag-based caching of codex.txt for 100% accurate filtering
- AGENTS.md: Renamed from CLAUDE.md for broader AI agent applicability

**Technical Changes:**
- Created lib/prompts/opencode-codex.ts for prompt verification caching
- Made request transformation pipeline async to support cache fetching
- Enhanced isOpenCodeSystemPrompt() with dual verification (cached + fallback)
- Updated bridge prompt with "Advanced Tools" section (~550 tokens, was ~450)
- Added 6 new tests verifying AGENTS.md content safety (129 total tests)

**Verification System:**
Primary: Compare against cached OpenCode prompt from GitHub
Fallback: Text signature matching ("You are a coding agent running in")
This ensures 100% confidence when filtering OpenCode system prompts

**Breaking Changes:** None - all changes are additive

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Numman Ali
2025-10-04 11:22:17 +01:00
co-authored by Hanzo Dev
parent 63cc0761fc
commit b32f67ba81
10 changed files with 656 additions and 675 deletions
+3 -1
View File
@@ -3,4 +3,6 @@ bun.lockb
pnpm-lock.yaml
dist/
.DS_Store
.history/
.history/
opencode.json
.opencode/
+144 -519
View File
@@ -1,567 +1,192 @@
# AI Agent Maintenance Guide
# AGENTS.md
This guide is specifically written for AI coding assistants (Claude, GPT-4, etc.) maintaining the OpenAI Codex OAuth plugin. It provides quick reference for common tasks and patterns to follow.
This file provides coding guidance for AI agents (including Claude Code, Codex, and others) when working with code in this repository.
## 🎯 Core Philosophy
## Overview
This codebase follows these principles:
1. **Single Responsibility** - Each function does one thing well
2. **Centralized Configuration** - All magic values in `lib/constants.ts`
3. **Type Safety First** - Strict TypeScript throughout
4. **Test Everything** - 93 tests covering all functionality
5. **Clear Naming** - Function names describe exactly what they do
6. **Comprehensive JSDoc** - Every public function documented
This is an **opencode plugin** that enables OAuth authentication with OpenAI's ChatGPT Plus/Pro Codex backend. It allows users to access `gpt-5-codex` and `gpt-5` models through their ChatGPT subscription instead of using OpenAI Platform API credits.
## 📁 Codebase Structure
**Key architecture principle**: 7-step fetch flow that intercepts opencode's OpenAI SDK requests, transforms them for the ChatGPT backend API, and handles OAuth token management.
### Quick Navigation
```
index.ts → Main plugin entry (213 lines)
├── exports OpenAIAuthPlugin
├── contains fetch() implementation (42 lines, 7 steps)
└── contains authorize() OAuth flow
lib/
├── constants.ts → ALL magic values, URLs, error messages
├── types.ts → TypeScript type definitions
├── fetch-helpers.ts → 10 focused helper functions
├── auth.ts → OAuth/PKCE authentication
├── request-transformer.ts → Request body transformation
├── response-handler.ts → SSE to JSON conversion
├── browser.ts → Platform-specific browser opening
├── codex.ts → Codex instructions fetching
├── logger.ts → Debug logging utilities
└── server.ts → Local OAuth callback server
test/
├── fetch-helpers.test.ts → 15 tests for helper functions
├── browser.test.ts → 4 tests for platform detection
├── auth.test.ts → 16 tests for OAuth flow
├── request-transformer.test.ts → 30 tests for transformations
├── response-handler.test.ts → 10 tests for SSE conversion
├── config.test.ts → 13 tests for configuration
└── logger.test.ts → 5 tests for logging
config/
├── full-opencode.json → Complete configuration example
├── minimal-opencode.json → Minimal configuration example
└── README.md → Configuration documentation
```
## 🔧 Common Modification Scenarios
### 1. Adding a New Header
**Location**: `lib/fetch-helpers.ts` → `createCodexHeaders()`
```typescript
export function createCodexHeaders(
init: RequestInit | undefined,
accountId: string,
accessToken: string
): Headers {
const headers = new Headers(init?.headers ?? {});
// Add your new header here
headers.set("X-New-Header", "value");
return headers;
}
```
**Test Location**: `test/fetch-helpers.test.ts`
```typescript
it('should include new header', () => {
const headers = createCodexHeaders(undefined, accountId, accessToken);
expect(headers.get('X-New-Header')).toBe('value');
});
```
### 2. Adding a New Constant
**Location**: `lib/constants.ts`
```typescript
// Add to appropriate category
export const NEW_CONSTANT_GROUP = {
KEY1: "value1",
KEY2: "value2",
} as const;
```
**Pattern**: Always use `as const` for type safety and group related constants together.
### 3. Modifying URL Rewriting Logic
**Location**: `lib/fetch-helpers.ts` → `rewriteUrlForCodex()`
```typescript
export function rewriteUrlForCodex(url: string): string {
// Add new URL transformations here
return url
.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES)
.replace(URL_PATHS.NEW_PATH, URL_PATHS.NEW_CODEX_PATH); // New transformation
}
```
**Test Location**: `test/fetch-helpers.test.ts`
```typescript
it('should rewrite new path correctly', () => {
const url = 'https://example.com/new-path';
expect(rewriteUrlForCodex(url)).toBe('https://example.com/codex/new-path');
});
```
### 4. Adding Request Transformation Logic
**Location**: `lib/request-transformer.ts` → `transformRequestBody()`
This function already handles:
- Reasoning effort configuration
- Reasoning summary configuration
- Text verbosity configuration
- System message injection
**Pattern**: Follow existing configuration pattern in `transformRequestBody()`.
### 5. Modifying Token Refresh Logic
**Location**: `lib/fetch-helpers.ts` → `shouldRefreshToken()` and `refreshAndUpdateToken()`
```typescript
// Check condition
export function shouldRefreshToken(auth: Auth): boolean {
return auth.type !== "oauth"
|| !auth.access
|| auth.expires < Date.now()
|| yourNewCondition; // Add here
}
// Refresh implementation
export async function refreshAndUpdateToken(
currentAuth: Auth,
client: OpencodeClient
): Promise<{ success: true; auth: Auth } | { success: false; response: Response }> {
// Modification goes here
}
```
### 6. Adding a New Platform for Browser Opening
**Location**: `lib/browser.ts` → `getBrowserOpener()`
**Step 1**: Add constant to `lib/constants.ts`
```typescript
export const PLATFORM_OPENERS = {
darwin: "open",
win32: "start",
linux: "xdg-open",
freebsd: "xdg-open", // Add new platform
} as const;
```
**Step 2**: Update `getBrowserOpener()`
```typescript
export function getBrowserOpener(): string {
const platform = process.platform;
if (platform === "darwin") return PLATFORM_OPENERS.darwin;
if (platform === "win32") return PLATFORM_OPENERS.win32;
if (platform === "freebsd") return PLATFORM_OPENERS.freebsd; // Add here
return PLATFORM_OPENERS.linux;
}
```
**Step 3**: Add test in `test/browser.test.ts`
```typescript
it('should handle freebsd platform', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'freebsd' });
expect(getBrowserOpener()).toBe(PLATFORM_OPENERS.freebsd);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
```
### 7. Adding Error Messages
**Location**: `lib/constants.ts` → `ERROR_MESSAGES`
```typescript
export const ERROR_MESSAGES = {
NO_ACCOUNT_ID: "Failed to extract accountId from token",
TOKEN_REFRESH_FAILED: "Failed to refresh token, authentication required",
NEW_ERROR: "Your new error message", // Add here
} as const;
```
**Usage**:
```typescript
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.NEW_ERROR}`);
```
### 8. Adding Configuration Options
**Location**: `lib/types.ts` → `ConfigOptions`
```typescript
export interface ConfigOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high";
reasoningSummary?: "auto" | "concise" | "detailed";
textVerbosity?: "low" | "medium" | "high";
newOption?: "value1" | "value2"; // Add here
include?: string[];
}
```
**Then update**: `lib/request-transformer.ts` to handle the new option.
## 🧪 Testing Requirements
### When to Add Tests
**ALWAYS add tests when**:
- Adding new functions
- Modifying existing logic
- Adding new constants (if they affect behavior)
- Adding new configuration options
- Changing URL rewriting
- Modifying header creation
- Updating token refresh logic
### Test Structure Pattern
```typescript
describe('Module Name', () => {
describe('functionName', () => {
it('should handle normal case', () => {
// Arrange
const input = createTestInput();
// Act
const result = functionName(input);
// Assert
expect(result).toBe(expectedValue);
});
it('should handle edge case', () => {
// Test edge case
});
it('should handle error case', () => {
// Test error handling
});
});
});
```
### Running Tests
## Build & Test Commands
```bash
npm test # Run all tests
npm test -- --watch # Watch mode
npm test -- --ui # Visual UI
npm test -- --coverage # Coverage report
# Build (compiles TypeScript + copies HTML file)
npm run build
# Type checking only (no build)
npm run typecheck
# Run all tests
npm test
# Watch mode for TDD
npm run test:watch
# Interactive test UI
npm run test:ui
# Coverage report
npm run test:coverage
```
**Requirement**: All 93 tests must pass before committing.
**Important**: The build script has a critical step that copies `lib/oauth-success.html` to `dist/lib/`. This HTML file is required for the OAuth callback flow.
## 📝 Code Patterns to Follow
## Code Architecture
### 1. Function Naming
### Plugin Flow (index.ts)
✅ **Good** - Verb + Noun, describes what it does:
```typescript
shouldRefreshToken()
createCodexHeaders()
extractRequestUrl()
rewriteUrlForCodex()
handleErrorResponse()
```
The main entry point orchestrates a **7-step fetch flow**:
❌ **Bad** - Vague or unclear:
```typescript
checkToken() // Check what about token?
makeHeaders() // Make what kind?
getUrl() // Get from where?
```
1. **Token Management**: Check token expiration, refresh if needed
2. **URL Rewriting**: Transform OpenAI Platform API URLs → ChatGPT backend API (`https://chatgpt.com/backend-api/codex/responses`)
3. **Request Transformation**:
- Normalize model names (all variants → `gpt-5` or `gpt-5-codex`)
- Inject Codex system instructions from latest GitHub release
- Apply reasoning configuration (effort, summary, verbosity)
- Add CODEX_MODE bridge prompt (default) or tool remap message (legacy)
- Filter OpenCode system prompts when in CODEX_MODE
- Filter conversation history (remove `rs_*` IDs for stateless operation)
4. **Headers**: Add OAuth token + ChatGPT account ID
5. **Request Execution**: Send to Codex backend
6. **Response Logging**: Optional debug logging (ENABLE_PLUGIN_REQUEST_LOGGING=1)
7. **Response Handling**: Convert SSE to JSON (non-tool requests) or pass through
### 2. JSDoc Comments
### Module Organization
**Always include** for public functions:
```typescript
/**
* Brief description of what function does
*
* @param paramName - Description of parameter
* @param anotherParam - Description of another parameter
* @returns Description of return value
*
* @example
* const result = functionName(input);
*/
export function functionName(paramName: Type, anotherParam: Type): ReturnType {
// implementation
}
```
**Core Plugin** (`index.ts`)
- Plugin definition and main fetch orchestration
- OAuth loader (extracts ChatGPT account ID from JWT)
- Configuration loading and CODEX_MODE determination
### 3. Error Handling
**Authentication** (`lib/auth/`)
- `auth.ts`: OAuth flow (PKCE, token exchange, JWT decoding, refresh)
- `server.ts`: Local HTTP server for OAuth callback (port 1455)
- `browser.ts`: Platform-specific browser opening
**Pattern**: Return result objects instead of throwing
```typescript
// ✅ Good - Discriminated union return type
type Result =
| { success: true; data: Data }
| { success: false; error: Error };
**Request Handling** (`lib/request/`)
- `fetch-helpers.ts`: 10 focused helper functions for main fetch flow
- `request-transformer.ts`: Body transformations (model normalization, reasoning config, input filtering)
- `response-handler.ts`: SSE to JSON conversion
export function doSomething(): Result {
if (errorCondition) {
return { success: false, error: new Error("message") };
}
return { success: true, data: result };
}
**Prompts** (`lib/prompts/`)
- `codex.ts`: Fetches Codex instructions from GitHub (ETag-cached), tool remap message
- `codex-opencode-bridge.ts`: CODEX_MODE bridge prompt for CLI parity
// Usage
const result = doSomething();
if (!result.success) {
return result.error;
}
// Use result.data
```
**Configuration** (`lib/`)
- `config.ts`: Plugin config loading, CODEX_MODE determination
- `constants.ts`: All magic values, URLs, error messages
- `types.ts`: TypeScript type definitions
- `logger.ts`: Debug logging (controlled by env var)
### 4. Logging
### Key Design Patterns
**Pattern**: Use constants and plugin name prefix
```typescript
import { PLUGIN_NAME, ERROR_MESSAGES, LOG_STAGES } from "./constants.js";
**1. Stateless Operation**: Uses `store: false` + `include: ["reasoning.encrypted_content"]`
- Allows multi-turn conversations without server-side storage
- Encrypted reasoning content persists context across turns
// ✅ Good
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`);
logRequest(LOG_STAGES.RESPONSE, data);
**2. CODEX_MODE** (enabled by default):
- **Priority**: `CODEX_MODE` env var > `~/.opencode/openai-codex-auth-config.json` > default (true)
- When enabled: Filters out OpenCode system prompts, adds Codex-OpenCode bridge prompt with Task tool & MCP awareness
- When disabled: Uses legacy tool remap message
- Bridge prompt (~550 tokens): Tool mappings, available tools, working style, **Task tool/sub-agent awareness**, **MCP tool awareness**
- **Prompt verification**: Caches OpenCode's codex.txt from GitHub (ETag-based) to verify exact prompt removal, with fallback to text signature matching
// ❌ Bad
console.error("Failed to refresh token");
console.log("Response:", data);
```
**3. Configuration Merging**:
- Global options (`provider.openai.options`) + per-model options (`provider.openai.models[name].options`)
- Model-specific options override global
- Plugin defaults: `reasoningEffort: "medium"`, `reasoningSummary: "auto"`, `textVerbosity: "medium"`
### 5. Type Definitions
**4. Model Normalization**:
- All `gpt-5-codex` variants → `gpt-5-codex`
- All `gpt-5` variants → `gpt-5`
- `minimal` effort auto-normalized to `low` for gpt-5-codex (API limitation)
**Pattern**: Import types with `type` keyword
```typescript
// ✅ Good
import type { Auth, OpencodeClient } from "@opencode-ai/sdk";
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
import type { UserConfig, ConfigOptions } from "./types.js";
**5. Codex Instructions Caching**:
- Fetches from latest release tag (not main branch)
- ETag-based HTTP conditional requests
- Cache invalidation when release tag changes
- Falls back to bundled version if GitHub unavailable
// ❌ Bad
import { Auth } from "@opencode-ai/sdk"; // Runtime import when only type is needed
```
## Development Patterns
### 6. Constants
### Adding New Configuration Options
**Pattern**: Group related constants, use `as const`
```typescript
// ✅ Good
export const OPENAI_HEADERS = {
ACCOUNT_ID: "ChatGPT-Account-ID",
BETA: "OpenAI-Beta",
SESSION_ID: "OpenAI-Sentinel-Chat-Session-Id",
} as const;
// ❌ Bad
export const HEADER_ACCOUNT_ID = "ChatGPT-Account-ID";
export const HEADER_BETA = "OpenAI-Beta";
export const HEADER_SESSION_ID = "OpenAI-Sentinel-Chat-Session-Id";
```
## 🎯 Main Fetch Flow (Critical Understanding)
The `fetch()` function in `index.ts` is the heart of the plugin. It follows a strict 7-step flow:
```typescript
async fetch(input: Request | string | URL, init?: RequestInit): Promise<Response> {
// Step 1: Check and refresh token if needed
const currentAuth = await getAuth();
if (shouldRefreshToken(currentAuth)) {
const refreshResult = await refreshAndUpdateToken(currentAuth, client);
if (!refreshResult.success) return refreshResult.response;
}
// Step 2: Extract and rewrite URL for Codex backend
const originalUrl = extractRequestUrl(input);
const url = rewriteUrlForCodex(originalUrl);
// Step 3: Transform request body with Codex instructions
const transformation = transformRequestForCodex(init, url, CODEX_INSTRUCTIONS, userConfig);
const hasTools = transformation?.body.tools !== undefined;
const requestInit = transformation?.updatedInit ?? init;
// Step 4: Create headers with OAuth and ChatGPT account info
const accessToken = currentAuth.type === "oauth" ? currentAuth.access : "";
const headers = createCodexHeaders(requestInit, accountId, accessToken);
// Step 5: Make request to Codex API
const response = await fetch(url, { ...requestInit, headers });
// Step 6: Log response
logRequest(LOG_STAGES.RESPONSE, { status, ok, statusText, headers });
// Step 7: Handle error or success response
if (!response.ok) return await handleErrorResponse(response);
return await handleSuccessResponse(response, hasTools);
}
```
**Important**: Each step is isolated in its own helper function for maintainability.
## 🔍 Where to Look for Specific Functionality
| Need to modify... | Go to... |
|-------------------|----------|
| URL rewriting | `lib/fetch-helpers.ts` → `rewriteUrlForCodex()` |
| Headers | `lib/fetch-helpers.ts` → `createCodexHeaders()` |
| Token refresh | `lib/fetch-helpers.ts` → `refreshAndUpdateToken()` |
| Token validation | `lib/fetch-helpers.ts` → `shouldRefreshToken()` |
| Request body transformation | `lib/request-transformer.ts` → `transformRequestBody()` |
| SSE to JSON conversion | `lib/response-handler.ts` → `convertSseToJson()` |
| Error handling | `lib/fetch-helpers.ts` → `handleErrorResponse()` |
| OAuth flow | `lib/auth.ts` → `createAuthorizationFlow()` |
| Browser opening | `lib/browser.ts` → `openBrowserUrl()` |
| Codex instructions | `lib/codex.ts` → `getCodexInstructions()` |
| Any constant/magic value | `lib/constants.ts` |
| Type definitions | `lib/types.ts` |
## 🚀 Quick Start for Common Tasks
### Task: Fix a Bug in Token Refresh
1. Read `lib/fetch-helpers.ts` → `shouldRefreshToken()` and `refreshAndUpdateToken()`
2. Read existing tests in `test/fetch-helpers.test.ts`
3. Add test case reproducing the bug
4. Fix the bug in the helper function
5. Verify all 93 tests pass: `npm test`
6. Run build: `npm run build`
### Task: Add Support for New Model Configuration
1. Update `lib/types.ts` → `ConfigOptions` interface
2. Update `lib/request-transformer.ts` → `transformRequestBody()`
1. Add to `ConfigOptions` interface in `lib/types.ts`
2. Update `transformRequestBody()` in `lib/request/request-transformer.ts`
3. Add tests in `test/request-transformer.test.ts`
4. Update `config/full-opencode.json` with example
5. Update `config/README.md` with documentation
6. Verify all tests pass
4. Document in README.md configuration section
### Task: Change How URLs are Rewritten
### Modifying Request Transformation
1. Update `lib/fetch-helpers.ts` → `rewriteUrlForCodex()`
2. Update constants in `lib/constants.ts` if needed
3. Add tests in `test/fetch-helpers.test.ts`
4. Verify all tests pass
All request transformations go through `transformRequestBody()`:
- Input filtering: `filterInput()`, `filterOpenCodeSystemPrompts()`
- Message injection: `addCodexBridgeMessage()` or `addToolRemapMessage()`
- Reasoning config: `getReasoningConfig()` (follows Codex CLI defaults, not opencode defaults)
- Model config: `getModelConfig()` (merges global + per-model options)
### Task: Improve Error Messages
### OAuth Flow Modifications
1. Update `lib/constants.ts` → `ERROR_MESSAGES`
2. Update usage in relevant files (search for old message)
3. Update tests that check error messages
4. Verify all tests pass
OAuth implementation follows OpenAI Codex CLI patterns:
- Client ID: `app_EMoamEEZ73f0CkXaXp7hrann`
- PKCE with S256 challenge
- Special params: `codex_cli_simplified_flow=true`, `originator=codex_cli_rs`
- Callback server on port 1455 (matches Codex CLI)
## ⚠️ Critical Rules
### Testing Strategy
1. **NEVER modify code without reading relevant tests first**
2. **ALWAYS run tests before and after changes**
3. **NEVER use magic strings** - add to `lib/constants.ts`
4. **ALWAYS add JSDoc** to new public functions
5. **NEVER create functions > 70 lines** - extract helpers
6. **ALWAYS use discriminated unions** for result types
7. **NEVER throw exceptions** - return error objects
8. **ALWAYS import types with `type` keyword** when only type is needed
9. **NEVER commit with failing tests**
10. **ALWAYS check TypeScript compilation**: `npm run build`
- **123 comprehensive tests** covering all modules
- Test files mirror source structure (`test/auth.test.ts` ↔ `lib/auth/auth.ts`)
- Mock-heavy testing (no actual network calls or file I/O in tests)
- Focus on edge cases: token expiration, model normalization, input filtering, CODEX_MODE toggling
## 📊 Quality Checklist
## Important Configuration Differences
Before completing any task:
This plugin **intentionally differs from opencode defaults** because it accesses ChatGPT backend API (not OpenAI Platform API):
- [ ] All 93 tests pass (`npm test`)
- [ ] TypeScript compiles without errors (`npm run build`)
- [ ] New functions have JSDoc comments
- [ ] New functionality has tests
- [ ] No magic strings (all in `constants.ts`)
- [ ] Functions are < 70 lines
- [ ] Clear, descriptive naming
- [ ] Types imported with `type` keyword where applicable
- [ ] No `any` types used
- [ ] Error handling follows result pattern
| Setting | opencode Default | This Plugin Default | Reason |
|---------|-----------------|---------------------|--------|
| `reasoningEffort` | "high" (gpt-5) | "medium" | Matches Codex CLI default |
| `textVerbosity` | "low" (gpt-5) | "medium" | Matches Codex CLI default |
| `reasoningSummary` | "detailed" | "auto" | Matches Codex CLI default |
| gpt-5-codex config | (excluded) | Full support | opencode excludes gpt-5-codex from auto-config |
| `store` | true | false | Required for ChatGPT backend |
| `include` | (not set) | `["reasoning.encrypted_content"]` | Required for stateless operation |
## 🎓 Understanding the Architecture
## File Paths & Locations
### Why 10 Helper Functions?
- **Plugin config**: `~/.opencode/openai-codex-auth-config.json`
- **Cache dir**: `~/.opencode/cache/`
- `codex-instructions.md` (Codex CLI instructions from GitHub)
- `codex-instructions-meta.json` (ETag + release tag for Codex instructions)
- `opencode-codex.txt` (OpenCode system prompt from GitHub, for verification)
- `opencode-codex-meta.json` (ETag for OpenCode prompt)
- **Debug logs**: `~/.opencode/logs/codex-plugin/` (when `ENABLE_PLUGIN_REQUEST_LOGGING=1`)
- **OAuth callback**: `http://localhost:1455/auth/callback`
The original `fetch()` was 148 lines. Breaking it into 10 focused functions:
- Reduces cognitive load (each function < 40 lines)
- Enables isolated testing
- Makes modifications surgical instead of risky
- Allows LLMs to load only relevant context
- Documents the flow through function names
## Environment Variables
### Why Centralized Constants?
- `CODEX_MODE`: Override config file (1=enable, 0=disable)
- `ENABLE_PLUGIN_REQUEST_LOGGING`: Enable detailed request logging (1=enable)
Having all magic values in `lib/constants.ts`:
- Single source of truth
- Easy to find and modify
- Type-safe access
- Reveals all configurable values at a glance
- Reduces context needed to understand strings/values
## TypeScript Configuration
### Why Discriminated Unions for Results?
- Target: ES2022
- Module: ES2022 with bundler resolution
- Output: `./dist/`
- Strict mode enabled
- Declaration files generated
- Source maps enabled
- Excludes: `test/`, `node_modules/`, `dist/`
Pattern like `{ success: true; data } | { success: false; error }`:
- Type-safe error handling
- Forces consumers to handle errors
- No unexpected exceptions
- Clear success/failure paths
- Better than throwing exceptions
## Dependencies
### Why Separate Test Files?
**Production**:
- `@openauthjs/openauth` (OAuth PKCE implementation)
Each module has its own test file:
- Tests colocated with functionality conceptually
- Easy to find relevant tests
- Faster test execution (can run subset)
- Clear coverage per module
- Easier to maintain
**Development**:
- `@opencode-ai/plugin` (peer dependency)
- `vitest` (testing framework)
- TypeScript
## 🔮 Future Extension Points
The architecture makes these additions easy:
1. **New authentication method** → Add to `lib/auth.ts`, follow OAuth pattern
2. **New model variant** → Update `config/` examples, no code changes needed
3. **New configuration option** → Update `lib/types.ts` and `lib/request-transformer.ts`
4. **New platform support** → Add to `lib/browser.ts` and `lib/constants.ts`
5. **Additional headers** → Modify `createCodexHeaders()` in `lib/fetch-helpers.ts`
6. **Different response format** → Add handler in `lib/response-handler.ts`
7. **Enhanced logging** → Update `lib/logger.ts`
8. **Rate limiting** → Add helper in `lib/fetch-helpers.ts`, integrate in step flow
## 📚 Additional Resources
- **config/README.md** - Configuration examples and documentation
- **test/** - Reference implementations showing how to use each module
- **lib/types.ts** - Complete type definitions for the plugin
## 🤝 Maintenance Philosophy
This codebase is optimized for AI agent maintenance. When making changes:
1. **Read first, write second** - Understand existing patterns before modifying
2. **Follow established patterns** - Consistency reduces cognitive load
3. **Test everything** - Tests are documentation and safety net
4. **Keep functions small** - < 70 lines, single responsibility
5. **Document intent** - JSDoc explains the "why", code shows the "how"
6. **Use type system** - Let TypeScript catch errors early
7. **Centralize configuration** - DRY principle for all constants
Remember: The goal is maintainability by AI assistants. Every change should make the codebase easier to understand, modify, and extend.
**Zero external runtime dependencies** - only uses Node.js built-ins for file I/O, HTTP, crypto.
+113 -32
View File
@@ -16,10 +16,10 @@ Follow me on [X @nummanthinks](https://x.com/nummanthinks) for future updates an
- ✅ **Auto-refreshing tokens** - Handles token expiration automatically
- ✅ **Smart auto-updating Codex instructions** - Tracks latest stable release with ETag caching
- ✅ **Full tool support** - write, edit, bash, grep, glob, and more
- ✅ **CODEX_MODE** - Codex-OpenCode bridge prompt for CLI parity (enabled by default)
- ✅ **CODEX_MODE** - Codex-OpenCode bridge prompt with Task tool & MCP awareness (enabled by default)
- ✅ **Automatic tool remapping** - Codex tools → opencode tools
- ✅ **Configurable reasoning** - Control effort, summary verbosity, and text output
- ✅ **Type-safe & tested** - Strict TypeScript with 123 comprehensive tests
- ✅ **Type-safe & tested** - Strict TypeScript with 129 comprehensive tests
- ✅ **Modular architecture** - Easy to maintain and extend
## Installation
@@ -156,9 +156,12 @@ For a simpler setup (uses plugin defaults: medium reasoning, auto summaries):
2. **That's it!** opencode will auto-install the plugin on first run.
> **Note on Updates**: Sometimes opencode does NOT automatically update plugins to new versions. To update:
> - Pin to a specific version: `"[email protected]"` and change the number when updating
> - Or clear the plugin cache: `rm -rf ~/.cache/opencode/node_modules/opencode-openai-codex-auth`
> **Note on Updates**: opencode does NOT automatically update plugins. To force a fresh install of the latest version:
> ```bash
> # Remove cached plugin entry and folder
> sed -i.bak '/"opencode-openai-codex-auth"/d' ~/.cache/opencode/package.json && rm -rf ~/.cache/opencode/node_modules/opencode-openai-codex-auth
> ```
> Then run `opencode` - it will reinstall the latest version.
>
> Check [releases](https://github.com/numman-ali/opencode-openai-codex-auth/releases) for the latest version.
@@ -227,6 +230,52 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t
All accessed via your ChatGPT Plus/Pro subscription.
### Using Model Variants in Custom Commands & Agents
When using model aliases in **custom command frontmatter** or **agent configurations**, you **must include the provider prefix** (`openai/`):
#### ✅ Correct - Custom Command Example
```yaml
---
description: Create a git commit message
agent: build
model: openai/GPT 5 Codex Low (ChatGPT Subscription)
---
Create a commit message for the staged changes
```
#### ❌ Incorrect - Missing Provider Prefix
```yaml
---
description: Create a git commit message
agent: build
model: GPT 5 Codex Low (ChatGPT Subscription) # ❌ Will fail - missing "openai/"
---
```
#### ✅ Correct - Agent Configuration Example
```json
{
"agent": {
"commit": {
"model": "openai/GPT 5 Codex Low (ChatGPT Subscription)",
"prompt": "You are a git commit message expert..."
}
}
}
```
**Why?** The `--model` CLI flag, command frontmatter, and agent configs all use the same model resolution format: `provider/model`. Model aliases defined in your config are indexed under the provider namespace.
**Alternative**: Instead of specifying the model in each command, you can:
1. Set a default model in your agent configuration
2. Use the agent's model setting instead of specifying per-command
3. Rely on the global default model in your opencode config
### Plugin Defaults
When no configuration is specified, the plugin uses these defaults for all GPT-5 models:
@@ -371,8 +420,8 @@ The plugin implements a 7-step fetch flow in TypeScript:
- Model normalization (`gpt-5-codex` variants → `gpt-5-codex`, `gpt-5` variants → `gpt-5`)
- Injects Codex instructions from latest [openai/codex](https://github.com/openai/codex) release
- Applies reasoning configuration (effort, summary, verbosity)
- Adds Codex-OpenCode bridge prompt (CODEX_MODE=true, default) or tool remap message (CODEX_MODE=false)
- Filters OpenCode system prompts when in CODEX_MODE
- Adds Codex-OpenCode bridge prompt with Task tool & MCP awareness (CODEX_MODE=true, default) or tool remap message (CODEX_MODE=false)
- Filters OpenCode system prompts when in CODEX_MODE (verified against cached OpenCode prompt for accuracy)
- Filters conversation history (removes stored IDs for stateless operation)
4. **Headers**: Adds OAuth token and ChatGPT account ID headers
5. **Request Execution**: Sends to Codex backend API
@@ -383,10 +432,10 @@ The plugin implements a 7-step fetch flow in TypeScript:
- **Modular Design**: 10 focused helper functions, each < 40 lines
- **Type-Safe**: Strict TypeScript with comprehensive type definitions
- **Tested**: 123 tests covering all functionality including CODEX_MODE
- **Tested**: 129 tests covering all functionality including CODEX_MODE
- **Zero Dependencies**: Only uses @openauthjs/openauth
- **Codex Instructions**: ETag-cached from GitHub, auto-updates on new releases
- **CODEX_MODE**: Configurable bridge prompt for Codex CLI parity (enabled by default)
- **CODEX_MODE**: Configurable bridge prompt for Codex CLI parity with Task tool & MCP awareness (enabled by default)
- **Stateless Operation**: Uses `store: false` with encrypted reasoning content for multi-turn conversations
## Limitations
@@ -405,7 +454,7 @@ The plugin implements a 7-step fetch flow in TypeScript:
- Verify plugin is loaded in `opencode.json`
- Check that model is set to `openai/gpt-5-codex`
- Check `~/.opencode/cache/` for cached instructions (auto-downloads from GitHub)
- Check `~/.opencode/cache/` for cached instructions and OpenCode prompts (auto-downloads from GitHub)
### Request Errors
@@ -413,6 +462,29 @@ The plugin implements a 7-step fetch flow in TypeScript:
- **400 Bad Request**: Check console output for specific error details
- **403 Forbidden**: Subscription may be expired or invalid
### Model Not Found in Custom Commands/Agents
If you get a "model not found" error when using custom commands or agents with model aliases:
**Problem**: Model aliases in command frontmatter or agent configs require the provider prefix.
```yaml
# ❌ This will fail
model: GPT 5 Codex Low (ChatGPT Subscription)
# ✅ This works
model: openai/GPT 5 Codex Low (ChatGPT Subscription)
```
**Why?** The `--model` CLI flag, custom commands, and agent configurations all use the `provider/model` format. Bare aliases (without `openai/`) are not automatically resolved to the provider namespace.
**Solution**: Always include the provider prefix when using model aliases in:
- Custom command frontmatter (`model: openai/...`)
- Agent configurations (`"model": "openai/..."`)
- CLI `--model` flag (`--model=openai/...`)
See [Using Model Variants in Custom Commands & Agents](#using-model-variants-in-custom-commands--agents) for detailed examples.
### Plugin Issues
If you encounter issues with the latest version, you can pin to a specific stable release:
@@ -457,22 +529,28 @@ Each request generates 3-4 JSON files:
opencode-openai-codex-auth/
├── index.ts # Main plugin entry point
├── lib/
│ ├── auth.ts # OAuth authentication logic
│ ├── codex.ts # Codex instructions & tool remapping
│ ├── server.ts # Local OAuth callback server
│ ├── browser.ts # Platform-specific browser opening
│ ├── logger.ts # Request logging (debug mode)
│ ├── request-transformer.ts # Request body transformations
│ ├── response-handler.ts # SSE to JSON conversion
│ ├── fetch-helpers.ts # Focused helper functions
│ ├── constants.ts # All magic values and URLs
│ └── types.ts # TypeScript type definitions
│ ├── auth/ # OAuth authentication modules
│ │ ├── auth.ts # OAuth flow, PKCE, token exchange
│ │ ├── server.ts # Local OAuth callback server
│ │ └── browser.ts # Platform-specific browser opening
│ ├── prompts/ # Prompt management modules
│ │ ├── codex.ts # Codex instructions fetching/caching
│ │ ├── opencode-codex.ts # OpenCode prompt verification cache
│ │ └── codex-opencode-bridge.ts # Bridge prompt with Task/MCP awareness
│ ├── request/ # Request handling modules
│ │ ├── request-transformer.ts # Request body transformations
│ │ ├── response-handler.ts # SSE to JSON conversion
│ │ └── fetch-helpers.ts # 10 focused helper functions
│ ├── config.ts # Plugin configuration & CODEX_MODE
│ ├── logger.ts # Request logging (debug mode)
│ ├── constants.ts # All magic values and URLs
│ └── types.ts # TypeScript type definitions
├── config/
│ ├── full-opencode.json # Complete config with all variants
│ ├── minimal-opencode.json # Minimal config example
│ └── README.md # Configuration documentation
├── test/ # Comprehensive test suite (93 tests)
├── AGENTS.md # AI agent maintenance guide
│ ├── full-opencode.json # Complete config with all variants
│ ├── minimal-opencode.json # Minimal config example
│ └── README.md # Configuration documentation
├── test/ # Comprehensive test suite (129 tests)
├── AGENTS.md # Agent coding guidance
├── package.json
├── README.md
└── LICENSE
@@ -481,14 +559,17 @@ opencode-openai-codex-auth/
### Module Overview
- **index.ts**: Main plugin export and fetch() orchestration (7-step flow)
- **lib/auth.ts**: OAuth flow, PKCE, token exchange, JWT decoding
- **lib/codex.ts**: Fetches/caches Codex instructions from GitHub, tool remapping
- **lib/server.ts**: Local HTTP server for OAuth callback handling
- **lib/browser.ts**: Platform detection and browser opening
- **lib/auth/auth.ts**: OAuth flow, PKCE, token exchange, JWT decoding
- **lib/auth/server.ts**: Local HTTP server for OAuth callback handling
- **lib/auth/browser.ts**: Platform detection and browser opening
- **lib/prompts/codex.ts**: Fetches/caches Codex instructions from GitHub, tool remapping
- **lib/prompts/opencode-codex.ts**: Fetches/caches OpenCode system prompts for verification
- **lib/prompts/codex-opencode-bridge.ts**: Bridge prompt with Task tool & MCP awareness
- **lib/request/request-transformer.ts**: Request transformations (model normalization, reasoning config, prompt filtering)
- **lib/request/response-handler.ts**: Response handling (SSE to JSON conversion)
- **lib/request/fetch-helpers.ts**: 10 focused helper functions for main fetch flow
- **lib/config.ts**: Plugin configuration loading and CODEX_MODE determination
- **lib/logger.ts**: Debug logging (controlled by environment variable)
- **lib/request-transformer.ts**: Request transformations (model normalization, reasoning config)
- **lib/response-handler.ts**: Response handling (SSE to JSON conversion)
- **lib/fetch-helpers.ts**: 10 focused helper functions for main fetch flow
- **lib/constants.ts**: Centralized constants, URLs, error messages
- **lib/types.ts**: TypeScript type definitions
+1 -1
View File
@@ -127,7 +127,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
const url = rewriteUrlForCodex(originalUrl);
// Step 3: Transform request body with Codex instructions
const transformation = transformRequestForCodex(init, url, CODEX_INSTRUCTIONS, userConfig, codexMode);
const transformation = await transformRequestForCodex(init, url, CODEX_INSTRUCTIONS, userConfig, codexMode);
const hasTools = transformation?.body.tools !== undefined;
const requestInit = transformation?.updatedInit ?? init;
+19 -3
View File
@@ -85,6 +85,20 @@ If ANY answer is NO → STOP and correct before proceeding.
**Testing:**
- If tests exist: Start specific to your changes, then broader validation
## Advanced Tools
**Task Tool (Sub-Agents):**
- Specialized agents are available via the Task tool
- Check the Task tool description for current agent types and their capabilities
- Useful for complex analysis, specialized workflows, or tasks requiring isolated context
- The agent list is dynamically generated - refer to tool schema for available agents
**MCP Tools:**
- Model Context Protocol servers provide additional capabilities
- MCP tools are prefixed: \`mcp__<server-name>__<tool-name>\`
- Check your available tools for MCP integrations
- Use when the tool's functionality matches your task needs
## What Remains from Codex
Sandbox policies, approval mechanisms, final answer formatting, git commit protocols, and file reference formats all follow Codex instructions.`;
@@ -98,12 +112,14 @@ export interface CodexOpenCodeBridgeMeta {
}
export const CODEX_OPENCODE_BRIDGE_META: CodexOpenCodeBridgeMeta = {
estimatedTokens: 450,
reductionVsCurrent: "90%",
reductionVsToolRemap: "20%",
estimatedTokens: 550,
reductionVsCurrent: "88%",
reductionVsToolRemap: "10%",
protects: [
"Tool name confusion (apply_patch/update_plan)",
"Missing tool awareness",
"Task tool / sub-agent awareness",
"MCP tool awareness",
"Premature yielding to user",
"Over-modification of existing code",
"Environment confusion",
+109
View File
@@ -0,0 +1,109 @@
/**
* OpenCode Codex Prompt Fetcher
*
* Fetches and caches the codex.txt system prompt from OpenCode's GitHub repository.
* Uses ETag-based caching to efficiently track updates.
*/
import { join } from "node:path";
import { homedir } from "node:os";
import { mkdir, readFile, writeFile } from "node:fs/promises";
const OPENCODE_CODEX_URL =
"https://raw.githubusercontent.com/sst/opencode/main/packages/opencode/src/session/prompt/codex.txt";
const CACHE_DIR = join(homedir(), ".opencode", "cache");
const CACHE_FILE = join(CACHE_DIR, "opencode-codex.txt");
const CACHE_META_FILE = join(CACHE_DIR, "opencode-codex-meta.json");
interface CacheMeta {
etag: string;
lastFetch: string;
}
/**
* Fetch OpenCode's codex.txt prompt with ETag-based caching
* @returns The codex.txt content
*/
export async function getOpenCodeCodexPrompt(): Promise<string> {
await mkdir(CACHE_DIR, { recursive: true });
// Try to load cached content and metadata
let cachedContent: string | null = null;
let cachedMeta: CacheMeta | null = null;
try {
cachedContent = await readFile(CACHE_FILE, "utf-8");
const metaContent = await readFile(CACHE_META_FILE, "utf-8");
cachedMeta = JSON.parse(metaContent);
} catch {
// Cache doesn't exist or is invalid, will fetch fresh
}
// Fetch from GitHub with conditional request
const headers: Record<string, string> = {};
if (cachedMeta?.etag) {
headers["If-None-Match"] = cachedMeta.etag;
}
try {
const response = await fetch(OPENCODE_CODEX_URL, { headers });
// 304 Not Modified - cache is still valid
if (response.status === 304 && cachedContent) {
return cachedContent;
}
// 200 OK - new content available
if (response.ok) {
const content = await response.text();
const etag = response.headers.get("etag") || "";
// Save to cache
await writeFile(CACHE_FILE, content, "utf-8");
await writeFile(
CACHE_META_FILE,
JSON.stringify(
{
etag,
lastFetch: new Date().toISOString(),
} satisfies CacheMeta,
null,
2
),
"utf-8"
);
return content;
}
// Fallback to cache if available
if (cachedContent) {
return cachedContent;
}
throw new Error(`Failed to fetch OpenCode codex.txt: ${response.status}`);
} catch (error) {
// Network error - fallback to cache
if (cachedContent) {
return cachedContent;
}
throw new Error(
`Failed to fetch OpenCode codex.txt and no cache available: ${error}`
);
}
}
/**
* Get first N characters of the cached OpenCode prompt for verification
* @param chars Number of characters to get (default: 50)
* @returns First N characters or null if not cached
*/
export async function getCachedPromptPrefix(chars = 50): Promise<string | null> {
try {
const content = await readFile(CACHE_FILE, "utf-8");
return content.substring(0, chars);
} catch {
return null;
}
}
+20 -15
View File
@@ -26,11 +26,7 @@ import {
* @returns True if token is expired or invalid
*/
export function shouldRefreshToken(auth: Auth): boolean {
return (
auth.type !== "oauth" ||
!auth.access ||
auth.expires < Date.now()
);
return auth.type !== "oauth" || !auth.access || auth.expires < Date.now();
}
/**
@@ -41,8 +37,10 @@ export function shouldRefreshToken(auth: Auth): boolean {
*/
export async function refreshAndUpdateToken(
currentAuth: Auth,
client: OpencodeClient
): Promise<{ success: true; auth: Auth } | { success: false; response: Response }> {
client: OpencodeClient,
): Promise<
{ success: true; auth: Auth } | { success: false; response: Response }
> {
const refreshToken = currentAuth.type === "oauth" ? currentAuth.refresh : "";
const refreshResult = await refreshAccessToken(refreshToken);
@@ -52,7 +50,7 @@ export async function refreshAndUpdateToken(
success: false,
response: new Response(
JSON.stringify({ error: "Token refresh failed" }),
{ status: HTTP_STATUS.UNAUTHORIZED }
{ status: HTTP_STATUS.UNAUTHORIZED },
),
};
}
@@ -107,13 +105,13 @@ export function rewriteUrlForCodex(url: string): string {
* @param codexMode - Enable CODEX_MODE (bridge prompt instead of tool remap)
* @returns Transformed body and updated init, or undefined if no body
*/
export function transformRequestForCodex(
export async function transformRequestForCodex(
init: RequestInit | undefined,
url: string,
codexInstructions: string,
userConfig: UserConfig,
codexMode: boolean = true
): { body: RequestBody; updatedInit: RequestInit } | undefined {
codexMode = true,
): Promise<{ body: RequestBody; updatedInit: RequestInit } | undefined> {
if (!init?.body) return undefined;
try {
@@ -133,7 +131,12 @@ export function transformRequestForCodex(
});
// Transform request body
const transformedBody = transformRequestBody(body, codexInstructions, userConfig, codexMode);
const transformedBody = await transformRequestBody(
body,
codexInstructions,
userConfig,
codexMode,
);
// Log transformed request
logRequest(LOG_STAGES.AFTER_TRANSFORM, {
@@ -169,7 +172,7 @@ export function transformRequestForCodex(
export function createCodexHeaders(
init: RequestInit | undefined,
accountId: string,
accessToken: string
accessToken: string,
): Headers {
const headers = new Headers(init?.headers ?? {});
headers.delete("x-api-key"); // Remove any existing API key
@@ -186,7 +189,9 @@ export function createCodexHeaders(
* @param response - Error response from API
* @returns Response with error details
*/
export async function handleErrorResponse(response: Response): Promise<Response> {
export async function handleErrorResponse(
response: Response,
): Promise<Response> {
const text = await response.text();
console.error(`[${PLUGIN_NAME}] ${response.status} error:`, text);
@@ -211,7 +216,7 @@ export async function handleErrorResponse(response: Response): Promise<Response>
*/
export async function handleSuccessResponse(
response: Response,
hasTools: boolean
hasTools: boolean,
): Promise<Response> {
const responseHeaders = ensureContentType(response.headers);
+85 -25
View File
@@ -1,6 +1,13 @@
import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js";
import { CODEX_OPENCODE_BRIDGE } from "../prompts/codex-opencode-bridge.js";
import type { UserConfig, ConfigOptions, ReasoningConfig, RequestBody, InputItem } from "../types.js";
import { getOpenCodeCodexPrompt } from "../prompts/opencode-codex.js";
import type {
UserConfig,
ConfigOptions,
ReasoningConfig,
RequestBody,
InputItem,
} from "../types.js";
/**
* Normalize model name to Codex-supported variants
@@ -27,7 +34,10 @@ export function normalizeModel(model: string | undefined): string {
* @param userConfig - Full user configuration object
* @returns Merged configuration for this model
*/
export function getModelConfig(modelName: string, userConfig: UserConfig = { global: {}, models: {} }): ConfigOptions {
export function getModelConfig(
modelName: string,
userConfig: UserConfig = { global: {}, models: {} },
): ConfigOptions {
const globalOptions = userConfig.global || {};
const modelOptions = userConfig.models?.[modelName]?.options || {};
@@ -47,13 +57,18 @@ export function getModelConfig(modelName: string, userConfig: UserConfig = { glo
* @param userConfig - User configuration object
* @returns Reasoning configuration
*/
export function getReasoningConfig(originalModel: string | undefined, userConfig: ConfigOptions = {}): ReasoningConfig {
export function getReasoningConfig(
originalModel: string | undefined,
userConfig: ConfigOptions = {},
): ReasoningConfig {
const isLightweight =
originalModel?.includes("nano") || originalModel?.includes("mini");
const isCodex = originalModel?.includes("codex");
// Default based on model type (Codex CLI defaults)
const defaultEffort: "minimal" | "low" | "medium" | "high" = isLightweight ? "minimal" : "medium";
const defaultEffort: "minimal" | "low" | "medium" | "high" = isLightweight
? "minimal"
: "medium";
// Get user-requested effort
let effort = userConfig.reasoningEffort || defaultEffort;
@@ -76,7 +91,9 @@ export function getReasoningConfig(originalModel: string | undefined, userConfig
* @param input - Original input array
* @returns Filtered input array
*/
export function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
export function filterInput(
input: InputItem[] | undefined,
): InputItem[] | undefined {
if (!Array.isArray(input)) return input;
return input.filter((item) => {
@@ -90,26 +107,52 @@ export function filterInput(input: InputItem[] | undefined): InputItem[] | undef
/**
* Check if an input item is the OpenCode system prompt
* Uses cached OpenCode codex.txt for verification with fallback to text matching
* @param item - Input item to check
* @param cachedPrompt - Cached OpenCode codex.txt content
* @returns True if this is the OpenCode system prompt
*/
export function isOpenCodeSystemPrompt(item: InputItem): boolean {
export function isOpenCodeSystemPrompt(
item: InputItem,
cachedPrompt: string | null,
): boolean {
const isSystemRole = item.role === "developer" || item.role === "system";
if (!isSystemRole) return false;
// Check string content
if (typeof item.content === "string") {
return item.content.startsWith("You are a coding agent running in");
const getContentText = (item: InputItem): string => {
if (typeof item.content === "string") {
return item.content;
}
if (Array.isArray(item.content)) {
return item.content
.filter((c) => c.type === "input_text" && c.text)
.map((c) => c.text)
.join("\n");
}
return "";
};
const contentText = getContentText(item);
if (!contentText) return false;
// Primary check: Compare against cached OpenCode prompt
if (cachedPrompt) {
// Exact match (trim whitespace for comparison)
if (contentText.trim() === cachedPrompt.trim()) {
return true;
}
// Partial match: Check if first 200 chars match (handles minor variations)
const contentPrefix = contentText.trim().substring(0, 200);
const cachedPrefix = cachedPrompt.trim().substring(0, 200);
if (contentPrefix === cachedPrefix) {
return true;
}
}
// Check array content
if (Array.isArray(item.content)) {
return item.content.some(
(c) => c.type === "input_text" && c.text?.startsWith("You are a coding agent running in")
);
}
return false;
// Fallback check: Known OpenCode prompt signature (for safety)
// This catches the prompt even if cache fails
return contentText.startsWith("You are a coding agent running in");
}
/**
@@ -118,14 +161,25 @@ export function isOpenCodeSystemPrompt(item: InputItem): boolean {
* @param input - Input array
* @returns Input array without OpenCode system prompts
*/
export function filterOpenCodeSystemPrompts(input: InputItem[] | undefined): InputItem[] | undefined {
export async function filterOpenCodeSystemPrompts(
input: InputItem[] | undefined,
): Promise<InputItem[] | undefined> {
if (!Array.isArray(input)) return input;
// Fetch cached OpenCode prompt for verification
let cachedPrompt: string | null = null;
try {
cachedPrompt = await getOpenCodeCodexPrompt();
} catch {
// If fetch fails, fallback to text-based detection only
// This is safe because we still have the "starts with" check
}
return input.filter((item) => {
// Keep user messages
if (item.role === "user") return true;
// Filter out OpenCode system prompts
return !isOpenCodeSystemPrompt(item);
return !isOpenCodeSystemPrompt(item, cachedPrompt);
});
}
@@ -135,7 +189,10 @@ export function filterOpenCodeSystemPrompts(input: InputItem[] | undefined): Inp
* @param hasTools - Whether tools are present in request
* @returns Input array with bridge message prepended if needed
*/
export function addCodexBridgeMessage(input: InputItem[] | undefined, hasTools: boolean): InputItem[] | undefined {
export function addCodexBridgeMessage(
input: InputItem[] | undefined,
hasTools: boolean,
): InputItem[] | undefined {
if (!hasTools || !Array.isArray(input)) return input;
const bridgeMessage: InputItem = {
@@ -158,7 +215,10 @@ export function addCodexBridgeMessage(input: InputItem[] | undefined, hasTools:
* @param hasTools - Whether tools are present in request
* @returns Input array with tool remap message prepended if needed
*/
export function addToolRemapMessage(input: InputItem[] | undefined, hasTools: boolean): InputItem[] | undefined {
export function addToolRemapMessage(
input: InputItem[] | undefined,
hasTools: boolean,
): InputItem[] | undefined {
if (!hasTools || !Array.isArray(input)) return input;
const toolRemapMessage: InputItem = {
@@ -189,12 +249,12 @@ export function addToolRemapMessage(input: InputItem[] | undefined, hasTools: bo
* @param codexMode - Enable CODEX_MODE (bridge prompt instead of tool remap) - defaults to true
* @returns Transformed request body
*/
export function transformRequestBody(
export async function transformRequestBody(
body: RequestBody,
codexInstructions: string,
userConfig: UserConfig = { global: {}, models: {} },
codexMode: boolean = true
): RequestBody {
codexMode = true,
): Promise<RequestBody> {
const originalModel = body.model;
const normalizedModel = normalizeModel(body.model);
@@ -215,7 +275,7 @@ export function transformRequestBody(
if (codexMode) {
// CODEX_MODE: Remove OpenCode system prompt, add bridge prompt
body.input = filterOpenCodeSystemPrompts(body.input);
body.input = await filterOpenCodeSystemPrompts(body.input);
body.input = addCodexBridgeMessage(body.input, !!body.tools);
} else {
// DEFAULT MODE: Keep original behavior with tool remap message
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "opencode-openai-codex-auth",
"version": "2.0.0",
"version": "2.1.0",
"description": "OpenAI ChatGPT (Codex backend) OAuth auth plugin for opencode - use your ChatGPT Plus/Pro subscription instead of API credits",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+161 -78
View File
@@ -14,36 +14,36 @@ import type { RequestBody, UserConfig, InputItem } from '../lib/types.js';
describe('Request Transformer Module', () => {
describe('normalizeModel', () => {
it('should normalize gpt-5-codex', () => {
it('should normalize gpt-5-codex', async () => {
expect(normalizeModel('gpt-5-codex')).toBe('gpt-5-codex');
});
it('should normalize gpt-5', () => {
it('should normalize gpt-5', async () => {
expect(normalizeModel('gpt-5')).toBe('gpt-5');
});
it('should normalize variants containing "codex"', () => {
it('should normalize variants containing "codex"', async () => {
expect(normalizeModel('openai/gpt-5-codex')).toBe('gpt-5-codex');
expect(normalizeModel('custom-gpt-5-codex-variant')).toBe('gpt-5-codex');
});
it('should normalize variants containing "gpt-5"', () => {
it('should normalize variants containing "gpt-5"', async () => {
expect(normalizeModel('gpt-5-mini')).toBe('gpt-5');
expect(normalizeModel('gpt-5-nano')).toBe('gpt-5');
});
it('should return gpt-5 as default for unknown models', () => {
it('should return gpt-5 as default for unknown models', async () => {
expect(normalizeModel('unknown-model')).toBe('gpt-5');
expect(normalizeModel('gpt-4')).toBe('gpt-5');
});
it('should return gpt-5 for undefined', () => {
it('should return gpt-5 for undefined', async () => {
expect(normalizeModel(undefined)).toBe('gpt-5');
});
});
describe('filterInput', () => {
it('should keep items without IDs', () => {
it('should keep items without IDs', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
@@ -51,7 +51,7 @@ describe('Request Transformer Module', () => {
expect(result).toEqual(input);
});
it('should remove items with rs_ IDs', () => {
it('should remove items with rs_ IDs', async () => {
const input: InputItem[] = [
{ id: 'rs_123', type: 'message', role: 'user', content: 'hello' },
{ id: 'msg_456', type: 'message', role: 'user', content: 'world' },
@@ -61,7 +61,7 @@ describe('Request Transformer Module', () => {
expect(result![0].id).toBe('msg_456');
});
it('should handle mixed items', () => {
it('should handle mixed items', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: '1' },
{ id: 'rs_stored', type: 'message', role: 'assistant', content: '2' },
@@ -73,18 +73,18 @@ describe('Request Transformer Module', () => {
expect(result![1].content).toBe('3');
});
it('should return undefined for undefined input', () => {
it('should return undefined for undefined input', async () => {
expect(filterInput(undefined)).toBeUndefined();
});
it('should return non-array input as-is', () => {
it('should return non-array input as-is', async () => {
const notArray = { notAnArray: true };
expect(filterInput(notArray as any)).toBe(notArray);
});
});
describe('addToolRemapMessage', () => {
it('should prepend tool remap message when tools present', () => {
it('should prepend tool remap message when tools present', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
@@ -96,7 +96,7 @@ describe('Request Transformer Module', () => {
expect((result![0].content as any)[0].text).toContain('apply_patch');
});
it('should not modify input when tools not present', () => {
it('should not modify input when tools not present', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
@@ -104,27 +104,27 @@ describe('Request Transformer Module', () => {
expect(result).toEqual(input);
});
it('should return undefined for undefined input', () => {
it('should return undefined for undefined input', async () => {
expect(addToolRemapMessage(undefined, true)).toBeUndefined();
});
it('should handle non-array input', () => {
it('should handle non-array input', async () => {
const notArray = { notAnArray: true };
expect(addToolRemapMessage(notArray as any, true)).toBe(notArray);
});
});
describe('isOpenCodeSystemPrompt', () => {
it('should detect OpenCode system prompt with string content', () => {
it('should detect OpenCode system prompt with string content', async () => {
const item: InputItem = {
type: 'message',
role: 'developer',
content: 'You are a coding agent running in OpenCode',
};
expect(isOpenCodeSystemPrompt(item)).toBe(true);
expect(isOpenCodeSystemPrompt(item, null)).toBe(true);
});
it('should detect OpenCode system prompt with array content', () => {
it('should detect OpenCode system prompt with array content', async () => {
const item: InputItem = {
type: 'message',
role: 'developer',
@@ -135,39 +135,79 @@ describe('Request Transformer Module', () => {
},
],
};
expect(isOpenCodeSystemPrompt(item)).toBe(true);
expect(isOpenCodeSystemPrompt(item, null)).toBe(true);
});
it('should detect with system role', () => {
it('should detect with system role', async () => {
const item: InputItem = {
type: 'message',
role: 'system',
content: 'You are a coding agent running in OpenCode',
};
expect(isOpenCodeSystemPrompt(item)).toBe(true);
expect(isOpenCodeSystemPrompt(item, null)).toBe(true);
});
it('should not detect non-system roles', () => {
it('should not detect non-system roles', async () => {
const item: InputItem = {
type: 'message',
role: 'user',
content: 'You are a coding agent running in OpenCode',
};
expect(isOpenCodeSystemPrompt(item)).toBe(false);
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
});
it('should not detect different content', () => {
it('should not detect different content', async () => {
const item: InputItem = {
type: 'message',
role: 'developer',
content: 'Different message',
};
expect(isOpenCodeSystemPrompt(item)).toBe(false);
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
});
it('should NOT detect AGENTS.md content', async () => {
const item: InputItem = {
type: 'message',
role: 'developer',
content: '# Project Guidelines\n\nThis is custom AGENTS.md content for the project.',
};
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
});
it('should NOT detect environment info concatenated with AGENTS.md', async () => {
const item: InputItem = {
type: 'message',
role: 'developer',
content: 'Environment: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions here.',
};
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
});
it('should NOT detect content with codex signature in the middle', async () => {
const cachedPrompt = 'You are a coding agent running in OpenCode.';
const item: InputItem = {
type: 'message',
role: 'developer',
// Has codex.txt content but with environment prepended (like OpenCode does)
content: 'Environment info here\n\nYou are a coding agent running in OpenCode.',
};
// First 200 chars won't match because of prepended content
expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(false);
});
it('should detect with cached prompt exact match', async () => {
const cachedPrompt = 'You are a coding agent running in OpenCode';
const item: InputItem = {
type: 'message',
role: 'developer',
content: 'You are a coding agent running in OpenCode',
};
expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(true);
});
});
describe('filterOpenCodeSystemPrompts', () => {
it('should filter out OpenCode system prompts', () => {
it('should filter out OpenCode system prompts', async () => {
const input: InputItem[] = [
{
type: 'message',
@@ -176,36 +216,79 @@ describe('Request Transformer Module', () => {
},
{ type: 'message', role: 'user', content: 'hello' },
];
const result = filterOpenCodeSystemPrompts(input);
const result = await filterOpenCodeSystemPrompts(input);
expect(result).toHaveLength(1);
expect(result![0].role).toBe('user');
});
it('should keep user messages', () => {
it('should keep user messages', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'message 1' },
{ type: 'message', role: 'user', content: 'message 2' },
];
const result = filterOpenCodeSystemPrompts(input);
const result = await filterOpenCodeSystemPrompts(input);
expect(result).toHaveLength(2);
});
it('should keep non-OpenCode developer messages', () => {
it('should keep non-OpenCode developer messages', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'developer', content: 'Custom instruction' },
{ type: 'message', role: 'user', content: 'hello' },
];
const result = filterOpenCodeSystemPrompts(input);
const result = await filterOpenCodeSystemPrompts(input);
expect(result).toHaveLength(2);
});
it('should return undefined for undefined input', () => {
expect(filterOpenCodeSystemPrompts(undefined)).toBeUndefined();
it('should keep AGENTS.md content (not filter it)', async () => {
const input: InputItem[] = [
{
type: 'message',
role: 'developer',
content: 'You are a coding agent running in OpenCode', // This is codex.txt
},
{
type: 'message',
role: 'developer',
content: '# Project Guidelines\n\nThis is AGENTS.md content.', // This is AGENTS.md
},
{ type: 'message', role: 'user', content: 'hello' },
];
const result = await filterOpenCodeSystemPrompts(input);
// Should filter codex.txt but keep AGENTS.md
expect(result).toHaveLength(2);
expect(result![0].content).toContain('AGENTS.md');
expect(result![1].role).toBe('user');
});
it('should keep environment+AGENTS.md concatenated message', async () => {
const input: InputItem[] = [
{
type: 'message',
role: 'developer',
content: 'You are a coding agent running in OpenCode', // codex.txt alone
},
{
type: 'message',
role: 'developer',
// environment + AGENTS.md joined (like OpenCode does)
content: 'Working directory: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions.',
},
{ type: 'message', role: 'user', content: 'hello' },
];
const result = await filterOpenCodeSystemPrompts(input);
// Should filter first message (codex.txt) but keep second (env+AGENTS.md)
expect(result).toHaveLength(2);
expect(result![0].content).toContain('AGENTS.md');
expect(result![1].role).toBe('user');
});
it('should return undefined for undefined input', async () => {
expect(await filterOpenCodeSystemPrompts(undefined)).toBeUndefined();
});
});
describe('addCodexBridgeMessage', () => {
it('should prepend bridge message when tools present', () => {
it('should prepend bridge message when tools present', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
@@ -217,7 +300,7 @@ describe('Request Transformer Module', () => {
expect((result![0].content as any)[0].text).toContain('Codex Running in OpenCode');
});
it('should not modify input when tools not present', () => {
it('should not modify input when tools not present', async () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
@@ -225,7 +308,7 @@ describe('Request Transformer Module', () => {
expect(result).toEqual(input);
});
it('should return undefined for undefined input', () => {
it('should return undefined for undefined input', async () => {
expect(addCodexBridgeMessage(undefined, true)).toBeUndefined();
});
});
@@ -233,39 +316,39 @@ describe('Request Transformer Module', () => {
describe('transformRequestBody', () => {
const codexInstructions = 'Test Codex Instructions';
it('should set required Codex fields', () => {
it('should set required Codex fields', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.store).toBe(false);
expect(result.stream).toBe(true);
expect(result.instructions).toBe(codexInstructions);
});
it('should normalize model name', () => {
it('should normalize model name', async () => {
const body: RequestBody = {
model: 'gpt-5-mini',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.model).toBe('gpt-5');
});
it('should apply default reasoning config', () => {
it('should apply default reasoning config', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.reasoning?.effort).toBe('medium');
expect(result.reasoning?.summary).toBe('auto');
});
it('should apply user reasoning config', () => {
it('should apply user reasoning config', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
@@ -277,22 +360,22 @@ describe('Request Transformer Module', () => {
},
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
const result = await transformRequestBody(body, codexInstructions, userConfig);
expect(result.reasoning?.effort).toBe('high');
expect(result.reasoning?.summary).toBe('detailed');
});
it('should apply default text verbosity', () => {
it('should apply default text verbosity', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.text?.verbosity).toBe('medium');
});
it('should apply user text verbosity', () => {
it('should apply user text verbosity', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
@@ -301,20 +384,20 @@ describe('Request Transformer Module', () => {
global: { textVerbosity: 'low' },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
const result = await transformRequestBody(body, codexInstructions, userConfig);
expect(result.text?.verbosity).toBe('low');
});
it('should set default include for encrypted reasoning', () => {
it('should set default include for encrypted reasoning', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.include).toEqual(['reasoning.encrypted_content']);
});
it('should use user-configured include', () => {
it('should use user-configured include', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
@@ -323,11 +406,11 @@ describe('Request Transformer Module', () => {
global: { include: ['custom_field', 'reasoning.encrypted_content'] },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
const result = await transformRequestBody(body, codexInstructions, userConfig);
expect(result.include).toEqual(['custom_field', 'reasoning.encrypted_content']);
});
it('should filter input array', () => {
it('should filter input array', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [
@@ -335,43 +418,43 @@ describe('Request Transformer Module', () => {
{ type: 'message', role: 'user', content: 'new' },
],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.input).toHaveLength(1);
expect(result.input![0].content).toBe('new');
});
it('should add tool remap message when tools present', () => {
it('should add tool remap message when tools present', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
tools: [{ name: 'test_tool' }],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.input![0].role).toBe('developer');
});
it('should not add tool remap message when tools absent', () => {
it('should not add tool remap message when tools absent', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.input![0].role).toBe('user');
});
it('should remove unsupported parameters', () => {
it('should remove unsupported parameters', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
max_output_tokens: 1000,
max_completion_tokens: 2000,
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.max_output_tokens).toBeUndefined();
expect(result.max_completion_tokens).toBeUndefined();
});
it('should normalize minimal to low for gpt-5-codex', () => {
it('should normalize minimal to low for gpt-5-codex', async () => {
const body: RequestBody = {
model: 'gpt-5-codex',
input: [],
@@ -380,11 +463,11 @@ describe('Request Transformer Module', () => {
global: { reasoningEffort: 'minimal' },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
const result = await transformRequestBody(body, codexInstructions, userConfig);
expect(result.reasoning?.effort).toBe('low');
});
it('should preserve minimal for non-codex models', () => {
it('should preserve minimal for non-codex models', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
@@ -393,34 +476,34 @@ describe('Request Transformer Module', () => {
global: { reasoningEffort: 'minimal' },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
const result = await transformRequestBody(body, codexInstructions, userConfig);
expect(result.reasoning?.effort).toBe('minimal');
});
it('should use minimal effort for lightweight models', () => {
it('should use minimal effort for lightweight models', async () => {
const body: RequestBody = {
model: 'gpt-5-nano',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
expect(result.reasoning?.effort).toBe('minimal');
});
describe('CODEX_MODE parameter', () => {
it('should use bridge message when codexMode=true and tools present (default)', () => {
it('should use bridge message when codexMode=true and tools present (default)', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
tools: [{ name: 'test_tool' }],
};
const result = transformRequestBody(body, codexInstructions, undefined, true);
const result = await transformRequestBody(body, codexInstructions, undefined, true);
expect(result.input).toHaveLength(2);
expect(result.input![0].role).toBe('developer');
expect((result.input![0].content as any)[0].text).toContain('Codex Running in OpenCode');
});
it('should filter OpenCode prompts when codexMode=true', () => {
it('should filter OpenCode prompts when codexMode=true', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [
@@ -433,7 +516,7 @@ describe('Request Transformer Module', () => {
],
tools: [{ name: 'test_tool' }],
};
const result = transformRequestBody(body, codexInstructions, undefined, true);
const result = await transformRequestBody(body, codexInstructions, undefined, true);
// Should have bridge message + user message (OpenCode prompt filtered out)
expect(result.input).toHaveLength(2);
@@ -442,31 +525,31 @@ describe('Request Transformer Module', () => {
expect(result.input![1].role).toBe('user');
});
it('should not add bridge message when codexMode=true but no tools', () => {
it('should not add bridge message when codexMode=true but no tools', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
};
const result = transformRequestBody(body, codexInstructions, undefined, true);
const result = await transformRequestBody(body, codexInstructions, undefined, true);
expect(result.input).toHaveLength(1);
expect(result.input![0].role).toBe('user');
});
it('should use tool remap message when codexMode=false', () => {
it('should use tool remap message when codexMode=false', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
tools: [{ name: 'test_tool' }],
};
const result = transformRequestBody(body, codexInstructions, undefined, false);
const result = await transformRequestBody(body, codexInstructions, undefined, false);
expect(result.input).toHaveLength(2);
expect(result.input![0].role).toBe('developer');
expect((result.input![0].content as any)[0].text).toContain('apply_patch');
});
it('should not filter OpenCode prompts when codexMode=false', () => {
it('should not filter OpenCode prompts when codexMode=false', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [
@@ -479,7 +562,7 @@ describe('Request Transformer Module', () => {
],
tools: [{ name: 'test_tool' }],
};
const result = transformRequestBody(body, codexInstructions, undefined, false);
const result = await transformRequestBody(body, codexInstructions, undefined, false);
// Should have tool remap + opencode prompt + user message
expect(result.input).toHaveLength(3);
@@ -489,14 +572,14 @@ describe('Request Transformer Module', () => {
expect(result.input![2].role).toBe('user');
});
it('should default to codexMode=true when parameter not provided', () => {
it('should default to codexMode=true when parameter not provided', async () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
tools: [{ name: 'test_tool' }],
};
// Not passing codexMode parameter - should default to true
const result = transformRequestBody(body, codexInstructions);
const result = await transformRequestBody(body, codexInstructions);
// Should use bridge message (codexMode=true by default)
expect(result.input![0].role).toBe('developer');