feat(runtime): add codex-native phase 0 app integration
This commit is contained in:
parent
fbf299f276
commit
ba37c1caf5
23 changed files with 7908 additions and 103 deletions
5148
docs/research/codex-native-runtime-integration-decision.md
Normal file
5148
docs/research/codex-native-runtime-integration-decision.md
Normal file
File diff suppressed because it is too large
Load diff
1216
docs/research/codex-native-runtime-phase-0-implementation-spec.md
Normal file
1216
docs/research/codex-native-runtime-phase-0-implementation-spec.md
Normal file
File diff suppressed because it is too large
Load diff
226
docs/research/codex-native-runtime-phase-0-signoff-evidence.md
Normal file
226
docs/research/codex-native-runtime-phase-0-signoff-evidence.md
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
# Codex Native Runtime - Phase 0 Sign-off Evidence
|
||||
|
||||
Captured on 2026-04-19.
|
||||
|
||||
This file is the repo-visible evidence package referenced by:
|
||||
|
||||
- [codex-native-runtime-phase-0-implementation-spec.md](./codex-native-runtime-phase-0-implementation-spec.md)
|
||||
|
||||
## Verdict
|
||||
|
||||
Phase 0 sign-off evidence is now captured.
|
||||
|
||||
What this proves:
|
||||
|
||||
- the `codex-native` lane executes through the raw `codex exec --json` seam
|
||||
- persisted transcript projection remains parseable by current `claude_team` readers
|
||||
- `ephemeral` and `persistent` runs keep different history-completeness truth
|
||||
- thread status, warning attribution, executable identity, and usage authority survive end-to-end
|
||||
- old Codex lane fallback truth remains covered by targeted regression tests
|
||||
|
||||
What this does **not** mean:
|
||||
|
||||
- `codex-native` should be unlocked for general runtime selection
|
||||
- `auto` should start resolving to `codex-native`
|
||||
- broader plugin or interactive capability claims are now safe
|
||||
|
||||
## Command Package
|
||||
|
||||
### `agent_teams_orchestrator`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun test src/services/codexNative/signOffHarness.test.ts \
|
||||
src/services/codexNative/statusAuthority.test.ts \
|
||||
src/services/codexNative/transcriptProjector.test.ts \
|
||||
src/services/codexNative/turnExecutor.test.ts \
|
||||
src/services/codexNative/execRunner.test.ts \
|
||||
src/services/codexNative/jsonlMapper.test.ts \
|
||||
src/services/runtimeBackends/codexBackendResolver.test.ts \
|
||||
src/services/runtimeBackends/registry.agentTeams.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `27 pass`
|
||||
- `0 fail`
|
||||
|
||||
### `claude_team`
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run \
|
||||
test/main/utils/jsonl.test.ts \
|
||||
test/main/services/parsing/SessionParser.test.ts \
|
||||
test/main/services/team/BoardTaskExactLogStrictParser.test.ts \
|
||||
test/main/ipc/configValidation.test.ts \
|
||||
test/main/services/runtime/ProviderConnectionService.test.ts \
|
||||
test/main/services/runtime/providerAwareCliEnv.test.ts \
|
||||
test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts \
|
||||
test/renderer/components/runtime/providerConnectionUi.test.ts \
|
||||
test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts \
|
||||
test/renderer/components/cli/CliStatusVisibility.test.ts
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- `134 pass`
|
||||
- `0 fail`
|
||||
|
||||
### Diff cleanliness
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- clean in both worktrees
|
||||
|
||||
## Live Native Run Evidence
|
||||
|
||||
### Common live-run facts
|
||||
|
||||
Observed from both runs:
|
||||
|
||||
- native binary path: `/usr/local/bin/codex`
|
||||
- native binary source: `system-path`
|
||||
- native binary version: `codex-cli 0.117.0`
|
||||
- credential input source for the sign-off harness: `OPENAI_API_KEY`
|
||||
- credential source observed by the runner: `explicit-api-key`
|
||||
- capability profile: `headless-limited`
|
||||
- final assistant text: `OK`
|
||||
|
||||
### Ephemeral run
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun run ./scripts/codex-native-phase0-signoff.ts \
|
||||
--cwd /tmp \
|
||||
--prompt 'Reply only with OK' \
|
||||
--ephemeral
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- thread id: `019da680-6f43-7e10-824c-4d985bcdca12`
|
||||
- completion policy: `ephemeral`
|
||||
- final history completeness: `live-only`
|
||||
- final usage authority: `live-turn-completed`
|
||||
- assistant usage:
|
||||
- input tokens: `23616`
|
||||
- cached input tokens: `0`
|
||||
- output tokens: `42`
|
||||
|
||||
History authority proof:
|
||||
|
||||
- projected warning subtype: `codex_native_warning`
|
||||
- projected warning source: `history`
|
||||
- observed warning text contained:
|
||||
- `thread/read failed while backfilling turn items for turn completion`
|
||||
- `ephemeral threads do not support includeTurns`
|
||||
|
||||
This is the explicit proof that `ephemeral` live stream does **not** equal canonical hydrated history.
|
||||
|
||||
### Persistent run
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
bun run ./scripts/codex-native-phase0-signoff.ts \
|
||||
--cwd /tmp \
|
||||
--prompt 'Reply only with OK' \
|
||||
--persistent
|
||||
```
|
||||
|
||||
Observed result:
|
||||
|
||||
- thread id: `019da680-6f42-77c0-94f1-4e450a69d1f1`
|
||||
- completion policy: `persistent`
|
||||
- final history completeness: `explicit-hydration-required`
|
||||
- final usage authority: `live-turn-completed`
|
||||
- assistant usage:
|
||||
- input tokens: `23616`
|
||||
- cached input tokens: `0`
|
||||
- output tokens: `33`
|
||||
|
||||
This is the explicit proof that persistent native runs keep a different history-completeness contract from `ephemeral` runs.
|
||||
|
||||
## Warning Attribution Proof
|
||||
|
||||
The live runs produced both:
|
||||
|
||||
- process/runtime warnings
|
||||
- history-completeness warnings
|
||||
|
||||
Observed process-attributed warnings included:
|
||||
|
||||
- plugin cache / featured plugins unauthorized warnings
|
||||
- state DB migration mismatch warnings
|
||||
- shell snapshot timeout warnings
|
||||
- MCP process-group termination warnings
|
||||
|
||||
Observed history-attributed warning included:
|
||||
|
||||
- `thread/read failed while backfilling turn items for turn completion: ... ephemeral threads do not support includeTurns`
|
||||
|
||||
This proves the lane now keeps `process` and `history` warning truth distinct in projected transcript rows.
|
||||
|
||||
## Thread-status Proof
|
||||
|
||||
Observed projected system rows included:
|
||||
|
||||
- `codex_native_thread_status`
|
||||
- `running`
|
||||
- `completed`
|
||||
|
||||
This proves the lane now writes native thread-status authority into persisted transcript-compatible rows instead of forcing UI and replay consumers to infer health from provider-global process truth.
|
||||
|
||||
## Parser And Exact-log Proof
|
||||
|
||||
Covered by green targeted tests:
|
||||
|
||||
- `test/main/utils/jsonl.test.ts`
|
||||
- `test/main/services/parsing/SessionParser.test.ts`
|
||||
- `test/main/services/team/BoardTaskExactLogStrictParser.test.ts`
|
||||
|
||||
These tests prove:
|
||||
|
||||
- projected assistant usage remains parseable
|
||||
- projected warning/source metadata remains parseable
|
||||
- projected execution-summary/history metadata remains parseable
|
||||
- exact-log readers do not drop the native authority rows
|
||||
|
||||
## Degraded Old-lane Fallback Proof
|
||||
|
||||
Covered by green targeted tests:
|
||||
|
||||
- `src/services/runtimeBackends/codexBackendResolver.test.ts`
|
||||
- `src/services/runtimeBackends/registry.agentTeams.test.ts`
|
||||
|
||||
Those tests prove:
|
||||
|
||||
- `auto` still does not silently resolve to `codex-native`
|
||||
- native lane remains unavailable without:
|
||||
- feature flag
|
||||
- binary
|
||||
- `CODEX_API_KEY`
|
||||
- old Codex lane remains the truthful fallback when native is absent or degraded
|
||||
|
||||
## Sign-off Conclusion
|
||||
|
||||
✅ The Phase 0 code path is implementation-complete and evidence-backed.
|
||||
|
||||
⚠️ The lane should still remain:
|
||||
|
||||
- feature-flagged
|
||||
- non-default
|
||||
- non-auto-resolved
|
||||
- non-selectable for normal runtime switching
|
||||
|
||||
That remaining lock is now a rollout-policy choice, not a missing-code problem.
|
||||
|
|
@ -442,10 +442,16 @@ function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | V
|
|||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
if (backendId !== 'auto' && backendId !== 'adapter') {
|
||||
if (
|
||||
backendId !== 'auto' &&
|
||||
backendId !== 'adapter' &&
|
||||
backendId !== 'api' &&
|
||||
backendId !== 'codex-native'
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'runtime.providerBackends.codex must be one of: auto, adapter',
|
||||
error:
|
||||
'runtime.providerBackends.codex must be one of: auto, adapter, api, codex-native',
|
||||
};
|
||||
}
|
||||
providerBackends.codex = backendId;
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ export interface GeneralConfig {
|
|||
export interface RuntimeConfig {
|
||||
providerBackends: {
|
||||
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||
codex: 'auto' | 'adapter';
|
||||
codex: 'auto' | 'adapter' | 'api' | 'codex-native';
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial<Record<CliProviderId, string>> = {
|
|||
};
|
||||
|
||||
const CODEX_API_KEY_BETA_ENV_VAR = 'CLAUDE_CODE_CODEX_API_KEY_BETA';
|
||||
const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY';
|
||||
const CODEX_NATIVE_BACKEND_ID = 'codex-native';
|
||||
|
||||
export class ProviderConnectionService {
|
||||
private static instance: ProviderConnectionService | null = null;
|
||||
|
|
@ -64,7 +66,7 @@ export class ProviderConnectionService {
|
|||
|
||||
if (providerId === 'codex') {
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
return codexConnection.apiKeyBetaEnabled ? codexConnection.authMode : null;
|
||||
return this.shouldExposeCodexConnectionModes() ? codexConnection.authMode : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -107,31 +109,55 @@ export class ProviderConnectionService {
|
|||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled) {
|
||||
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
|
||||
if (!this.shouldExposeCodexConnectionModes()) {
|
||||
delete env[CODEX_API_KEY_BETA_ENV_VAR];
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
return env;
|
||||
}
|
||||
|
||||
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
|
||||
if (codexConnection.apiKeyBetaEnabled) {
|
||||
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
|
||||
} else {
|
||||
delete env[CODEX_API_KEY_BETA_ENV_VAR];
|
||||
}
|
||||
|
||||
if (codexConnection.authMode === 'oauth') {
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = 'adapter';
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
return env;
|
||||
}
|
||||
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = 'api';
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
|
||||
if (storedKey?.value.trim()) {
|
||||
env.OPENAI_API_KEY = storedKey.value;
|
||||
const existingOpenAiKey =
|
||||
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()
|
||||
? env.OPENAI_API_KEY
|
||||
: null;
|
||||
const existingNativeKey =
|
||||
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
|
||||
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
|
||||
: null;
|
||||
const resolvedApiKey =
|
||||
storedKey?.value.trim() ||
|
||||
existingOpenAiKey ||
|
||||
(codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null);
|
||||
|
||||
if (resolvedApiKey) {
|
||||
env.OPENAI_API_KEY = resolvedApiKey;
|
||||
if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID) {
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
|
||||
} else {
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
if (typeof env.OPENAI_API_KEY !== 'string' || !env.OPENAI_API_KEY.trim()) {
|
||||
delete env.OPENAI_API_KEY;
|
||||
}
|
||||
delete env[CODEX_NATIVE_API_KEY_ENV_VAR];
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
@ -165,20 +191,48 @@ export class ProviderConnectionService {
|
|||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled) {
|
||||
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
|
||||
if (!this.shouldExposeCodexConnectionModes()) {
|
||||
return env;
|
||||
}
|
||||
|
||||
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
|
||||
env.CLAUDE_CODE_CODEX_BACKEND = codexConnection.authMode === 'oauth' ? 'adapter' : 'api';
|
||||
if (codexConnection.apiKeyBetaEnabled) {
|
||||
env[CODEX_API_KEY_BETA_ENV_VAR] = '1';
|
||||
} else {
|
||||
delete env[CODEX_API_KEY_BETA_ENV_VAR];
|
||||
}
|
||||
|
||||
if (codexConnection.authMode !== 'api_key') {
|
||||
return env;
|
||||
}
|
||||
|
||||
const storedKey = await this.apiKeyService.lookupPreferred('OPENAI_API_KEY');
|
||||
const existingOpenAiKey =
|
||||
typeof env.OPENAI_API_KEY === 'string' && env.OPENAI_API_KEY.trim()
|
||||
? env.OPENAI_API_KEY
|
||||
: null;
|
||||
const existingNativeKey =
|
||||
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
|
||||
? env[CODEX_NATIVE_API_KEY_ENV_VAR]
|
||||
: null;
|
||||
const resolvedApiKey =
|
||||
storedKey?.value.trim() ||
|
||||
existingOpenAiKey ||
|
||||
(codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID ? existingNativeKey : null);
|
||||
|
||||
if (storedKey?.value.trim()) {
|
||||
env.OPENAI_API_KEY = storedKey.value;
|
||||
} else if (
|
||||
!existingOpenAiKey &&
|
||||
existingNativeKey &&
|
||||
codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID
|
||||
) {
|
||||
env.OPENAI_API_KEY = existingNativeKey;
|
||||
}
|
||||
|
||||
if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID && resolvedApiKey) {
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR] = resolvedApiKey;
|
||||
}
|
||||
|
||||
return env;
|
||||
|
|
@ -216,7 +270,8 @@ export class ProviderConnectionService {
|
|||
}
|
||||
|
||||
const codexConnection = this.configManager.getConfig().providerConnections.codex;
|
||||
if (!codexConnection.apiKeyBetaEnabled || codexConnection.authMode !== 'api_key') {
|
||||
const codexRuntimeBackend = this.getConfiguredCodexRuntimeBackend();
|
||||
if (!this.shouldExposeCodexConnectionModes() || codexConnection.authMode !== 'api_key') {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -224,10 +279,17 @@ export class ProviderConnectionService {
|
|||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
'Codex API key mode is enabled, but no OPENAI_API_KEY is configured. ' +
|
||||
'Add a stored/environment API key or switch Codex auth mode back to OAuth.'
|
||||
);
|
||||
if (
|
||||
codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID &&
|
||||
typeof env[CODEX_NATIVE_API_KEY_ENV_VAR] === 'string' &&
|
||||
env[CODEX_NATIVE_API_KEY_ENV_VAR]?.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID
|
||||
? 'Codex API key mode is enabled for codex-native, but no OPENAI_API_KEY or CODEX_API_KEY is configured. Add a stored/environment API key or switch Codex auth mode back to OAuth.'
|
||||
: 'Codex API key mode is enabled, but no OPENAI_API_KEY is configured. Add a stored/environment API key or switch Codex auth mode back to OAuth.';
|
||||
}
|
||||
|
||||
async getConfiguredConnectionIssues(
|
||||
|
|
@ -260,17 +322,23 @@ export class ProviderConnectionService {
|
|||
async getConnectionInfo(providerId: CliProviderId): Promise<CliProviderConnectionInfo> {
|
||||
const capabilities = PROVIDER_CAPABILITIES[providerId];
|
||||
const storedApiKey = await this.getStoredApiKey(providerId);
|
||||
const externalCredential = this.getExternalCredential(providerId);
|
||||
const codexRuntimeBackend =
|
||||
providerId === 'codex' ? this.getConfiguredCodexRuntimeBackend() : null;
|
||||
const externalCredential = this.getExternalCredential(providerId, codexRuntimeBackend);
|
||||
const codexBetaEnabled =
|
||||
providerId === 'codex'
|
||||
? this.configManager.getConfig().providerConnections.codex.apiKeyBetaEnabled
|
||||
: undefined;
|
||||
const configurableAuthModes =
|
||||
providerId === 'codex' && codexBetaEnabled
|
||||
providerId === 'codex' &&
|
||||
(codexBetaEnabled || codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID)
|
||||
? (['oauth', 'api_key'] as CliProviderAuthMode[])
|
||||
: capabilities.configurableAuthModes;
|
||||
const configuredAuthMode =
|
||||
providerId === 'codex' && !codexBetaEnabled ? null : this.getConfiguredAuthMode(providerId);
|
||||
providerId === 'codex' &&
|
||||
!(codexBetaEnabled || codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID)
|
||||
? null
|
||||
: this.getConfiguredAuthMode(providerId);
|
||||
|
||||
return {
|
||||
...capabilities,
|
||||
|
|
@ -301,7 +369,22 @@ export class ProviderConnectionService {
|
|||
return this.apiKeyService.lookupPreferred(envVarName);
|
||||
}
|
||||
|
||||
private getExternalCredential(providerId: CliProviderId): ExternalCredential {
|
||||
private getConfiguredCodexRuntimeBackend(): 'auto' | 'adapter' | 'api' | 'codex-native' {
|
||||
return this.configManager.getConfig().runtime.providerBackends.codex;
|
||||
}
|
||||
|
||||
private shouldExposeCodexConnectionModes(): boolean {
|
||||
const config = this.configManager.getConfig();
|
||||
return (
|
||||
config.providerConnections.codex.apiKeyBetaEnabled ||
|
||||
config.runtime.providerBackends.codex === CODEX_NATIVE_BACKEND_ID
|
||||
);
|
||||
}
|
||||
|
||||
private getExternalCredential(
|
||||
providerId: CliProviderId,
|
||||
codexRuntimeBackend: 'auto' | 'adapter' | 'api' | 'codex-native' | null = null
|
||||
): ExternalCredential {
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const sources = [shellEnv, process.env];
|
||||
|
||||
|
|
@ -336,6 +419,16 @@ export class ProviderConnectionService {
|
|||
}
|
||||
|
||||
if (providerId === 'codex') {
|
||||
if (codexRuntimeBackend === CODEX_NATIVE_BACKEND_ID) {
|
||||
const nativeApiKey = findEnvValue(CODEX_NATIVE_API_KEY_ENV_VAR);
|
||||
if (nativeApiKey) {
|
||||
return {
|
||||
label: `Detected from ${CODEX_NATIVE_API_KEY_ENV_VAR}`,
|
||||
value: nativeApiKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = findEnvValue('OPENAI_API_KEY');
|
||||
if (apiKey) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -198,9 +198,22 @@ export interface AssistantEntry extends ConversationalEntry {
|
|||
|
||||
export interface SystemEntry extends ConversationalEntry {
|
||||
type: 'system';
|
||||
subtype: 'turn_duration' | 'init';
|
||||
durationMs: number;
|
||||
subtype?: 'turn_duration' | 'init' | 'informational' | 'permission_retry' | 'api_retry' | string;
|
||||
durationMs?: number;
|
||||
isMeta: boolean;
|
||||
content?: string;
|
||||
level?: 'info' | 'warning' | 'error' | 'suggestion' | string;
|
||||
toolUseID?: string;
|
||||
preventContinuation?: boolean;
|
||||
codexNativeWarningSource?: string;
|
||||
codexNativeThreadStatus?: string;
|
||||
codexNativeThreadId?: string;
|
||||
codexNativeCompletionPolicy?: 'ephemeral' | 'persistent' | string;
|
||||
codexNativeHistoryCompleteness?: string;
|
||||
codexNativeFinalUsageAuthority?: string;
|
||||
codexNativeExecutablePath?: string;
|
||||
codexNativeExecutableSource?: string;
|
||||
codexNativeExecutableVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface SummaryEntry extends BaseEntry {
|
||||
|
|
|
|||
|
|
@ -109,6 +109,19 @@ export interface ParsedMessage {
|
|||
isCompactSummary?: boolean;
|
||||
/** API request ID for deduplicating streaming entries */
|
||||
requestId?: string;
|
||||
/** System-message severity when available in the raw transcript */
|
||||
level?: string;
|
||||
/** Raw system subtype when available in the transcript */
|
||||
subtype?: string;
|
||||
codexNativeWarningSource?: string;
|
||||
codexNativeThreadStatus?: string;
|
||||
codexNativeThreadId?: string;
|
||||
codexNativeCompletionPolicy?: string;
|
||||
codexNativeHistoryCompleteness?: string;
|
||||
codexNativeFinalUsageAuthority?: string;
|
||||
codexNativeExecutablePath?: string;
|
||||
codexNativeExecutableSource?: string;
|
||||
codexNativeExecutableVersion?: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -244,6 +244,17 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
let gitBranch: string | undefined;
|
||||
let agentId: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
let level: string | undefined;
|
||||
let subtype: string | undefined;
|
||||
let codexNativeWarningSource: string | undefined;
|
||||
let codexNativeThreadStatus: string | undefined;
|
||||
let codexNativeThreadId: string | undefined;
|
||||
let codexNativeCompletionPolicy: string | undefined;
|
||||
let codexNativeHistoryCompleteness: string | undefined;
|
||||
let codexNativeFinalUsageAuthority: string | undefined;
|
||||
let codexNativeExecutablePath: string | undefined;
|
||||
let codexNativeExecutableSource: string | undefined;
|
||||
let codexNativeExecutableVersion: string | null | undefined;
|
||||
let isSidechain = false;
|
||||
let isMeta = false;
|
||||
let userType: string | undefined;
|
||||
|
|
@ -283,7 +294,19 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
agentId = entry.agentId;
|
||||
requestId = entry.requestId;
|
||||
} else if (entry.type === 'system') {
|
||||
content = entry.content ?? '';
|
||||
isMeta = entry.isMeta ?? false;
|
||||
level = entry.level;
|
||||
subtype = entry.subtype;
|
||||
codexNativeWarningSource = entry.codexNativeWarningSource;
|
||||
codexNativeThreadStatus = entry.codexNativeThreadStatus;
|
||||
codexNativeThreadId = entry.codexNativeThreadId;
|
||||
codexNativeCompletionPolicy = entry.codexNativeCompletionPolicy;
|
||||
codexNativeHistoryCompleteness = entry.codexNativeHistoryCompleteness;
|
||||
codexNativeFinalUsageAuthority = entry.codexNativeFinalUsageAuthority;
|
||||
codexNativeExecutablePath = entry.codexNativeExecutablePath;
|
||||
codexNativeExecutableSource = entry.codexNativeExecutableSource;
|
||||
codexNativeExecutableVersion = entry.codexNativeExecutableVersion;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -310,6 +333,17 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
|||
isMeta,
|
||||
userType,
|
||||
isCompactSummary,
|
||||
level,
|
||||
subtype,
|
||||
codexNativeWarningSource,
|
||||
codexNativeThreadStatus,
|
||||
codexNativeThreadId,
|
||||
codexNativeCompletionPolicy,
|
||||
codexNativeHistoryCompleteness,
|
||||
codexNativeFinalUsageAuthority,
|
||||
codexNativeExecutablePath,
|
||||
codexNativeExecutableSource,
|
||||
codexNativeExecutableVersion,
|
||||
// Tool info
|
||||
toolCalls,
|
||||
toolResults: toolResultsList,
|
||||
|
|
|
|||
|
|
@ -240,6 +240,15 @@ function getProviderTerminalCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
@ -259,6 +268,15 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -100,7 +100,10 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
disabled={!option.available && option.id !== selectedBackendId}
|
||||
disabled={
|
||||
(!option.available || option.selectable === false) &&
|
||||
option.id !== selectedBackendId
|
||||
}
|
||||
className="py-2.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
|
|
@ -127,6 +130,16 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
>
|
||||
Unavailable
|
||||
</span>
|
||||
) : option.selectable === false ? (
|
||||
<span
|
||||
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
color: 'var(--color-text-secondary)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
Locked
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
@ -179,6 +192,27 @@ export const ProviderRuntimeBackendSelector = ({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : selectedOption.selectable === false ? (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="cursor-help rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
color: 'var(--color-text-secondary)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
Locked
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{selectedOption.detailMessage ??
|
||||
selectedOption.statusMessage ??
|
||||
'This backend cannot be selected yet.'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,17 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv
|
|||
return providerId === 'anthropic' || providerId === 'codex' || providerId === 'gemini';
|
||||
}
|
||||
|
||||
function hasExplicitRuntimeBackends(provider: CliProviderStatus): boolean {
|
||||
return (provider.availableBackends?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function isCodexNativeLane(provider: CliProviderStatus): boolean {
|
||||
return (
|
||||
provider.providerId === 'codex' &&
|
||||
(provider.selectedBackendId === 'codex-native' || provider.resolvedBackendId === 'codex-native')
|
||||
);
|
||||
}
|
||||
|
||||
function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null {
|
||||
const matches = apiKeys.filter((entry) => entry.envVarName === envVarName);
|
||||
return matches.find((entry) => entry.scope === 'user') ?? null;
|
||||
|
|
@ -113,9 +124,11 @@ function getConnectionDescription(provider: CliProviderStatus): string {
|
|||
case 'anthropic':
|
||||
return 'Choose how app-launched Anthropic sessions authenticate.';
|
||||
case 'codex':
|
||||
return provider.connection?.apiKeyBetaEnabled
|
||||
? 'Choose whether app-launched Codex sessions use your Codex subscription or an OpenAI API key. Runtime follows this automatically.'
|
||||
: 'Codex uses your subscription session by default. Enable API key mode if you want to switch Codex to OPENAI_API_KEY billing.';
|
||||
return hasExplicitRuntimeBackends(provider)
|
||||
? 'Choose which credentials app-launched Codex sessions should use. Runtime backend is configured separately below.'
|
||||
: provider.connection?.apiKeyBetaEnabled
|
||||
? 'Choose whether app-launched Codex sessions use your Codex subscription or an OpenAI API key.'
|
||||
: 'Codex uses your subscription session by default. Enable API key mode if you want to switch Codex credential routing to API-key billing.';
|
||||
case 'gemini':
|
||||
return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
|
||||
}
|
||||
|
|
@ -126,7 +139,9 @@ function getRuntimeDescription(provider: CliProviderStatus): string {
|
|||
case 'anthropic':
|
||||
return 'Anthropic currently has no separate runtime backend selector.';
|
||||
case 'codex':
|
||||
return 'Codex runtime selection follows the active connection method automatically.';
|
||||
return hasExplicitRuntimeBackends(provider)
|
||||
? 'Choose which Codex runtime backend multimodel should use. Connection method only controls credentials.'
|
||||
: 'Codex runtime selection follows the active connection method automatically.';
|
||||
case 'gemini':
|
||||
return 'Choose which Gemini runtime backend multimodel should use.';
|
||||
}
|
||||
|
|
@ -146,8 +161,8 @@ function getAuthModeDescription(providerId: CliProviderId, authMode: CliProvider
|
|||
|
||||
if (providerId === 'codex') {
|
||||
return authMode === 'api_key'
|
||||
? 'Use OPENAI_API_KEY and the public OpenAI Responses API backend.'
|
||||
: 'Use your Codex subscription session and the built-in Codex runtime.';
|
||||
? 'Use API-key credentials for app-launched Codex sessions. The selected runtime backend decides how those credentials are consumed.'
|
||||
: 'Use your Codex subscription session. API-key-only backends remain unavailable until you switch this credential mode.';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
|
@ -185,25 +200,20 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
|
|||
|
||||
if (
|
||||
provider.providerId === 'codex' &&
|
||||
provider.connection?.apiKeyBetaEnabled &&
|
||||
authMode === 'api_key' &&
|
||||
!provider.connection?.apiKeyConfigured
|
||||
) {
|
||||
return 'API key mode is selected, but no OPENAI_API_KEY credential is available yet.';
|
||||
return isCodexNativeLane(provider)
|
||||
? 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.'
|
||||
: 'API key mode is selected, but no OPENAI_API_KEY credential is available yet.';
|
||||
}
|
||||
|
||||
if (
|
||||
provider.providerId === 'codex' &&
|
||||
provider.connection?.apiKeyBetaEnabled &&
|
||||
authMode === 'oauth' &&
|
||||
!hasCodexSubscriptionSession
|
||||
) {
|
||||
if (provider.providerId === 'codex' && authMode === 'oauth' && !hasCodexSubscriptionSession) {
|
||||
return 'Codex subscription mode is selected. Sign in with Codex to use this provider.';
|
||||
}
|
||||
|
||||
if (
|
||||
provider.providerId === 'codex' &&
|
||||
provider.connection?.apiKeyBetaEnabled &&
|
||||
authMode === 'oauth' &&
|
||||
provider.connection?.apiKeySource === 'stored'
|
||||
) {
|
||||
|
|
@ -266,7 +276,9 @@ function getConnectionMethodCardOptions(
|
|||
|
||||
function getConnectionMethodCardsHint(provider: CliProviderStatus): string | null {
|
||||
if (provider.providerId === 'codex') {
|
||||
return 'Runtime follows your connection method automatically.';
|
||||
return hasExplicitRuntimeBackends(provider)
|
||||
? 'Connection method controls credentials only. Runtime backend selection is independent.'
|
||||
: 'Runtime follows your connection method automatically.';
|
||||
}
|
||||
|
||||
if (provider.providerId === 'anthropic') {
|
||||
|
|
@ -899,7 +911,8 @@ export const ProviderRuntimeSettingsDialog = ({
|
|||
|
||||
{selectedProvider.providerId === 'codex' &&
|
||||
selectedProvider.connection?.apiKeyBetaAvailable &&
|
||||
!selectedProvider.connection.apiKeyBetaEnabled ? (
|
||||
!selectedProvider.connection.apiKeyBetaEnabled &&
|
||||
!showConnectionMethodCards ? (
|
||||
<div
|
||||
className="space-y-3 rounded-md border p-3"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
|
|
|
|||
|
|
@ -69,11 +69,22 @@ export function formatProviderAuthMethodLabelForProvider(
|
|||
return formatProviderAuthMethodLabel(authMethod);
|
||||
}
|
||||
|
||||
function isCodexNativeLane(provider: CliProviderStatus): boolean {
|
||||
return (
|
||||
provider.providerId === 'codex' &&
|
||||
(provider.resolvedBackendId === 'codex-native' || provider.selectedBackendId === 'codex-native')
|
||||
);
|
||||
}
|
||||
|
||||
export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus): boolean {
|
||||
return provider.providerId === 'codex';
|
||||
return provider.providerId === 'codex' && (provider.availableBackends?.length ?? 0) === 0;
|
||||
}
|
||||
|
||||
function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string {
|
||||
if (isCodexNativeLane(provider) && provider.backend?.label) {
|
||||
return provider.backend.label;
|
||||
}
|
||||
|
||||
if (provider.authenticated) {
|
||||
return provider.authMethod === 'api_key' ? CODEX_API_KEY_LABEL : CODEX_SUBSCRIPTION_LABEL;
|
||||
}
|
||||
|
|
@ -86,7 +97,7 @@ function getCodexCurrentRuntimeLabel(provider: CliProviderStatus): string {
|
|||
}
|
||||
|
||||
export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null {
|
||||
if (provider.providerId !== 'codex') {
|
||||
if (provider.providerId !== 'codex' || !isConnectionManagedRuntimeProvider(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +174,12 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
|
|||
}
|
||||
|
||||
if (provider.providerId === 'codex' && provider.connection?.apiKeyBetaEnabled !== true) {
|
||||
if (isCodexNativeLane(provider)) {
|
||||
return provider.connection.apiKeySource === 'stored'
|
||||
? 'Saved API key available in Manage'
|
||||
: (provider.connection.apiKeySourceLabel ?? 'API key is configured');
|
||||
}
|
||||
|
||||
return provider.connection.apiKeySource === 'stored'
|
||||
? 'OpenAI API key is saved in Manage. Enable API key mode to use it.'
|
||||
: 'OpenAI API key detected. Enable API key mode in Manage to use it.';
|
||||
|
|
|
|||
|
|
@ -105,6 +105,15 @@ function getProviderTerminalCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
@ -124,6 +133,15 @@ function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
|||
};
|
||||
}
|
||||
|
||||
if (provider.providerId === 'codex') {
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
env: {
|
||||
CLAUDE_CODE_CODEX_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ export interface AppConfig {
|
|||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||
codex: 'auto' | 'adapter';
|
||||
codex: 'auto' | 'adapter' | 'api' | 'codex-native';
|
||||
};
|
||||
};
|
||||
/** Display and UI settings */
|
||||
|
|
|
|||
|
|
@ -239,4 +239,49 @@ describe('configValidation', () => {
|
|||
expect(result.error).toContain('providerConnections.codex.authMode');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts Codex runtime backend updates for api and codex-native', () => {
|
||||
const apiResult = validateConfigUpdatePayload('runtime', {
|
||||
providerBackends: {
|
||||
codex: 'api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(apiResult.valid).toBe(true);
|
||||
if (apiResult.valid) {
|
||||
expect(apiResult.data).toEqual({
|
||||
providerBackends: {
|
||||
codex: 'api',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const nativeResult = validateConfigUpdatePayload('runtime', {
|
||||
providerBackends: {
|
||||
codex: 'codex-native',
|
||||
},
|
||||
});
|
||||
|
||||
expect(nativeResult.valid).toBe(true);
|
||||
if (nativeResult.valid) {
|
||||
expect(nativeResult.data).toEqual({
|
||||
providerBackends: {
|
||||
codex: 'codex-native',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown Codex runtime backends', () => {
|
||||
const result = validateConfigUpdatePayload('runtime', {
|
||||
providerBackends: {
|
||||
codex: 'native',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.error).toContain('auto, adapter, api, codex-native');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
218
test/main/services/parsing/CodexNativePhase0Smoke.test.ts
Normal file
218
test/main/services/parsing/CodexNativePhase0Smoke.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// @vitest-environment node
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LocalFileSystemProvider } from '../../../../src/main/services/infrastructure/LocalFileSystemProvider';
|
||||
import { SessionParser } from '../../../../src/main/services/parsing/SessionParser';
|
||||
import { BoardTaskExactLogStrictParser } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const mockProjectScanner = {
|
||||
scan: vi.fn(),
|
||||
getSessionPath: vi.fn(),
|
||||
listSessionsPaginated: vi.fn(),
|
||||
listSessions: vi.fn(),
|
||||
listSubagentFiles: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
listWorktreeSessions: vi.fn(),
|
||||
scanWithWorktreeGrouping: vi.fn(),
|
||||
getFileSystemProvider: vi.fn().mockReturnValue(new LocalFileSystemProvider()),
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0, tempDirs.length).map(async (dirPath) => {
|
||||
await fs.promises.rm(dirPath, { recursive: true, force: true });
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('codex-native phase 0 smoke', () => {
|
||||
let parser: SessionParser;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// @ts-expect-error test partial
|
||||
parser = new SessionParser(mockProjectScanner);
|
||||
});
|
||||
|
||||
it('keeps native projected runtime truth parseable through session and exact-log readers', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-native-phase0-smoke-'));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const filePath = path.join(tempDir, 'native-phase0-session.jsonl');
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-smoke',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'native-running',
|
||||
timestamp: '2026-04-19T10:00:00.000Z',
|
||||
subtype: 'codex_native_thread_status',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content: 'Codex native thread started: thread-smoke',
|
||||
codexNativeThreadStatus: 'running',
|
||||
codexNativeThreadId: 'thread-smoke',
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-smoke',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'native-warning',
|
||||
timestamp: '2026-04-19T10:00:01.000Z',
|
||||
subtype: 'codex_native_warning',
|
||||
level: 'warning',
|
||||
isMeta: false,
|
||||
content: 'thread/read failed while backfilling turn items for turn completion',
|
||||
codexNativeWarningSource: 'history',
|
||||
codexNativeThreadId: 'thread-smoke',
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: 'user-smoke-1',
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-smoke',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-smoke',
|
||||
requestId: 'request-smoke',
|
||||
timestamp: '2026-04-19T10:00:02.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'gpt-5.4-mini',
|
||||
id: 'msg-native-smoke',
|
||||
type: 'message',
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
cache_read_input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
},
|
||||
content: [{ type: 'text', text: 'OK' }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-smoke',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'native-summary',
|
||||
timestamp: '2026-04-19T10:00:03.000Z',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content:
|
||||
'Codex native execution summary: thread=thread-smoke, completion=ephemeral, history=live-only, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
|
||||
codexNativeThreadId: 'thread-smoke',
|
||||
codexNativeCompletionPolicy: 'ephemeral',
|
||||
codexNativeHistoryCompleteness: 'live-only',
|
||||
codexNativeFinalUsageAuthority: 'live-turn-completed',
|
||||
codexNativeExecutableSource: 'system-path',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-smoke',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'native-completed',
|
||||
timestamp: '2026-04-19T10:00:04.000Z',
|
||||
subtype: 'codex_native_thread_status',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content: 'Codex native thread completed: thread-smoke',
|
||||
codexNativeThreadStatus: 'completed',
|
||||
codexNativeThreadId: 'thread-smoke',
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const parsed = await parser.parseSessionFile(filePath);
|
||||
const exact = await new BoardTaskExactLogStrictParser().parseFiles([filePath]);
|
||||
const exactRows = exact.get(filePath) ?? [];
|
||||
|
||||
expect(parsed.byType.assistant).toMatchObject([
|
||||
{
|
||||
uuid: 'assistant-smoke',
|
||||
requestId: 'request-smoke',
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
cache_read_input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(parsed.byType.system).toMatchObject([
|
||||
{
|
||||
uuid: 'native-running',
|
||||
subtype: 'codex_native_thread_status',
|
||||
codexNativeThreadStatus: 'running',
|
||||
},
|
||||
{
|
||||
uuid: 'native-warning',
|
||||
subtype: 'codex_native_warning',
|
||||
codexNativeWarningSource: 'history',
|
||||
},
|
||||
{
|
||||
uuid: 'native-summary',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
codexNativeCompletionPolicy: 'ephemeral',
|
||||
codexNativeHistoryCompleteness: 'live-only',
|
||||
},
|
||||
{
|
||||
uuid: 'native-completed',
|
||||
subtype: 'codex_native_thread_status',
|
||||
codexNativeThreadStatus: 'completed',
|
||||
},
|
||||
]);
|
||||
expect(exactRows.map((row) => row.uuid)).toEqual([
|
||||
'native-running',
|
||||
'native-warning',
|
||||
'assistant-smoke',
|
||||
'native-summary',
|
||||
'native-completed',
|
||||
]);
|
||||
expect(exactRows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
uuid: 'native-warning',
|
||||
subtype: 'codex_native_warning',
|
||||
codexNativeWarningSource: 'history',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
uuid: 'native-summary',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,9 @@
|
|||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
SessionParser,
|
||||
|
|
@ -173,6 +176,145 @@ describe('SessionParser', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('parseSessionFile', () => {
|
||||
it('keeps codex-native projected assistant usage and modern system warnings parseable', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-parser-native-'));
|
||||
const filePath = path.join(tempDir, 'native-session.jsonl');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-parse',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'system-native-warning-1',
|
||||
timestamp: '2026-04-19T10:00:00.000Z',
|
||||
subtype: 'codex_native_warning',
|
||||
level: 'warning',
|
||||
isMeta: false,
|
||||
content: 'native stderr warning',
|
||||
codexNativeWarningSource: 'process',
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: 'user-native-1',
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-parse',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-native-1',
|
||||
requestId: 'native-request-1',
|
||||
timestamp: '2026-04-19T10:00:01.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'gpt-5.4-mini',
|
||||
id: 'msg-native-1',
|
||||
type: 'message',
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
cache_read_input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
},
|
||||
content: [{ type: 'text', text: 'OK' }],
|
||||
},
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const parsed = await parser.parseSessionFile(filePath);
|
||||
|
||||
expect(parsed.byType.system).toMatchObject([
|
||||
{
|
||||
uuid: 'system-native-warning-1',
|
||||
content: 'native stderr warning',
|
||||
level: 'warning',
|
||||
subtype: 'codex_native_warning',
|
||||
codexNativeWarningSource: 'process',
|
||||
},
|
||||
]);
|
||||
expect(parsed.byType.assistant).toMatchObject([
|
||||
{
|
||||
uuid: 'assistant-native-1',
|
||||
requestId: 'native-request-1',
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
cache_read_input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps codex-native execution summary metadata parseable for replay and history truth', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-parser-native-summary-'));
|
||||
const filePath = path.join(tempDir, 'native-summary-session.jsonl');
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-summary',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'system-native-summary-1',
|
||||
timestamp: '2026-04-19T10:00:02.000Z',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content:
|
||||
'Codex native execution summary: thread=thread-persistent, completion=persistent, history=explicit-hydration-required, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
|
||||
codexNativeThreadId: 'thread-persistent',
|
||||
codexNativeCompletionPolicy: 'persistent',
|
||||
codexNativeHistoryCompleteness: 'explicit-hydration-required',
|
||||
codexNativeFinalUsageAuthority: 'live-turn-completed',
|
||||
codexNativeExecutablePath: '/usr/local/bin/codex',
|
||||
codexNativeExecutableSource: 'system-path',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const parsed = await parser.parseSessionFile(filePath);
|
||||
|
||||
expect(parsed.byType.system).toMatchObject([
|
||||
{
|
||||
uuid: 'system-native-summary-1',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
codexNativeThreadId: 'thread-persistent',
|
||||
codexNativeCompletionPolicy: 'persistent',
|
||||
codexNativeHistoryCompleteness: 'explicit-hydration-required',
|
||||
codexNativeExecutableSource: 'system-path',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponses', () => {
|
||||
it('should get assistant responses after user message', () => {
|
||||
const userMsgUuid = 'user-1';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
import type { PathLike } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getProviderConnectionModeSummary,
|
||||
getProviderCurrentRuntimeSummary,
|
||||
isConnectionManagedRuntimeProvider,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
|
||||
const execCliMock = vi.fn();
|
||||
const buildProviderAwareCliEnvMock = vi.fn();
|
||||
|
|
@ -293,4 +298,145 @@ describe('ClaudeMultimodelBridgeService', () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps codex-native lane truth honest from unified runtime status through renderer summaries', async () => {
|
||||
execCliMock.mockResolvedValue({
|
||||
stdout: JSON.stringify({
|
||||
providers: {
|
||||
anthropic: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
models: ['claude-sonnet-4-5'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: { status: 'supported', ownership: 'shared', reason: null },
|
||||
mcp: { status: 'supported', ownership: 'shared', reason: null },
|
||||
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||
},
|
||||
},
|
||||
backend: { kind: 'anthropic', label: 'Anthropic' },
|
||||
},
|
||||
codex: {
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'oauth_token',
|
||||
verificationState: 'verified',
|
||||
canLoginFromUi: true,
|
||||
statusMessage: 'Codex native lane is wired but remains locked for normal selection.',
|
||||
detailMessage: 'Use the fallback adapter/API lane unless the experimental native lane is explicitly enabled.',
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
availableBackends: [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
selectable: true,
|
||||
recommended: true,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'codex-native',
|
||||
label: 'Codex native',
|
||||
selectable: false,
|
||||
recommended: false,
|
||||
available: true,
|
||||
statusMessage: 'Experimental native lane',
|
||||
detailMessage: 'Phase 0 keeps the lane locked behind rollout policy.',
|
||||
},
|
||||
],
|
||||
externalRuntimeDiagnostics: [
|
||||
{
|
||||
id: 'codex-cli',
|
||||
label: 'Codex CLI',
|
||||
detected: true,
|
||||
statusMessage: 'Detected',
|
||||
detailMessage: 'System codex binary available.',
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: true,
|
||||
extensions: {
|
||||
plugins: {
|
||||
status: 'unsupported',
|
||||
ownership: 'shared',
|
||||
reason: 'codex-native phase 0 keeps plugin execution disabled.',
|
||||
},
|
||||
mcp: {
|
||||
status: 'unsupported',
|
||||
ownership: 'shared',
|
||||
reason: 'Headless-limited lane',
|
||||
},
|
||||
skills: {
|
||||
status: 'unsupported',
|
||||
ownership: 'shared',
|
||||
reason: 'Headless-limited lane',
|
||||
},
|
||||
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||
},
|
||||
},
|
||||
backend: {
|
||||
kind: 'codex-native',
|
||||
label: 'Codex native',
|
||||
authMethodDetail: 'API key',
|
||||
},
|
||||
},
|
||||
gemini: {
|
||||
supported: false,
|
||||
authenticated: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const { ClaudeMultimodelBridgeService } =
|
||||
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||
const service = new ClaudeMultimodelBridgeService();
|
||||
|
||||
const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator');
|
||||
const codex = providers.find((provider) => provider.providerId === 'codex');
|
||||
|
||||
expect(codex).toMatchObject({
|
||||
providerId: 'codex',
|
||||
authenticated: true,
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
backend: {
|
||||
kind: 'codex-native',
|
||||
label: 'Codex native',
|
||||
},
|
||||
availableBackends: [
|
||||
expect.objectContaining({
|
||||
id: 'auto',
|
||||
selectable: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'codex-native',
|
||||
selectable: false,
|
||||
available: true,
|
||||
statusMessage: 'Experimental native lane',
|
||||
}),
|
||||
],
|
||||
externalRuntimeDiagnostics: [
|
||||
expect.objectContaining({
|
||||
id: 'codex-cli',
|
||||
detected: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(codex?.capabilities.extensions.plugins).toMatchObject({
|
||||
status: 'unsupported',
|
||||
});
|
||||
expect(isConnectionManagedRuntimeProvider(codex!)).toBe(false);
|
||||
expect(getProviderConnectionModeSummary(codex!)).toBeNull();
|
||||
expect(getProviderCurrentRuntimeSummary(codex!)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,16 +9,30 @@ vi.mock('@main/utils/shellEnv', () => ({
|
|||
|
||||
describe('ProviderConnectionService', () => {
|
||||
const originalOpenAiApiKey = process.env.OPENAI_API_KEY;
|
||||
const originalCodexApiKey = process.env.CODEX_API_KEY;
|
||||
|
||||
function createConfig(authMode: 'auto' | 'oauth' | 'api_key' = 'auto') {
|
||||
function createConfig(
|
||||
authMode: 'auto' | 'oauth' | 'api_key' = 'auto',
|
||||
overrides?: {
|
||||
codexAuthMode?: 'oauth' | 'api_key';
|
||||
codexApiKeyBetaEnabled?: boolean;
|
||||
codexRuntimeBackend?: 'auto' | 'adapter' | 'api' | 'codex-native';
|
||||
}
|
||||
) {
|
||||
return {
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode,
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: false,
|
||||
authMode: 'oauth' as const,
|
||||
apiKeyBetaEnabled: overrides?.codexApiKeyBetaEnabled ?? false,
|
||||
authMode: overrides?.codexAuthMode ?? ('oauth' as const),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto' as const,
|
||||
codex: overrides?.codexRuntimeBackend ?? ('auto' as const),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -29,15 +43,22 @@ describe('ProviderConnectionService', () => {
|
|||
vi.clearAllMocks();
|
||||
getCachedShellEnvMock.mockReturnValue({});
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.CODEX_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalOpenAiApiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = originalOpenAiApiKey;
|
||||
}
|
||||
|
||||
if (originalCodexApiKey === undefined) {
|
||||
delete process.env.CODEX_API_KEY;
|
||||
return;
|
||||
}
|
||||
|
||||
process.env.OPENAI_API_KEY = originalOpenAiApiKey;
|
||||
process.env.CODEX_API_KEY = originalCodexApiKey;
|
||||
});
|
||||
|
||||
it('removes Anthropic environment credentials when OAuth mode is selected', async () => {
|
||||
|
|
@ -284,15 +305,11 @@ describe('ProviderConnectionService', () => {
|
|||
} as never,
|
||||
{
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: true,
|
||||
authMode: 'api_key',
|
||||
},
|
||||
},
|
||||
...createConfig('auto', {
|
||||
codexApiKeyBetaEnabled: true,
|
||||
codexAuthMode: 'api_key',
|
||||
codexRuntimeBackend: 'api',
|
||||
}),
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
|
@ -307,11 +324,11 @@ describe('ProviderConnectionService', () => {
|
|||
|
||||
expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
|
||||
expect(result.OPENAI_API_KEY).toBe('openai-stored-key');
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('api');
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('auto');
|
||||
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
|
||||
});
|
||||
|
||||
it('forces the Codex adapter and strips OPENAI_API_KEY in OAuth mode', async () => {
|
||||
it('keeps the configured Codex backend and strips OPENAI_API_KEY in oauth mode', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
|
|
@ -320,16 +337,10 @@ describe('ProviderConnectionService', () => {
|
|||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: true,
|
||||
authMode: 'oauth',
|
||||
},
|
||||
},
|
||||
getConfig: () => createConfig('auto', {
|
||||
codexApiKeyBetaEnabled: true,
|
||||
codexAuthMode: 'oauth',
|
||||
codexRuntimeBackend: 'api',
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
|
@ -343,7 +354,7 @@ describe('ProviderConnectionService', () => {
|
|||
);
|
||||
|
||||
expect(result.OPENAI_API_KEY).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('auto');
|
||||
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
|
||||
});
|
||||
|
||||
|
|
@ -356,17 +367,12 @@ describe('ProviderConnectionService', () => {
|
|||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: true,
|
||||
authMode: 'api_key',
|
||||
},
|
||||
},
|
||||
}),
|
||||
getConfig: () =>
|
||||
createConfig('auto', {
|
||||
codexApiKeyBetaEnabled: true,
|
||||
codexAuthMode: 'api_key',
|
||||
codexRuntimeBackend: 'api',
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
|
|
@ -376,7 +382,7 @@ describe('ProviderConnectionService', () => {
|
|||
expect(issue).toContain('OPENAI_API_KEY');
|
||||
});
|
||||
|
||||
it('augments PTY env for Codex without deleting an existing OPENAI_API_KEY in oauth mode', async () => {
|
||||
it('augments PTY env for Codex without rewriting the configured backend in oauth mode', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
|
|
@ -385,17 +391,12 @@ describe('ProviderConnectionService', () => {
|
|||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () => ({
|
||||
providerConnections: {
|
||||
anthropic: {
|
||||
authMode: 'auto',
|
||||
},
|
||||
codex: {
|
||||
apiKeyBetaEnabled: true,
|
||||
authMode: 'oauth',
|
||||
},
|
||||
},
|
||||
}),
|
||||
getConfig: () =>
|
||||
createConfig('auto', {
|
||||
codexApiKeyBetaEnabled: true,
|
||||
codexAuthMode: 'oauth',
|
||||
codexRuntimeBackend: 'api',
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
|
|
@ -407,7 +408,106 @@ describe('ProviderConnectionService', () => {
|
|||
);
|
||||
|
||||
expect(result.OPENAI_API_KEY).toBe('shell-key');
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('adapter');
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBe('1');
|
||||
});
|
||||
|
||||
it('exposes Codex connection modes when codex-native is selected even without the old API beta toggle', async () => {
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () =>
|
||||
createConfig('auto', {
|
||||
codexApiKeyBetaEnabled: false,
|
||||
codexAuthMode: 'oauth',
|
||||
codexRuntimeBackend: 'codex-native',
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
const info = await service.getConnectionInfo('codex');
|
||||
|
||||
expect(info).toMatchObject({
|
||||
configurableAuthModes: ['oauth', 'api_key'],
|
||||
configuredAuthMode: 'oauth',
|
||||
apiKeyBetaEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('mirrors a stored OpenAI key into CODEX_API_KEY for codex-native without changing the selected backend', async () => {
|
||||
const lookupPreferred = vi.fn().mockResolvedValue({
|
||||
envVarName: 'OPENAI_API_KEY',
|
||||
value: 'openai-stored-key',
|
||||
});
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred,
|
||||
} as never,
|
||||
{
|
||||
getConfig: () =>
|
||||
createConfig('auto', {
|
||||
codexApiKeyBetaEnabled: false,
|
||||
codexAuthMode: 'api_key',
|
||||
codexRuntimeBackend: 'codex-native',
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
const result = await service.applyConfiguredConnectionEnv(
|
||||
{
|
||||
CLAUDE_CODE_CODEX_BACKEND: 'codex-native',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(lookupPreferred).toHaveBeenCalledWith('OPENAI_API_KEY');
|
||||
expect(result.OPENAI_API_KEY).toBe('openai-stored-key');
|
||||
expect(result.CODEX_API_KEY).toBe('openai-stored-key');
|
||||
expect(result.CLAUDE_CODE_CODEX_BACKEND).toBe('codex-native');
|
||||
expect(result.CLAUDE_CODE_CODEX_API_KEY_BETA).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts CODEX_API_KEY as the native external credential source for codex-native', async () => {
|
||||
getCachedShellEnvMock.mockReturnValue({
|
||||
CODEX_API_KEY: 'native-key',
|
||||
});
|
||||
|
||||
const { ProviderConnectionService } =
|
||||
await import('@main/services/runtime/ProviderConnectionService');
|
||||
|
||||
const service = new ProviderConnectionService(
|
||||
{
|
||||
lookupPreferred: vi.fn().mockResolvedValue(null),
|
||||
} as never,
|
||||
{
|
||||
getConfig: () =>
|
||||
createConfig('auto', {
|
||||
codexApiKeyBetaEnabled: false,
|
||||
codexAuthMode: 'api_key',
|
||||
codexRuntimeBackend: 'codex-native',
|
||||
}),
|
||||
} as never
|
||||
);
|
||||
|
||||
const info = await service.getConnectionInfo('codex');
|
||||
const issue = await service.getConfiguredConnectionIssue(
|
||||
{
|
||||
CODEX_API_KEY: 'native-key',
|
||||
},
|
||||
'codex'
|
||||
);
|
||||
|
||||
expect(info.apiKeyConfigured).toBe(true);
|
||||
expect(info.apiKeySource).toBe('environment');
|
||||
expect(info.apiKeySourceLabel).toBe('Detected from CODEX_API_KEY');
|
||||
expect(issue).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,4 +44,102 @@ describe('BoardTaskExactLogStrictParser', () => {
|
|||
|
||||
expect(parsed.get(filePath)?.map((message) => message.uuid)).toEqual(['good-ts']);
|
||||
});
|
||||
|
||||
it('preserves codex-native replay and history authority rows for exact-log readers', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-native-'));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const filePath = path.join(tempDir, 'native-session.jsonl');
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-ephemeral',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'native-warning-1',
|
||||
timestamp: '2026-04-19T10:00:00.000Z',
|
||||
subtype: 'codex_native_warning',
|
||||
level: 'warning',
|
||||
isMeta: false,
|
||||
content: 'thread/read failed while backfilling turn items for turn completion',
|
||||
codexNativeWarningSource: 'history',
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-ephemeral',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'native-summary-ephemeral',
|
||||
timestamp: '2026-04-19T10:00:01.000Z',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content:
|
||||
'Codex native execution summary: thread=thread-ephemeral, completion=ephemeral, history=live-only, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
|
||||
codexNativeThreadId: 'thread-ephemeral',
|
||||
codexNativeCompletionPolicy: 'ephemeral',
|
||||
codexNativeHistoryCompleteness: 'live-only',
|
||||
codexNativeFinalUsageAuthority: 'live-turn-completed',
|
||||
codexNativeExecutableSource: 'system-path',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-persistent',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'native-summary-persistent',
|
||||
timestamp: '2026-04-19T10:00:02.000Z',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content:
|
||||
'Codex native execution summary: thread=thread-persistent, completion=persistent, history=explicit-hydration-required, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
|
||||
codexNativeThreadId: 'thread-persistent',
|
||||
codexNativeCompletionPolicy: 'persistent',
|
||||
codexNativeHistoryCompleteness: 'explicit-hydration-required',
|
||||
codexNativeFinalUsageAuthority: 'live-turn-completed',
|
||||
codexNativeExecutableSource: 'system-path',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]);
|
||||
|
||||
expect(parsed.get(filePath)).toMatchObject([
|
||||
{
|
||||
uuid: 'native-warning-1',
|
||||
subtype: 'codex_native_warning',
|
||||
codexNativeWarningSource: 'history',
|
||||
},
|
||||
{
|
||||
uuid: 'native-summary-ephemeral',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
codexNativeCompletionPolicy: 'ephemeral',
|
||||
codexNativeHistoryCompleteness: 'live-only',
|
||||
},
|
||||
{
|
||||
uuid: 'native-summary-persistent',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
codexNativeCompletionPolicy: 'persistent',
|
||||
codexNativeHistoryCompleteness: 'explicit-hydration-required',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -276,5 +276,145 @@ describe('jsonl', () => {
|
|||
expect(parsed?.sourceToolUseID).toBe('call-bash-real');
|
||||
expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real');
|
||||
});
|
||||
|
||||
it('parses codex-native projected assistant rows with usage intact', () => {
|
||||
const parsed = parseJsonlLine(
|
||||
JSON.stringify({
|
||||
parentUuid: 'user-1',
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-1',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-native-1',
|
||||
requestId: 'native-request-1',
|
||||
timestamp: '2026-04-19T10:00:00.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'gpt-5-codex',
|
||||
id: 'msg-native-1',
|
||||
type: 'message',
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
cache_read_input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
},
|
||||
content: [{ type: 'text', text: 'OK' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
uuid: 'assistant-native-1',
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'OK' }],
|
||||
requestId: 'native-request-1',
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
cache_read_input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses modern system warning rows without dropping content or severity', () => {
|
||||
const parsed = parseJsonlLine(
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-1',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'system-native-warning-1',
|
||||
timestamp: '2026-04-19T10:00:01.000Z',
|
||||
subtype: 'informational',
|
||||
level: 'warning',
|
||||
isMeta: false,
|
||||
content: 'native stderr warning',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
uuid: 'system-native-warning-1',
|
||||
type: 'system',
|
||||
content: 'native stderr warning',
|
||||
level: 'warning',
|
||||
subtype: 'informational',
|
||||
isMeta: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses codex-native execution-summary and warning metadata from projected system rows', () => {
|
||||
const warningParsed = parseJsonlLine(
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-warning',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'system-native-warning-2',
|
||||
timestamp: '2026-04-19T10:00:02.000Z',
|
||||
subtype: 'codex_native_warning',
|
||||
level: 'warning',
|
||||
isMeta: false,
|
||||
content: 'thread/read failed while backfilling turn items for turn completion',
|
||||
codexNativeWarningSource: 'history',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(warningParsed).toMatchObject({
|
||||
uuid: 'system-native-warning-2',
|
||||
subtype: 'codex_native_warning',
|
||||
codexNativeWarningSource: 'history',
|
||||
});
|
||||
|
||||
const summaryParsed = parseJsonlLine(
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-native-summary',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'system',
|
||||
uuid: 'system-native-summary-1',
|
||||
timestamp: '2026-04-19T10:00:03.000Z',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content:
|
||||
'Codex native execution summary: thread=thread-ephemeral, completion=ephemeral, history=live-only, usageAuthority=live-turn-completed, binary=codex-cli 0.117.0',
|
||||
codexNativeThreadId: 'thread-ephemeral',
|
||||
codexNativeCompletionPolicy: 'ephemeral',
|
||||
codexNativeHistoryCompleteness: 'live-only',
|
||||
codexNativeFinalUsageAuthority: 'live-turn-completed',
|
||||
codexNativeExecutablePath: '/usr/local/bin/codex',
|
||||
codexNativeExecutableSource: 'system-path',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summaryParsed).toMatchObject({
|
||||
uuid: 'system-native-summary-1',
|
||||
subtype: 'codex_native_execution_summary',
|
||||
codexNativeThreadId: 'thread-ephemeral',
|
||||
codexNativeCompletionPolicy: 'ephemeral',
|
||||
codexNativeHistoryCompleteness: 'live-only',
|
||||
codexNativeFinalUsageAuthority: 'live-turn-completed',
|
||||
codexNativeExecutableSource: 'system-path',
|
||||
codexNativeExecutableVersion: 'codex-cli 0.117.0',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
getProviderConnectionModeSummary,
|
||||
getProviderCredentialSummary,
|
||||
getProviderCurrentRuntimeSummary,
|
||||
isConnectionManagedRuntimeProvider,
|
||||
} from '@renderer/components/runtime/providerConnectionUi';
|
||||
import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities';
|
||||
|
||||
|
|
@ -51,6 +52,10 @@ function createCodexProvider(
|
|||
overrides?: Partial<CliProviderStatus['connection']> & {
|
||||
authenticated?: boolean;
|
||||
authMethod?: string | null;
|
||||
selectedBackendId?: string | null;
|
||||
resolvedBackendId?: string | null;
|
||||
availableBackends?: CliProviderStatus['availableBackends'];
|
||||
backend?: CliProviderStatus['backend'];
|
||||
}
|
||||
): CliProviderStatus {
|
||||
return {
|
||||
|
|
@ -68,14 +73,16 @@ function createCodexProvider(
|
|||
oneShot: true,
|
||||
extensions: createDefaultCliExtensionCapabilities(),
|
||||
},
|
||||
selectedBackendId: 'auto',
|
||||
resolvedBackendId: 'adapter',
|
||||
availableBackends: [],
|
||||
selectedBackendId: overrides?.selectedBackendId ?? 'auto',
|
||||
resolvedBackendId: overrides?.resolvedBackendId ?? 'adapter',
|
||||
availableBackends: overrides?.availableBackends ?? [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: {
|
||||
kind: 'adapter',
|
||||
label: 'Codex subscription',
|
||||
},
|
||||
backend:
|
||||
overrides?.backend ??
|
||||
({
|
||||
kind: 'adapter',
|
||||
label: 'Codex subscription',
|
||||
} satisfies NonNullable<CliProviderStatus['backend']>),
|
||||
connection: {
|
||||
supportsOAuth: true,
|
||||
supportsApiKey: true,
|
||||
|
|
@ -185,4 +192,64 @@ describe('providerConnectionUi', () => {
|
|||
'OpenAI API key is saved in Manage. Enable API key mode to use it.'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats Codex as lane-managed once explicit backend options exist', () => {
|
||||
const provider = createCodexProvider({
|
||||
availableBackends: [
|
||||
{
|
||||
id: 'auto',
|
||||
label: 'Auto',
|
||||
description: 'Automatically choose the best backend.',
|
||||
selectable: true,
|
||||
recommended: true,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'codex-native',
|
||||
label: 'Codex native',
|
||||
description: 'Use codex exec JSON mode.',
|
||||
selectable: true,
|
||||
recommended: false,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
backend: {
|
||||
kind: 'codex-native',
|
||||
label: 'Codex native',
|
||||
},
|
||||
});
|
||||
|
||||
expect(isConnectionManagedRuntimeProvider(provider)).toBe(false);
|
||||
expect(getProviderCurrentRuntimeSummary(provider)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not tell the user to enable API key mode when codex-native is already selected', () => {
|
||||
const provider = createCodexProvider({
|
||||
apiKeyBetaEnabled: false,
|
||||
configuredAuthMode: 'api_key',
|
||||
apiKeyConfigured: true,
|
||||
apiKeySource: 'stored',
|
||||
apiKeySourceLabel: 'Stored in app',
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
availableBackends: [
|
||||
{
|
||||
id: 'codex-native',
|
||||
label: 'Codex native',
|
||||
description: 'Use codex exec JSON mode.',
|
||||
selectable: true,
|
||||
recommended: false,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
backend: {
|
||||
kind: 'codex-native',
|
||||
label: 'Codex native',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getProviderCredentialSummary(provider)).toBe('Saved API key available in Manage');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue