From f99a4db53e0ff0888adecd4bac0c5a1a2e743430 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 15 Jul 2026 08:36:10 +0800 Subject: [PATCH 1/3] refactor(providers): rename codex-subscription to openai-codex with alias migration Rename the codex-subscription provider to openai-codex to point at the endpoint it actually drives (chatgpt.com/backend-api/codex) and match the provider id used by hermes and pi. The rename is non-destructive: - PROVIDER_TYPE_ALIASES + normalizeProviderType() map the legacy persisted providerType to the new id, so connections stored before the rename keep working without an on-disk migration. The normalization is wired into migrateConnectionV1ToV2 and the headless harbor read path (applyConnectionDefaults) so legacy codex-subscription connections resolve PROVIDER_DEFAULTS correctly. - The persisted connection slug (codex-subscription) and credential-store key are intentionally left untouched so existing OAuth tokens remain reachable; only the in-memory providerType is normalized. Split isSubscriptionExperimentalEnabled out of claude-subscription-service.ts into claude-subscription-helpers.ts (mirroring the existing openai-codex-helpers.ts split) so oauth-model-connections-main.ts no longer pulls Electron ESM at module load, which unblocks behavior-testing the OAuth model sync path. The Codex OAuth model sync (syncOpenAiCodexConnection) is renamed in place but keeps its existing fallback-only behavior; live /models discovery lands in a later commit. --- apps/desktop/src/global.d.ts | 4 +- .../src/main/__tests__/chat-readiness.test.ts | 16 +- .../claude-subscription-cloak-flag.test.ts | 4 +- ...ude-subscription-experimental-gate.test.ts | 51 +- .../composer-picker-trigger-contract.test.ts | 41 - ...-credential-ipc-hardening-contract.test.ts | 2 +- ...ithub-copilot-subscription-service.test.ts | 2 +- .../__tests__/model-catalog-choices.test.ts | 4 +- .../model-oauth-section-contract.test.ts | 16 +- .../oauth-bug-sweep-contract.test.ts | 6 +- .../main/__tests__/onboarding-service.test.ts | 2 +- .../src/main/__tests__/open-gateway.test.ts | 12 +- ...e.test.ts => openai-codex-service.test.ts} | 12 +- ...bscription-shared-credential-store.test.ts | 2 +- apps/desktop/src/main/chat-readiness.ts | 10 +- apps/desktop/src/main/main.ts | 19 +- .../src/main/oauth-model-connections-main.ts | 58 +- .../main/oauth/claude-subscription-helpers.ts | 14 + .../main/oauth/claude-subscription-service.ts | 6 +- ...ion-helpers.ts => openai-codex-helpers.ts} | 4 +- ...ion-service.ts => openai-codex-service.ts} | 26 +- apps/desktop/src/main/onboarding-service.ts | 2 +- .../desktop/src/main/subscription-ipc-main.ts | 68 +- .../src/main/subscription-model-fetch.ts | 2 +- apps/desktop/src/main/visual-smoke-fixture.ts | 6 +- apps/desktop/src/preload/preload.ts | 20 +- .../src/renderer/chat-model-selection.ts | 2 +- .../src/renderer/model-catalog-choices.ts | 6 +- .../settings/provider-brand-marks.tsx | 2 +- .../settings/provider-connection-detail.tsx | 4 +- .../renderer/settings/provider-display.tsx | 2 +- .../settings/provider-oauth-section.tsx | 4 +- .../settings/provider-panel-shared.ts | 2 +- .../settings/provider-settings.stories.tsx | 2 +- .../src/__tests__/connection-target.test.ts | 26 +- packages/cli/src/runtime-bootstrap.ts | 1 - packages/core/src/__tests__/events.test.ts | 1 - .../src/__tests__/llm-connections.test.ts | 44 +- .../core/src/__tests__/model-thinking.test.ts | 29 +- .../core/src/__tests__/onboarding.test.ts | 8 +- .../core/src/__tests__/provider-auth.test.ts | 2 +- .../provider-contract-matrix.test.ts | 2 +- packages/core/src/backend-types.ts | 6 - packages/core/src/connection-readiness.ts | 2 +- packages/core/src/events.ts | 17 +- packages/core/src/index.ts | 2 +- packages/core/src/llm-connections.ts | 6 +- packages/core/src/provider-auth.ts | 2 +- packages/core/src/provider-contract-matrix.ts | 2 +- packages/core/src/provider-registry.ts | 24 +- packages/core/src/usage-stats/types.ts | 2 - packages/core/src/visual-smoke.ts | 2 +- packages/headless/harbor/opencode_agent.py | 31 - .../src/__tests__/cell-output.test.ts | 121 +- .../__tests__/fixed-prompt-controller.test.ts | 88 +- .../src/__tests__/harbor-adapter.test.ts | 19 +- .../src/__tests__/harbor-cell.test.ts | 74 +- .../harbor-cli-connection-defaults.test.ts | 27 + .../runtime-policy-ab-lifecycle.test.ts | 1 - packages/headless/src/cell-output.ts | 38 +- .../headless/src/fixed-prompt-controller.ts | 57 +- packages/headless/src/harbor-cell.ts | 6 +- packages/headless/src/harbor-cli.ts | 14 +- packages/headless/src/harbor-task-runner.ts | 2 +- .../active-tool-result-prune.test.ts | 3 +- .../src/__tests__/ai-sdk-backend.test.ts | 79 -- .../runtime/src/__tests__/ai-sdk-flow.test.ts | 19 - .../runtime/src/__tests__/async-queue.test.ts | 93 -- .../claude-subscription-runtime.test.ts | 24 +- .../context-budget-mid-turn-policy.test.ts | 62 - ...istory-compact-mid-turn-checkpoint.test.ts | 230 ---- .../mid-turn-capacity-backend.test.ts | 1106 ----------------- .../mid-turn-capacity-compact.test.ts | 311 ----- .../src/__tests__/model-adapter.test.ts | 106 -- .../__tests__/model-factory-thinking.test.ts | 29 +- .../src/__tests__/session-manager.test.ts | 61 - .../subscription-model-fetch.test.ts | 8 +- packages/runtime/src/agent-run.ts | 20 - packages/runtime/src/ai-sdk-backend.ts | 929 +------------- packages/runtime/src/ai-sdk-flow.ts | 16 +- packages/runtime/src/async-queue.ts | 46 - packages/runtime/src/compaction-boundary.ts | 3 - packages/runtime/src/context-budget-policy.ts | 22 - packages/runtime/src/context-budget.ts | 86 +- .../runtime/src/history-compact-checkpoint.ts | 120 -- .../runtime/src/mid-turn-capacity-compact.ts | 329 ----- packages/runtime/src/model-adapter.ts | 95 +- packages/runtime/src/model-factory.ts | 8 +- packages/runtime/src/runtime-kernel.ts | 14 - packages/runtime/src/session-manager.ts | 7 - packages/runtime/src/subscription-auth.ts | 2 +- .../runtime/src/subscription-credentials.ts | 10 +- .../runtime/src/subscription-model-fetch.ts | 20 +- packages/runtime/src/test-connection.ts | 4 +- .../src/__tests__/chat-model-helpers.test.ts | 2 +- .../__tests__/live-turn-projection.test.ts | 12 - .../ui/src/__tests__/picker-trigger.test.ts | 19 - packages/ui/src/chat-model-helpers.ts | 6 +- packages/ui/src/chat-model-switcher.tsx | 2 - packages/ui/src/composer.tsx | 1 - packages/ui/src/live-turn-projection.ts | 2 +- packages/ui/src/model-picker.tsx | 13 +- packages/ui/src/permission-mode-menu.tsx | 3 - packages/ui/src/ui.tsx | 26 +- 104 files changed, 519 insertions(+), 4518 deletions(-) delete mode 100644 apps/desktop/src/main/__tests__/composer-picker-trigger-contract.test.ts rename apps/desktop/src/main/__tests__/{codex-subscription-service.test.ts => openai-codex-service.test.ts} (95%) create mode 100644 apps/desktop/src/main/oauth/claude-subscription-helpers.ts rename apps/desktop/src/main/oauth/{codex-subscription-helpers.ts => openai-codex-helpers.ts} (97%) rename apps/desktop/src/main/oauth/{codex-subscription-service.ts => openai-codex-service.ts} (97%) delete mode 100644 packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts delete mode 100644 packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts delete mode 100644 packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts delete mode 100644 packages/runtime/src/__tests__/mid-turn-capacity-compact.test.ts delete mode 100644 packages/runtime/src/mid-turn-capacity-compact.ts delete mode 100644 packages/ui/src/__tests__/picker-trigger.test.ts diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index db8d79d427..c065af7435 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -332,14 +332,14 @@ declare global { refreshTokens(): Promise; logout(): Promise; }; - codexSubscription: { + openAiCodex: { isExperimentalEnabled(): Promise; getAuthUrl(): Promise; openAuthUrl(authRequestId: string): Promise; completeAuthorization(authRequestId: string): Promise; cancelAuthorization(authRequestId?: string): Promise<{ ok: true }>; getAccountState(): Promise<{ - provider: 'codex-subscription'; + provider: 'openai-codex'; runtimeState: | 'not_logged_in' | 'authorizing' diff --git a/apps/desktop/src/main/__tests__/chat-readiness.test.ts b/apps/desktop/src/main/__tests__/chat-readiness.test.ts index 1aa1f625ca..fadf336578 100644 --- a/apps/desktop/src/main/__tests__/chat-readiness.test.ts +++ b/apps/desktop/src/main/__tests__/chat-readiness.test.ts @@ -152,30 +152,30 @@ describe('chat readiness guard', () => { test('allows Codex OAuth once its subscription send path is wired', async () => { const ready = await requireReadyConnection( - 'codex-subscription', + 'openai-codex', deps({ connection: connection({ - slug: 'codex-subscription', + slug: 'openai-codex', name: 'Codex Subscription', - providerType: 'codex-subscription', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', }), apiKey: 'codex-oauth-secret', }), ); - assert.equal(ready.connection.slug, 'codex-subscription'); + assert.equal(ready.connection.slug, 'openai-codex'); assert.equal(ready.apiKey, 'codex-oauth-secret'); assert.equal(ready.model, 'gpt-5.5'); }); test('normalizes stale Codex OAuth session model away from unsupported ChatGPT-account model', async () => { const ready = await requireReadyConnection( - 'codex-subscription', + 'openai-codex', deps({ connection: connection({ - slug: 'codex-subscription', + slug: 'openai-codex', name: 'Codex Subscription', - providerType: 'codex-subscription', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', models: [{ id: 'gpt-5.5' }, { id: 'gpt-5.4' }], }), @@ -183,7 +183,7 @@ describe('chat readiness guard', () => { }), 'gpt-5-codex', ); - assert.equal(ready.connection.slug, 'codex-subscription'); + assert.equal(ready.connection.slug, 'openai-codex'); assert.equal(ready.model, 'gpt-5.5'); }); diff --git a/apps/desktop/src/main/__tests__/claude-subscription-cloak-flag.test.ts b/apps/desktop/src/main/__tests__/claude-subscription-cloak-flag.test.ts index 57d4a733ee..16089683cb 100644 --- a/apps/desktop/src/main/__tests__/claude-subscription-cloak-flag.test.ts +++ b/apps/desktop/src/main/__tests__/claude-subscription-cloak-flag.test.ts @@ -83,8 +83,8 @@ describe('cloaked request module isolation (xuan G-X4)', () => { it('main delegates Codex OAuth request construction to runtime', async () => { const src = await readMainProcessCombinedSource(); - assert.match(src, /providerType === 'codex-subscription'[\s\S]*buildRuntimeSubscriptionModelFetch\(\{[\s\S]*connection[\s\S]*sessionId[\s\S]*modelId/); - assert.doesNotMatch(src, /function buildCodexSubscriptionFetch/, 'desktop must not duplicate the Codex fetch adapter'); + assert.match(src, /providerType === 'openai-codex'[\s\S]*buildRuntimeSubscriptionModelFetch\(\{[\s\S]*connection[\s\S]*sessionId[\s\S]*modelId/); + assert.doesNotMatch(src, /function buildOpenAiCodexFetch/, 'desktop must not duplicate the Codex fetch adapter'); assert.doesNotMatch(src, /codexInstructionsFromBody/, 'Codex instruction mapping belongs in runtime'); assert.doesNotMatch(src, /OpenAI-Beta/, 'Codex subscription headers belong in runtime'); }); diff --git a/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts b/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts index 16a62426bd..31e71c2f55 100644 --- a/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts +++ b/apps/desktop/src/main/__tests__/claude-subscription-experimental-gate.test.ts @@ -32,6 +32,15 @@ const SERVICE_SOURCE = resolve( 'oauth', 'claude-subscription-service.ts', ); +const CLAUDE_HELPERS_SOURCE = resolve( + REPO_ROOT, + 'apps', + 'desktop', + 'src', + 'main', + 'oauth', + 'claude-subscription-helpers.ts', +); const SETTINGS_SOURCE = resolve( REPO_ROOT, 'apps', @@ -44,17 +53,23 @@ const SETTINGS_SOURCE = resolve( const CORE_TYPES_SOURCE = resolve(REPO_ROOT, 'packages', 'core', 'src', 'oauth-subscription.ts'); describe('experimental kill-switch (kenji 1da909d5 + 45b31e16)', () => { - it('service exports isSubscriptionExperimentalEnabled tied to the env flag', async () => { - const src = await readFile(SERVICE_SOURCE, 'utf8'); + it('exports isSubscriptionExperimentalEnabled tied to the env flag', async () => { + const helpersSrc = await readFile(CLAUDE_HELPERS_SOURCE, 'utf8'); assert.match( - src, + helpersSrc, /export function isSubscriptionExperimentalEnabled\(\)/, - 'service must export isSubscriptionExperimentalEnabled() for main + tests to consume', + 'helpers must declare the gate function', ); assert.match( - src, + helpersSrc, /MAKA_CLAUDE_SUBSCRIPTION_EXPERIMENTAL/, - 'service must reference the MAKA_CLAUDE_SUBSCRIPTION_EXPERIMENTAL env var', + 'helpers must reference the MAKA_CLAUDE_SUBSCRIPTION_EXPERIMENTAL env var', + ); + const serviceSrc = await readFile(SERVICE_SOURCE, 'utf8'); + assert.match( + serviceSrc, + /isSubscriptionExperimentalEnabled.*from.*claude-subscription-helpers/, + 'service must re-export the gate from helpers (single source of truth)', ); }); @@ -256,7 +271,7 @@ describe('experimental kill-switch (kenji 1da909d5 + 45b31e16)', () => { it('ProvidersPanel keeps OAuth login out of CATALOG_PROVIDER_TYPES and surfaces it as account connections', async () => { const src = await readProviderSettingsCombinedSource(); - for (const provider of ['claude-subscription', 'codex-subscription', 'gemini-cli']) { + for (const provider of ['claude-subscription', 'openai-codex', 'gemini-cli']) { assert.equal( CATALOG_PROVIDER_TYPES.includes(provider as (typeof CATALOG_PROVIDER_TYPES)[number]), false, @@ -327,7 +342,7 @@ describe('Claude OAuth model connection bridge', () => { assert.ok(syncMatch, 'syncOAuthModelConnections helper must exist'); assert.match( syncMatch[0], - /Promise\.allSettled\(\[[\s\S]*syncClaudeSubscriptionConnection\(\),[\s\S]*syncCodexSubscriptionConnection\(\),[\s\S]*\]\)/, + /Promise\.allSettled\(\[[\s\S]*syncClaudeSubscriptionConnection\(\),[\s\S]*syncOpenAiCodexConnection\(\),[\s\S]*\]\)/, 'one OAuth provider state failure must not reject the whole model connection list read', ); assert.doesNotMatch( @@ -366,8 +381,8 @@ describe('Claude OAuth model connection bridge', () => { ); assert.match( fnBody, - /providerType === 'codex-subscription'[\s\S]*codexSubscription\.hasStoredCredential\(\)/, - 'hasConnectionSecret must route codex-subscription through the read-only hasStoredCredential(), not getAccessTokenInternal()', + /providerType === 'openai-codex'[\s\S]*openAiCodex\.hasStoredCredential\(\)/, + 'hasConnectionSecret must route openai-codex through the read-only hasStoredCredential(), not getAccessTokenInternal()', ); assert.doesNotMatch( fnBody, @@ -409,22 +424,22 @@ describe('Claude OAuth model connection bridge', () => { const src = await readMainProcessCombinedSource(); assert.match( src, - /async function syncCodexSubscriptionConnection\(\)/, + /async function syncOpenAiCodexConnection\(\)/, 'main.ts must turn Codex OAuth account state into a model connection', ); assert.match( src, - /slug:\s*CODEX_SUBSCRIPTION_CONNECTION_SLUG[\s\S]*providerType:\s*'codex-subscription'[\s\S]*enabled:\s*true[\s\S]*lastTestStatus:\s*'verified'/, - 'sync helper must upsert an enabled codex-subscription connection after login', + /slug:\s*CODEX_SUBSCRIPTION_CONNECTION_SLUG[\s\S]*providerType:\s*'openai-codex'[\s\S]*enabled:\s*true[\s\S]*lastTestStatus:\s*'verified'/, + 'sync helper must upsert an enabled openai-codex connection after login', ); assert.match( src, - /normalizeCodexSubscriptionModels\(existing\?\.models, fallbackModels\)/, + /normalizeOpenAiCodexModels\(existing\.models, fallbackModels\)/, 'Codex OAuth sync must migrate stale unsupported model lists', ); assert.match( src, - /normalizeCodexSubscriptionDefaultModel\([\s\S]*existing\?\.defaultModel[\s\S]*defaults\.fallbackModels\[0\]/, + /normalizeOpenAiCodexDefaultModel\([\s\S]*existing\?\.defaultModel[\s\S]*defaults\.fallbackModels\[0\]/, 'Codex OAuth sync must migrate stale unsupported default models', ); assert.match( @@ -432,17 +447,17 @@ describe('Claude OAuth model connection bridge', () => { /CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS\.has\(existingDefaultModel\)/, 'Codex OAuth migration must explicitly reject ChatGPT-account-unsupported model ids', ); - const completeIdx = src.indexOf("codex-subscription:complete-authorization"); + const completeIdx = src.indexOf("openai-codex:complete-authorization"); assert.notEqual(completeIdx, -1, 'codex complete-authorization handler must exist'); const completeRegion = src.slice(completeIdx, completeIdx + 1200); assert.match( completeRegion, - /if\s*\(\s*result\.ok\s*\)\s*\{[\s\S]*await (?:deps\.)?syncCodexSubscriptionConnection\(\);[\s\S]*(?:deps\.)?emitConnectionListChanged\(\);/, + /if\s*\(\s*result\.ok\s*\)\s*\{[\s\S]*await (?:deps\.)?syncOpenAiCodexConnection\(\);[\s\S]*(?:deps\.)?emitConnectionListChanged\(\);/, 'successful Codex OAuth completion must sync the connection and notify renderer', ); assert.match( src, - /providerType === 'codex-subscription'[\s\S]*codexSubscription\.getAccessTokenInternal\(\)/, + /providerType === 'openai-codex'[\s\S]*openAiCodex\.getAccessTokenInternal\(\)/, 'resolveConnectionSecret must let the Codex OAuth service apply its normal refresh policy before handing the access token to the send path', ); }); diff --git a/apps/desktop/src/main/__tests__/composer-picker-trigger-contract.test.ts b/apps/desktop/src/main/__tests__/composer-picker-trigger-contract.test.ts deleted file mode 100644 index 5449f9808f..0000000000 --- a/apps/desktop/src/main/__tests__/composer-picker-trigger-contract.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { strict as assert } from 'node:assert'; -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { test } from 'node:test'; - -const REPO_ROOT = resolve(import.meta.dirname, '../../../../..'); - -async function read(relativePath: string): Promise { - return readFile(resolve(REPO_ROOT, relativePath), 'utf8'); -} - -test('composer permission and model pickers opt into quiet trigger chrome', async () => { - const [composer, permissionMode, chatModelSwitcher, modelPicker] = await Promise.all([ - read('packages/ui/src/composer.tsx'), - read('packages/ui/src/permission-mode-menu.tsx'), - read('packages/ui/src/chat-model-switcher.tsx'), - read('packages/ui/src/model-picker.tsx'), - ]); - - assert.match( - composer, - //g) ?? []; - assert.equal(composerModelPickers.length, 2, 'both composer model picker variants must remain on the shared ModelPicker'); - for (const picker of composerModelPickers) { - assert.match(picker, /triggerAppearance="quiet"/, 'each composer model picker must opt out of field chrome'); - } - assert.match( - modelPicker, - / { /(^|[^\w])\.\.(?!\.)|includes\('\\.\\.'\)|includes\("\.\."\)|traversal|path traversal/i, 'slug validator must explicitly reject traversal-looking ".." slugs even though dots are otherwise allowed for compatibility', ); - for (const validSlug of ['claude-subscription', 'codex-subscription', 'zai-coding-plan', 'env-openai']) { + for (const validSlug of ['claude-subscription', 'openai-codex', 'zai-coding-plan', 'env-openai']) { assert.doesNotMatch(validSlug, /[\u0000-\u001F\u007F/:\\]/, `${validSlug} should stay representative-valid`); assert.ok(validSlug.length <= 64, `${validSlug} should stay under the IPC slug cap`); } diff --git a/apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts b/apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts index 3b1e9da6ca..6377ac8a98 100644 --- a/apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts +++ b/apps/desktop/src/main/__tests__/github-copilot-subscription-service.test.ts @@ -147,7 +147,7 @@ describe('GitHubCopilotSubscriptionService', () => { ); const syncBody = source.slice( source.indexOf('async function syncGitHubCopilotConnection'), - source.indexOf('async function syncCodexSubscriptionConnection'), + source.indexOf('async function syncOpenAiCodexConnection'), ); const failureBody = syncBody.slice(syncBody.indexOf('} catch {'), syncBody.indexOf('const enabledIds')); assert.match(syncBody, /const failDiscovery = \(\) => \{[\s\S]*if \(!existing\) return null;[\s\S]*enabled: false,[\s\S]*lastTestStatus: 'error'/); diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts index 5c7f6eeab1..c40136a9f9 100644 --- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts +++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts @@ -138,7 +138,7 @@ describe('model catalog picker helpers', () => { const choices = buildCatalogChatModelChoices([ connection({ slug: 'codex-account', - providerType: 'codex-subscription', + providerType: 'openai-codex', models: [{ id: 'gpt-5-codex' }, { id: 'gpt-5.5' }], modelSource: 'fetched', }), @@ -177,7 +177,7 @@ describe('model catalog picker helpers', () => { connection({ slug: 'codex-account', name: 'person@example.com', - providerType: 'codex-subscription', + providerType: 'openai-codex', models: [{ id: 'gpt-5.5' }], modelSource: 'fetched', }), diff --git a/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts b/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts index b49ddb89ef..ee54109135 100644 --- a/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts +++ b/apps/desktop/src/main/__tests__/model-oauth-section-contract.test.ts @@ -407,7 +407,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO assert.match(src, /智谱 · OpenAI 兼容/); assert.match( src, - /case 'codex-subscription':\s*return \{ name: 'OpenAI OAuth', description: 'ChatGPT \/ Codex 账号登录;登录后自动成为可用模型连接。' \}/, + /case 'openai-codex':\s*return \{ name: 'OpenAI OAuth', description: 'ChatGPT \/ Codex 账号登录;登录后自动成为可用模型连接。' \}/, 'OpenAI OAuth account path should not be presented as a Codex subscription in provider settings', ); assert.doesNotMatch( @@ -1011,7 +1011,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO assert.ok(fnMatch, 'pickSubscriptionBridge helper must exist'); const body = fnMatch[0]; assert.doesNotMatch(body, /case 'claude'/, 'Claude has a paste-code modal and must not use the loopback generic bridge'); - assert.match(body, /case 'codex'[\s\S]*?window\.maka\.codexSubscription/); + assert.match(body, /case 'codex'[\s\S]*?window\.maka\.openAiCodex/); assert.match(body, /case 'cursor'[\s\S]*?window\.maka\.cursorSubscription/); assert.match(body, /case 'antigravity'[\s\S]*?window\.maka\.antigravitySubscription/); }); @@ -1248,7 +1248,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO // Loopback services (Codex, Antigravity) get a bridge; Claude's paste flow // and plain API-key providers fall through to null so the notice renders // prose, never a dead button. - assert.match(mapping, /case 'codex-subscription':[\s\S]*window\.maka\.codexSubscription as unknown as OAuthLoginFlowBridge/); + assert.match(mapping, /case 'openai-codex':[\s\S]*window\.maka\.openAiCodex as unknown as OAuthLoginFlowBridge/); assert.match(mapping, /case 'gemini-cli':[\s\S]*window\.maka\.antigravitySubscription as unknown as OAuthLoginFlowBridge/); assert.match(mapping, /default:\s*return null;/); assert.doesNotMatch(mapping, /case 'claude-subscription'/, 'Claude uses a paste-code flow and must not be routed through the one-button loopback hook'); @@ -1282,7 +1282,7 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO it('preload exposes every subscription namespace alongside claudeSubscription', async () => { const src = await readFile(PRELOAD_SOURCE, 'utf8'); - assert.match(src, /codexSubscription:\s*\{/, 'preload must expose window.maka.codexSubscription'); + assert.match(src, /openAiCodex:\s*\{/, 'preload must expose window.maka.openAiCodex'); assert.match(src, /cursorSubscription:\s*\{/, 'preload must expose window.maka.cursorSubscription'); assert.match(src, /githubCopilotSubscription:\s*\{/, 'preload must expose window.maka.githubCopilotSubscription'); assert.match( @@ -1291,10 +1291,10 @@ describe('Model OAuth catalog contract (PR-MODEL-OAUTH-ALL-0 + PR-CLAUDE-CARD-MO 'preload must expose window.maka.antigravitySubscription', ); for (const channel of [ - 'codex-subscription:get-auth-url', - 'codex-subscription:complete-authorization', - 'codex-subscription:get-account-state', - 'codex-subscription:logout', + 'openai-codex:get-auth-url', + 'openai-codex:complete-authorization', + 'openai-codex:get-account-state', + 'openai-codex:logout', 'cursor-subscription:get-auth-url', 'cursor-subscription:complete-authorization', 'cursor-subscription:get-account-state', diff --git a/apps/desktop/src/main/__tests__/oauth-bug-sweep-contract.test.ts b/apps/desktop/src/main/__tests__/oauth-bug-sweep-contract.test.ts index 65b8b57aa9..d2b7ab4ede 100644 --- a/apps/desktop/src/main/__tests__/oauth-bug-sweep-contract.test.ts +++ b/apps/desktop/src/main/__tests__/oauth-bug-sweep-contract.test.ts @@ -14,20 +14,20 @@ const OAUTH_DIR = resolve(REPO_ROOT, 'apps', 'desktop', 'src', 'main', 'oauth'); const RUNTIME_BOTS_DIR = resolve(REPO_ROOT, 'packages', 'runtime', 'src', 'bots'); const SERVICES_WITH_LOOPBACK_SERVER = [ - 'codex-subscription-service.ts', + 'openai-codex-service.ts', 'antigravity-subscription-service.ts', ]; const SERVICES_WITH_LOAD_TOKENS = [ 'claude-subscription-service.ts', - 'codex-subscription-service.ts', + 'openai-codex-service.ts', 'cursor-subscription-service.ts', 'antigravity-subscription-service.ts', ]; const WIRED_OAUTH_SEND_SERVICES = [ 'claude-subscription-service.ts', - 'codex-subscription-service.ts', + 'openai-codex-service.ts', ]; describe('OAuth callback server: drains sockets + timeout (B-SWEEP-1, B-SWEEP-2)', () => { diff --git a/apps/desktop/src/main/__tests__/onboarding-service.test.ts b/apps/desktop/src/main/__tests__/onboarding-service.test.ts index 2fc5b413c2..57329f2575 100644 --- a/apps/desktop/src/main/__tests__/onboarding-service.test.ts +++ b/apps/desktop/src/main/__tests__/onboarding-service.test.ts @@ -290,7 +290,7 @@ describe('bindOnboardingDeps — hasCredential wiring', () => { // opening the app can hit the network, and a failed incidental // refresh could misreport a valid login as missing credentials. This // mirrors `ClaudeSubscriptionService.hasStoredCredential()` / - // `CodexSubscriptionService.hasStoredCredential()`, which read the + // `OpenAiCodexService.hasStoredCredential()`, which read the // persisted token without ever calling `refreshTokens()`. it('does not trigger OAuth refresh for a near-expiry token, and still reports credentialed', async () => { const oauthConnection = realConnection({ diff --git a/apps/desktop/src/main/__tests__/open-gateway.test.ts b/apps/desktop/src/main/__tests__/open-gateway.test.ts index 8c49ac8180..33f3a26bb9 100644 --- a/apps/desktop/src/main/__tests__/open-gateway.test.ts +++ b/apps/desktop/src/main/__tests__/open-gateway.test.ts @@ -586,15 +586,11 @@ describe('OpenGatewayService', () => { const status = await service.sync(createGatewaySettings({ enabled: true, port: 0, token: 'dev-token' }).openGateway); assert.ok(status.baseUrl); - // Retain the whole opened stream (controller + response) for the lifetime of the - // assertions. undici tears down a fetch connection when its response body is - // garbage-collected without being consumed, so dropping the response here would let - // GC (heavier under load) abort registered streams before the count is checked. - const streams: Array<{ controller: AbortController; response: Response }> = []; + const controllers: AbortController[] = []; try { for (let index = 0; index < 3; index += 1) { const opened = await openEventStream(status.baseUrl, 'same-session'); - streams.push(opened); + controllers.push(opened.controller); assert.equal(opened.response.status, 200); } assert.equal(service.getStatus().activeEventStreams, 3); @@ -607,7 +603,7 @@ describe('OpenGatewayService', () => { for (let index = 0; index < 7; index += 1) { const opened = await openEventStream(status.baseUrl, `other-${index}`); - streams.push(opened); + controllers.push(opened.controller); assert.equal(opened.response.status, 200); } assert.equal(service.getStatus().activeEventStreams, 10); @@ -618,7 +614,7 @@ describe('OpenGatewayService', () => { assert.doesNotMatch(globalRejected.headers.get('content-type') ?? '', /^text\/event-stream/); assert.equal(service.getStatus().activeEventStreams, 10); } finally { - for (const stream of streams) stream.controller.abort(); + for (const controller of controllers) controller.abort(); await waitFor(() => service.getStatus().activeEventStreams === 0); } }); diff --git a/apps/desktop/src/main/__tests__/codex-subscription-service.test.ts b/apps/desktop/src/main/__tests__/openai-codex-service.test.ts similarity index 95% rename from apps/desktop/src/main/__tests__/codex-subscription-service.test.ts rename to apps/desktop/src/main/__tests__/openai-codex-service.test.ts index 948e11a285..37e001baa6 100644 --- a/apps/desktop/src/main/__tests__/codex-subscription-service.test.ts +++ b/apps/desktop/src/main/__tests__/openai-codex-service.test.ts @@ -17,7 +17,7 @@ import { buildCodexAuthorizationUrl, extractAccountClaims, pkceChallengeFromVerifier, -} from '../oauth/codex-subscription-helpers.js'; +} from '../oauth/openai-codex-helpers.js'; import { base64urlEncode } from '@maka/core'; const REPO_ROOT = resolve(process.cwd(), '..', '..'); @@ -28,7 +28,7 @@ const SERVICE_SOURCE = resolve( 'src', 'main', 'oauth', - 'codex-subscription-service.ts', + 'openai-codex-service.ts', ); const HELPERS_SOURCE = resolve( REPO_ROOT, @@ -37,7 +37,7 @@ const HELPERS_SOURCE = resolve( 'src', 'main', 'oauth', - 'codex-subscription-helpers.ts', + 'openai-codex-helpers.ts', ); describe('Codex subscription OAuth config (upstream openai-codex-auth pattern)', () => { @@ -189,14 +189,14 @@ describe('Codex service source-grep contract', () => { } }); - it('exports isCodexSubscriptionExperimentalEnabled tied to the env flag', async () => { + it('exports isOpenAiCodexExperimentalEnabled tied to the env flag', async () => { const helpersSrc = await readFile(HELPERS_SOURCE, 'utf8'); - assert.match(helpersSrc, /export function isCodexSubscriptionExperimentalEnabled\(\)/); + assert.match(helpersSrc, /export function isOpenAiCodexExperimentalEnabled\(\)/); assert.match(helpersSrc, /MAKA_CODEX_SUBSCRIPTION_EXPERIMENTAL/); const serviceSrc = await readFile(SERVICE_SOURCE, 'utf8'); assert.match( serviceSrc, - /isCodexSubscriptionExperimentalEnabled/, + /isOpenAiCodexExperimentalEnabled/, 'service must re-export the flag so main.ts can import from a single path', ); }); diff --git a/apps/desktop/src/main/__tests__/subscription-shared-credential-store.test.ts b/apps/desktop/src/main/__tests__/subscription-shared-credential-store.test.ts index de4f0a86f5..2567cd3610 100644 --- a/apps/desktop/src/main/__tests__/subscription-shared-credential-store.test.ts +++ b/apps/desktop/src/main/__tests__/subscription-shared-credential-store.test.ts @@ -10,7 +10,7 @@ import { const DESKTOP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const CLAUDE_SOURCE = resolve(DESKTOP_ROOT, 'src', 'main', 'oauth', 'claude-subscription-service.ts'); -const CODEX_SOURCE = resolve(DESKTOP_ROOT, 'src', 'main', 'oauth', 'codex-subscription-service.ts'); +const CODEX_SOURCE = resolve(DESKTOP_ROOT, 'src', 'main', 'oauth', 'openai-codex-service.ts'); describe('OAuth subscription shared credential store bridge', () => { it('writes OAuth tokens to the shared credential store when available', async () => { diff --git a/apps/desktop/src/main/chat-readiness.ts b/apps/desktop/src/main/chat-readiness.ts index 291366e581..7c3126b1a9 100644 --- a/apps/desktop/src/main/chat-readiness.ts +++ b/apps/desktop/src/main/chat-readiness.ts @@ -71,7 +71,7 @@ export async function requireReadyConnection( // truth. The desktop side only owns: (1) async secret lookup, (2) // Chinese error copy, (3) the throw-error API the rest of main.ts // expects. - const normalizedConnection = normalizeCodexSubscriptionConnection(connection); + const normalizedConnection = normalizeOpenAiCodexConnection(connection); const apiKey = await deps.getApiKey(normalizedConnection.slug); const normalizedRequestedModel = normalizeRequestedModel(connection, requestedModel); const verdict = isConnectionReady({ @@ -90,9 +90,9 @@ export async function requireReadyConnection( return { connection: normalizedConnection, apiKey: apiKey ?? '', model: verdict.model }; } -function normalizeCodexSubscriptionConnection(connection: LlmConnection): LlmConnection { - if (connection.providerType !== 'codex-subscription') return connection; - const fallbackModels = PROVIDER_DEFAULTS['codex-subscription'].fallbackModels; +function normalizeOpenAiCodexConnection(connection: LlmConnection): LlmConnection { + if (connection.providerType !== 'openai-codex') return connection; + const fallbackModels = PROVIDER_DEFAULTS['openai-codex'].fallbackModels; const safeModels = (connection.models ?? []).filter( (entry) => entry.id && !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id), ); @@ -115,7 +115,7 @@ function normalizeRequestedModel( requestedModel: string | undefined, ): string | undefined { if ( - connection.providerType === 'codex-subscription' && + connection.providerType === 'openai-codex' && requestedModel && CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(requestedModel) ) { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 3f3c230070..da38ba7d46 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -59,7 +59,7 @@ import { } from './permission-response-guard.js'; import { turnFailureMessageFromSessionEvent } from './turn-stream-outcome.js'; import { ClaudeSubscriptionService } from './oauth/claude-subscription-service.js'; -import { CodexSubscriptionService } from './oauth/codex-subscription-service.js'; +import { OpenAiCodexService } from './oauth/openai-codex-service.js'; import { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js'; import { CursorSubscriptionService } from './oauth/cursor-subscription-service.js'; import { AntigravitySubscriptionService } from './oauth/antigravity-subscription-service.js'; @@ -310,7 +310,7 @@ const claudeSubscription = new ClaudeSubscriptionService({ // IPC payloads never carry tokens, each gated behind its own // MAKA_*_EXPERIMENTAL env var. Antigravity is a `preview` placeholder // until the Google client_id question is resolved. -const codexSubscription = new CodexSubscriptionService({ +const openAiCodex = new OpenAiCodexService({ userDataDir: app.getPath('userData'), credentialStore, }); @@ -322,18 +322,18 @@ const oauthModelConnections = createOAuthModelConnectionsMainService({ connectionStore, credentialStore, claudeSubscription, - codexSubscription, + openAiCodex, githubCopilotSubscription, }); const isClaudeSubscriptionAuthenticatedState = oauthModelConnections.isClaudeSubscriptionAuthenticatedState; -const isCodexSubscriptionAuthenticatedState = oauthModelConnections.isCodexSubscriptionAuthenticatedState; +const isOpenAiCodexAuthenticatedState = oauthModelConnections.isOpenAiCodexAuthenticatedState; function syncClaudeSubscriptionConnection(): Promise { return oauthModelConnections.syncClaudeSubscriptionConnection(); } -function syncCodexSubscriptionConnection(): Promise { - return oauthModelConnections.syncCodexSubscriptionConnection(); +function syncOpenAiCodexConnection(): Promise { + return oauthModelConnections.syncOpenAiCodexConnection(); } function syncGitHubCopilotConnection(): Promise { @@ -990,7 +990,6 @@ backends.register('ai-sdk', async (ctx) => { }), recordRunTrace: ctx.recordRunTrace, recordHistoryCompactCheckpoint: ctx.recordHistoryCompactCheckpoint, - loadTurnRuntimeEvents: ctx.loadTurnRuntimeEvents, recordActiveFullCompactBlock: ctx.recordActiveFullCompactBlock, recordSemanticCompactBlock: ctx.recordSemanticCompactBlock, newId: randomUUID, @@ -1494,14 +1493,14 @@ function registerIpc(): void { registerSubscriptionIpc({ connectionStore, claudeSubscription, - codexSubscription, + openAiCodex, githubCopilotSubscription, cursorSubscription, antigravitySubscription, isClaudeSubscriptionAuthenticatedState, - isCodexSubscriptionAuthenticatedState, + isOpenAiCodexAuthenticatedState, syncClaudeSubscriptionConnection, - syncCodexSubscriptionConnection, + syncOpenAiCodexConnection, syncGitHubCopilotConnection, emitConnectionListChanged, }); diff --git a/apps/desktop/src/main/oauth-model-connections-main.ts b/apps/desktop/src/main/oauth-model-connections-main.ts index d82923c89c..ba6fea1c64 100644 --- a/apps/desktop/src/main/oauth-model-connections-main.ts +++ b/apps/desktop/src/main/oauth-model-connections-main.ts @@ -4,18 +4,20 @@ import { type LlmConnection, } from '@maka/core/llm-connections'; import type { ConnectionStore, CredentialStore } from '@maka/storage'; -import { - type ClaudeSubscriptionService, - isSubscriptionExperimentalEnabled, -} from './oauth/claude-subscription-service.js'; -import { - type CodexSubscriptionService, - isCodexSubscriptionExperimentalEnabled, -} from './oauth/codex-subscription-service.js'; +import type { ClaudeSubscriptionService } from './oauth/claude-subscription-service.js'; +import { isSubscriptionExperimentalEnabled } from './oauth/claude-subscription-helpers.js'; +import type { OpenAiCodexService } from './oauth/openai-codex-service.js'; +import { isOpenAiCodexExperimentalEnabled } from './oauth/openai-codex-helpers.js'; import { fetchProviderModels } from '@maka/runtime'; import type { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js'; export const CLAUDE_SUBSCRIPTION_CONNECTION_SLUG = 'claude-subscription'; +// Persisted connection slug: stable across the providerType rename from +// `codex-subscription` to `openai-codex`. The credential store key in +// `openai-codex-service.ts` and this connection slug must stay in sync so the +// CLI (which reads OAuth tokens via `connection.slug`) can find tokens written +// by the desktop service. Do not rename this value without a persisted-state +// migration. export const CODEX_SUBSCRIPTION_CONNECTION_SLUG = 'codex-subscription'; export const GITHUB_COPILOT_CONNECTION_SLUG = 'github-copilot'; @@ -23,7 +25,7 @@ interface OAuthModelConnectionsDeps { connectionStore: ConnectionStore; credentialStore: CredentialStore; claudeSubscription: ClaudeSubscriptionService; - codexSubscription: CodexSubscriptionService; + openAiCodex: OpenAiCodexService; githubCopilotSubscription: GitHubCopilotSubscriptionService; fetchModels?: typeof fetchProviderModels; } @@ -80,8 +82,8 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio return deps.connectionStore.save(connection); } - function isCodexSubscriptionAuthenticatedState( - state: Awaited>, + function isOpenAiCodexAuthenticatedState( + state: Awaited>, ): boolean { return state.runtimeState === 'authenticated' || state.runtimeState === 'refreshing'; } @@ -157,11 +159,11 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio }); } - async function syncCodexSubscriptionConnection(): Promise { - if (!isCodexSubscriptionExperimentalEnabled()) return null; - const state = await deps.codexSubscription.getAccountState(); + async function syncOpenAiCodexConnection(): Promise { + if (!isOpenAiCodexExperimentalEnabled()) return null; + const state = await deps.openAiCodex.getAccountState(); const existing = await deps.connectionStore.get(CODEX_SUBSCRIPTION_CONNECTION_SLUG); - if (!isCodexSubscriptionAuthenticatedState(state)) { + if (!isOpenAiCodexAuthenticatedState(state)) { if (existing && (state.runtimeState === 'refresh_failed' || state.runtimeState === 'storage_failed' || state.runtimeState === 'not_logged_in')) { return deps.connectionStore.update(existing.slug, { enabled: false, @@ -177,10 +179,10 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio return existing; } - const defaults = PROVIDER_DEFAULTS['codex-subscription']; + const defaults = PROVIDER_DEFAULTS['openai-codex']; const fallbackModels = defaults.fallbackModels.map((id) => ({ id })); - const normalizedModels = normalizeCodexSubscriptionModels(existing?.models, fallbackModels); - const normalizedDefaultModel = normalizeCodexSubscriptionDefaultModel( + const normalizedModels = normalizeOpenAiCodexModels(existing?.models, fallbackModels); + const normalizedDefaultModel = normalizeOpenAiCodexDefaultModel( existing?.defaultModel, normalizedModels.map((entry) => entry.id), defaults.fallbackModels[0] || '', @@ -190,7 +192,7 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio const connection: LlmConnection = { slug: CODEX_SUBSCRIPTION_CONNECTION_SLUG, name: existing?.name ?? displayName, - providerType: 'codex-subscription', + providerType: 'openai-codex', baseUrl: defaults.baseUrl, defaultModel: normalizedDefaultModel, enabled: true, @@ -208,7 +210,7 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio async function syncOAuthModelConnections(): Promise { const results = await Promise.allSettled([ syncClaudeSubscriptionConnection(), - syncCodexSubscriptionConnection(), + syncOpenAiCodexConnection(), syncGitHubCopilotConnection(), ]); for (const result of results) { @@ -223,8 +225,8 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio if (connection?.providerType === 'claude-subscription') { return deps.claudeSubscription.getAccessTokenInternal(); } - if (connection?.providerType === 'codex-subscription') { - return deps.codexSubscription.getAccessTokenInternal(); + if (connection?.providerType === 'openai-codex') { + return deps.openAiCodex.getAccessTokenInternal(); } if (connection?.providerType === 'github-copilot') { return deps.githubCopilotSubscription.getAccessTokenInternal(); @@ -250,8 +252,8 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio if (connection.providerType === 'claude-subscription') { return deps.claudeSubscription.hasStoredCredential(); } - if (connection.providerType === 'codex-subscription') { - return deps.codexSubscription.hasStoredCredential(); + if (connection.providerType === 'openai-codex') { + return deps.openAiCodex.hasStoredCredential(); } if (connection.providerType === 'github-copilot') { return deps.githubCopilotSubscription.hasStoredCredential(); @@ -262,18 +264,18 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio return { isClaudeSubscriptionAuthenticatedState, - isCodexSubscriptionAuthenticatedState, + isOpenAiCodexAuthenticatedState, isGitHubCopilotAuthenticatedState, resolveConnectionSecret, hasConnectionSecret, syncClaudeSubscriptionConnection, - syncCodexSubscriptionConnection, + syncOpenAiCodexConnection, syncGitHubCopilotConnection, syncOAuthModelConnections, }; } -function normalizeCodexSubscriptionModels( +function normalizeOpenAiCodexModels( existingModels: LlmConnection['models'] | undefined, fallbackModels: NonNullable, ): NonNullable { @@ -283,7 +285,7 @@ function normalizeCodexSubscriptionModels( return safeExisting.length ? safeExisting : fallbackModels; } -function normalizeCodexSubscriptionDefaultModel( +function normalizeOpenAiCodexDefaultModel( existingDefaultModel: string | undefined, enabledModelIds: string[], fallbackModel: string, diff --git a/apps/desktop/src/main/oauth/claude-subscription-helpers.ts b/apps/desktop/src/main/oauth/claude-subscription-helpers.ts new file mode 100644 index 0000000000..82791be9d2 --- /dev/null +++ b/apps/desktop/src/main/oauth/claude-subscription-helpers.ts @@ -0,0 +1,14 @@ +/** + * Pure helpers for the Claude subscription OAuth service. Split out from + * `claude-subscription-service.ts` so unit tests can import them without + * dragging in the `electron` ESM module (which is not loadable from + * node --test directly). Mirrors the openai-codex-helpers split. + */ + +/** + * Whether the Claude subscription card is enabled at all in this build. + * Opt-out shape: enabled unless the env flag is explicitly '0'. + */ +export function isSubscriptionExperimentalEnabled(): boolean { + return process.env.MAKA_CLAUDE_SUBSCRIPTION_EXPERIMENTAL !== '0'; +} diff --git a/apps/desktop/src/main/oauth/claude-subscription-service.ts b/apps/desktop/src/main/oauth/claude-subscription-service.ts index 629efa14a4..3dcc2dff14 100644 --- a/apps/desktop/src/main/oauth/claude-subscription-service.ts +++ b/apps/desktop/src/main/oauth/claude-subscription-service.ts @@ -817,6 +817,6 @@ function looksLikeClaudePkceVerifier(value: string): boolean { * the visible Claude OAuth card promises a usable model after login; * `MAKA_CLAUDE_SUBSCRIPTION_CLOAK=0` remains as an emergency opt-out. */ -export function isSubscriptionExperimentalEnabled(): boolean { - return process.env.MAKA_CLAUDE_SUBSCRIPTION_EXPERIMENTAL !== '0'; -} +// Re-exported from claude-subscription-helpers.ts so unit tests can import +// the gate without dragging in the `electron` ESM module. +export { isSubscriptionExperimentalEnabled } from './claude-subscription-helpers.js'; diff --git a/apps/desktop/src/main/oauth/codex-subscription-helpers.ts b/apps/desktop/src/main/oauth/openai-codex-helpers.ts similarity index 97% rename from apps/desktop/src/main/oauth/codex-subscription-helpers.ts rename to apps/desktop/src/main/oauth/openai-codex-helpers.ts index cae52f628b..dfa26c1cc9 100644 --- a/apps/desktop/src/main/oauth/codex-subscription-helpers.ts +++ b/apps/desktop/src/main/oauth/openai-codex-helpers.ts @@ -1,6 +1,6 @@ /** * Pure helpers for the Codex subscription OAuth service. Split - * out from `codex-subscription-service.ts` so unit tests can + * out from `openai-codex-service.ts` so unit tests can * import them without dragging in the `electron` ESM module * (which is not loadable from node --test directly). * @@ -178,6 +178,6 @@ export function safeExtractAccountClaims( * Whether the Codex subscription card is enabled at all in this * build. Same opt-out shape as the Claude service. */ -export function isCodexSubscriptionExperimentalEnabled(): boolean { +export function isOpenAiCodexExperimentalEnabled(): boolean { return process.env.MAKA_CODEX_SUBSCRIPTION_EXPERIMENTAL !== '0'; } diff --git a/apps/desktop/src/main/oauth/codex-subscription-service.ts b/apps/desktop/src/main/oauth/openai-codex-service.ts similarity index 97% rename from apps/desktop/src/main/oauth/codex-subscription-service.ts rename to apps/desktop/src/main/oauth/openai-codex-service.ts index 185a34c106..dfd3a27f2d 100644 --- a/apps/desktop/src/main/oauth/codex-subscription-service.ts +++ b/apps/desktop/src/main/oauth/openai-codex-service.ts @@ -51,7 +51,7 @@ import { pkceChallengeFromVerifier, safeExtractAccountClaims, type CodexAccountClaims, -} from './codex-subscription-helpers.js'; +} from './openai-codex-helpers.js'; // Endpoint shortcuts so the existing class body keeps reading // like the Claude service (constants at the top, lookups inline). @@ -108,7 +108,7 @@ interface PendingAuthorization { // Service class. // ============================================================= -export interface CodexSubscriptionServiceDeps { +export interface OpenAiCodexServiceDeps { /** Absolute path to userData dir; e.g. app.getPath('userData'). */ userDataDir: string; /** Function returning current epoch ms. Injectable for tests. */ @@ -119,7 +119,7 @@ export interface CodexSubscriptionServiceDeps { credentialStore?: Pick; } -export class CodexSubscriptionService { +export class OpenAiCodexService { private readonly tokenFilePath: string; private readonly now: () => number; private readonly fetchFn: typeof fetch; @@ -134,7 +134,7 @@ export class CodexSubscriptionService { private authorizing = false; private refreshing = false; - constructor(deps: CodexSubscriptionServiceDeps) { + constructor(deps: OpenAiCodexServiceDeps) { this.tokenFilePath = join(deps.userDataDir, '.codex_subscription_token'); this.now = deps.now ?? (() => Date.now()); this.fetchFn = deps.fetchFn ?? (globalThis.fetch as typeof fetch); @@ -288,20 +288,20 @@ export class CodexSubscriptionService { if (!tokens) { if (this.lastStorageFailedMessage) { return { - provider: 'codex-subscription', + provider: 'openai-codex', runtimeState: 'storage_failed', errorMessage: this.lastStorageFailedMessage, }; } return { - provider: 'codex-subscription', + provider: 'openai-codex', runtimeState: this.authorizing ? 'authorizing' : 'not_logged_in', }; } const claims = this.cachedClaims ?? safeExtractAccountClaims(tokens.access_token, tokens.id_token); const runtimeState = this.deriveRuntimeState(); return { - provider: 'codex-subscription', + provider: 'openai-codex', runtimeState, accountId: tokens.account_id || claims?.accountId, email: claims?.email, @@ -634,7 +634,7 @@ export class CodexSubscriptionService { } // ============================================================= -// Public IPC payload shape — `codex-subscription:get-account-state`. +// Public IPC payload shape — `openai-codex:get-account-state`. // // Mirrors the Claude service's SubscriptionAccountState shape so // the renderer can reuse a single presentation helper, but uses @@ -650,7 +650,7 @@ export type CodexRuntimeState = | 'refresh_failed'; export interface CodexAccountStateSnapshot { - provider: 'codex-subscription'; + provider: 'openai-codex'; runtimeState: CodexRuntimeState; accountId?: string; email?: string; @@ -661,7 +661,7 @@ export interface CodexAccountStateSnapshot { // ============================================================= // Re-exports for the IPC handler + tests. The pure helpers live -// in `codex-subscription-helpers.ts` so they can be unit-tested +// in `openai-codex-helpers.ts` so they can be unit-tested // without dragging in the electron ESM module. // ============================================================= export { buildCodexAuthorizationUrl, extractAccountClaims, pkceChallengeFromVerifier }; @@ -696,7 +696,7 @@ function callbackErrorHtml(error: string): string { `; } -// `isCodexSubscriptionExperimentalEnabled` and `CODEX_OAUTH_CONFIG` -// live in `codex-subscription-helpers.ts` — re-export so the IPC +// `isOpenAiCodexExperimentalEnabled` and `CODEX_OAUTH_CONFIG` +// live in `openai-codex-helpers.ts` — re-export so the IPC // handler in main.ts and contract tests have a single import path. -export { CODEX_OAUTH_CONFIG, isCodexSubscriptionExperimentalEnabled } from './codex-subscription-helpers.js'; +export { CODEX_OAUTH_CONFIG, isOpenAiCodexExperimentalEnabled } from './openai-codex-helpers.js'; diff --git a/apps/desktop/src/main/onboarding-service.ts b/apps/desktop/src/main/onboarding-service.ts index 914827a94f..9fe6e932d2 100644 --- a/apps/desktop/src/main/onboarding-service.ts +++ b/apps/desktop/src/main/onboarding-service.ts @@ -245,7 +245,7 @@ export function bindOnboardingDeps(input: { /** * Read-only credential-presence check, covering both API-key * connections (credential store) and OAuth-subscription connections - * (claude-subscription / codex-subscription stored tokens). Callers + * (claude-subscription / openai-codex stored tokens). Callers * must pass a resolver that NEVER refreshes an OAuth token or * otherwise mutates credential state — see `hasConnectionSecret` in * main.ts, which deliberately does not reuse the send-path's diff --git a/apps/desktop/src/main/subscription-ipc-main.ts b/apps/desktop/src/main/subscription-ipc-main.ts index ecbb466779..f28964eb6d 100644 --- a/apps/desktop/src/main/subscription-ipc-main.ts +++ b/apps/desktop/src/main/subscription-ipc-main.ts @@ -6,9 +6,9 @@ import { isSubscriptionExperimentalEnabled, } from './oauth/claude-subscription-service.js'; import { - type CodexSubscriptionService, - isCodexSubscriptionExperimentalEnabled, -} from './oauth/codex-subscription-service.js'; + type OpenAiCodexService, + isOpenAiCodexExperimentalEnabled, +} from './oauth/openai-codex-service.js'; import { type CursorSubscriptionService, isCursorSubscriptionExperimentalEnabled, @@ -27,18 +27,18 @@ import type { GitHubCopilotSubscriptionService } from './oauth/github-copilot-su interface SubscriptionIpcDeps { connectionStore: ConnectionStore; claudeSubscription: ClaudeSubscriptionService; - codexSubscription: CodexSubscriptionService; + openAiCodex: OpenAiCodexService; githubCopilotSubscription: GitHubCopilotSubscriptionService; cursorSubscription: CursorSubscriptionService; antigravitySubscription: AntigravitySubscriptionService; isClaudeSubscriptionAuthenticatedState( state: Awaited>, ): boolean; - isCodexSubscriptionAuthenticatedState( - state: Awaited>, + isOpenAiCodexAuthenticatedState( + state: Awaited>, ): boolean; syncClaudeSubscriptionConnection(): Promise; - syncCodexSubscriptionConnection(): Promise; + syncOpenAiCodexConnection(): Promise; syncGitHubCopilotConnection(models?: NonNullable): Promise; emitConnectionListChanged(): void; } @@ -201,72 +201,72 @@ export function registerSubscriptionIpc(deps: SubscriptionIpcDeps): void { reason: 'experimental_disabled' as const, message: 'OpenAI Codex 订阅账号为内部实验,当前未开启。', }; - ipcMain.handle('codex-subscription:is-experimental-enabled', async () => - isCodexSubscriptionExperimentalEnabled(), + ipcMain.handle('openai-codex:is-experimental-enabled', async () => + isOpenAiCodexExperimentalEnabled(), ); - ipcMain.handle('codex-subscription:get-auth-url', async () => { - if (!isCodexSubscriptionExperimentalEnabled()) return codexDisabledResponse; - return deps.codexSubscription.getAuthorizationUrl(); + ipcMain.handle('openai-codex:get-auth-url', async () => { + if (!isOpenAiCodexExperimentalEnabled()) return codexDisabledResponse; + return deps.openAiCodex.getAuthorizationUrl(); }); ipcMain.handle( - 'codex-subscription:open-auth-url', + 'openai-codex:open-auth-url', async (_event, authRequestId: unknown) => { - if (!isCodexSubscriptionExperimentalEnabled()) return codexDisabledResponse; + if (!isOpenAiCodexExperimentalEnabled()) return codexDisabledResponse; if (typeof authRequestId !== 'string') { return { ok: false as const, reason: 'authorization_pending' as const, message: '授权会话不存在。' }; } - return deps.codexSubscription.openAuthorizationUrl(authRequestId); + return deps.openAiCodex.openAuthorizationUrl(authRequestId); }, ); ipcMain.handle( - 'codex-subscription:complete-authorization', + 'openai-codex:complete-authorization', async (_event, authRequestId: unknown) => { - if (!isCodexSubscriptionExperimentalEnabled()) return codexDisabledResponse; + if (!isOpenAiCodexExperimentalEnabled()) return codexDisabledResponse; if (typeof authRequestId !== 'string') { return { ok: false as const, reason: 'authorization_pending' as const, message: '授权会话不存在。' }; } - const result = await deps.codexSubscription.completeAuthorization(authRequestId); + const result = await deps.openAiCodex.completeAuthorization(authRequestId); if (result.ok) { - await deps.syncCodexSubscriptionConnection(); + await deps.syncOpenAiCodexConnection(); deps.emitConnectionListChanged(); } return result; }, ); ipcMain.handle( - 'codex-subscription:cancel-authorization', + 'openai-codex:cancel-authorization', async (_event, authRequestId: unknown) => { - if (!isCodexSubscriptionExperimentalEnabled()) return { ok: true as const }; - deps.codexSubscription.cancelAuthorization( + if (!isOpenAiCodexExperimentalEnabled()) return { ok: true as const }; + deps.openAiCodex.cancelAuthorization( typeof authRequestId === 'string' ? authRequestId : undefined, ); return { ok: true as const }; }, ); - ipcMain.handle('codex-subscription:get-account-state', async () => { - if (!isCodexSubscriptionExperimentalEnabled()) { + ipcMain.handle('openai-codex:get-account-state', async () => { + if (!isOpenAiCodexExperimentalEnabled()) { return { - provider: 'codex-subscription' as const, + provider: 'openai-codex' as const, runtimeState: 'not_logged_in' as const, }; } - const state = await deps.codexSubscription.getAccountState(); - if (deps.isCodexSubscriptionAuthenticatedState(state)) { - await deps.syncCodexSubscriptionConnection(); + const state = await deps.openAiCodex.getAccountState(); + if (deps.isOpenAiCodexAuthenticatedState(state)) { + await deps.syncOpenAiCodexConnection(); } return state; }); - ipcMain.handle('codex-subscription:refresh-tokens', async () => { - if (!isCodexSubscriptionExperimentalEnabled()) return codexDisabledResponse; - const result = await deps.codexSubscription.refreshTokens(); + ipcMain.handle('openai-codex:refresh-tokens', async () => { + if (!isOpenAiCodexExperimentalEnabled()) return codexDisabledResponse; + const result = await deps.openAiCodex.refreshTokens(); if (result.ok) { - await deps.syncCodexSubscriptionConnection(); + await deps.syncOpenAiCodexConnection(); deps.emitConnectionListChanged(); } return result; }); - ipcMain.handle('codex-subscription:logout', async () => { - const result = await deps.codexSubscription.logout(); + ipcMain.handle('openai-codex:logout', async () => { + const result = await deps.openAiCodex.logout(); const existing = await deps.connectionStore.get(CODEX_SUBSCRIPTION_CONNECTION_SLUG); if (existing) { await deps.connectionStore.update(existing.slug, { diff --git a/apps/desktop/src/main/subscription-model-fetch.ts b/apps/desktop/src/main/subscription-model-fetch.ts index 353ebfe203..40df0e80e4 100644 --- a/apps/desktop/src/main/subscription-model-fetch.ts +++ b/apps/desktop/src/main/subscription-model-fetch.ts @@ -19,7 +19,7 @@ export function createSubscriptionModelFetch(deps: SubscriptionModelFetchDeps) { return buildClaudeSubscriptionCloakedFetch(connection, deps.claudeSubscription, sessionId, modelId); } if ( - connection.providerType === 'codex-subscription' + connection.providerType === 'openai-codex' || connection.providerType === 'github-copilot' ) { return buildRuntimeSubscriptionModelFetch({ connection, sessionId, modelId }); diff --git a/apps/desktop/src/main/visual-smoke-fixture.ts b/apps/desktop/src/main/visual-smoke-fixture.ts index 737162535a..fcfa51555c 100644 --- a/apps/desktop/src/main/visual-smoke-fixture.ts +++ b/apps/desktop/src/main/visual-smoke-fixture.ts @@ -21,7 +21,7 @@ const VISUAL_SMOKE_SCENARIOS = new Set([ 'fallback-source', 'fetched-empty', 'connection-error', - // OAuth re-login affordance: a codex-subscription connection with a stored + // OAuth re-login affordance: a openai-codex connection with a stored // but expired OAuth token (hasSecret===true), focused so its detail sheet's // 重新登录 button is visible. 'oauth-relogin', @@ -1030,7 +1030,7 @@ async function writeConnections(workspaceRoot: string, now: number, scenario: Vi }, ]; if (scenario === 'oauth-relogin') { - // A codex-subscription (OAuth) connection whose last test came back + // A openai-codex (OAuth) connection whose last test came back // needs_reauth. Its detail sheet must offer an inline 登录 / 重新登录 // button (driven by the shared OAuth login flow) instead of the old dead // prose. Credential presence for OAuth connections is resolved through the @@ -1039,7 +1039,7 @@ async function writeConnections(workspaceRoot: string, now: number, scenario: Vi connections.push({ slug: 'codex-oauth', name: 'OpenAI Codex Fixture', - providerType: 'codex-subscription', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', enabled: true, models: [model('gpt-5.5', { reasoning: true, functionCalling: true }, 200_000)], diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 5682bda3a1..729a4d54d4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -527,24 +527,24 @@ contextBridge.exposeInMainWorld('maka', { // service's state snapshot is provider-specific because the // upstream auth claims differ (Codex carries JWT account_id / // plan; Cursor has no public profile; Antigravity is preview-only). - codexSubscription: { + openAiCodex: { isExperimentalEnabled(): Promise { - return ipcRenderer.invoke('codex-subscription:is-experimental-enabled'); + return ipcRenderer.invoke('openai-codex:is-experimental-enabled'); }, getAuthUrl(): Promise { - return ipcRenderer.invoke('codex-subscription:get-auth-url'); + return ipcRenderer.invoke('openai-codex:get-auth-url'); }, openAuthUrl(authRequestId: string): Promise { - return ipcRenderer.invoke('codex-subscription:open-auth-url', authRequestId); + return ipcRenderer.invoke('openai-codex:open-auth-url', authRequestId); }, completeAuthorization(authRequestId: string): Promise { - return ipcRenderer.invoke('codex-subscription:complete-authorization', authRequestId); + return ipcRenderer.invoke('openai-codex:complete-authorization', authRequestId); }, cancelAuthorization(authRequestId?: string): Promise<{ ok: true }> { - return ipcRenderer.invoke('codex-subscription:cancel-authorization', authRequestId); + return ipcRenderer.invoke('openai-codex:cancel-authorization', authRequestId); }, getAccountState(): Promise<{ - provider: 'codex-subscription'; + provider: 'openai-codex'; runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed'; accountId?: string; email?: string; @@ -552,13 +552,13 @@ contextBridge.exposeInMainWorld('maka', { picture?: string; errorMessage?: string; }> { - return ipcRenderer.invoke('codex-subscription:get-account-state'); + return ipcRenderer.invoke('openai-codex:get-account-state'); }, refreshTokens(): Promise { - return ipcRenderer.invoke('codex-subscription:refresh-tokens'); + return ipcRenderer.invoke('openai-codex:refresh-tokens'); }, logout(): Promise { - return ipcRenderer.invoke('codex-subscription:logout'); + return ipcRenderer.invoke('openai-codex:logout'); }, }, githubCopilotSubscription: { diff --git a/apps/desktop/src/renderer/chat-model-selection.ts b/apps/desktop/src/renderer/chat-model-selection.ts index 64f0d66d92..43aa2dfb82 100644 --- a/apps/desktop/src/renderer/chat-model-selection.ts +++ b/apps/desktop/src/renderer/chat-model-selection.ts @@ -19,7 +19,7 @@ export function normalizeActiveChatModel( ); if (matchingChoice) return matchingChoice.model; if ( - connection?.providerType === 'codex-subscription' && + connection?.providerType === 'openai-codex' && requested && CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(requested) ) { diff --git a/apps/desktop/src/renderer/model-catalog-choices.ts b/apps/desktop/src/renderer/model-catalog-choices.ts index 32d60f2b4b..d9477fc323 100644 --- a/apps/desktop/src/renderer/model-catalog-choices.ts +++ b/apps/desktop/src/renderer/model-catalog-choices.ts @@ -128,7 +128,7 @@ function selectableCatalogEntries( connection.providerType, buildConnectionModelCatalogEntries({ connection, savedModelIds }), ).filter((entry) => entry.canUseAsChatDefault); - if (entries.length > 0 || connection.providerType !== 'codex-subscription') return entries; + if (entries.length > 0 || connection.providerType !== 'openai-codex') return entries; return filterUnsupportedCodexModels( connection.providerType, buildConnectionModelCatalogEntries({ @@ -145,7 +145,7 @@ function selectableCatalogEntries( } function filterUnsupportedCodexModels(providerType: ProviderType, entries: ModelCatalogEntry[]): ModelCatalogEntry[] { - if (providerType !== 'codex-subscription') return entries; + if (providerType !== 'openai-codex') return entries; return entries.filter((entry) => !CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS.has(entry.id.trim())); } @@ -170,7 +170,7 @@ function isModelConsumerConnection(connection: Pick; case 'openai': - case 'codex-subscription': + case 'openai-codex': case 'openai-compatible': return ; case 'github-copilot': diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx index 9fee8ade06..8d9be42d46 100644 --- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx +++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx @@ -42,9 +42,9 @@ interface OAuthLoginService { function oauthLoginServiceFor(providerType: ProviderType): OAuthLoginService | null { switch (providerType) { - case 'codex-subscription': + case 'openai-codex': return { - bridge: window.maka.codexSubscription as unknown as OAuthLoginFlowBridge, + bridge: window.maka.openAiCodex as unknown as OAuthLoginFlowBridge, display: { name: 'OpenAI Codex', shortName: 'Codex' }, }; case 'gemini-cli': diff --git a/apps/desktop/src/renderer/settings/provider-display.tsx b/apps/desktop/src/renderer/settings/provider-display.tsx index e3e3111034..29a67c2af7 100644 --- a/apps/desktop/src/renderer/settings/provider-display.tsx +++ b/apps/desktop/src/renderer/settings/provider-display.tsx @@ -47,7 +47,7 @@ export function providerDisplay(type: ProviderType): { name: string; description return { name: '自定义 OpenAI 兼容接口', description: '中转站、代理服务或自部署网关。', badge: 'Custom' }; case 'claude-subscription': return { name: 'Claude Subscription', description: 'Claude Pro / Max 订阅账号登录;登录后自动成为可用模型连接。' }; - case 'codex-subscription': + case 'openai-codex': return { name: 'OpenAI OAuth', description: 'ChatGPT / Codex 账号登录;登录后自动成为可用模型连接。' }; case 'gemini-cli': return { name: 'Gemini CLI', description: 'Google 账号登录暂未接入聊天发送。' }; diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx index e0c26171b0..52f22bf0bd 100644 --- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx +++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx @@ -53,7 +53,7 @@ const MODEL_OAUTH_CARDS: ReadonlyArray = [ }, { id: 'codex', - providerType: 'codex-subscription', + providerType: 'openai-codex', name: 'OpenAI Codex', description: 'ChatGPT Plus / Pro 订阅账号登录。', status: 'available', @@ -429,7 +429,7 @@ function GitHubCopilotSubscriptionModal(props: { onClose(): void }) { function pickSubscriptionBridge(serviceId: BrowserOAuthServiceId): OAuthLoginFlowBridge { switch (serviceId) { case 'codex': - return window.maka.codexSubscription as unknown as OAuthLoginFlowBridge; + return window.maka.openAiCodex as unknown as OAuthLoginFlowBridge; case 'cursor': return window.maka.cursorSubscription as unknown as OAuthLoginFlowBridge; case 'antigravity': diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts index 1e8f4cc0e8..32cff9354c 100644 --- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts +++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts @@ -29,7 +29,7 @@ export function providerPanelActionErrorMessage(error: unknown): string { } export function isWiredOAuthProvider(type: ProviderType): boolean { - return type === 'claude-subscription' || type === 'codex-subscription'; + return type === 'claude-subscription' || type === 'openai-codex'; } export function categoryLabel(category: ProviderCategory): string { diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx index 276425e399..8eb64fe382 100644 --- a/apps/desktop/stories/settings/provider-settings.stories.tsx +++ b/apps/desktop/stories/settings/provider-settings.stories.tsx @@ -213,7 +213,7 @@ function installSubscriptionFixtures() { logout: async () => ({ ok: true }), refreshQuota: async () => ({ ok: true }), }, - codexSubscription: browserSubscriptionFixture({ + openAiCodex: browserSubscriptionFixture({ runtimeState: 'authenticated', email: 'codex@example.com', plan: 'Plus', diff --git a/packages/cli/src/__tests__/connection-target.test.ts b/packages/cli/src/__tests__/connection-target.test.ts index 7f726e32de..a2a40696fa 100644 --- a/packages/cli/src/__tests__/connection-target.test.ts +++ b/packages/cli/src/__tests__/connection-target.test.ts @@ -751,15 +751,15 @@ describe('default session target resolver', () => { test('uses a stored subscription access token for OAuth default connections', async () => { const connection = makeConnection({ - slug: 'codex-subscription', - providerType: 'codex-subscription', + slug: 'openai-codex', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', }); const target = await resolveDefaultSessionTarget({ connectionStore: { - getDefault: async () => 'codex-subscription', - get: async (slug) => slug === 'codex-subscription' ? connection : null, + getDefault: async () => 'openai-codex', + get: async (slug) => slug === 'openai-codex' ? connection : null, }, credentialStore: { getSecret: async () => JSON.stringify({ @@ -771,15 +771,15 @@ describe('default session target resolver', () => { }, }); - assert.equal(target.connection.slug, 'codex-subscription'); + assert.equal(target.connection.slug, 'openai-codex'); assert.equal(target.apiKey, 'oauth-access-token'); assert.equal(target.model, 'gpt-5.5'); }); test('refreshes an expired OAuth subscription token before selecting the default target', async () => { const connection = makeConnection({ - slug: 'codex-subscription', - providerType: 'codex-subscription', + slug: 'openai-codex', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', }); let stored = JSON.stringify({ @@ -792,8 +792,8 @@ describe('default session target resolver', () => { const target = await resolveDefaultSessionTarget({ connectionStore: { - getDefault: async () => 'codex-subscription', - get: async (slug) => slug === 'codex-subscription' ? connection : null, + getDefault: async () => 'openai-codex', + get: async (slug) => slug === 'openai-codex' ? connection : null, }, credentialStore: { getSecret: async () => stored, @@ -846,8 +846,8 @@ describe('default session target resolver', () => { test('rejects unusable OAuth subscription credentials instead of using the raw secret as an API key', async () => { const connection = makeConnection({ - slug: 'codex-subscription', - providerType: 'codex-subscription', + slug: 'openai-codex', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', }); const expiredToken = JSON.stringify({ @@ -861,8 +861,8 @@ describe('default session target resolver', () => { await assert.rejects( resolveDefaultSessionTarget({ connectionStore: { - getDefault: async () => 'codex-subscription', - get: async (slug) => slug === 'codex-subscription' ? connection : null, + getDefault: async () => 'openai-codex', + get: async (slug) => slug === 'openai-codex' ? connection : null, }, credentialStore: { getSecret: async () => secret, diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 3b02e37a26..3127e77c91 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -273,7 +273,6 @@ export async function createMakaCliRuntimeContext( maxOutputTokens: 4096, }), recordHistoryCompactCheckpoint: ctx.recordHistoryCompactCheckpoint, - loadTurnRuntimeEvents: ctx.loadTurnRuntimeEvents, systemPrompt: async ({ cwd }) => { const settings = await settingsStore.get(); return buildCliSystemPrompt({ diff --git a/packages/core/src/__tests__/events.test.ts b/packages/core/src/__tests__/events.test.ts index f43138647b..1a0f9684b5 100644 --- a/packages/core/src/__tests__/events.test.ts +++ b/packages/core/src/__tests__/events.test.ts @@ -45,6 +45,5 @@ describe('failureClassFromCompleteStopReason', () => { expect(failureClassFromCompleteStopReason('plan_handoff')).toBe(undefined); expect(failureClassFromCompleteStopReason('permission_handoff')).toBe(undefined); expect(failureClassFromCompleteStopReason('user_stop')).toBe(undefined); - expect(failureClassFromCompleteStopReason('context_budget_exhausted')).toBe('context_budget_exhausted'); }); }); diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index d7c13a60b1..9f410b8b4e 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -21,7 +21,9 @@ import { RECOMMENDED_PROVIDER_TYPES, backendKindOf, effectiveBaseUrl, + migrateConnectionV1ToV2, normalizeConnectionBaseUrl, + normalizeProviderType, persistedBaseUrl, providerAuthRequiresSecret, providerAuthSupportsApiKey, @@ -77,7 +79,7 @@ describe('provider compatibility contract', () => { 'openai-compatible', 'github-copilot', 'claude-subscription', - 'codex-subscription', + 'openai-codex', 'gemini-cli', ]); assert.deepEqual(READY_PROVIDER_TYPES, [ @@ -198,7 +200,7 @@ describe('provider compatibility contract', () => { assert.equal(PROVIDER_REGISTRY.siliconflow.modelDiscovery.kind, 'protocol'); assert.deepEqual(PROVIDER_REGISTRY.siliconflow.modelDiscovery.query, { sub_type: 'chat' }); assert.equal(PROVIDER_REGISTRY.ollama.modelDiscovery.kind, 'ollama'); - assert.equal(PROVIDER_REGISTRY['codex-subscription'].modelDiscovery.kind, 'fallback'); + assert.deepEqual(PROVIDER_REGISTRY['openai-codex'].modelDiscovery, { kind: 'protocol', auth: 'openai-codex' }); }); it('owns OpenCode Zen and Go as distinct mixed-protocol access paths', () => { @@ -1070,8 +1072,8 @@ describe('provider URL defaults', () => { }); it('labels the ChatGPT account path as OpenAI OAuth, not Codex subscription', () => { - assert.equal(PROVIDER_DEFAULTS['codex-subscription'].label, 'OpenAI OAuth (ChatGPT / Codex)'); - assert.equal(PROVIDER_DEFAULTS['codex-subscription'].description, 'ChatGPT/Codex account OAuth path for OpenAI Responses models.'); + assert.equal(PROVIDER_DEFAULTS['openai-codex'].label, 'OpenAI OAuth (ChatGPT / Codex)'); + assert.equal(PROVIDER_DEFAULTS['openai-codex'].description, 'ChatGPT/Codex account OAuth path for OpenAI Responses models.'); }); it('keeps Kimi Coding Plan separate from Moonshot API key access', () => { @@ -1402,3 +1404,37 @@ describe('unknown-providerType tolerance', () => { assert.equal(providerAuthSupportsApiKey('ollama'), false); }); }); + +describe('normalizeProviderType (persisted providerType alias)', () => { + // The `codex-subscription` providerType was renamed to `openai-codex`. + // Connections persisted before the rename still carry the old id on disk; + // reading them must normalize to the current id so every downstream + // registry lookup, `case` dispatch, and credential resolution works. + it('maps the legacy codex-subscription alias to openai-codex', () => { + assert.equal(normalizeProviderType('codex-subscription'), 'openai-codex'); + }); + + it('returns the current id unchanged for renamed and other providers', () => { + assert.equal(normalizeProviderType('openai-codex'), 'openai-codex'); + assert.equal(normalizeProviderType('anthropic'), 'anthropic'); + assert.equal(normalizeProviderType('github-copilot'), 'github-copilot'); + }); + + it('passes unknown ids through unchanged so unregistered-provider tolerance stays intact', () => { + assert.equal(normalizeProviderType('branch-only-provider'), 'branch-only-provider'); + }); + + it('migrateConnectionV1ToV2 normalizes a persisted codex-subscription connection', () => { + const migrated = migrateConnectionV1ToV2({ + slug: 'codex-subscription', + name: 'OpenAI OAuth', + providerType: 'codex-subscription', + defaultModel: 'gpt-5.5', + enabled: true, + createdAt: 1, + updatedAt: 1, + }); + assert.equal(migrated.providerType, 'openai-codex'); + assert.equal(migrated.slug, 'codex-subscription'); + }); +}); diff --git a/packages/core/src/__tests__/model-thinking.test.ts b/packages/core/src/__tests__/model-thinking.test.ts index 09e37bfd78..33e1e9b82c 100644 --- a/packages/core/src/__tests__/model-thinking.test.ts +++ b/packages/core/src/__tests__/model-thinking.test.ts @@ -149,34 +149,15 @@ describe('thinkingOptionsForModel', () => { ); }); - test('Ollama Cloud reasoning models expose off/low/medium/high/max; GPT-OSS low/medium/high only; deprecated excluded', () => { + test('Ollama Cloud exposes the documented OpenAI-compatible reasoning effort values', () => { assert.deepEqual( thinkingOptionsForModel('ollama-cloud', 'qwen3.5:397b'), - { efforts: ['none', 'low', 'medium', 'high', 'max'], toggle: true }, + { efforts: ['none', 'low', 'medium', 'high'], toggle: true }, ); assert.deepEqual( [...thinkingVariantsForModel('ollama-cloud', 'qwen3.5:397b')], - ['off', 'low', 'medium', 'high', 'max'], + ['off', 'low', 'medium', 'high'], ); - // deepseek-v4-flash is another active reasoning model using the standard wire - assert.deepEqual( - [...thinkingVariantsForModel('ollama-cloud', 'deepseek-v4-flash')], - ['off', 'low', 'medium', 'high', 'max'], - ); - // GPT-OSS only accepts low/medium/high — no off, no max - assert.deepEqual( - thinkingOptionsForModel('ollama-cloud', 'gpt-oss:120b'), - { efforts: ['low', 'medium', 'high'] }, - ); - assert.deepEqual( - [...thinkingVariantsForModel('ollama-cloud', 'gpt-oss:120b')], - ['low', 'medium', 'high'], - ); - // Non-reasoning models still yield nothing - assert.deepEqual([...thinkingVariantsForModel('ollama-cloud', 'devstral-2:123b')], []); - // Deprecated models (retired from the API) do not get thinking options - assert.deepEqual([...thinkingVariantsForModel('ollama-cloud', 'cogito-2.1:671b')], []); - assert.deepEqual([...thinkingVariantsForModel('ollama-cloud', 'kimi-k2-thinking')], []); }); test('claude-subscription inherits anthropic thinking options (displayMetadataOnly preserves them)', () => { @@ -239,8 +220,8 @@ describe('thinkingVariantsForModel', () => { assert.deepEqual([...thinkingVariantsForModel('zai-coding-plan', 'glm-4.5-air')], []); }); - test('codex-subscription inherits openai gpt-5.5 thinking options', () => { - assert.deepEqual([...thinkingVariantsForModel('codex-subscription', 'gpt-5.5')], ['off', 'low', 'medium', 'high', 'xhigh']); + test('openai-codex inherits openai gpt-5.5 thinking options', () => { + assert.deepEqual([...thinkingVariantsForModel('openai-codex', 'gpt-5.5')], ['off', 'low', 'medium', 'high', 'xhigh']); }); test('claude-subscription inherits anthropic thinking options', () => { diff --git a/packages/core/src/__tests__/onboarding.test.ts b/packages/core/src/__tests__/onboarding.test.ts index 3fd98e098a..ef8f485875 100644 --- a/packages/core/src/__tests__/onboarding.test.ts +++ b/packages/core/src/__tests__/onboarding.test.ts @@ -373,8 +373,8 @@ describe('deriveOnboardingState invariants', () => { it('Codex OAuth subscription connections are onboarding-ready once their send path lands', () => { const conn = realConnection({ - slug: 'codex-subscription', - providerType: 'codex-subscription', + slug: 'openai-codex', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', models: [{ id: 'gpt-5.5' }], }); @@ -382,8 +382,8 @@ describe('deriveOnboardingState invariants', () => { assert.equal(ready.ready, true); const result = derive({ connections: [conn], - defaultSlug: 'codex-subscription', - secrets: { 'codex-subscription': true }, + defaultSlug: 'openai-codex', + secrets: { 'openai-codex': true }, }); assert.equal(result.kind, 'ready_empty'); }); diff --git a/packages/core/src/__tests__/provider-auth.test.ts b/packages/core/src/__tests__/provider-auth.test.ts index be2d47e0e4..489b2bd264 100644 --- a/packages/core/src/__tests__/provider-auth.test.ts +++ b/packages/core/src/__tests__/provider-auth.test.ts @@ -341,7 +341,7 @@ describe('ProviderAuth contract', () => { test('wired OAuth subscription providers route missing login to the OAuth setup path', () => { const contract = deriveProviderAuthContract({ - providerType: 'codex-subscription', + providerType: 'openai-codex', hasSecret: false, }); diff --git a/packages/core/src/__tests__/provider-contract-matrix.test.ts b/packages/core/src/__tests__/provider-contract-matrix.test.ts index ffb422fd74..c094dca748 100644 --- a/packages/core/src/__tests__/provider-contract-matrix.test.ts +++ b/packages/core/src/__tests__/provider-contract-matrix.test.ts @@ -43,7 +43,7 @@ describe('provider contract matrix — row selection', () => { }); it('excludes the experimental oauth providers and the unavailable gemini-cli', () => { - for (const excluded of ['claude-subscription', 'codex-subscription', 'gemini-cli'] as const) { + for (const excluded of ['claude-subscription', 'openai-codex', 'gemini-cli'] as const) { assert.ok(!plan.rows.some((row) => row.providerType === excluded), `${excluded} must not be a row`); } }); diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 143a47af8b..411990c0c6 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -20,12 +20,6 @@ export interface BackendSendInput { runId?: string; /** Caller-generated turn id shared by the persisted UserMessage and every emitted event. */ turnId: string; - /** - * The persisted initial user RuntimeEvent for this turn (the head anchor). - * Mid-turn capacity compaction keeps this event verbatim in every projection - * and needs its exact ledger identity for replay-checkable coverage. - */ - headAnchorRuntimeEvent?: RuntimeEvent; text: string; attachments?: AttachmentRef[]; /** diff --git a/packages/core/src/connection-readiness.ts b/packages/core/src/connection-readiness.ts index 13157622e0..01042b5c64 100644 --- a/packages/core/src/connection-readiness.ts +++ b/packages/core/src/connection-readiness.ts @@ -108,7 +108,7 @@ export function isConnectionReady(input: IsConnectionReadyInput): IsConnectionRe if ( authKind === 'oauth_token' && connection.providerType !== 'claude-subscription' && - connection.providerType !== 'codex-subscription' && + connection.providerType !== 'openai-codex' && connection.providerType !== 'github-copilot' ) { return { ready: false, reason: 'oauth_subscription_not_wired' }; diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 14f698b2ac..d4ea1190d4 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -492,30 +492,17 @@ export interface CompleteEvent extends BaseEvent { | 'plan_handoff' | 'permission_handoff' | 'step_limit' - | 'max_tokens' - | 'context_budget_exhausted'; - /** - * Detail for `stopReason: 'context_budget_exhausted'` — the runtime could not - * produce a provider-safe request even after mid-turn compaction. A first-class - * outcome, not a provider context-length error. - */ - contextBudgetExhaustedDetail?: ContextBudgetExhaustedDetail; + | 'max_tokens'; } -export type ContextBudgetExhaustedDetail = - | 'no_safe_completed_span' - | 'summarizer_failed' - | 'head_anchor_exceeds_capacity'; - export type CompleteStopReason = CompleteEvent['stopReason']; /** Stable failure taxonomy for complete events that did not finish the turn. */ export function failureClassFromCompleteStopReason( reason: CompleteStopReason, -): 'runtime_error' | 'tool_step_cap_reached' | 'context_budget_exhausted' | undefined { +): 'runtime_error' | 'tool_step_cap_reached' | undefined { if (reason === 'error') return 'runtime_error'; if (reason === 'step_limit') return 'tool_step_cap_reached'; - if (reason === 'context_budget_exhausted') return 'context_budget_exhausted'; return undefined; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 50008d4552..2676271645 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,7 +42,6 @@ export type { AttachmentRef, AttachmentIngestItem, CompleteStopReason, - ContextBudgetExhaustedDetail, } from './events.js'; export type { UserQuestion, @@ -901,6 +900,7 @@ export { effectiveBaseUrl, migrateConnectionV1ToV2, normalizeConnectionBaseUrl, + normalizeProviderType, persistedBaseUrl, validateConnectionBaseUrl, validateSlug, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 3a54480c51..d157cb3c30 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -11,6 +11,7 @@ import { PROVIDER_REGISTRY, READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, + normalizeProviderType, type ProviderCatalogGroup, type ProviderCategory, type ProviderDefaults, @@ -24,6 +25,7 @@ export { PROVIDER_REGISTRY, READY_PROVIDER_TYPES, RECOMMENDED_PROVIDER_TYPES, + normalizeProviderType, }; export type { ProviderCatalogGroup, @@ -334,7 +336,9 @@ export function migrateConnectionV1ToV2(old: unknown): LlmConnection { baseUrl?: string; createdAt?: number; }; - if (value.providerType) return value as LlmConnection; + if (value.providerType) { + return { ...value, providerType: normalizeProviderType(value.providerType) } as LlmConnection; + } if (!value.slug) throw new Error('Cannot migrate connection without slug'); const now = Date.now(); diff --git a/packages/core/src/provider-auth.ts b/packages/core/src/provider-auth.ts index 1dc3216c19..e4456688d4 100644 --- a/packages/core/src/provider-auth.ts +++ b/packages/core/src/provider-auth.ts @@ -66,7 +66,7 @@ export interface ProviderAuthContract { const WIRED_OAUTH_PROVIDERS = new Set([ 'claude-subscription', - 'codex-subscription', + 'openai-codex', 'github-copilot', ]); diff --git a/packages/core/src/provider-contract-matrix.ts b/packages/core/src/provider-contract-matrix.ts index 0e482d0541..4377a1c028 100644 --- a/packages/core/src/provider-contract-matrix.ts +++ b/packages/core/src/provider-contract-matrix.ts @@ -54,7 +54,7 @@ export type ProviderContractWire = /** Runtime-adapter kinds whose request wire is provider-specific (auth, headers, * per-model protocol) and therefore cannot be generated from the declaration. */ export const SUBSCRIPTION_WIRE_ADAPTER_KINDS: ReadonlySet = - new Set(['claude-subscription', 'codex-subscription', 'github-copilot']); + new Set(['claude-subscription', 'openai-codex', 'github-copilot']); /** Derived expectation for a generated `discovery` cell. */ export interface ProviderContractDiscoveryPlan { diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 6c0a204a72..f286dca21b 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -12,7 +12,7 @@ export type ProviderRuntimeAdapter = | { kind: 'anthropic'; auth: 'api-key' | 'bearer'; normalizeBaseUrl: boolean } | { kind: 'claude-subscription' } | { kind: 'openai' } - | { kind: 'codex-subscription' } + | { kind: 'openai-codex' } | { kind: 'google'; normalizeBaseUrl?: boolean } | { kind: 'github-copilot' } | { kind: 'cohere' } @@ -1250,7 +1250,7 @@ const providerRegistry = { category: 'oauth', catalogBadge: 'Experimental', }, - 'codex-subscription': { + 'openai-codex': { label: 'OpenAI OAuth (ChatGPT / Codex)', description: 'ChatGPT/Codex account OAuth path for OpenAI Responses models.', baseUrl: 'https://chatgpt.com/backend-api/codex', @@ -1259,7 +1259,7 @@ const providerRegistry = { fallbackModels: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'], status: 'phase3-experimental', protocol: 'openai', - runtimeAdapter: { kind: 'codex-subscription' }, + runtimeAdapter: { kind: 'openai-codex' }, modelDiscovery: { kind: 'fallback' }, category: 'oauth', catalogBadge: 'Account', @@ -1293,3 +1293,21 @@ function providerTypesByOrder(field: 'readyOrder' | 'catalogOrder' | 'recommende export const READY_PROVIDER_TYPES = providerTypesByOrder('readyOrder'); export const CATALOG_PROVIDER_TYPES = providerTypesByOrder('catalogOrder'); export const RECOMMENDED_PROVIDER_TYPES = providerTypesByOrder('recommendedOrder'); + +/** + * Persisted providerType aliases renamed away in the current registry. Each + * entry maps a legacy persisted id to its current id so connections stored + * before a rename keep working without a destructive on-disk migration. + * + * The alias normalizes the `providerType` field only. Persisted connection + * slugs and credential-store keys (e.g. the `codex-subscription` slug used by + * the OpenAI Codex OAuth service) are intentionally left untouched so existing + * OAuth tokens remain reachable. + */ +const PROVIDER_TYPE_ALIASES: Readonly> = { + 'codex-subscription': 'openai-codex', +}; + +export function normalizeProviderType(type: string): ProviderType { + return PROVIDER_TYPE_ALIASES[type] ?? (type as ProviderType); +} diff --git a/packages/core/src/usage-stats/types.ts b/packages/core/src/usage-stats/types.ts index afa7fcb595..f0430603a5 100644 --- a/packages/core/src/usage-stats/types.ts +++ b/packages/core/src/usage-stats/types.ts @@ -213,8 +213,6 @@ export interface CompactionDecisionDiagnostic { stage: CompactionStageDiagnostic; sourceKind: CompactionSourceDiagnosticKind; decision: CompactionDecisionDiagnosticKind; - /** Compaction phase; absent on legacy data = pre_turn. */ - phase?: 'pre_turn' | 'mid_turn'; boundaryKind?: string; boundaryIds?: string[]; coveredTurns?: number; diff --git a/packages/core/src/visual-smoke.ts b/packages/core/src/visual-smoke.ts index 1c5ffd8af3..739f3d8305 100644 --- a/packages/core/src/visual-smoke.ts +++ b/packages/core/src/visual-smoke.ts @@ -8,7 +8,7 @@ export type VisualSmokeScenario = | 'fallback-source' | 'fetched-empty' | 'connection-error' - // OAuth re-login: seeds a codex-subscription (OAuth) connection that last + // OAuth re-login: seeds a openai-codex (OAuth) connection that last // tested needs_reauth and focuses its detail sheet, so the inline 登录 / // 重新登录 affordance the detail sheet gained is visible where an expired // OAuth login must be re-run — the surface that used to be dead prose. diff --git a/packages/headless/harbor/opencode_agent.py b/packages/headless/harbor/opencode_agent.py index 4b62399769..1a4d02f334 100644 --- a/packages/headless/harbor/opencode_agent.py +++ b/packages/headless/harbor/opencode_agent.py @@ -16,19 +16,6 @@ from trial_pricing import estimate_cost, pricing_from_env -_UPSTREAM_CURL_INSTALL_COMMAND = "apt-get update && apt-get install -y curl" -_RETRYING_CURL_INSTALL_COMMAND = ( - "command -v curl >/dev/null 2>&1 && exit 0; " - "status=1; " - "for attempt in 1 2 3; do " - "apt-get -o Acquire::Retries=3 update && " - "apt-get -o Acquire::Retries=3 install -y curl && exit 0; " - "status=$?; " - '[ "$attempt" -eq 3 ] && exit "$status"; ' - "sleep $((attempt * 5)); " - 'done; exit "$status"' -) - class MakaOpenCodeAgent(OpenCode): """Run Harbor's OpenCode agent while normalizing trial cost fields.""" @@ -37,24 +24,6 @@ class MakaOpenCodeAgent(OpenCode): def name() -> str: return "opencode" - async def exec_as_root( - self, - environment: BaseEnvironment, - command: str, - env: dict[str, str] | None = None, - cwd: str | None = None, - timeout_sec: int | None = None, - ) -> Any: - if command == _UPSTREAM_CURL_INSTALL_COMMAND: - command = _RETRYING_CURL_INSTALL_COMMAND - return await super().exec_as_root( - environment, - command=command, - env=env, - cwd=cwd, - timeout_sec=timeout_sec, - ) - @with_prompt_template async def run( self, diff --git a/packages/headless/src/__tests__/cell-output.test.ts b/packages/headless/src/__tests__/cell-output.test.ts index 1f8d994b83..d0b9a0dc8a 100644 --- a/packages/headless/src/__tests__/cell-output.test.ts +++ b/packages/headless/src/__tests__/cell-output.test.ts @@ -24,7 +24,6 @@ describe('Harbor cell output contract', () => { cacheMissInputSource: 'explicit', reasoning: 2, total: 17, - runtimeSteps: 1, costUsd: 0.00123, systemPromptHash: 'sha256:prompt-a', promptSegments: [ @@ -50,7 +49,6 @@ describe('Harbor cell output contract', () => { cacheRead: 2, cacheCreation: 1, total: 10, - runtimeSteps: 1, costUsd: 0.004, systemPromptHash: 'sha256:prompt-a', }, @@ -98,7 +96,7 @@ describe('Harbor cell output contract', () => { actualToolNames: ['Bash', 'Read'], actualToolCallCounts: { Bash: 1, Read: 1 }, }, - steps: 2, + steps: 5, durationMs: 150, startedAt: 100, finishedAt: 250, @@ -136,34 +134,6 @@ describe('Harbor cell output contract', () => { }); }); - test('persists a real-provider failure when token usage is unavailable', () => { - const output = buildHarborCellOutput({ - invocation: { - invocationId: 'inv-missing-usage', - runId: 'run-missing-usage', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'failed', - failure: { class: 'network' }, - events: [runtimeEvent({ id: 'user-event', role: 'user', author: 'user' })], - startedAt: 100, - finishedAt: 250, - }, - runtimeEventsPath: '/logs/agent/runtime-events.jsonl', - executionIdentity: { - llmConnectionSlug: 'zai-coding-plan', - model: 'glm-5.2', - systemPromptHash: 'sha256:prompt-a', - pricingProfile: 'zai-public', - }, - }); - - assert.equal(output.status, 'failed'); - assert.equal(output.errorClass, 'network'); - assert.equal('tokenSummary' in output, false); - assert.deepEqual(validateHarborCellOutput(output), output); - }); - test('keeps output when runtime emits more than one prompt hash', () => { const output = buildHarborCellOutput({ invocation: { @@ -215,101 +185,12 @@ describe('Harbor cell output contract', () => { runtimeEventsPath: '/logs/agent/runtime-events.jsonl', }); - assert.ok(output.tokenSummary); assert.equal(output.tokenSummary.input, 13); assert.equal(output.tokenSummary.output, 12); assert.equal(output.tokenSummary.reasoning, 2); assert.equal(output.tokenSummary.total, 27); }); - test('counts completed model steps instead of streaming event chunks', () => { - const partialChunks = Array.from({ length: 100 }, (_, index) => runtimeEvent({ - id: `partial-${index}`, - partial: true, - content: { kind: 'text', text: 'x' }, - })); - const output = buildHarborCellOutput({ - invocation: { - invocationId: 'inv-stream-steps', - runId: 'run-stream-steps', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'failed', - failure: { class: 'network' }, - events: [ - ...partialChunks, - runtimeEvent({ id: 'final-thinking', content: { kind: 'thinking', text: 'done' } }), - runtimeEvent({ id: 'final-text', content: { kind: 'text', text: '' } }), - ], - startedAt: 100, - finishedAt: 250, - }, - runtimeEventsPath: '/logs/agent/runtime-events.jsonl', - }); - - assert.equal(output.steps, 1); - }); - - test('counts a pure-tool step when runtime usage does not report steps', () => { - const output = buildHarborCellOutput({ - invocation: { - invocationId: 'inv-tool-steps', - runId: 'run-tool-steps', - sessionId: 'session-1', - turnId: 'turn-1', - status: 'completed', - events: [ - runtimeEvent({ - id: 'tool-step', - content: { kind: 'function_call', id: 'call-1', name: 'Read', args: {} }, - refs: { toolCallId: 'call-1', stepId: 'step-1' }, - }), - runtimeEvent({ - id: 'final-text', - content: { kind: 'text', text: 'done' }, - refs: { providerEventId: 'step-2' }, - }), - ], - startedAt: 100, - finishedAt: 250, - }, - runtimeEventsPath: '/logs/agent/runtime-events.jsonl', - }); - - assert.equal(output.steps, 2); - }); - - test('uses step identity only for turns whose runtime step usage is unavailable', () => { - const output = buildHarborCellOutput({ - invocation: { - invocationId: 'inv-mixed-steps', - runId: 'run-mixed-steps', - sessionId: 'session-1', - turnId: 'turn-2', - status: 'failed', - failure: { class: 'tool_step_cap_reached' }, - events: [ - runtimeEvent({ - id: 'reported-turn', - turnId: 'turn-1', - actions: { tokenUsage: { input: 1, output: 1, runtimeSteps: 1 } }, - }), - runtimeEvent({ - id: 'unmetered-tool-step', - turnId: 'turn-2', - content: { kind: 'function_call', id: 'call-2', name: 'Read', args: {} }, - refs: { toolCallId: 'call-2', stepId: 'step-2' }, - }), - ], - startedAt: 100, - finishedAt: 250, - }, - runtimeEventsPath: '/logs/agent/runtime-events.jsonl', - }); - - assert.equal(output.steps, 2); - }); - test('summarizes context budget diagnostics from token usage events', () => { const output = buildHarborCellOutput({ invocation: { diff --git a/packages/headless/src/__tests__/fixed-prompt-controller.test.ts b/packages/headless/src/__tests__/fixed-prompt-controller.test.ts index 2f20a64784..579b7a7805 100644 --- a/packages/headless/src/__tests__/fixed-prompt-controller.test.ts +++ b/packages/headless/src/__tests__/fixed-prompt-controller.test.ts @@ -751,7 +751,7 @@ describe('fixed prompt controller', () => { }); }); - test('excludes an early-attested timeout without a usage checkpoint', async () => { + test('uses early execution identity to attribute a timeout before cell output', async () => { await withDir(async (dir) => { const systemPromptPath = join(dir, 'system_prompt.md'); await writeFile(systemPromptPath, 'fixed prompt\n', 'utf8'); @@ -781,8 +781,8 @@ describe('fixed prompt controller', () => { const event = result.events[0]; assert.equal(event?.type, 'task_budget_exhausted'); if (event?.type !== 'task_budget_exhausted') assert.fail('expected budget exhaustion event'); - assert.equal(event.eligible, false); - assert.equal(event.evidenceErrorClass, 'missing_token_usage'); + assert.equal(event.eligible, true); + assert.equal(event.evidenceErrorClass, undefined); assert.deepEqual( (event as { executionIdentity?: typeof executionIdentity }).executionIdentity, executionIdentity, @@ -1743,83 +1743,6 @@ describe('fixed prompt controller', () => { }); }); - test('records missing real-provider usage as a plumbing failure', async () => { - await withDir(async (dir) => { - const systemPromptPath = join(dir, 'system_prompt.md'); - await writeFile(systemPromptPath, 'fixed prompt\n', 'utf8'); - - const result = await runFixedPromptController({ - runId: 'run-1', - roundId: 'round-1', - config, - systemPromptPath, - resultsJsonlPath: join(dir, 'results.jsonl'), - resultsTsvPath: join(dir, 'results.tsv'), - tasks: [{ id: 'task-a', path: '/bench/task-a' }], - requireExecutionIdentity: true, - expectedPricingProfile: 'test-profile', - harborRunner: async () => harborOutput({ - taskId: 'task-a', - omitTokenSummary: true, - executionIdentity: { - llmConnectionSlug: 'fake', - model: 'fake-model', - systemPromptHash: hashSystemPrompt('fixed prompt\n'), - pricingProfile: 'test-profile', - }, - }), - now: () => 100, - newId: idFactory(), - }); - - assert.equal(result.events[0]?.type, 'task_plumbing_failed'); - assert.equal(result.events[0]?.eligible, false); - assert.equal(result.events[0]?.errorClass, 'missing_token_usage'); - }); - }); - - test('excludes an attested failed cell when usage is unavailable', async () => { - await withDir(async (dir) => { - const systemPromptPath = join(dir, 'system_prompt.md'); - const resultsTsvPath = join(dir, 'results.tsv'); - await writeFile(systemPromptPath, 'fixed prompt\n', 'utf8'); - - const result = await runFixedPromptController({ - runId: 'run-1', - roundId: 'round-1', - config, - systemPromptPath, - resultsJsonlPath: join(dir, 'results.jsonl'), - resultsTsvPath, - tasks: [{ id: 'task-a', path: '/bench/task-a' }], - requireExecutionIdentity: true, - expectedPricingProfile: 'test-profile', - harborRunner: async () => harborOutput({ - taskId: 'task-a', - status: 'failed', - errorClass: 'tool_step_cap_reached', - omitTokenSummary: true, - executionIdentity: { - llmConnectionSlug: 'fake', - model: 'fake-model', - systemPromptHash: hashSystemPrompt('fixed prompt\n'), - pricingProfile: 'test-profile', - }, - }), - now: () => 100, - newId: idFactory(), - }); - - assert.equal(result.events[0]?.type, 'task_plumbing_failed'); - assert.equal(result.events[0]?.eligible, false); - assert.equal(result.events[0]?.errorClass, 'missing_token_usage'); - assert.equal('tokenSummary' in result.events[0]!, false); - const [, row] = (await readFile(resultsTsvPath, 'utf8')).trimEnd().split('\n'); - assert.equal(row?.split('\t')[7], ''); - assert.equal(row?.split('\t')[8], ''); - }); - }); - test('records prompt hash mismatches as plumbing failures', async () => { await withDir(async (dir) => { const systemPromptPath = join(dir, 'system_prompt.md'); @@ -1978,7 +1901,6 @@ function harborOutput(input: { promptHash?: string; omitPromptHash?: boolean; tokenSummary?: HarborTaskRunOutput['cell']['tokenSummary']; - omitTokenSummary?: boolean; contextBudgetPolicy?: HarborTaskRunOutput['cell']['contextBudgetPolicy']; contextBudgetSummary?: HarborTaskRunOutput['cell']['contextBudgetSummary']; continuationSummary?: HarborTaskRunOutput['cell']['continuationSummary']; @@ -1997,9 +1919,7 @@ function harborOutput(input: { traceEventsPath: `/logs/${input.taskId}/events.jsonl`, ...(input.omitPromptHash ? {} : { promptHash: input.promptHash ?? hashSystemPrompt('fixed prompt\n') }), ...(input.executionIdentity ? { executionIdentity: input.executionIdentity } : {}), - ...(input.omitTokenSummary - ? {} - : { tokenSummary: input.tokenSummary ?? tokenSummary({ input: 1, output: 2, reasoning: 0, total: 3, costUsd: 0.02 }) }), + tokenSummary: input.tokenSummary ?? tokenSummary({ input: 1, output: 2, reasoning: 0, total: 3, costUsd: 0.02 }), ...(input.contextBudgetPolicy ? { contextBudgetPolicy: input.contextBudgetPolicy } : {}), ...(input.contextBudgetSummary ? { contextBudgetSummary: input.contextBudgetSummary } : {}), ...(input.continuationSummary ? { continuationSummary: input.continuationSummary } : {}), diff --git a/packages/headless/src/__tests__/harbor-adapter.test.ts b/packages/headless/src/__tests__/harbor-adapter.test.ts index c5112f5e8e..f514ceb426 100644 --- a/packages/headless/src/__tests__/harbor-adapter.test.ts +++ b/packages/headless/src/__tests__/harbor-adapter.test.ts @@ -1419,16 +1419,6 @@ class OpenCode: def _error_messages(self): return [] - async def exec_as_root(self, environment, command, **kwargs): - return await environment.exec(command, **kwargs) - - async def install(self, environment): - await self.exec_as_root( - environment, - command="apt-get update && apt-get install -y curl", - env={"DEBIAN_FRONTEND": "noninteractive"}, - ) - async def run(self, instruction, environment, context): raise AssertionError("MakaOpenCodeAgent should use its stop-sentinel run path") @@ -1490,13 +1480,6 @@ try: environment.agent_commands.append((command, env or {})) agent.exec_as_agent = exec_as_agent - asyncio.run(agent.install(environment)) - install_command, install_kwargs = environment.root_commands[0] - assert "for attempt in 1 2 3" in install_command, install_command - assert "Acquire::Retries=3" in install_command, install_command - assert "sleep $((attempt * 5))" in install_command, install_command - assert install_kwargs["env"] == {"DEBIAN_FRONTEND": "noninteractive"}, install_kwargs - asyncio.run(agent.run("hi", environment, context)) command, env = next(item for item in environment.agent_commands if "opencode --model=" in item[0]) assert "opencode-stop-runner.mjs" in command, command @@ -1521,7 +1504,7 @@ try: assert 'cat --' not in command, command assert "test-zai-key" not in command, command assert "test-zai-key" not in json.dumps(env), env - assert len(environment.root_commands) == 1, environment.root_commands + assert environment.root_commands == [], environment.root_commands assert os.environ.get("ZAI_API_KEY") is None assert os.environ.get("ZAI_BASE_URL") is None diff --git a/packages/headless/src/__tests__/harbor-cell.test.ts b/packages/headless/src/__tests__/harbor-cell.test.ts index f5d3e7d72b..20d2a8cc43 100644 --- a/packages/headless/src/__tests__/harbor-cell.test.ts +++ b/packages/headless/src/__tests__/harbor-cell.test.ts @@ -183,39 +183,6 @@ function registerStepCapThenCompleteBackend(seen: { backend?: StepCapThenComplet }; } -class UnmeteredStepCapThenCompleteBackend extends StepCapThenCompleteBackend { - async *send(input: BackendSendInput): AsyncIterable { - if (this.prompts.length > 0) { - yield* super.send(input); - return; - } - const ts = Date.now(); - this.prompts.push(input.text); - this.cwds.push(this.ctx.header.cwd); - yield { - type: 'tool_start', - id: 'unmetered-tool-step', - turnId: input.turnId, - ts, - toolUseId: 'call-1', - toolName: 'Read', - args: { path: 'README.md' }, - stepId: 'step-1', - }; - yield { type: 'complete', id: 'unmetered-step-cap', turnId: input.turnId, ts, stopReason: 'step_limit' }; - } -} - -function registerUnmeteredStepCapThenCompleteBackend(seen: { backend?: UnmeteredStepCapThenCompleteBackend }) { - return (registry: BackendRegistry): void => { - registry.register('fake', (ctx) => { - const backend = new UnmeteredStepCapThenCompleteBackend({ sessionId: ctx.sessionId, header: ctx.header }); - seen.backend = backend; - return backend; - }); - }; -} - class StepCapThenThrowBackend extends StepCapThenCompleteBackend { async *send(input: BackendSendInput): AsyncIterable { if (this.prompts.length > 0) { @@ -415,7 +382,6 @@ describe('runHarborCell', () => { assert.equal(result.output.status, 'completed'); assert.equal(result.output.promptHash, 'sha256:cell-prompt'); assert.equal(result.output.runtimeEventsPath, join(outputDir, HARBOR_CELL_RUNTIME_EVENTS_FILENAME)); - assert.ok(result.output.tokenSummary); assert.equal(result.output.tokenSummary.costUsd, 0.0042); assert.deepEqual(result.output.executionIdentity, { llmConnectionSlug: 'fake', @@ -541,13 +507,12 @@ describe('runHarborCell', () => { continuedTurns: 1, stepCapHits: 1, capExhausted: false, - totalRuntimeSteps: 51, + totalRuntimeSteps: 50, turns: [ { turnIndex: 0, status: 'failed', stepCapHit: true, runtimeSteps: 50 }, - { turnIndex: 1, status: 'completed', stepCapHit: false, runtimeSteps: 1 }, + { turnIndex: 1, status: 'completed', stepCapHit: false, runtimeSteps: 0 }, ], }); - assert.ok(result.output.tokenSummary); assert.equal(result.output.tokenSummary.input, 13); assert.equal(result.output.tokenSummary.costUsd, 0.03); const runtimeEvents = await readFile(join(outputDir, HARBOR_CELL_RUNTIME_EVENTS_FILENAME), 'utf8'); @@ -636,32 +601,6 @@ describe('runHarborCell', () => { }); }); - test('stops continuation at the step budget when the capped turn has no usage', async () => { - await withDirs(async ({ workspaceDir, outputDir, storageRoot }) => { - const seen: { backend?: UnmeteredStepCapThenCompleteBackend } = {}; - const result = await runHarborCell({ - config, - instruction: 'solve the benchmark task', - cwd: workspaceDir, - outputDir, - storageRoot, - registerBackends: registerUnmeteredStepCapThenCompleteBackend(seen), - continuationPolicy: { - enabled: true, - maxTurns: 3, - maxTotalRuntimeSteps: 1, - prompt: 'Continue neutrally from current workspace.', - }, - }); - - assert.equal(result.output.status, 'failed'); - assert.equal(result.output.errorClass, 'tool_step_cap_reached'); - assert.deepEqual(seen.backend?.prompts, ['solve the benchmark task']); - assert.equal(result.output.continuationSummary?.totalRuntimeSteps, 1); - assert.equal('tokenSummary' in result.output, false); - }); - }); - test('does not spend continuation step budget from diagnostic event count', async () => { await withDirs(async ({ workspaceDir, outputDir, storageRoot }) => { const seen: { backend?: NoisyStepCapThenCompleteBackend } = {}; @@ -693,10 +632,10 @@ describe('runHarborCell', () => { continuedTurns: 1, stepCapHits: 1, capExhausted: false, - totalRuntimeSteps: 51, + totalRuntimeSteps: 50, turns: [ { turnIndex: 0, status: 'failed', stepCapHit: true, runtimeSteps: 50 }, - { turnIndex: 1, status: 'completed', stepCapHit: false, runtimeSteps: 1 }, + { turnIndex: 1, status: 'completed', stepCapHit: false, runtimeSteps: 0 }, ], }); }); @@ -735,11 +674,11 @@ describe('runHarborCell', () => { continuedTurns: 2, stepCapHits: 2, capExhausted: false, - totalRuntimeSteps: 101, + totalRuntimeSteps: 100, turns: [ { turnIndex: 0, status: 'failed', stepCapHit: true, runtimeSteps: 50 }, { turnIndex: 1, status: 'failed', stepCapHit: true, runtimeSteps: 50 }, - { turnIndex: 2, status: 'completed', stepCapHit: false, runtimeSteps: 1 }, + { turnIndex: 2, status: 'completed', stepCapHit: false, runtimeSteps: 0 }, ], }); }); @@ -2142,7 +2081,6 @@ console.log(JSON.stringify({ type: 'agent_end', messages: [{ role: 'assistant', assert.equal(argv.at(-1), '-p'); assert.equal(argv.includes('solve through default pi transport'), false); assert.equal(await readFile(join(workspaceDir, 'pi-default-stdin.txt'), 'utf8'), 'solve through default pi transport'); - assert.ok(result.output.tokenSummary); assert.equal(result.output.tokenSummary.input, 5); assert.equal(result.output.tokenSummary.output, 2); assert.equal(result.output.tokenSummary.costUsd, 0.0003); diff --git a/packages/headless/src/__tests__/harbor-cli-connection-defaults.test.ts b/packages/headless/src/__tests__/harbor-cli-connection-defaults.test.ts index bcffcf21ba..3ed6632cf9 100644 --- a/packages/headless/src/__tests__/harbor-cli-connection-defaults.test.ts +++ b/packages/headless/src/__tests__/harbor-cli-connection-defaults.test.ts @@ -394,6 +394,33 @@ describe('applyConnectionDefaults', () => { assert.equal(env.MAKA_LLM_CONNECTION_SLUG, undefined); assert.equal(env.MAKA_BASE_URL, undefined); }); + + test('legacy codex-subscription providerType normalized to openai-codex', () => { + // Connections persisted before the codex-subscription -> openai-codex + // rename keep the old providerType on disk. applyConnectionDefaults reads + // llm-connections.json directly (bypassing ConnectionStore's on-read + // normalization), so it must normalize the alias itself or the headless + // path silently drops a still-valid connection. + const connectionsPath = makeTempConnections({ + defaultSlug: 'codex-subscription', + connections: [ + { + slug: 'codex-subscription', + providerType: 'codex-subscription', + defaultModel: 'gpt-5.6-sol', + baseUrl: 'https://chatgpt.com/backend-api/codex', + enabled: true, + }, + ], + }); + + const env: Record = { MAKA_CONNECTIONS_PATH: connectionsPath }; + applyConnectionDefaults(env); + + assert.equal(env.MAKA_MODEL, 'openai-codex/gpt-5.6-sol'); + assert.equal(env.MAKA_LLM_CONNECTION_SLUG, 'codex-subscription'); + assert.equal(env.MAKA_BASE_URL, 'https://chatgpt.com/backend-api/codex'); + }); }); describe('resolveHarborRunOptions backend guard', () => { diff --git a/packages/headless/src/__tests__/runtime-policy-ab-lifecycle.test.ts b/packages/headless/src/__tests__/runtime-policy-ab-lifecycle.test.ts index 2fd6343c8f..35006f3ce2 100644 --- a/packages/headless/src/__tests__/runtime-policy-ab-lifecycle.test.ts +++ b/packages/headless/src/__tests__/runtime-policy-ab-lifecycle.test.ts @@ -246,7 +246,6 @@ test('pilot candidate pass against an attested baseline timeout can launch full systemPromptHash: hashSystemPrompt(runInput.systemPrompt), pricingProfile: 'test-profile', }, - tokenSummary: tokenSummary({ input: 4, output: 6, reasoning: 0, total: 10, costUsd: 0.01 }), }); } return output(runInput, candidate); diff --git a/packages/headless/src/cell-output.ts b/packages/headless/src/cell-output.ts index f3a416545c..b1a069dfe3 100644 --- a/packages/headless/src/cell-output.ts +++ b/packages/headless/src/cell-output.ts @@ -102,7 +102,7 @@ export interface HarborCellOutput { runtimeEventsPath: string; promptHash?: string; executionIdentity?: HarborCellExecutionIdentity; - tokenSummary?: HarborCellTokenSummary; + tokenSummary: HarborCellTokenSummary; contextBudgetPolicy?: HarborCellContextBudgetPolicySnapshot; contextBudgetSummary?: HarborCellContextBudgetSummary; continuationSummary?: HarborCellContinuationSummary; @@ -124,7 +124,6 @@ export function buildHarborCellOutput(input: { taskToolSummaryEnabled?: boolean; }): HarborCellOutput { const { invocation } = input; - const tokenSummary = summarizeCellTokens(invocation.events); return { schemaVersion: HARBOR_CELL_OUTPUT_SCHEMA_VERSION, status: invocation.status, @@ -132,13 +131,13 @@ export function buildHarborCellOutput(input: { runtimeEventsPath: input.runtimeEventsPath, ...promptHashField(invocation.events), ...(input.executionIdentity ? { executionIdentity: input.executionIdentity } : {}), - ...(tokenSummary ? { tokenSummary } : {}), + tokenSummary: summarizeCellTokens(invocation.events), ...(input.contextBudgetPolicy ? { contextBudgetPolicy: input.contextBudgetPolicy } : {}), ...contextBudgetSummaryField(invocation.events), ...(input.continuationSummary ? { continuationSummary: input.continuationSummary } : {}), toolSummary: summarizeCellTools(invocation.events), ...taskToolSummaryField(invocation.events, input.taskToolSummaryEnabled ?? false), - steps: countRuntimeSteps(invocation.events), + steps: invocation.events.length, durationMs: invocation.finishedAt - invocation.startedAt, startedAt: invocation.startedAt, finishedAt: invocation.finishedAt, @@ -151,26 +150,6 @@ export function buildHarborCellOutput(input: { }; } -export function countRuntimeSteps(events: readonly RuntimeEvent[]): number { - const turns = new Map; legacyTextSteps: number }>(); - for (const event of events) { - const turn = turns.get(event.turnId) ?? { reported: 0, stepIds: new Set(), legacyTextSteps: 0 }; - turn.reported += event.actions?.tokenUsage?.runtimeSteps ?? 0; - turns.set(event.turnId, turn); - if (event.role !== 'model' || event.partial === true) continue; - const stepId = event.refs?.stepId ?? event.refs?.providerEventId; - if (stepId) { - turn.stepIds.add(stepId); - } else if (event.content?.kind === 'text') { - turn.legacyTextSteps += 1; - } - } - return [...turns.values()].reduce( - (sum, turn) => sum + (turn.reported > 0 ? turn.reported : turn.stepIds.size + turn.legacyTextSteps), - 0, - ); -} - export function hashHarborSystemPrompt(systemPrompt: string): string { return `sha256:${createHash('sha256').update(JSON.stringify(systemPrompt)).digest('hex')}`; } @@ -190,9 +169,7 @@ export function validateHarborCellOutput(value: unknown): HarborCellOutput { const executionIdentity = 'executionIdentity' in value ? validateHarborCellExecutionIdentity(value.executionIdentity) : undefined; - const tokenSummary = 'tokenSummary' in value - ? validateHarborCellTokenSummary(value.tokenSummary) - : undefined; + const tokenSummary = validateHarborCellTokenSummary(value.tokenSummary); const contextBudgetPolicy = 'contextBudgetPolicy' in value ? validateContextBudgetPolicySnapshot(value.contextBudgetPolicy) : undefined; @@ -218,7 +195,7 @@ export function validateHarborCellOutput(value: unknown): HarborCellOutput { runtimeEventsPath, ...(promptHash !== undefined ? { promptHash } : {}), ...(executionIdentity !== undefined ? { executionIdentity } : {}), - ...(tokenSummary ? { tokenSummary } : {}), + tokenSummary, ...(contextBudgetPolicy !== undefined ? { contextBudgetPolicy } : {}), ...(contextBudgetSummary !== undefined ? { contextBudgetSummary } : {}), ...(continuationSummary !== undefined ? { continuationSummary } : {}), @@ -294,7 +271,7 @@ function requireContinuationTurns(value: unknown): HarborCellContinuationTurnSum }); } -export function summarizeCellTokens(events: readonly RuntimeEvent[]): HarborCellTokenSummary | undefined { +export function summarizeCellTokens(events: readonly RuntimeEvent[]): HarborCellTokenSummary { const summary: HarborCellTokenSummary = { input: 0, output: 0, @@ -309,11 +286,9 @@ export function summarizeCellTokens(events: readonly RuntimeEvent[]): HarborCell }; let sawExplicitCacheMiss = false; let sawDerivedCacheMiss = false; - let sawUsage = false; for (const event of events) { const usage = event.actions?.tokenUsage; if (!usage) continue; - sawUsage = true; summary.input += usage.input ?? 0; summary.output += usage.output ?? 0; const cacheHitInput = usage.cacheHitInput ?? usage.cacheRead ?? 0; @@ -337,7 +312,6 @@ export function summarizeCellTokens(events: readonly RuntimeEvent[]): HarborCell summary.total += usage.total ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.reasoning ?? 0); summary.costUsd += usage.costUsd ?? 0; } - if (!sawUsage) return undefined; if (sawExplicitCacheMiss) { summary.cacheMissInputSource = 'explicit'; } else if (sawDerivedCacheMiss) { diff --git a/packages/headless/src/fixed-prompt-controller.ts b/packages/headless/src/fixed-prompt-controller.ts index 973910fad6..b822f4f24c 100644 --- a/packages/headless/src/fixed-prompt-controller.ts +++ b/packages/headless/src/fixed-prompt-controller.ts @@ -98,7 +98,7 @@ export interface FixedPromptTaskCompletedEvent { errorClass?: string; promptHash?: string; executionIdentity?: HarborCellExecutionIdentity; - tokenSummary?: HarborCellTokenSummary; + tokenSummary: HarborCellTokenSummary; contextBudgetPolicy?: HarborCellContextBudgetPolicySnapshot; contextBudgetSummary?: HarborCellContextBudgetSummary; continuationSummary?: HarborCellContinuationSummary; @@ -175,7 +175,7 @@ export interface FixedPromptTaskPlumbingFailedEvent { passed: false; scored: false; eligible: false; - errorClass: 'missing_token_usage' | 'zero_cost_with_tokens' | 'prompt_hash_mismatch' | 'missing_prompt_hash' | 'missing_execution_identity' | 'execution_identity_mismatch'; + errorClass: 'zero_cost_with_tokens' | 'prompt_hash_mismatch' | 'missing_prompt_hash' | 'missing_execution_identity' | 'execution_identity_mismatch'; error: string; promptHash?: string; expectedPromptHash?: string; @@ -520,21 +520,18 @@ export async function writeFixedPromptResultsTsv( 'cost_usd', 'runtime_events_path', ]; - const rows = events.map((event) => { - const tokenSummary = eventTokenSummary(event); - return [ - event.taskId, - event.status, - String(event.passed), - String(event.scored), - String(event.eligible), - event.errorClass ?? '', - 'promptHash' in event ? event.promptHash ?? '' : '', - tokenSummary ? String(tokenSummary.total) : '', - tokenSummary ? String(tokenSummary.costUsd) : '', - 'runtimeEventsPath' in event ? event.runtimeEventsPath ?? '' : '', - ]; - }); + const rows = events.map((event) => [ + event.taskId, + event.status, + String(event.passed), + String(event.scored), + String(event.eligible), + event.errorClass ?? '', + 'promptHash' in event ? event.promptHash ?? '' : '', + String(eventTokenSummary(event)?.total ?? 0), + String(eventTokenSummary(event)?.costUsd ?? 0), + 'runtimeEventsPath' in event ? event.runtimeEventsPath ?? '' : '', + ]); const body = [header, ...rows].map((row) => row.map(tsvCell).join('\t')).join('\n'); await writeFile(path, `${body}\n`, 'utf8'); } @@ -720,7 +717,7 @@ function taskCompletedEvent(input: { ...(errorClass ? { errorClass } : {}), ...(output.cell.promptHash ? { promptHash: output.cell.promptHash } : {}), ...(output.cell.executionIdentity ? { executionIdentity: output.cell.executionIdentity } : {}), - ...(output.cell.tokenSummary ? { tokenSummary: output.cell.tokenSummary } : {}), + tokenSummary: output.cell.tokenSummary, ...(output.cell.contextBudgetPolicy ? { contextBudgetPolicy: output.cell.contextBudgetPolicy } : {}), ...(output.cell.contextBudgetSummary ? { contextBudgetSummary: output.cell.contextBudgetSummary } : {}), ...(output.cell.continuationSummary ? { continuationSummary: output.cell.continuationSummary } : {}), @@ -769,7 +766,7 @@ function taskPlumbingFailedEvent(input: { error: input.error, ...(input.output.cell.promptHash ? { promptHash: input.output.cell.promptHash } : {}), expectedPromptHash: input.expectedPromptHash, - ...(input.output.cell.tokenSummary ? { tokenSummary: input.output.cell.tokenSummary } : {}), + tokenSummary: input.output.cell.tokenSummary, ...(input.output.cell.contextBudgetPolicy ? { contextBudgetPolicy: input.output.cell.contextBudgetPolicy } : {}), @@ -816,17 +813,7 @@ function classifyPlumbingFailure(output: HarborTaskRunOutput, expectedPromptHash error: `Harbor cell prompt hash ${output.cell.promptHash} did not match ${expectedPromptHash}`, }; } - if ( - (output.cell.status === 'completed' || output.cell.errorClass === 'tool_step_cap_reached') - && output.cell.executionIdentity - && (!output.cell.tokenSummary || output.cell.tokenSummary.total <= 0) - ) { - return { - errorClass: 'missing_token_usage', - error: 'Harbor cell did not report token usage for the attested real-provider execution', - }; - } - if (output.cell.tokenSummary && output.cell.tokenSummary.total > 0 && output.cell.tokenSummary.costUsd === 0) { + if (output.cell.tokenSummary.total > 0 && output.cell.tokenSummary.costUsd === 0) { return { errorClass: 'zero_cost_with_tokens', error: 'Harbor cell reported token usage but zero costUsd', @@ -967,16 +954,6 @@ function taskBudgetExhaustedEvent(input: { if (evidenceFailure?.errorClass === 'missing_execution_identity') { evidenceFailure = { ...evidenceFailure, error: LEGACY_TIMEOUT_MISSING_EXECUTION_IDENTITY_ERROR }; } - if ( - evidenceFailure === undefined - && artifactRefs.executionIdentity - && (!artifactRefs.tokenSummary || artifactRefs.tokenSummary.total <= 0) - ) { - evidenceFailure = { - errorClass: 'missing_token_usage', - error: 'Harbor cell did not report token usage for the attested real-provider execution', - }; - } } const tokenSummary = artifactRefs.cellOutput?.tokenSummary ?? artifactRefs.tokenSummary; const tokenSummarySource = tokenSummary diff --git a/packages/headless/src/harbor-cell.ts b/packages/headless/src/harbor-cell.ts index d95d75627b..c5ee923327 100644 --- a/packages/headless/src/harbor-cell.ts +++ b/packages/headless/src/harbor-cell.ts @@ -46,7 +46,6 @@ import { import { registerFakeBackend } from './backends.js'; import { buildHarborCellOutput, - countRuntimeSteps, hashHarborSystemPrompt, validateHarborCellOutput, type HarborCellContextBudgetPolicySnapshot, @@ -645,7 +644,10 @@ function continuationTurnSummary( } function invocationRuntimeSteps(invocation: InvocationResult): number { - return countRuntimeSteps(invocation.events); + return invocation.events.reduce((sum, event) => { + const runtimeSteps = event.actions?.tokenUsage?.runtimeSteps; + return sum + (runtimeSteps ?? 0); + }, 0); } function failedInvocationFromError(error: unknown, input: { diff --git a/packages/headless/src/harbor-cli.ts b/packages/headless/src/harbor-cli.ts index ca9a00629a..a9ce8cf976 100644 --- a/packages/headless/src/harbor-cli.ts +++ b/packages/headless/src/harbor-cli.ts @@ -4,7 +4,7 @@ import { mkdir, readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import type { BackendKind, ProviderType } from '@maka/core'; -import { PROVIDER_DEFAULTS } from '@maka/core'; +import { PROVIDER_DEFAULTS, normalizeProviderType } from '@maka/core'; import type { Config, Task } from './contracts.js'; import { runAutonomousTask } from './autonomous-agent-loop.js'; import type { BenchmarkAdapterRegistry } from './benchmark-adapters.js'; @@ -581,10 +581,14 @@ export function applyConnectionDefaults(env: Record) if (!file.defaultSlug || !Array.isArray(file.connections)) return; const conn = file.connections.find(c => c.slug === file.defaultSlug && c.enabled !== false); if (!conn?.providerType || !conn.defaultModel) return; - // Validate providerType against known providers - if (!(conn.providerType in PROVIDER_DEFAULTS)) return; - - env.MAKA_MODEL = `${conn.providerType}/${conn.defaultModel}`; + // Normalize legacy persisted providerType ids (e.g. codex-subscription -> + // openai-codex) so connections stored before a rename keep resolving. + // applyConnectionDefaults reads llm-connections.json directly, bypassing + // ConnectionStore's on-read normalization. + const providerType = normalizeProviderType(conn.providerType); + if (!(providerType in PROVIDER_DEFAULTS)) return; + + env.MAKA_MODEL = `${providerType}/${conn.defaultModel}`; if (env.MAKA_LLM_CONNECTION_SLUG === undefined) env.MAKA_LLM_CONNECTION_SLUG = conn.slug; if (env.MAKA_BASE_URL === undefined && conn.baseUrl) env.MAKA_BASE_URL = conn.baseUrl; // credentials.json lives next to llm-connections.json in the workspace; diff --git a/packages/headless/src/harbor-task-runner.ts b/packages/headless/src/harbor-task-runner.ts index ab66fcaa45..4136220ff8 100644 --- a/packages/headless/src/harbor-task-runner.ts +++ b/packages/headless/src/harbor-task-runner.ts @@ -289,7 +289,7 @@ function cellArtifactRefs(cell: HarborCellOutput, hostEventsPath: string, trialD return { runtimeEventsPath: hostEventsPath, traceEventsPath, - ...(cell.tokenSummary ? { tokenSummary: cell.tokenSummary } : {}), + tokenSummary: cell.tokenSummary, cellOutput: { ...cell, runtimeEventsPath: hostEventsPath, traceEventsPath }, }; } diff --git a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts index 7bae65a979..5f7221834e 100644 --- a/packages/runtime/src/__tests__/active-tool-result-prune.test.ts +++ b/packages/runtime/src/__tests__/active-tool-result-prune.test.ts @@ -53,7 +53,6 @@ describe('active current-turn tool-result pruning', () => { }; const composed = composePrepareStep( () => ({ activeTools: ['Read', LOAD_TOOLS_NAME] }), - undefined, activePrune, ); @@ -386,7 +385,7 @@ describe('active current-turn tool-result pruning', () => { messages: [{ role: 'user', content: 'load rive' }], tools: aiSdkTools, activeTools: plan.activeTools, - prepareStep: composePrepareStep(plan.prepareStep, undefined, activePrune), + prepareStep: composePrepareStep(plan.prepareStep, activePrune), abortSignal: new AbortController().signal, repairToolCall: async () => null, }); diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 41d4a3374e..dea22f0946 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3898,47 +3898,6 @@ describe('AiSdkBackend usage telemetry', () => { ]); }); - test('does not record fabricated zero telemetry when provider usage is unavailable', async () => { - const llmRecords: LlmCallRecord[] = []; - const model = new MockLanguageModelV3({ - doStream: { - stream: simulateReadableStream({ - chunks: [ - { type: 'stream-start', warnings: [] }, - { - type: 'finish', - finishReason: { unified: 'stop', raw: 'stop' }, - usage: { - inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, - outputTokens: { total: undefined, text: undefined, reasoning: undefined }, - } as never, - }, - ], - initialDelayInMs: null, - chunkDelayInMs: null, - }), - }, - }); - const backend = new AiSdkBackend({ - sessionId: 'session-1', - header: header(), - appendMessage: async () => {}, - connection: connection(), - apiKey: 'sk-test', - modelId: 'mock-model-id', - permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), - modelFactory: () => model, - tools: [], - newId: idGenerator(), - now: monotonicClock(), - recordLlmCall: (record) => { llmRecords.push(record); }, - }); - - await drain(backend.send({ turnId: 'turn-1', text: 'hi', context: [] })); - - assert.deepEqual(llmRecords, []); - }); - test('keeps checkpoint cost unknown when model pricing is unavailable', async () => { const usageCheckpoints: Array<{ costUsd?: number }> = []; const model = new MockLanguageModelV3({ @@ -4218,44 +4177,6 @@ describe('AiSdkBackend usage telemetry', () => { assert.equal(recordedBlocks[0]?.blockId, 'afcompact-sync-test'); }); - test('does not record semantic compact usage when provider usage is unavailable', () => { - const llmRecords: LlmCallRecord[] = []; - const backend = new AiSdkBackend({ - sessionId: 'session-1', - header: header(), - appendMessage: async () => {}, - connection: connection(), - apiKey: 'sk-test', - modelId: 'mock-model-id', - permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), - modelFactory: () => completionModel(), - tools: [], - newId: idGenerator(), - now: monotonicClock(), - recordLlmCall: (record) => { llmRecords.push(record); }, - }); - - (backend as unknown as { - recordSemanticCompactSummaryCall(input: { - callId: string; - turnId: string; - modelId: string; - startedAt: number; - latencyMs: number; - status: LlmCallRecord['status']; - }): void; - }).recordSemanticCompactSummaryCall({ - callId: 'semantic-1', - turnId: 'turn-1', - modelId: 'mock-model-id', - startedAt: 1, - latencyMs: 2, - status: 'error', - }); - - assert.deepEqual(llmRecords, []); - }); - test('semantic compact records a separate no-tools summarizer LLM call', async () => { const messages: unknown[] = []; const events: SessionEvent[] = []; diff --git a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts index a46c9cd1cd..63993898f0 100644 --- a/packages/runtime/src/__tests__/ai-sdk-flow.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-flow.test.ts @@ -701,25 +701,6 @@ describe('mapSessionEventToRuntimeEvent (pure)', () => { }); }); - test('context_budget_exhausted keeps its detail in the durable terminal state', () => { - const mapped = mapSessionEventToRuntimeEvent( - ev({ - type: 'complete', - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', - }), - ctx, - createSessionEventMapMemory(), - ); - - assert.equal(mapped.status, 'failed'); - assert.deepEqual(mapped.actions?.stateDelta, { - stopReason: 'context_budget_exhausted', - failureClass: 'context_budget_exhausted', - contextBudgetExhaustedDetail: 'head_anchor_exceeds_capacity', - }); - }); - test('tool_output_delta and tool_progress map to partial tool-role heartbeats', () => { const mem = createSessionEventMapMemory(); const a = mapSessionEventToRuntimeEvent( diff --git a/packages/runtime/src/__tests__/async-queue.test.ts b/packages/runtime/src/__tests__/async-queue.test.ts index 9d89d5a058..20175c0dc7 100644 --- a/packages/runtime/src/__tests__/async-queue.test.ts +++ b/packages/runtime/src/__tests__/async-queue.test.ts @@ -108,96 +108,3 @@ describe('AsyncEventQueue', () => { expect(out).toEqual([10, 20, 30]); }); }); - -describe('AsyncEventQueue seq-ack counters', () => { - test('pushedCount stamps enqueues; ackConsumed counts processed events', () => { - const q = new AsyncEventQueue(); - expect(q.pushedCount).toBe(0); - q.push(1); - q.push(2); - expect(q.pushedCount).toBe(2); - expect(q.consumedCount).toBe(0); - q.ackConsumed(); - expect(q.consumedCount).toBe(1); - // A dropped push (after close) is not stamped: the counter tracks events - // a consumer can ever receive, or the boundary would be unreachable. - q.close(); - q.push(3); - expect(q.pushedCount).toBe(2); - }); - - test('waitForProgress resolves on push, ack, close, and detach — condition-variable, not a poll', async () => { - const q = new AsyncEventQueue(); - let wakes = 0; - const arm = (): void => { - void q.waitForProgress().then(() => { wakes += 1; }); - }; - - arm(); - q.push(1); - await Promise.resolve(); - expect(wakes).toBe(1); - - arm(); - q.ackConsumed(); - await Promise.resolve(); - expect(wakes).toBe(2); - - arm(); - q.noteConsumerDetached(); - await Promise.resolve(); - expect(wakes).toBe(3); - expect(q.consumerDetached).toBe(true); - - arm(); - q.close(); - await Promise.resolve(); - expect(wakes).toBe(4); - }); - - test('a seq-ack boundary wait observes consumed >= pushed exactly when the consumer has processed everything', async () => { - const q = new AsyncEventQueue(); - const processed: number[] = []; - - // Producer-side boundary waiter: everything pushed so far must be processed. - const boundary = q.pushedCount; // 0 — then push 3 events - q.push(1); - q.push(2); - q.push(3); - q.close(); - expect(boundary).toBe(0); - - const waiter = (async () => { - while (q.consumedCount < q.pushedCount) { - await q.waitForProgress(); - } - return [q.pushedCount, q.consumedCount]; - })(); - - // Consumer acks after fully processing each event (the drain() pattern). - for await (const v of q) { - processed.push(v); - q.ackConsumed(); - } - expect(await waiter).toEqual([3, 3]); - expect(processed).toEqual([1, 2, 3]); - }); - - test('a detached consumer wakes boundary waiters instead of deadlocking them', async () => { - const q = new AsyncEventQueue(); - q.push(1); - q.push(2); - const waiter = (async () => { - while (q.consumedCount < q.pushedCount) { - if (q.consumerDetached) return 'detached'; - await q.waitForProgress(); - } - return 'acked'; - })(); - // The consumer abandons the stream after one event without acking the rest. - const iter = q[Symbol.asyncIterator](); - await iter.next(); - q.noteConsumerDetached(); - expect(await waiter).toBe('detached'); - }); -}); diff --git a/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts b/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts index fcec11567f..d26a17995c 100644 --- a/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts +++ b/packages/runtime/src/__tests__/claude-subscription-runtime.test.ts @@ -28,7 +28,7 @@ describe('Claude subscription runtime wiring', () => { const src = await readFile(new URL('../../src/model-factory.ts', import.meta.url), 'utf8'); const caseIdx = src.indexOf("case 'claude-subscription'"); assert.notEqual(caseIdx, -1, 'claude-subscription case must exist'); - const caseRegion = src.slice(caseIdx, src.indexOf("case 'codex-subscription'", caseIdx)); + const caseRegion = src.slice(caseIdx, src.indexOf("case 'openai-codex'", caseIdx)); assert.match(caseRegion, /createAnthropic\(\{[\s\S]*authToken:\s*apiKey/, 'Claude OAuth must use AI SDK Anthropic authToken'); assert.match(caseRegion, /baseURL:\s*anthropicV1BaseUrl\(baseURL\)/, 'Claude OAuth must pass the AI SDK a /v1 Anthropic base URL'); assert.match(caseRegion, /fetch,/, 'Claude OAuth must accept the desktop cloak fetch wrapper'); @@ -115,15 +115,15 @@ describe('Claude subscription runtime wiring', () => { assert.deepEqual(PROVIDER_REGISTRY['MiniMax-cn'].runtimeAdapter, expected); }); - test('model factory wires codex-subscription to OpenAI Responses with account-scoped fetch/header shape', async () => { + test('model factory wires openai-codex to OpenAI Responses with account-scoped fetch/header shape', async () => { const src = await readFile(new URL('../../src/model-factory.ts', import.meta.url), 'utf8'); - assert.deepEqual(PROVIDER_REGISTRY['codex-subscription'].runtimeAdapter, { kind: 'codex-subscription' }); - const caseIdx = src.indexOf("case 'codex-subscription'"); - assert.notEqual(caseIdx, -1, 'codex-subscription case must exist'); + assert.deepEqual(PROVIDER_REGISTRY['openai-codex'].runtimeAdapter, { kind: 'openai-codex' }); + const caseIdx = src.indexOf("case 'openai-codex'"); + assert.notEqual(caseIdx, -1, 'openai-codex case must exist'); const caseRegion = src.slice(caseIdx, src.indexOf("case 'unavailable'", caseIdx)); assert.match(caseRegion, /createOpenAI\(\{[\s\S]*apiKey/, 'Codex OAuth must use OpenAI client with OAuth token'); assert.match(caseRegion, /fetch,/, 'Codex OAuth must accept the desktop ChatGPT backend fetch wrapper'); - assert.match(caseRegion, /codexSubscriptionHeaders\(apiKey\)/, 'Codex OAuth must attach account-scoped headers'); + assert.match(caseRegion, /openAiCodexHeaders\(apiKey\)/, 'Codex OAuth must attach account-scoped headers'); assert.match(caseRegion, /\.responses\(modelId\)/, 'Codex OAuth must use Responses API'); assert.doesNotMatch(caseRegion, /throw new Error/, 'Codex OAuth must not remain in the experimental throw branch'); }); @@ -138,21 +138,21 @@ describe('Claude subscription runtime wiring', () => { const src = await readFile(new URL('../../src/subscription-auth.ts', import.meta.url), 'utf8'); assert.match(src, /OpenAI-Beta['"]:\s*['"]responses=experimental/, 'Codex OAuth must opt into ChatGPT Responses beta'); assert.equal( - (await import('../subscription-auth.js')).codexSubscriptionHeaders(codexAccessToken('acct_test'))['ChatGPT-Account-Id'], + (await import('../subscription-auth.js')).openAiCodexHeaders(codexAccessToken('acct_test'))['ChatGPT-Account-Id'], 'acct_test', ); }); test('Codex OAuth headers do not fall back to JWT sub as ChatGPT account id', async () => { - const { codexSubscriptionHeaders } = await import('../subscription-auth.js'); - const headers = codexSubscriptionHeaders(codexAccessTokenWithoutChatGptAccount('sub_not_account')); + const { openAiCodexHeaders } = await import('../subscription-auth.js'); + const headers = openAiCodexHeaders(codexAccessTokenWithoutChatGptAccount('sub_not_account')); assert.equal(headers['ChatGPT-Account-Id'], undefined); }); test('Codex OAuth provider options use non-persistent ChatGPT backend defaults', async () => { const src = await readFile(new URL('../../src/model-factory.ts', import.meta.url), 'utf8'); const fnIdx = src.indexOf('export function buildProviderOptions'); - const caseIdx = src.indexOf("case 'codex-subscription'", fnIdx); + const caseIdx = src.indexOf("case 'openai-codex'", fnIdx); const caseRegion = src.slice(caseIdx, src.indexOf("case 'openai'", caseIdx)); assert.match(caseRegion, /store:\s*false/, 'Codex OAuth sends must not persist Responses API inputs by default'); assert.match(caseRegion, /textVerbosity:\s*['"]medium['"]/, 'Codex OAuth sends must use the ChatGPT backend text verbosity shape'); @@ -173,9 +173,9 @@ function claudeOAuthConnection(): LlmConnection { function codexOAuthConnection(): LlmConnection { return { - slug: 'codex-subscription', + slug: 'openai-codex', name: 'Codex OAuth', - providerType: 'codex-subscription', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', enabled: true, createdAt: 1, diff --git a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts b/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts deleted file mode 100644 index 35e2f6f680..0000000000 --- a/packages/runtime/src/__tests__/context-budget-mid-turn-policy.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import type { LlmConnection } from '@maka/core'; -import { buildDefaultContextBudgetPolicy } from '../context-budget-policy.js'; - -describe('mid-turn history compact policy env plumbing', () => { - test('defaults off: no midTurn subconfig without an explicit opt-in', () => { - const policy = buildDefaultContextBudgetPolicy(connection(), { - env: { MAKA_CONTEXT_HISTORY_COMPACT: 'on' }, - }); - assert.equal(policy?.historyCompact?.enabled, true); - assert.equal(policy?.historyCompact?.midTurn, undefined); - }); - - test('opts in with MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN=on and the shared reserve', () => { - const policy = buildDefaultContextBudgetPolicy(connection(), { - env: { - MAKA_CONTEXT_HISTORY_COMPACT: 'on', - MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN: 'on', - }, - }); - assert.deepEqual(policy?.historyCompact?.midTurn, { enabled: true, reserveTokens: 16_384 }); - }); - - test('honors explicit reserve and tail-event overrides', () => { - const policy = buildDefaultContextBudgetPolicy(connection(), { - env: { - MAKA_CONTEXT_HISTORY_COMPACT: 'on', - MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN: 'on', - MAKA_CONTEXT_HISTORY_COMPACT_RESERVE_TOKENS: '8000', - MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN_TAIL_EVENTS: '2', - }, - }); - assert.deepEqual(policy?.historyCompact?.midTurn, { - enabled: true, - reserveTokens: 8_000, - reserveTailEvents: 2, - }); - }); - - test('mid_turn=off keeps it disabled even with history compact on', () => { - const policy = buildDefaultContextBudgetPolicy(connection(), { - env: { - MAKA_CONTEXT_HISTORY_COMPACT: 'on', - MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN: 'off', - }, - }); - assert.equal(policy?.historyCompact?.midTurn, undefined); - }); -}); - -function connection(): LlmConnection { - return { - slug: 'anthropic-main', - name: 'Anthropic', - providerType: 'anthropic', - defaultModel: 'claude-sonnet-4-5-20250929', - enabled: true, - createdAt: 1, - updatedAt: 1, - }; -} diff --git a/packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts b/packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts deleted file mode 100644 index 9d034dec66..0000000000 --- a/packages/runtime/src/__tests__/history-compact-mid-turn-checkpoint.test.ts +++ /dev/null @@ -1,230 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { - buildHistoryCompactCheckpoint, - matchHistoryCompactCheckpointPrefix, - midTurnHeadAnchorEvent, - projectHistoryCompactCheckpointReplay, - validateHistoryCompactCheckpointShape, -} from '../history-compact-checkpoint.js'; -import { applyRuntimeEventHistoryCompact } from '../context-budget.js'; - -describe('mid-turn history compact checkpoint', () => { - test('builds a mid_turn checkpoint that re-renders the covered head anchor verbatim', () => { - // [prior turn user, prior turn model, head anchor user, current step model] - const events = [ - textEvent('prior-user', 'turn-0', 'user'), - textEvent('prior-model', 'turn-0', 'model'), - textEvent('anchor', 'turn-1', 'user'), - textEvent('step-model', 'turn-1', 'model'), - ]; - const checkpoint = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: events, - summary: 'Prior work plus the current turn opening.', - phase: 'mid_turn', - headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, - now: 1_800_000_010_000, - }); - - assert.equal(checkpoint.phase, 'mid_turn'); - assert.deepEqual(checkpoint.headAnchor, { runtimeEventId: 'anchor', turnId: 'turn-1' }); - assert.equal(validateHistoryCompactCheckpointShape(checkpoint, 'session-1'), true); - // Coverage remains a contiguous prefix whose digest matches the raw events. - const match = matchHistoryCompactCheckpointPrefix(checkpoint, [...events, textEvent('tail', 'turn-1', 'model')]); - assert.equal(match.coveredEventCount, 4); - assert.deepEqual(match.successorRuntimeEvents.map((event) => event.id), ['tail']); - - const anchor = midTurnHeadAnchorEvent(checkpoint, match.coveredRuntimeEvents); - assert.equal(anchor?.id, 'anchor'); - const projected = projectHistoryCompactCheckpointReplay( - checkpoint, - match.coveredRuntimeEvents, - match.successorRuntimeEvents, - ); - assert.deepEqual(projected.map((event) => event.id), [ - `history-compact:${checkpoint.checkpointId}`, - 'anchor', - 'tail', - ]); - // Head anchor is byte-identical to the covered raw event. - assert.deepEqual(projected[1], events[2]); - }); - - test('rejects a mid_turn checkpoint without a covered head anchor', () => { - const events = [textEvent('a', 'turn-1', 'user'), textEvent('b', 'turn-1', 'model')]; - assert.throws(() => buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'x', phase: 'mid_turn', - }), /requires a head anchor/); - assert.throws(() => buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'x', phase: 'mid_turn', - headAnchor: { runtimeEventId: 'missing', turnId: 'turn-1' }, - }), /must be a covered RuntimeEvent/); - }); - - test('rejects a head anchor that is not the compacted turn\'s user event', () => { - const events = [textEvent('a', 'turn-1', 'user'), textEvent('b', 'turn-1', 'model')]; - // Anchor turnId disagrees with the covered event. - assert.throws(() => buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'x', phase: 'mid_turn', - headAnchor: { runtimeEventId: 'a', turnId: 'turn-9' }, - }), /must be the compacted turn's user event/); - // Anchor references a model event, not the turn's user message. - assert.throws(() => buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'x', phase: 'mid_turn', - headAnchor: { runtimeEventId: 'b', turnId: 'turn-1' }, - }), /must be the compacted turn's user event/); - }); - - test('rejects a head anchor pointing at another covered turn\'s user event', () => { - // A self-consistent anchor (role user, matching self-reported turnId) that - // resolves to a PRIOR turn's prompt would silently drop the real current - // prompt from the replay — the compacted turn is the last covered event's - // turn, and the anchor must belong to it. - const events = [ - textEvent('prior-user', 'turn-0', 'user'), - textEvent('prior-model', 'turn-0', 'model'), - textEvent('anchor', 'turn-1', 'user'), - textEvent('step-model', 'turn-1', 'model'), - ]; - assert.throws(() => buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'x', phase: 'mid_turn', - headAnchor: { runtimeEventId: 'prior-user', turnId: 'turn-0' }, - }), /must be the compacted turn's user event/); - - // Matcher: a persisted checkpoint whose anchor was tampered to the prior - // turn's user event must fail closed as coverage_miss. - const checkpoint = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'mid turn summary', - phase: 'mid_turn', headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, - }); - const priorUserAnchor = { - ...checkpoint, - headAnchor: { runtimeEventId: 'prior-user', turnId: 'turn-0' }, - }; - assert.equal(matchHistoryCompactCheckpointPrefix(priorUserAnchor, events).reason, 'coverage_miss'); - assert.equal(matchHistoryCompactCheckpointPrefix(checkpoint, events).reason, undefined); - }); - - test('rejects coverage that includes a partial streaming snapshot', () => { - const events = [ - textEvent('a', 'turn-1', 'user'), - { ...textEvent('b-partial', 'turn-1', 'model'), partial: true }, - textEvent('c', 'turn-1', 'model'), - ]; - assert.throws(() => buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'x', phase: 'mid_turn', - headAnchor: { runtimeEventId: 'a', turnId: 'turn-1' }, - }), /must not include partial events/); - }); - - test('fails the prefix match closed when the head anchor reference is corrupted', () => { - const events = [ - textEvent('anchor', 'turn-1', 'user'), - textEvent('step-model', 'turn-1', 'model'), - textEvent('tail', 'turn-1', 'model'), - ]; - const checkpoint = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: events.slice(0, 2), - summary: 'mid turn summary', - phase: 'mid_turn', - headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, - }); - - // Anchor id pointing outside the coverage: the replay would silently drop - // the turn's user message, so the match must fail closed instead. - const missingAnchor = { - ...checkpoint, - headAnchor: { runtimeEventId: 'tail', turnId: 'turn-1' }, - }; - assert.equal(matchHistoryCompactCheckpointPrefix(missingAnchor, events).reason, 'coverage_miss'); - - // Anchor resolving to a covered non-user event fails the same way. - const modelAnchor = { - ...checkpoint, - headAnchor: { runtimeEventId: 'step-model', turnId: 'turn-1' }, - }; - assert.equal(matchHistoryCompactCheckpointPrefix(modelAnchor, events).reason, 'coverage_miss'); - - // Anchor turnId disagreeing with the covered event fails too. - const wrongTurnAnchor = { - ...checkpoint, - headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-9' }, - }; - assert.equal(matchHistoryCompactCheckpointPrefix(wrongTurnAnchor, events).reason, 'coverage_miss'); - - // The intact checkpoint still matches. - assert.equal(matchHistoryCompactCheckpointPrefix(checkpoint, events).reason, undefined); - }); - - test('keeps pre_turn checkpoint ids stable when phase is absent or explicit', () => { - const events = [textEvent('a', 'turn-0', 'user'), textEvent('b', 'turn-0', 'model')]; - const implicit = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'same', now: 5, - }); - const explicit = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'same', phase: 'pre_turn', now: 5, - }); - assert.equal(implicit.phase, undefined); - assert.equal(explicit.checkpointId, implicit.checkpointId); - // A mid_turn checkpoint over the same coverage is a distinct id. - const mid = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', coveredRuntimeEvents: events, summary: 'same', now: 5, - phase: 'mid_turn', headAnchor: { runtimeEventId: 'a', turnId: 'turn-0' }, - }); - assert.notEqual(mid.checkpointId, implicit.checkpointId); - }); - - test('replays a mid_turn checkpoint as [block, verbatim head anchor, uncovered tail]', () => { - const events = [ - textEvent('prior-user', 'turn-0', 'user'), - textEvent('prior-model', 'turn-0', 'model'), - textEvent('anchor', 'turn-1', 'user'), - textEvent('step-model', 'turn-1', 'model'), - textEvent('step-tool', 'turn-1', 'model'), - ]; - const checkpoint = buildHistoryCompactCheckpoint({ - sessionId: 'session-1', - coveredRuntimeEvents: events.slice(0, 4), - summary: 'mid turn summary', - phase: 'mid_turn', - headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, - }); - - // Normal thresholds: the raw projection is far below the default high - // water, and the accepted mid_turn checkpoint must STILL replay — the - // covered raw span may never be re-injected on recovery. - const replay = applyRuntimeEventHistoryCompact(events, { - maxHistoryEstimatedTokens: 1_000, - charsPerToken: 1, - historyCompact: { enabled: true, mode: 'read_write', checkpoint }, - }); - - assert.equal(replay.checkpoint?.checkpointId, checkpoint.checkpointId); - assert.deepEqual(replay.events.map((event) => event.id), [ - `history-compact:${checkpoint.checkpointId}`, - 'anchor', - 'step-tool', - ]); - // The head anchor renders verbatim and is not duplicated in the tail. - const anchorCount = replay.events.filter((event) => event.id === 'anchor').length; - assert.equal(anchorCount, 1); - }); -}); - -function textEvent(id: string, turnId: string, role: 'user' | 'model'): RuntimeEvent { - return { - id, - sessionId: 'session-1', - runId: 'run-1', - turnId, - invocationId: 'run-1', - ts: 1_800_000_000_000, - partial: false, - role, - author: role === 'user' ? 'user' : 'agent', - content: { kind: 'text', text: `payload-${id}` }, - }; -} diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts deleted file mode 100644 index 9896075535..0000000000 --- a/packages/runtime/src/__tests__/mid-turn-capacity-backend.test.ts +++ /dev/null @@ -1,1106 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import { setImmediate as flushMacrotask } from 'node:timers/promises'; -import { MockLanguageModelV3, simulateReadableStream } from 'ai/test'; -import type { LanguageModelV3StreamPart } from '@ai-sdk/provider'; -import type { LlmConnection, SessionHeader } from '@maka/core'; -import type { SessionEvent } from '@maka/core/events'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; -import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; -import { z } from 'zod'; -import { AiSdkBackend } from '../ai-sdk-backend.js'; -import { AiSdkFlow, createSessionEventMapMemory, mapSessionEventToRuntimeEvent } from '../ai-sdk-flow.js'; -import type { InvocationContext } from '../invocation-context.js'; -import { PermissionEngine } from '../permission-engine.js'; -import { applyRuntimeEventContextBudget, evaluateHistoryCompactCheckpointReplay } from '../context-budget.js'; -import type { HistoryCompactCheckpoint } from '../history-compact-checkpoint.js'; -import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types'; - -const RAW_SPAN_ONE = 'RAW_SPAN_ONE_'.repeat(24); -const RAW_SPAN_TWO = 'RAW_SPAN_TWO_'.repeat(160); -/** Third-step result big enough that even the rolled-forward fold cannot fit. */ -const ROLLING_TAIL = 'ROLLING_TAIL_'.repeat(740); -const HUGE_RESULT = 'HUGE_RESULT_'.repeat(670); -const ANCHOR_TEXT = 'compact this very long turn but keep my exact words'; - -interface MidTurnFixture { - backend: AiSdkBackend; - model: MockLanguageModelV3; - recorded: HistoryCompactCheckpoint[]; - recordedBeforeThirdRequest: () => boolean; - toolExecutions: string[]; - summarizerCalls: number; - priorEvents: RuntimeEvent[]; - anchor: RuntimeEvent; - /** The fixture's durable RuntimeEvent ledger for the current turn/run. */ - ledger: RuntimeEvent[]; - ledgerReads: number; - events: SessionEvent[]; - messages: unknown[]; - llmCalls: Array<{ - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - status?: string; - errorClass?: string; - contextBudget?: ContextBudgetDiagnostic; - }>; - /** JSON of each summarizer call's folded runtime events (coverage evidence). */ - summarizedSources: string[]; - persist: (event: SessionEvent) => void; -} - -interface MidTurnFixtureOptions { - contextWindow?: number; - reserveTokens?: number; - summarize?: () => Promise | string | undefined; - branch?: string; - /** Omit the prior turns so the compaction pool has no safe completed span. */ - withoutPriorTurns?: boolean; - /** Enable the default-on active tool-result prune with a tiny threshold. */ - activeToolResultPrune?: boolean; - /** Enable semantic compaction so it competes with the capacity hook. */ - semanticCompact?: boolean; - /** Override the checkpoint recorder (e.g. to simulate a write failure). */ - record?: (checkpoint: HistoryCompactCheckpoint) => void; - /** Make the prior turns large so folding them rescues an over-window turn. */ - bigPriors?: boolean; - /** First tool result is huge (finding C: prune must be able to rescue it). */ - hugeFirstResult?: boolean; - /** The model finishes on the second request instead of running three steps. */ - finalAtSecondCall?: boolean; - /** Add a third tool step whose result outgrows even a rolled-forward fold (finding A). */ - rollingOverflow?: boolean; - /** Economy tool availability with a huge-schema group behind load_tools (finding D). */ - bigToolGroup?: boolean; - /** The first step emits assistant text before its tool call (finding B). */ - assistantTextInFirstStep?: boolean; - /** Override the first step's reported usage; 'missing' = empty usage object. */ - firstStepUsage?: { input: number; output: number } | 'missing'; - /** Volatile per-request turn tail (cwd/task state) appended to the user message. */ - volatileTurnTail?: boolean; - /** Very large prior turns (~20k chars) so a large summary still shrinks the fold. */ - giantPriors?: boolean; - /** Large system prompt sent via the separate `system` field (finding: cold start). */ - bigSystemPrompt?: boolean; -} - -/** - * Consumer scheduling mode for a fixture turn. `slow` reproduces the review's - * scheduling perturbation: the event consumer (which persists to the durable - * ledger) yields several macrotasks before persisting each event, so the - * ledger genuinely lags the SDK's step progression and the trigger's seq-ack - * durability boundary is exercised for real. - */ -type ConsumerMode = 'immediate' | 'slow'; - -function buildFixture(options: MidTurnFixtureOptions = {}): MidTurnFixture { - const contextWindow = options.contextWindow ?? 2_000; - const reserveTokens = options.reserveTokens ?? 1_500; - const recorded: HistoryCompactCheckpoint[] = []; - const toolExecutions: string[] = []; - const events: SessionEvent[] = []; - const messages: unknown[] = []; - const llmCalls: Array<{ - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - status?: string; - errorClass?: string; - contextBudget?: ContextBudgetDiagnostic; - }> = []; - const summarizedSources: string[] = []; - let recordedAtThirdRequest = false; - const fixture = { summarizerCalls: 0, ledgerReads: 0 }; - const usage = (input: number, output: number) => ({ - inputTokens: { total: input, noCache: input, cacheRead: 0, cacheWrite: 0 }, - outputTokens: { total: output, text: output, reasoning: 0 }, - }); - const firstStepUsage = (): ReturnType => { - // A usage object the SDK accepts but whose token counts are absent — the - // adapter's normalization fails closed (undefined) and the capacity hook's - // usability check must fall back to cold start (the finding-1 shape). - if (options.firstStepUsage === 'missing') return { inputTokens: {}, outputTokens: {} } as ReturnType; - if (options.firstStepUsage) return usage(options.firstStepUsage.input, options.firstStepUsage.output); - return usage(100, 20); - }; - const toolCallChunks = (id: string, name: string, args: object): LanguageModelV3StreamPart[] => [ - { type: 'stream-start', warnings: [] }, - { type: 'tool-call', toolCallId: id, toolName: name, input: JSON.stringify(args) }, - { - type: 'finish', - finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, - usage: id === 'tool-1' ? firstStepUsage() : usage(150, 30), - }, - ]; - const doneChunks = (): LanguageModelV3StreamPart[] => [ - { type: 'stream-start', warnings: [] }, - { type: 'text-start', id: 'text-1' }, - { type: 'text-delta', id: 'text-1', delta: 'done' }, - { type: 'text-end', id: 'text-1' }, - { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: usage(120, 10) }, - ]; - const chunksForCall = (call: number): LanguageModelV3StreamPart[] => { - if (options.bigToolGroup) { - return call === 1 ? toolCallChunks('tool-1', 'load_tools', { group: 'big' }) : doneChunks(); - } - if (call === 1) { - const first = toolCallChunks('tool-1', 'Read', { path: 'one.md' }); - if (!options.assistantTextInFirstStep) return first; - return [ - first[0]!, - { type: 'text-start', id: 'step1-text' }, - { type: 'text-delta', id: 'step1-text', delta: 'ASSISTANT_SENTINEL step one reasoning' }, - { type: 'text-end', id: 'step1-text' }, - ...first.slice(1), - ]; - } - if (options.finalAtSecondCall) return doneChunks(); - if (call === 2) return toolCallChunks('tool-2', 'Read', { path: 'two.md' }); - if (options.rollingOverflow && call === 3) return toolCallChunks('tool-3', 'Read', { path: 'three.md' }); - return doneChunks(); - }; - const model = new MockLanguageModelV3({ - doStream: async (streamOptions: { abortSignal?: AbortSignal }) => { - // A real transport rejects immediately on an already-aborted signal; the - // mock must mirror that so an exhausted turn never streams the - // over-budget request. - if (streamOptions.abortSignal?.aborted) { - throw Object.assign(new Error('aborted'), { name: 'AbortError' }); - } - const call = model.doStreamCalls.length; - if (call === 3) recordedAtThirdRequest = recorded.length > 0; - const chunks = chunksForCall(call); - return { stream: simulateReadableStream({ chunks, initialDelayInMs: null, chunkDelayInMs: null }) }; - }, - }); - const priorChars = options.giantPriors ? 10_000 : options.bigPriors ? 2_000 : 120; - const priorEvents: RuntimeEvent[] = options.withoutPriorTurns ? [] : [ - runtimeTextEvent('prior-user', 'turn-0', 'user', `PRIOR_FACT question ${'p'.repeat(priorChars)}`), - runtimeTextEvent('prior-model', 'turn-0', 'model', `PRIOR_FACT answer ${'q'.repeat(priorChars)}`), - ]; - const anchor: RuntimeEvent = { - ...runtimeTextEvent('anchor-1', 'turn-1', 'user', ANCHOR_TEXT), - ...(options.branch !== undefined ? { branch: options.branch } : {}), - }; - - // The fixture's durable run ledger: the consumer persists every non-partial - // mapped RuntimeEvent exactly the way AgentRun.acceptMappedEvent does (same - // mapper, same InvocationContext incl. branch), and the durable-read seam - // serves it back after pending consumer work has flushed. - const ledger: RuntimeEvent[] = [anchor]; - const ledgerCtx: InvocationContext = { - sessionId: 'session-1', - invocationId: 'run-1', - runId: 'run-1', - turnId: 'turn-1', - ...(options.branch !== undefined ? { branch: options.branch } : {}), - source: 'desktop', - startedAt: 1, - request: { sessionId: 'session-1', turnId: 'turn-1', text: ANCHOR_TEXT, source: 'desktop' }, - newId: idGenerator(), - now: monotonicClock(), - }; - const ledgerMemory = createSessionEventMapMemory(); - const persist = (event: SessionEvent): void => { - const mapped = mapSessionEventToRuntimeEvent(event, ledgerCtx, ledgerMemory); - // Partial snapshots live in side files and non-terminal errors are never - // persisted; the immutable ledger holds everything else. - if (mapped.partial === true) return; - if (mapped.content?.kind === 'error') return; - ledger.push(mapped); - }; - - const backend = new AiSdkBackend({ - sessionId: 'session-1', - header: header(), - appendMessage: async (message) => { messages.push(message); }, - connection: { ...connection(), models: [{ id: 'mock-model-id', contextWindow }] }, - apiKey: 'sk-test', - modelId: 'mock-model-id', - permissionEngine: new PermissionEngine({ newId: () => 'permission-id', now: () => 1 }), - modelFactory: () => model, - tools: [ - { - name: 'Read', - description: 'Read description', - parameters: z.object({ path: z.string() }), - permissionRequired: false, - impl: async (args: { path: string }) => { - toolExecutions.push(args.path); - if (args.path === 'one.md') return { body: options.hugeFirstResult ? HUGE_RESULT : RAW_SPAN_ONE }; - if (args.path === 'three.md') return { body: ROLLING_TAIL }; - return { body: RAW_SPAN_TWO }; - }, - }, - ...(options.bigToolGroup - ? [{ - name: 'Big', - // A same-turn load_tools activation adds this schema to every later - // request; the trigger must count it (finding D). - description: `BIG_SCHEMA ${'D'.repeat(12_000)}`, - parameters: z.object({ q: z.string() }), - permissionRequired: false, - impl: async () => ({ ok: true }), - }] - : []), - ], - ...(options.bigToolGroup - ? { toolAvailability: { economy: true, groups: [{ id: 'big', toolNames: ['Big'] }] } } - : {}), - ...(options.volatileTurnTail - ? { turnTailPrompt: 'VOLATILE_TAIL_SENTINEL cwd=/tmp/maka task=keep-going' } - : {}), - ...(options.bigSystemPrompt - ? { systemPrompt: 'SYSTEM_CONTEXT '.repeat(400) } - : {}), - contextBudget: { - name: 'mid-turn-test', - maxHistoryEstimatedTokens: 100_000, - minRecentTurns: 1, - historyCompact: { - enabled: true, - mode: 'read_write', - midTurn: { enabled: true, reserveTokens }, - }, - ...(options.activeToolResultPrune - ? { activeToolResultPrune: { enabled: true, maxCurrentResultEstimatedTokens: 30 } } - : {}), - ...(options.semanticCompact - ? { - semanticCompact: { - enabled: true, - mode: 'replace' as const, - minStepNumber: 2, - maxActiveEstimatedTokens: 1, - }, - } - : {}), - }, - ...(options.activeToolResultPrune - ? { archiveToolResult: () => ({ artifactId: 'artifact-archived-1' }) } - : {}), - summarizeHistoryCompact: async (input) => { - fixture.summarizerCalls += 1; - summarizedSources.push(JSON.stringify(input.source.foldedRuntimeEvents)); - const summary = options.summarize ? await options.summarize() : 'MID_TURN_SUMMARY_SENTINEL'; - return summary; - }, - recordHistoryCompactCheckpoint: (checkpoint) => { - if (options.record) return options.record(checkpoint); - recorded.push(checkpoint); - }, - loadTurnRuntimeEvents: async (turnId) => { - fixture.ledgerReads += 1; - // Emulate the durable read: let the event consumer's pending microtask - // work flush (the real seam awaits the run's serialized write queue). - await flushMacrotask(); - return ledger.filter((event) => event.turnId === turnId); - }, - recordLlmCall: (record) => { llmCalls.push(record as (typeof llmCalls)[number]); }, - newId: idGenerator(), - now: monotonicClock(), - }); - return { - backend, - model, - recorded, - recordedBeforeThirdRequest: () => recordedAtThirdRequest, - toolExecutions, - get summarizerCalls() { return fixture.summarizerCalls; }, - get ledgerReads() { return fixture.ledgerReads; }, - priorEvents, - anchor, - ledger, - events, - messages, - llmCalls, - summarizedSources, - persist, - }; -} - -async function runFixtureTurn(fixture: MidTurnFixture, consumer: ConsumerMode = 'immediate'): Promise { - for await (const event of fixture.backend.send({ - runId: 'run-1', - turnId: 'turn-1', - headAnchorRuntimeEvent: fixture.anchor, - text: ANCHOR_TEXT, - context: [], - runtimeContext: [...fixture.priorEvents], - })) { - if (consumer === 'slow') { - // Scheduling perturbation: hold the durable write back across several - // macrotasks so the ledger lags the SDK between steps. - await flushMacrotask(); - await flushMacrotask(); - await flushMacrotask(); - } - // The consumer persists before continuing, exactly like AgentRun. - fixture.persist(event); - fixture.events.push(event); - } -} - -function promptJson(fixture: MidTurnFixture, call: number): string { - return JSON.stringify(fixture.model.doStreamCalls[call]?.prompt.map((message) => ({ - role: message.role, - content: message.content, - }))); -} - -function compactionDecisions( - fixture: MidTurnFixture, -): NonNullable { - const usageEvent = fixture.events.find((event) => event.type === 'token_usage') as - | { contextBudget?: ContextBudgetDiagnostic } - | undefined; - return usageEvent?.contextBudget?.compactionDecisions ?? []; -} - -function defineMidTurnSuite(consumer: ConsumerMode): void { - test('compacts over the high water, persists first, and continues the same turn', async () => { - const fixture = buildFixture(); - await runFixtureTurn(fixture, consumer); - - // The turn ran three steps and completed normally. - assert.equal(fixture.model.doStreamCalls.length, 3); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - - // Coverage came from the durable ledger read, not a mirrored stream. - assert.equal(fixture.ledgerReads > 0, true); - - // A mid_turn checkpoint was durably recorded before the third request. - assert.equal(fixture.recorded.length, 1); - assert.equal(fixture.recordedBeforeThirdRequest(), true); - const checkpoint = fixture.recorded[0]!; - assert.equal(checkpoint.phase, 'mid_turn'); - assert.deepEqual(checkpoint.headAnchor, { runtimeEventId: 'anchor-1', turnId: 'turn-1' }); - // Coverage: [prior-user, prior-model, anchor, call-1, result-1] — all of - // them durable in the ledger before the checkpoint was recorded. - assert.equal(checkpoint.coverage.eventCount, 5); - - // The next step's prompt is [compact block, verbatim head anchor, preserved tail]. - const thirdPrompt = promptJson(fixture, 2); - assert.match(thirdPrompt, /maka_history_compact_checkpoint/); - assert.match(thirdPrompt, /MID_TURN_SUMMARY_SENTINEL/); - assert.equal(thirdPrompt.includes(ANCHOR_TEXT), true); - // The replaced raw span (first tool result and prior turns) is gone... - assert.equal(thirdPrompt.includes('RAW_SPAN_ONE_'), false); - assert.equal(thirdPrompt.includes('PRIOR_FACT'), false); - // ...while the reserved tail (second tool call/result pair) stays verbatim. - assert.equal(thirdPrompt.includes('RAW_SPAN_TWO_'), true); - assert.match(thirdPrompt, /tool-2/); - - // Completed tool calls are not executed again. - assert.deepEqual(fixture.toolExecutions, ['one.md', 'two.md']); - - // The compaction decision lands in the usage diagnostics with phase mid_turn. - const midTurnDecision = compactionDecisions(fixture).find((decision) => decision.phase === 'mid_turn'); - assert.equal(midTurnDecision?.decision, 'replaced'); - assert.equal(midTurnDecision?.reason, 'context_limit'); - assert.deepEqual(midTurnDecision?.boundaryIds, [checkpoint.checkpointId]); - - // Invariant: a persisted checkpoint always passes the single replay gate - // under the same policy the backend replays with — the next projection - // selects it (no coverage_miss, no size rejection). - const fit = evaluateHistoryCompactCheckpointReplay(checkpoint, fixture.ledger, { - maxHistoryEstimatedTokens: 100_000, - minRecentTurns: 1, - historyCompact: { enabled: true, mode: 'read_write' }, - }); - assert.equal(fit.fits, true); - }); - - test('recovery re-projection with ctx.branch replays the checkpoint without the raw span', async () => { - const fixture = buildFixture({ branch: 'lane-7' }); - await runFixtureTurn(fixture, consumer); - assert.equal(fixture.recorded.length, 1); - const checkpoint = fixture.recorded[0]!; - - // The durable ledger the coverage was computed over carries the branch on - // every current-turn event, because the fixture consumer maps with the - // same InvocationContext (incl. branch) as AiSdkFlow. - for (const event of fixture.ledger) { - assert.equal(event.branch, 'lane-7'); - } - - // Recovery: re-project prior turns + the durable current-turn ledger with - // normal thresholds — the checkpoint replays and the covered raw span is - // never re-injected, even though the raw history is below the high water. - const replay = applyRuntimeEventContextBudget([...fixture.priorEvents, ...fixture.ledger], { - maxHistoryEstimatedTokens: 100_000, - minRecentTurns: 1, - historyCompact: { enabled: true, mode: 'read_write', checkpoint }, - }); - - assert.ok(replay); - const replayIds = replay.events.map((event) => event.id); - assert.equal(replayIds[0], `history-compact:${checkpoint.checkpointId}`); - assert.equal(replayIds.includes('anchor-1'), true); - assert.deepEqual(replay.events[1], fixture.anchor); - assert.equal(replayIds.includes('prior-user'), false); - assert.equal(replayIds.includes('prior-model'), false); - const replayJson = JSON.stringify(replay.events); - assert.equal(replayJson.includes('RAW_SPAN_ONE_'), false); - assert.equal(replayJson.includes('RAW_SPAN_TWO_'), true); - }); - - test('ends the turn with context_budget_exhausted when over the window with no safe span', async () => { - // No prior turns and a window the first step's usage already exceeds: the - // pool is [anchor, one open call/result pair], so no safe completed span. - const fixture = buildFixture({ contextWindow: 120, reserveTokens: 100, withoutPriorTurns: true }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); - // Explicit outcome, not a raw provider error. - assert.equal(fixture.events.some((event) => event.type === 'error'), false); - // The over-budget request was aborted before it could stream (the second - // doStream attempt sees an already-aborted signal and rejects). - assert.equal(fixture.model.doStreamCalls.length <= 2, true); - assert.equal(fixture.events.some((event) => event.type === 'tool_start' && event.toolName === 'Read' && JSON.stringify(event.args).includes('two.md')), false); - }); - - test('ends the turn with summarizer_failed detail when over the window and the summary fails', async () => { - // Estimate at the first boundary ≈ 120 real usage + result chars/4 ≈ 200; - // window 150 puts it over the hard cap while priors leave a safe span. - const fixture = buildFixture({ - contextWindow: 150, - reserveTokens: 100, - summarize: () => { throw new Error('summarizer down'); }, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'summarizer_failed'); - }); - - test('ends the turn with head_anchor_exceeds_capacity when even the minimal projection cannot fit', async () => { - // Big priors leave a safe span, the summary succeeds, and the fold - // GENUINELY shrinks the payload — but the last request's real input - // (1400 tokens) is so large that even the [block, anchor, open pair] - // projection stays over the 150-token window: the irreducible remainder - // exceeds capacity. (A non-shrinking fold is a different failure — - // summarizer_failed via replacement_not_smaller.) - const fixture = buildFixture({ - contextWindow: 150, - reserveTokens: 100, - bigPriors: true, - firstStepUsage: { input: 1_400, output: 20 }, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'head_anchor_exceeds_capacity'); - // The fold itself was valid and durable; it just could not rescue. - assert.equal(fixture.recorded.length, 1); - }); - - test('fails open under the window when the summarizer fails, with a diagnostic', async () => { - const fixture = buildFixture({ summarize: () => undefined }); - await runFixtureTurn(fixture, consumer); - - // The turn still completes; the third request keeps the raw span. - assert.equal(fixture.model.doStreamCalls.length, 3); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - assert.equal(fixture.recorded.length, 0); - const thirdPrompt = promptJson(fixture, 2); - assert.equal(thirdPrompt.includes('RAW_SPAN_ONE_'), true); - assert.equal(thirdPrompt.includes('maka_history_compact_checkpoint'), false); - - const failedOpen = compactionDecisions(fixture).find( - (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', - ); - assert.equal(failedOpen?.failOpenReason, 'summarizer_failed'); - // The recorder was never reached, so the diagnostics claim no write. - const usageEvent = fixture.events.find((event) => event.type === 'token_usage') as - | { contextBudget?: ContextBudgetDiagnostic } - | undefined; - assert.equal(usageEvent?.contextBudget?.historyCompactWritesAttempted, undefined); - assert.equal(usageEvent?.contextBudget?.historyCompactWriteFailures, undefined); - }); - - test('fails open with write_failed diagnostics when the checkpoint write fails under the window', async () => { - const fixture = buildFixture({ record: () => { throw new Error('disk full'); } }); - await runFixtureTurn(fixture, consumer); - - // The turn still completes on the raw projection; nothing durable claims - // a successful write. - assert.equal(fixture.model.doStreamCalls.length, 3); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - assert.equal(fixture.recorded.length, 0); - assert.equal(promptJson(fixture, 2).includes('RAW_SPAN_ONE_'), true); - - const failedOpen = compactionDecisions(fixture).find( - (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', - ); - assert.equal(failedOpen?.failOpenReason, 'write_failed'); - // The recorder WAS invoked and failed: exactly that is what the counters say. - const usageEvent = fixture.events.find((event) => event.type === 'token_usage') as - | { contextBudget?: ContextBudgetDiagnostic } - | undefined; - assert.equal(usageEvent?.contextBudget?.historyCompactWritesAttempted, 1); - assert.equal(usageEvent?.contextBudget?.historyCompactWriteFailures, 1); - }); - - test('exhausts with write_failed in the durable diagnostics when the write fails over the window', async () => { - // Big priors make folding rescue the over-window estimate, so the plan - // compacts and the failure happens AT the recorder — over the window that - // is the explicit exhausted outcome, and the durable diagnostics must - // carry write_failed even though the terminal enum has no write member. - const fixture = buildFixture({ - contextWindow: 150, - reserveTokens: 100, - bigPriors: true, - record: () => { throw new Error('disk full'); }, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'summarizer_failed'); - - const lastCall = fixture.llmCalls.at(-1); - const exhaustedDecision = (lastCall?.contextBudget?.compactionDecisions ?? []).find( - (decision) => decision.phase === 'mid_turn' && decision.reason === 'context_budget_exhausted', - ); - assert.equal(exhaustedDecision?.skippedReasonCounts?.write_failed, 1); - assert.equal(lastCall?.contextBudget?.historyCompactWritesAttempted, 1); - assert.equal(lastCall?.contextBudget?.historyCompactWriteFailures, 1); - }); - - test('fails open with a diagnostic when the durable ledger read fails (never a silent skip)', async () => { - const fixture = buildFixture(); - // Break the seam after construction: every trigger read now rejects. - (fixture.backend as unknown as { - input: { loadTurnRuntimeEvents: () => Promise }; - }).input.loadTurnRuntimeEvents = () => Promise.reject(new Error('ledger offline')); - await runFixtureTurn(fixture, consumer); - - // The turn still completes on the raw projection; nothing was recorded. - assert.equal(fixture.model.doStreamCalls.length, 3); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - assert.equal(fixture.recorded.length, 0); - assert.equal(promptJson(fixture, 2).includes('RAW_SPAN_ONE_'), true); - - const failedOpen = compactionDecisions(fixture).find( - (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', - ); - assert.equal(failedOpen?.failOpenReason, 'ledger_read_failed'); - }); - - test('active tool-result prune re-converges the rebuilt tail after a capacity replacement', async () => { - const fixture = buildFixture({ activeToolResultPrune: true }); - await runFixtureTurn(fixture, consumer); - - assert.equal(fixture.model.doStreamCalls.length, 3); - assert.equal(fixture.recorded.length, 1); - const thirdPrompt = promptJson(fixture, 2); - // Capacity compaction owns the projection: compact block + verbatim anchor. - assert.match(thirdPrompt, /maka_history_compact_checkpoint/); - assert.equal(thirdPrompt.includes(ANCHOR_TEXT), true); - assert.equal(thirdPrompt.includes('RAW_SPAN_ONE_'), false); - // The large tool result in the rebuilt tail is re-archived to a - // placeholder by the prune hook running AFTER the capacity hook — the - // capacity replacement must not resurrect the raw body. - assert.equal(thirdPrompt.includes('RAW_SPAN_TWO_'), false); - assert.match(thirdPrompt, /artifact-archived-1/); - assert.match(thirdPrompt, /active_current_turn_tool_result_pruned_before_next_step/); - }); - - test('semantic compaction yields on the step the capacity hook replaced', async () => { - const fixture = buildFixture({ semanticCompact: true }); - await runFixtureTurn(fixture, consumer); - - // The capacity projection won the replaced step. - assert.equal(fixture.model.doStreamCalls.length, 3); - assert.equal(fixture.recorded.length, 1); - assert.match(promptJson(fixture, 2), /maka_history_compact_checkpoint/); - - // Deterministic priority: semantic compaction was skipped for that step - // with an explicit decision — one step never runs two summarizers. - const yielded = compactionDecisions(fixture).find( - (decision) => decision.reason === 'mid_turn_capacity_precedence', - ); - assert.equal(yielded?.decision, 'unchanged'); - assert.equal(fixture.summarizerCalls, 1); - // No semantic summary model call was ever made. - assert.equal(fixture.events.some((event) => event.type === 'error'), false); - }); - - test('a rolling second compaction that still exceeds the window ends explicitly (review finding A)', async () => { - // Review round-3 finding A: the old post-fold re-estimate subtracted the - // RAW covered span from a usage estimate anchored to the ALREADY-compacted - // previous request, over-crediting the second fold and letting a - // still-over-window request stream. The final-payload owner measures the - // real replacement projection instead: the third step's huge result makes - // even [second block, anchor, tail] exceed the window, so the turn must - // end with the explicit outcome — never send the over-window request. - const fixture = buildFixture({ bigPriors: true, rollingOverflow: true }); - await runFixtureTurn(fixture, consumer); - - // The first fold happened and its projection was used (three requests ran). - assert.equal(fixture.recorded.length, 2); - assert.equal(fixture.recorded[0]?.phase, 'mid_turn'); - // The second fold rolled forward from the first checkpoint... - assert.equal(fixture.recorded[1]?.previousCheckpointId, fixture.recorded[0]?.checkpointId); - // ...but its replacement still exceeds the window: explicit outcome. - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'head_anchor_exceeds_capacity'); - // The over-window fourth request never streamed. - assert.equal(fixture.events.some((event) => event.type === 'text_complete' && event.text === 'done'), false); - }); - - test('an aborted multi-step send records the accumulated usage of the completed steps', async () => { - // The terminal LLM-call record is fail-closed on usage evidence (#972), - // and an aborted send never resolves the SDK's totalUsage promise. But - // every COMPLETED step reported real usage at its finish-step boundary, - // so the terminal record must carry that accumulated sum — the capacity - // verdict diagnostics ride this record and the completed steps' cost is - // real. Three steps stream (100/20 + 150/30 + 150/30) before the step-4 - // verdict aborts the send. - const fixture = buildFixture({ bigPriors: true, rollingOverflow: true }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'context_budget_exhausted'); - // Three requests streamed; the fourth doStream call rejects immediately on - // the already-aborted signal and never reports usage. - assert.equal(fixture.model.doStreamCalls.length, 4); - const lastCall = fixture.llmCalls.at(-1); - assert.equal(lastCall?.status, 'error'); - assert.equal(lastCall?.errorClass, 'ContextBudgetExhausted'); - assert.equal(lastCall?.inputTokens, 400); - assert.equal(lastCall?.outputTokens, 80); - assert.equal(lastCall?.totalTokens, 480); - }); - - test('an unusable completed-step usage sample fails the whole record closed — no partial sum (review round-7)', async () => { - // #972 semantics: incomplete usage evidence fails closed. The first - // completed step's usage is unusable (normalization returns undefined), - // so the sum of the remaining steps (150/30 + 150/30) is a PARTIAL cost. - // LlmCallRecord has no partial marker — downstream reads any record as - // the whole call — so the truthful outcome is no record at all; the - // terminal result stays observable on the durable CompleteEvent. - const fixture = buildFixture({ bigPriors: true, rollingOverflow: true, firstStepUsage: 'missing' }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'context_budget_exhausted'); - assert.equal(fixture.llmCalls.length, 0); - }); - - test('the verdict is issued after pruning — a prune-rescuable step is not exhausted (review finding C)', async () => { - // Review round-3 finding C repro: one huge tool result, no safe completed - // span for the capacity hook, but the active tool-result prune (which runs - // AFTER the capacity hook) archives the result down to a placeholder that - // fits the window. A verdict inside the capacity hook would have declared - // context_budget_exhausted before the rescue could run. - const fixture = buildFixture({ - contextWindow: 500, - reserveTokens: 100, - withoutPriorTurns: true, - hugeFirstResult: true, - finalAtSecondCall: true, - activeToolResultPrune: true, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - assert.equal(fixture.model.doStreamCalls.length, 2); - // The second request carries the archive placeholder, not the raw body. - const secondPrompt = promptJson(fixture, 1); - assert.equal(secondPrompt.includes('HUGE_RESULT_'), false); - assert.match(secondPrompt, /artifact-archived-1/); - // The capacity hook's failure is a diagnostic, not a terminal outcome. - const failedOpen = compactionDecisions(fixture).find( - (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', - ); - assert.equal(failedOpen?.failOpenReason, 'no_safe_completed_span'); - }); - - test('the trigger counts same-turn tool-schema growth from load_tools (review finding D)', async () => { - // Review round-3 finding D repro: the model activates a ~12.7k-char tool - // group mid-turn. The schema lands in every later request, so the payload - // estimate must count it: the next request cannot fit the 500-token window - // and the pool has no safe completed span, so the turn ends explicitly - // instead of streaming a ~3k-token request into a 500-token window. - const fixture = buildFixture({ - contextWindow: 500, - reserveTokens: 100, - withoutPriorTurns: true, - bigToolGroup: true, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); - // The over-window second request never streamed the expanded schema. - assert.equal(fixture.events.some((event) => event.type === 'text_complete' && event.text === 'done'), false); - }); - - test('a fold that cannot shrink the real payload is refused, not applied (runaway summary)', async () => { - // The summarizer returns a block far larger than the span it replaces. - // Applying it would hand the verdict owner a WORSE request than the raw - // projection; the hook measures the materialized payload and keeps the raw - // messages instead. Validation runs before the recorder, so the rejected - // checkpoint is never persisted (asserted below). - const fixture = buildFixture({ summarize: () => 'GIANT_SUMMARY_'.repeat(600) }); - await runFixtureTurn(fixture, consumer); - - assert.equal(fixture.model.doStreamCalls.length, 3); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - // The raw span stayed; the giant block was never sent. - const thirdPrompt = promptJson(fixture, 2); - assert.equal(thirdPrompt.includes('RAW_SPAN_ONE_'), true); - assert.equal(thirdPrompt.includes('GIANT_SUMMARY_'), false); - const failedOpen = compactionDecisions(fixture).find( - (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', - ); - assert.equal(failedOpen?.failOpenReason, 'replacement_not_smaller'); - // Review round-4 finding 3: a checkpoint whose replacement was REJECTED - // must never be persisted — replay applies the session's latest checkpoint - // before any high-water check, so a persisted runaway block would poison - // every later projection even though this step correctly refused it. - assert.equal(fixture.recorded.length, 0); - // The recorder was never reached, so the diagnostics claim no write. - const usageEvent = fixture.events.find((event) => event.type === 'token_usage') as - | { contextBudget?: ContextBudgetDiagnostic } - | undefined; - assert.equal(usageEvent?.contextBudget?.historyCompactWritesAttempted, undefined); - }); - - test('the usage baseline is the last request\'s INPUT tokens — output is not double-counted (review finding 1)', async () => { - // Review round-4 finding 1 repro shape: a step with heavy output. The - // signed payload delta already carries the freshly generated assistant - // output and tool results, so a baseline of input+output counts the - // output twice (~500 real tokens estimated as ~900) and terminates a - // turn that actually fits the window. - const fixture = buildFixture({ - contextWindow: 500, - reserveTokens: 100, - withoutPriorTurns: true, - finalAtSecondCall: true, - firstStepUsage: { input: 300, output: 380 }, - }); - await runFixtureTurn(fixture, consumer); - - // input(300) + payload delta (~hundred tokens) fits the 500 window; the - // double-counting baseline (680 + delta) would have exhausted it. - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - assert.equal(fixture.model.doStreamCalls.length, 2); - }); - - test('a usage object without usable input tokens falls back to cold start, never to zero (review finding 1)', async () => { - // Reverse direction: the adapter normalizes missing token fields to 0. A - // zero baseline plus a small delta estimates a huge request as tiny and - // lets it stream over the window; an unusable usage sample must instead - // fall back to the whole-payload cold-start estimate, which triggers the - // fold here (big priors leave a safe span, so compaction rescues). - const fixture = buildFixture({ - contextWindow: 1_000, - reserveTokens: 100, - bigPriors: true, - finalAtSecondCall: true, - firstStepUsage: 'missing', - }); - await runFixtureTurn(fixture, consumer); - - assert.equal(fixture.recorded.length, 1); - const secondPrompt = promptJson(fixture, 1); - assert.match(secondPrompt, /maka_history_compact_checkpoint/); - assert.equal(secondPrompt.includes('PRIOR_FACT'), false); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - }); - - test('the volatile turn tail survives a capacity replacement (review finding 2)', async () => { - // The initial provider user message decorates the durable anchor text - // with a volatile turn tail (cwd, shell context, task state). The - // replacement projection is materialized from the ledger, where the - // anchor holds only the raw user text — the rendering must go through - // the same decoration owner or compaction silently drops that context - // (and even counts the drop as shrinkage). - const fixture = buildFixture({ volatileTurnTail: true }); - await runFixtureTurn(fixture, consumer); - - assert.equal(fixture.recorded.length, 1); - const thirdPrompt = promptJson(fixture, 2); - assert.match(thirdPrompt, /maka_history_compact_checkpoint/); - assert.equal(thirdPrompt.includes(ANCHOR_TEXT), true); - assert.equal(thirdPrompt.includes('VOLATILE_TAIL_SENTINEL'), true); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - }); - - test('an over-window runaway summary terminates as summarizer_failed, not head_anchor_exceeds_capacity (review finding 4)', async () => { - // A non-shrinking replacement proves the summarizer's output is unusable, - // not that the irreducible remainder (anchor + tail + overhead) exceeds - // capacity — the terminal detail must say so; the diagnostic reason keeps - // the precise replacement_not_smaller cause. - const fixture = buildFixture({ - contextWindow: 150, - reserveTokens: 100, - summarize: () => 'GIANT_SUMMARY_'.repeat(600), - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'summarizer_failed'); - const lastCall = fixture.llmCalls.at(-1); - const exhaustedDecision = (lastCall?.contextBudget?.compactionDecisions ?? []).find( - (decision) => decision.phase === 'mid_turn' && decision.reason === 'context_budget_exhausted', - ); - assert.equal(exhaustedDecision?.skippedReasonCounts?.replacement_not_smaller, 1); - // The rejected checkpoint was never persisted. - assert.equal(fixture.recorded.length, 0); - }); - - test('a checkpoint the next replay would reject is never persisted (review round-5 finding 1)', async () => { - // Review round-5 repro: maxSummaryEstimatedTokens defaults to 1024, and a - // 5000-char summary yields a ~1133-token checkpoint envelope. The fold - // clearly SHRINKS the giant covered span, so materialize+smaller alone - // accept and persist it — but the recovery path's single replay gate - // (max_block_tokens) rejects it next round and re-projects the covered - // raw span, violating the never-re-injected invariant. Validation must - // therefore include replay admissibility, through the same gate function - // with the same policy — one acceptance standard, not two. - const fixture = buildFixture({ - giantPriors: true, - summarize: () => 'S'.repeat(5_000), - }); - await runFixtureTurn(fixture, consumer); - - // The inadmissible checkpoint was never persisted... - assert.equal(fixture.recorded.length, 0); - // ...the step failed open on the raw projection... - assert.equal(fixture.model.doStreamCalls.length, 3); - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - const thirdPrompt = promptJson(fixture, 2); - assert.equal(thirdPrompt.includes('PRIOR_FACT'), true); - assert.equal(thirdPrompt.includes('maka_history_compact_checkpoint'), false); - // ...with the precise gate reason in the diagnostics. - const failedOpen = compactionDecisions(fixture).find( - (decision) => decision.phase === 'mid_turn' && decision.decision === 'failedOpen', - ); - assert.equal(failedOpen?.failOpenReason, 'replay_rejected_max_block_tokens'); - }); - - test('the cold-start estimate covers the FULL provider input including the system prompt (review round-5 finding 2)', async () => { - // The system prompt travels in the separate `system` field, not in - // messages. With usage missing, a cold-start estimate over messages+tools - // alone (~2150 tokens) stays under the 2900 high water and lets a real - // ~3650-token request stream into a 3000-token window. The single payload - // measure must include the system prompt: constant between adjacent - // requests (signed deltas unaffected), decisive for cold start. - const fixture = buildFixture({ - contextWindow: 3_000, - reserveTokens: 100, - withoutPriorTurns: true, - hugeFirstResult: true, - finalAtSecondCall: true, - firstStepUsage: 'missing', - bigSystemPrompt: true, - }); - await runFixtureTurn(fixture, consumer); - - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type, 'complete'); - if (complete?.type !== 'complete') return; - assert.equal(complete.stopReason, 'context_budget_exhausted'); - assert.equal(complete.contextBudgetExhaustedDetail, 'no_safe_completed_span'); - // The over-window second request never streamed. - assert.equal(fixture.events.some((event) => event.type === 'text_complete' && event.text === 'done'), false); - // Every completed step's usage was unusable, so there is no usage - // evidence at all: the fail-closed terminal record is skipped and the - // exhausted outcome is observable only through the CompleteEvent above. - assert.equal(fixture.llmCalls.length, 0); - }); - - test('a completed step\'s assistant text is never dropped from the replacement (review finding B)', async () => { - // Review round-3 finding B repro: the FIRST step emits assistant text AND - // a tool call, and the trigger fires at that step's own boundary. The old - // durable watermark waited only for the tool call/response pair — which - // are enqueued DURING the step, before the pump flushes the step's - // text_complete — so under a slow consumer the ledger could satisfy the - // watermark while the already-emitted assistant text was still missing. - // Because the replacement projection replaces the WHOLE message list, - // that text silently vanished from the next request. The seq-ack boundary - // counts the event stream itself (pump flush of the step boundary + the - // consumer's processed ack), so the durable pool must contain the step's - // text before any coverage is computed. - const fixture = buildFixture({ - // High water at 200 tokens: the first step's usage (120) plus its tool - // result delta crosses it, so the trigger fires at the step-1 boundary. - reserveTokens: 1_800, - assistantTextInFirstStep: true, - finalAtSecondCall: true, - }); - await runFixtureTurn(fixture, consumer); - - // The text was emitted to the user... - assert.equal( - fixture.events.some((event) => event.type === 'text_complete' && event.text.includes('ASSISTANT_SENTINEL')), - true, - ); - // ...and the projection accounts for it: the step-1 text event is in the - // durable pool when coverage is computed, so it survives either verbatim - // in the preserved tail of the second request or inside the summarized - // covered span — never silently dropped from both. - assert.equal(fixture.recorded.length, 1); - const secondPrompt = promptJson(fixture, 1); - assert.match(secondPrompt, /maka_history_compact_checkpoint/); - const inTail = secondPrompt.includes('ASSISTANT_SENTINEL'); - const inCoveredSpan = fixture.summarizedSources.join('\n').includes('ASSISTANT_SENTINEL'); - assert.equal(inTail || inCoveredSpan, true); - // The turn still completes normally on the compacted projection. - const complete = fixture.events.find((event) => event.type === 'complete'); - assert.equal(complete?.type === 'complete' ? complete.stopReason : undefined, 'end_turn'); - }); - -} - -describe('mid-turn capacity compaction in the streaming backend', () => { - defineMidTurnSuite('immediate'); -}); - -describe('mid-turn capacity compaction with a slow ledger consumer', () => { - // Review round-2/3 repro: the consumer that persists to the durable ledger - // yields several macrotasks per event, so the ledger genuinely lags the - // SDK's step progression. The seq-ack durability boundary must make every - // behavior above hold identically — no over-window request slipping out, - // and no completed-step content silently dropped from a replacement. - defineMidTurnSuite('slow'); -}); - -describe('mid-turn capacity compaction flow plumbing', () => { - test('AiSdkFlow forwards the persisted head anchor to backend.send', async () => { - const sendInputs: BackendSendInput[] = []; - const anchor = runtimeTextEvent('anchor-1', 'turn-1', 'user', ANCHOR_TEXT); - const fakeBackend: AgentBackend = { - kind: 'ai-sdk', - sessionId: 'session-1', - // eslint-disable-next-line @typescript-eslint/require-await - async *send(input: BackendSendInput): AsyncIterable { - sendInputs.push(input); - yield { type: 'complete', id: 'complete-1', turnId: input.turnId, ts: 2, stopReason: 'end_turn' }; - }, - stop: async () => {}, - respondToPermission: async () => {}, - dispose: async () => {}, - }; - const flow = new AiSdkFlow({ backend: fakeBackend }); - const ctx: InvocationContext = { - sessionId: 'session-1', - invocationId: 'run-1', - runId: 'run-1', - turnId: 'turn-1', - branch: 'lane-7', - source: 'desktop', - startedAt: 1, - request: { - sessionId: 'session-1', - turnId: 'turn-1', - text: 'hello', - source: 'desktop', - initialRuntimeEvent: anchor, - }, - newId: idGenerator(), - now: monotonicClock(), - }; - for await (const _event of flow.run(ctx, { text: 'hello', context: [] })) { - // drain - } - assert.equal(sendInputs.length, 1); - assert.equal(sendInputs[0]?.headAnchorRuntimeEvent, anchor); - }); -}); - -function runtimeTextEvent(id: string, turnId: string, role: 'user' | 'model', text: string): RuntimeEvent { - return { - id, - sessionId: 'session-1', - runId: 'run-1', - turnId, - invocationId: 'run-1', - ts: 1_800_000_000_000, - partial: false, - role, - author: role === 'user' ? 'user' : 'agent', - content: { kind: 'text', text }, - }; -} - -function header(): SessionHeader { - return { - id: 'session-1', - workspaceRoot: '/tmp/maka', - cwd: '/tmp/maka', - createdAt: 1, - lastUsedAt: 1, - name: 'Test', - isFlagged: false, - labels: [], - isArchived: false, - status: 'active', - statusUpdatedAt: 1, - hasUnread: false, - backend: 'ai-sdk', - llmConnectionSlug: 'anthropic-main', - connectionLocked: true, - model: 'mock-model-id', - permissionMode: 'ask', - schemaVersion: 1, - }; -} - -function connection(): LlmConnection { - return { - slug: 'anthropic-main', - name: 'Anthropic', - providerType: 'anthropic', - defaultModel: 'mock-model-id', - enabled: true, - createdAt: 1, - updatedAt: 1, - }; -} - -function idGenerator(): () => string { - let index = 0; - return () => `id-${++index}`; -} - -function monotonicClock(): () => number { - let value = 1_000; - return () => ++value; -} diff --git a/packages/runtime/src/__tests__/mid-turn-capacity-compact.test.ts b/packages/runtime/src/__tests__/mid-turn-capacity-compact.test.ts deleted file mode 100644 index cf834708f5..0000000000 --- a/packages/runtime/src/__tests__/mid-turn-capacity-compact.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, test } from 'node:test'; -import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { - estimateNextRequestTokens, - exceedsContextWindow, - exceedsHighWater, - planMidTurnCapacityCompaction, - selectMidTurnSafeBoundary, - type PlanMidTurnCapacityCompactionInput, -} from '../mid-turn-capacity-compact.js'; -import { applyRuntimeEventHistoryCompact } from '../context-budget.js'; -import { matchHistoryCompactCheckpointPrefix } from '../history-compact-checkpoint.js'; - -describe('mid-turn capacity trigger measurement', () => { - test('anchors on real provider usage plus a tail char/4 delta', () => { - // last step: 100 input + 40 output real tokens, then 400 chars of new tool results - assert.equal( - estimateNextRequestTokens({ priorUsageTokens: 140, appendedChars: 400, charsPerToken: 4 }), - 140 + 100, - ); - }); - - test('credits a SIGNED negative payload delta after a compaction shrank the projection', () => { - // The last usage sample measured the PRE-compaction request; the payload - // delta is negative after the fold, so the estimate must shrink with it — - // clamping the delta at zero would judge the compacted request by the - // pre-compaction usage and wrongly exhaust a rescued turn. - assert.equal( - estimateNextRequestTokens({ priorUsageTokens: 700, appendedChars: -1_200, charsPerToken: 4 }), - 400, - ); - // The estimate never goes below zero even when the shrink exceeds usage. - assert.equal( - estimateNextRequestTokens({ priorUsageTokens: 100, appendedChars: -4_000, charsPerToken: 4 }), - 0, - ); - }); - - test('falls back to whole-projection char/4 on cold start (no usage)', () => { - assert.equal( - estimateNextRequestTokens({ appendedChars: 40, charsPerToken: 4, coldStartChars: 800 }), - 200, - ); - }); - - test('high-water crosses at contextWindow minus reserve; hard cap at the window', () => { - assert.equal(exceedsHighWater(100_000, 128_000, 16_384), false); - assert.equal(exceedsHighWater(120_000, 128_000, 16_384), true); - assert.equal(exceedsContextWindow(120_000, 128_000), false); - assert.equal(exceedsContextWindow(130_000, 128_000), true); - }); -}); - -describe('mid-turn safe boundary selection', () => { - test('folds the largest immutable non-partial prefix, leaving the reserved tail', () => { - const events = [ - user('anchor', 'turn-1'), - model('m1', 'turn-1'), - model('m2', 'turn-1'), - model('m3', 'turn-1'), - ]; - const boundary = selectMidTurnSafeBoundary(events, { reserveTailEvents: 1 }); - assert.deepEqual(boundary, { ok: true, coveredCount: 3 }); - }); - - test('never cuts on a partial (streaming) event', () => { - const events = [ - user('anchor', 'turn-1'), - model('m1', 'turn-1'), - { ...model('m2-partial', 'turn-1'), partial: true }, - ]; - // Reserving 0 tail would cut after the partial; it must retreat to m1. - const boundary = selectMidTurnSafeBoundary(events, { reserveTailEvents: 0 }); - assert.deepEqual(boundary, { ok: true, coveredCount: 2 }); - }); - - test('never splits a tool call/result pair', () => { - const events = [ - user('anchor', 'turn-1'), - call('c1', 'call-1', 'turn-1'), - result('r1', 'call-1', 'turn-1'), - call('c2', 'call-2', 'turn-1'), - result('r2', 'call-2', 'turn-1'), - ]; - // reserveTail=2 would cut at index 3, between call-2 and its result → retreat to 3? No: - // index 3 straddles call-2(3)/result-2(4)? call at 3 >= 3, result at 4 >= 3, both outside → safe. - // Force a straddle: reserveTail=1 → maxCut=4 straddles nothing (call-2 at 3<4, result-2 at 4>=4) → straddle, retreat to 3. - const boundary = selectMidTurnSafeBoundary(events, { reserveTailEvents: 1 }); - assert.deepEqual(boundary, { ok: true, coveredCount: 3 }); - }); - - test('retreats before a partial in the middle of the prefix, not just at the cut', () => { - const events = [ - user('anchor', 'turn-1'), - { ...model('m-partial', 'turn-1'), partial: true }, - model('m-final', 'turn-1'), - ]; - // With no reserved tail the largest cut ends on the immutable m-final, but - // the prefix would still span the partial snapshot — coverage must stop - // strictly before the first partial. - const boundary = selectMidTurnSafeBoundary(events, { reserveTailEvents: 0 }); - assert.deepEqual(boundary, { ok: true, coveredCount: 1 }); - }); - - test('never covers an open tool call whose response has not arrived', () => { - const events = [ - model('prior', 'turn-0'), - user('anchor', 'turn-1'), - call('open-call', 'call-open', 'turn-1'), - ]; - // Even with no reserved tail, covering the open call would orphan the - // response that lands after compaction — the cut must stop before it. - const boundary = selectMidTurnSafeBoundary(events, { reserveTailEvents: 0 }); - assert.deepEqual(boundary, { ok: true, coveredCount: 2 }); - }); - - test('reports no safe completed span when the whole pool is one atomic pair', () => { - const events = [ - call('c1', 'call-1', 'turn-1'), - result('r1', 'call-1', 'turn-1'), - ]; - // Reserving 1 tail forces maxCut=1, which straddles the only pair → no safe span. - const boundary = selectMidTurnSafeBoundary(events, { reserveTailEvents: 1 }); - assert.deepEqual(boundary, { ok: false, reason: 'no_safe_completed_span' }); - }); -}); - -describe('plan mid-turn capacity compaction', () => { - // A long turn: two prior turns folded already conceptually, plus the current - // turn's head anchor and several completed steps. - function longTurnEvents(): RuntimeEvent[] { - return [ - model('prior-0', 'turn-0'), - model('prior-1', 'turn-0'), - user('anchor', 'turn-1'), - call('call-a', 'ca', 'turn-1'), - result('res-a', 'ca', 'turn-1'), - call('call-b', 'cb', 'turn-1'), - result('res-b', 'cb', 'turn-1'), - ]; - } - function planInput(over: Partial = {}): PlanMidTurnCapacityCompactionInput { - return { - sessionId: 'session-1', - orderedEvents: longTurnEvents(), - headAnchor: { runtimeEventId: 'anchor', turnId: 'turn-1' }, - estimatedNextRequestTokens: 120_000, - contextWindow: 128_000, - reserveTokens: 16_384, - reserveTailEvents: 1, - charsPerToken: 4, - now: 1_800_000_010_000, - summarize: () => 'A faithful mid-turn summary.', - ...over, - }; - } - - test('skips below the high-water threshold', async () => { - const result = await planMidTurnCapacityCompaction(planInput({ estimatedNextRequestTokens: 100_000 })); - assert.deepEqual(result, { decision: 'skip', reason: 'below_high_water' }); - }); - - test('compacts a safe prefix, keeps the head anchor verbatim, and continues with the tail', async () => { - const result = await planMidTurnCapacityCompaction(planInput()); - assert.equal(result.decision, 'compacted'); - if (result.decision !== 'compacted') return; - assert.equal(result.checkpoint.phase, 'mid_turn'); - // Replacement is [block, verbatim head anchor, ...tail]; completed tool - // calls/results before the boundary are folded, not replayed raw. - const ids = result.replacementEvents.map((event) => event.id); - assert.equal(ids[0], `history-compact:${result.checkpoint.checkpointId}`); - assert.equal(ids[1], 'anchor'); - // Head anchor byte-identical to the raw event. - assert.deepEqual(result.replacementEvents[1], longTurnEvents()[2]); - // No folded raw event re-appears in the replacement. - assert.equal(ids.includes('call-a'), false); - assert.equal(ids.includes('res-a'), false); - // The reserved tail (last event) is preserved verbatim. - assert.equal(ids.at(-1), 'res-b'); - }); - - test('persisted checkpoint replay-validates against the same ledger prefix (recovery)', async () => { - const events = longTurnEvents(); - const result = await planMidTurnCapacityCompaction(planInput({ orderedEvents: events })); - assert.equal(result.decision, 'compacted'); - if (result.decision !== 'compacted') return; - - // Re-projecting the ledger with the persisted checkpoint must match the exact - // covered prefix and not re-inject any raw covered event. - const match = matchHistoryCompactCheckpointPrefix(result.checkpoint, events); - assert.equal(match.reason, undefined); - assert.equal(match.coveredEventCount, result.coveredRuntimeEvents.length); - - // Normal thresholds: even though the raw ledger is far below the default - // high water, the accepted mid_turn checkpoint replays — recovery never - // re-injects the replaced raw span. - const replay = applyRuntimeEventHistoryCompact(events, { - maxHistoryEstimatedTokens: 1_000_000, - charsPerToken: 4, - historyCompact: { enabled: true, mode: 'read_write', checkpoint: result.checkpoint }, - }); - assert.equal(replay.checkpoint?.checkpointId, result.checkpoint.checkpointId); - const replayIds = replay.events.map((event) => event.id); - assert.equal(replayIds[0], `history-compact:${result.checkpoint.checkpointId}`); - assert.equal(replayIds.includes('anchor'), true); - assert.equal(replayIds.includes('call-a'), false); - }); - - test('fails open below the window when the summarizer fails', async () => { - const result = await planMidTurnCapacityCompaction(planInput({ - estimatedNextRequestTokens: 120_000, // over high-water, under window - summarize: () => { throw new Error('summarizer down'); }, - })); - assert.deepEqual(result, { decision: 'fail_open', reason: 'summarizer_failed' }); - }); - - test('fails open (never terminates) above the window when the summarizer fails', async () => { - // The engine is a pure shaper: the over-window pass/terminate verdict is - // issued by the backend's final-request estimate owner, never here. - const result = await planMidTurnCapacityCompaction(planInput({ - estimatedNextRequestTokens: 130_000, // over the window itself - summarize: () => '', - })); - assert.deepEqual(result, { decision: 'fail_open', reason: 'summarizer_failed' }); - }); - - test('fails open with no_safe_completed_span when the pool has no safe cut past the anchor', async () => { - // Only the head anchor and one open call/result pair; reserving the tail - // leaves no safe completed span that also covers a step past the anchor. - const events = [user('anchor', 'turn-1'), call('c', 'c1', 'turn-1'), result('r', 'c1', 'turn-1')]; - const outcome = await planMidTurnCapacityCompaction(planInput({ - orderedEvents: events, - estimatedNextRequestTokens: 130_000, - reserveTailEvents: 1, - })); - assert.deepEqual(outcome, { decision: 'fail_open', reason: 'no_safe_completed_span' }); - }); - - test('still shapes when the estimate exceeds the window — no post-fold window verdict here', async () => { - // Review round-3 finding A: a post-fold re-estimate that subtracts the - // RAW covered span is wrong on a rolling (second) compaction — the - // previous request was already `[block, anchor, tail]` and never carried - // that raw prefix, so the subtraction over-credits the fold and passes a - // still-over-window request. The engine therefore makes NO window claim - // after folding: it returns the shape and the backend owner re-measures - // the actual replacement payload. - const outcome = await planMidTurnCapacityCompaction(planInput({ - estimatedNextRequestTokens: 10_000, - contextWindow: 1_000, - reserveTokens: 100, - })); - assert.equal(outcome.decision, 'compacted'); - }); - - // Note: a fold whose materialized replacement does not SHRINK the request - // (e.g. a runaway summary block) is refused at the backend hook, which - // measures the real payload bytes — see the mid-turn backend suite. The - // engine works on runtime-event char estimates and makes no such claim. - - test('rolls forward from a matching previous checkpoint (only the new span is summarized)', async () => { - const events = longTurnEvents(); - const first = await planMidTurnCapacityCompaction(planInput({ - orderedEvents: events.slice(0, 5), // fold through res-a - })); - assert.equal(first.decision, 'compacted'); - if (first.decision !== 'compacted') return; - - let seenNewlyFolded: string[] = []; - const second = await planMidTurnCapacityCompaction(planInput({ - orderedEvents: events, - previousCheckpoint: first.checkpoint, - summarize: ({ newlyFoldedRuntimeEvents, previousCheckpoint }) => { - seenNewlyFolded = newlyFoldedRuntimeEvents.map((event) => event.id); - assert.equal(previousCheckpoint?.checkpointId, first.checkpoint.checkpointId); - return 'rolled-forward summary'; - }, - })); - assert.equal(second.decision, 'compacted'); - if (second.decision !== 'compacted') return; - // First folded through `anchor`; the second folds through `res-a`, so only - // the span after the previous checkpoint's coverage is re-summarized. - assert.deepEqual(seenNewlyFolded, ['call-a', 'res-a']); - assert.equal(second.checkpoint.previousCheckpointId, first.checkpoint.checkpointId); - }); -}); - -function base(id: string, turnId: string): Omit { - return { - id, sessionId: 'session-1', runId: 'run-1', turnId, invocationId: 'run-1', - ts: 1_800_000_000_000, partial: false, - }; -} -function user(id: string, turnId: string): RuntimeEvent { - return { ...base(id, turnId), role: 'user', author: 'user', content: { kind: 'text', text: id } }; -} -function model(id: string, turnId: string, text: string = id): RuntimeEvent { - return { ...base(id, turnId), role: 'model', author: 'agent', content: { kind: 'text', text } }; -} -function call(id: string, callId: string, turnId: string): RuntimeEvent { - return { - ...base(id, turnId), role: 'model', author: 'agent', - content: { kind: 'function_call', id: callId, name: 'tool', args: {} }, - }; -} -function result(id: string, callId: string, turnId: string, payload: string = 'ok'): RuntimeEvent { - return { - ...base(id, turnId), role: 'tool', author: 'tool', - content: { kind: 'function_response', id: callId, name: 'tool', result: payload }, - }; -} diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index f0a517c551..bacbecc0c0 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -237,112 +237,6 @@ describe('ModelAdapter stream and error normalization', () => { ); }); - test('treats provider usage without token values as unavailable', () => { - assert.equal(normalizeAiSdkUsage({ - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - }), undefined); - }); - - test('treats incomplete provider usage as unavailable unless total can supply the missing side', () => { - assert.equal(normalizeAiSdkUsage({ inputTokens: 12 }), undefined); - assert.equal(normalizeAiSdkUsage({ outputTokens: 3 }), undefined); - assert.equal(normalizeAiSdkUsage({ totalTokens: 15 }), undefined); - - assert.deepEqual(normalizeAiSdkUsage({ inputTokens: 12, totalTokens: 15 }), { - inputTokens: 12, - outputTokens: 3, - cacheHitInputTokens: 0, - cacheMissInputTokens: 12, - cacheMissInputSource: 'derived', - cachedInputTokens: 0, - cacheWriteInputTokens: 0, - reasoningTokens: 0, - totalTokens: 15, - }); - assert.deepEqual(normalizeAiSdkUsage({ outputTokens: 3, totalTokens: 15 }), { - inputTokens: 12, - outputTokens: 3, - cacheHitInputTokens: 0, - cacheMissInputTokens: 12, - cacheMissInputSource: 'derived', - cachedInputTokens: 0, - cacheWriteInputTokens: 0, - reasoningTokens: 0, - totalTokens: 15, - }); - assert.deepEqual(normalizeAiSdkUsage({ inputTokens: 0, outputTokens: 0 }), { - inputTokens: 0, - outputTokens: 0, - cacheHitInputTokens: 0, - cacheMissInputTokens: 0, - cacheMissInputSource: 'derived', - cachedInputTokens: 0, - cacheWriteInputTokens: 0, - reasoningTokens: 0, - totalTokens: 0, - }); - }); - - test('derives totals from detail-only AI SDK usage', () => { - assert.deepEqual( - normalizeAiSdkUsage({ - inputTokens: { - total: undefined, - noCache: 10, - cacheRead: 5, - cacheWrite: 2, - }, - outputTokens: { - total: undefined, - text: 4, - reasoning: 3, - }, - }), - { - inputTokens: 17, - outputTokens: 7, - cacheHitInputTokens: 5, - cacheMissInputTokens: 10, - cacheMissInputSource: 'explicit', - cachedInputTokens: 5, - cacheWriteInputTokens: 2, - reasoningTokens: 3, - totalTokens: 24, - }, - ); - }); - - test('derives totals from the public AI SDK 6 detail shape', () => { - const usage = { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - inputTokenDetails: { - noCacheTokens: 10, - cacheReadTokens: 5, - cacheWriteTokens: 2, - }, - outputTokenDetails: { - textTokens: 4, - reasoningTokens: 3, - }, - } as unknown as Parameters[0]; - - assert.deepEqual(normalizeAiSdkUsage(usage), { - inputTokens: 17, - outputTokens: 7, - cacheHitInputTokens: 5, - cacheMissInputTokens: 10, - cacheMissInputSource: 'explicit', - cachedInputTokens: 5, - cacheWriteInputTokens: 2, - reasoningTokens: 3, - totalTokens: 24, - }); - }); - test('preserves DeepSeek and OpenAI-compatible raw usage fields', () => { assert.deepEqual( normalizeAiSdkUsage( diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 859a59bbd8..9c87b58da5 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -41,10 +41,10 @@ describe('buildProviderOptions: thinking level', () => { assert.deepEqual(buildProviderOptions(conn('openai'), 'gpt-5.5', 'off'), { openai: { store: false, reasoningEffort: 'none' } }); }); - test('codex-subscription (gpt-5.5) preserves store:false / textVerbosity and merges reasoningEffort', () => { - assert.deepEqual(buildProviderOptions(conn('codex-subscription'), 'gpt-5.5'), { openai: { store: false, textVerbosity: 'medium' } }); - assert.deepEqual(buildProviderOptions(conn('codex-subscription'), 'gpt-5.5', 'high'), { openai: { store: false, textVerbosity: 'medium', reasoningEffort: 'high' } }); - assert.deepEqual(buildProviderOptions(conn('codex-subscription'), 'gpt-5.5', 'off'), { openai: { store: false, textVerbosity: 'medium', reasoningEffort: 'none' } }); + test('openai-codex (gpt-5.5) preserves store:false / textVerbosity and merges reasoningEffort', () => { + assert.deepEqual(buildProviderOptions(conn('openai-codex'), 'gpt-5.5'), { openai: { store: false, textVerbosity: 'medium' } }); + assert.deepEqual(buildProviderOptions(conn('openai-codex'), 'gpt-5.5', 'high'), { openai: { store: false, textVerbosity: 'medium', reasoningEffort: 'high' } }); + assert.deepEqual(buildProviderOptions(conn('openai-codex'), 'gpt-5.5', 'off'), { openai: { store: false, textVerbosity: 'medium', reasoningEffort: 'none' } }); }); test('google effort model (gemini-3) sends thinkingLevel; Gemini 2.5 Flash off sends thinkingBudget 0; safetySettings always present', () => { @@ -174,23 +174,6 @@ describe('buildProviderOptions: thinking level', () => { assert.deepEqual(buildProviderOptions(conn('vercel'), 'grok-4.3', 'high'), {}); }); - test('Ollama Cloud sends reasoningEffort under its namespace; standard models expose off, GPT-OSS does not', () => { - // qwen3.5:397b — standard reasoning model: off/low/medium/high/max - assert.deepEqual([...thinkingVariantsForModel('ollama-cloud', 'qwen3.5:397b')], ['off', 'low', 'medium', 'high', 'max']); - assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'qwen3.5:397b', 'high'), { - 'ollama-cloud': { reasoningEffort: 'high' }, - }); - assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'qwen3.5:397b', 'off'), { - 'ollama-cloud': { reasoningEffort: 'none' }, - }); - // gpt-oss:120b — only low/medium/high, no off - assert.deepEqual([...thinkingVariantsForModel('ollama-cloud', 'gpt-oss:120b')], ['low', 'medium', 'high']); - assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'gpt-oss:120b', 'high'), { - 'ollama-cloud': { reasoningEffort: 'high' }, - }); - assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'gpt-oss:120b', 'off'), {}); - }); - test('a level the model does not support is dropped (defensive)', () => { assert.deepEqual(buildProviderOptions(conn('openai'), 'gpt-4o', 'high'), { openai: { store: false } }); assert.deepEqual(buildProviderOptions(conn('anthropic'), 'claude-haiku-4-5', 'max'), { anthropic: {} }); @@ -267,7 +250,7 @@ describe('buildProviderOptions: resolver/options drift guard', () => { { providerType: 'claude-subscription', model: 'claude-opus-4-8' }, { providerType: 'openai', model: 'gpt-5.5' }, { providerType: 'openai', model: 'gpt-5' }, - { providerType: 'codex-subscription', model: 'gpt-5.5' }, + { providerType: 'openai-codex', model: 'gpt-5.5' }, { providerType: 'google', model: 'gemini-3-pro-preview' }, { providerType: 'google', model: 'gemini-3.5-flash' }, { providerType: 'deepseek', model: 'deepseek-v4-flash' }, @@ -275,8 +258,6 @@ describe('buildProviderOptions: resolver/options drift guard', () => { { providerType: 'groq', model: 'openai/gpt-oss-120b' }, { providerType: 'openrouter', model: 'openai/gpt-5.6-sol' }, { providerType: 'vercel', model: 'xai/grok-4.3' }, - { providerType: 'ollama-cloud', model: 'qwen3.5:397b' }, - { providerType: 'ollama-cloud', model: 'gpt-oss:120b' }, { providerType: 'cloudflare-workers-ai', model: '@cf/moonshotai/kimi-k2.6' }, { providerType: 'zai-coding-plan', model: 'glm-5.2', slug: 'zai-coding-plan' }, { providerType: 'volcengine-ark', model: 'doubao-seed-2-0-pro-260215' }, diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 8d9478ee2b..ac6bd3a87d 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -3011,67 +3011,6 @@ describe('SessionManager permission mode updates', () => { expect(result.artifactIds).toEqual(['artifact-1']); }); - test('the durable turn-ledger seam reaches parent runs but is withheld from child sessions', async () => { - const store = new MemorySessionStore(); - const runStore = new MemoryAgentRunStore(); - const backends = new BackendRegistry(); - const contexts: BackendFactoryContext[] = []; - const seamReads: Array<{ turnId: string; eventIds: string[] } | undefined> = []; - class SeamProbeBackend extends TestBackend { - constructor(private readonly probeCtx: BackendFactoryContext) { - super(probeCtx); - } - - override async *send(input: BackendSendInput): AsyncIterable { - seamReads.push(this.probeCtx.loadTurnRuntimeEvents - ? { - turnId: input.turnId, - eventIds: (await this.probeCtx.loadTurnRuntimeEvents(input.turnId)).map((event) => event.id), - } - : undefined); - yield* super.send(input); - } - } - backends.register('fake', (ctx) => { - contexts.push(ctx); - return new SeamProbeBackend(ctx); - }); - const manager = new SessionManager({ - store, - runStore, runtimeEventStore: runStore, backends, - childTools: [testTool('Read'), testTool('Glob'), testTool('Grep')], - newId: nextId(), - now: nextNow(6_850), - runtimeSource: 'test', - }); - const session = await manager.createSession(makeInput({ permissionMode: 'ask' })); - await drain(manager.sendMessage(session.id, { turnId: 'parent-turn', text: 'parent context' })); - const [parentRun] = await runStore.listSessionRuns(session.id); - if (!parentRun) throw new Error('parent run was not recorded'); - await manager.spawnChildAgent(session.id, { - turnId: 'child-turn', - parentRunId: parentRun.runId, - spec: { id: LOCAL_READ_AGENT_ID, name: 'Reader', systemPrompt: 'read only' }, - prompt: 'inspect', - }); - - // The parent (main-session) backend can read its turn's durable ledger — - // the seam resolves the active run and returns the persisted events. - expect(seamReads.length).toBe(2); - expect(seamReads[0]?.turnId).toBe('parent-turn'); - expect((seamReads[0]?.eventIds.length ?? 0) > 0).toBe(true); - - // The child factory context is NOT given the seam: a child run has no - // top-level prior context, so a mid-turn checkpoint built from its - // child-only ledger would claim session-prefix coverage and poison the - // session-global checkpoint stream for the parent projection. Without - // the seam, child mid-turn capacity compaction cannot arm. - expect(contexts.length).toBe(2); - expect(typeof contexts[0]?.loadTurnRuntimeEvents).toBe('function'); - expect(contexts[1]?.loadTurnRuntimeEvents).toBe(undefined); - expect(seamReads[1]).toBe(undefined); - }); - test('spawnChildAgent returns the terminal RuntimeEvent status when the child header commit fails', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore({ failUpdateRunStatusOnce: 'completed' }); diff --git a/packages/runtime/src/__tests__/subscription-model-fetch.test.ts b/packages/runtime/src/__tests__/subscription-model-fetch.test.ts index 7382cf5599..f97b831d77 100644 --- a/packages/runtime/src/__tests__/subscription-model-fetch.test.ts +++ b/packages/runtime/src/__tests__/subscription-model-fetch.test.ts @@ -89,7 +89,7 @@ describe('subscription model fetch', () => { let observedHeaders = new Headers(); let observedBody = ''; const modelFetch = buildSubscriptionModelFetch({ - connection: codexSubscriptionConnection(), + connection: openAiCodexConnection(), sessionId: 'session-123', modelId: 'gpt-5.5', fetchFn: async (_url, init) => { @@ -183,11 +183,11 @@ function claudeSubscriptionConnection(): LlmConnection { }; } -function codexSubscriptionConnection(): LlmConnection { +function openAiCodexConnection(): LlmConnection { return { - slug: 'codex-subscription', + slug: 'openai-codex', name: 'OpenAI OAuth', - providerType: 'codex-subscription', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', enabled: true, createdAt: 1, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index 95ff35fb74..9a1f9c1361 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -221,26 +221,6 @@ export class AgentRun { }, { rethrow: true }); } - /** - * Durable read of this run's RuntimeEvent ledger for the mid-turn capacity - * invariant: waits for every write enqueued so far, then reads the store, so - * a caller-derived coverage prefix can only ever span events that are - * already persisted. Rejects when the store is unavailable — coverage must - * never be computed over a projection the ledger cannot replay. - */ - async loadTurnRuntimeEvents(): Promise { - if (!this.input.runtimeEventStore || !this.runtimeEventStoreAvailable) { - throw new Error('RuntimeEvent store is unavailable for turn runtime events'); - } - await this.runtimeEventQueue.catch(() => {}); - // A write may have failed while we waited; a snapshot from a store that - // just went unavailable must not be treated as a complete durable read. - if (!this.runtimeEventStoreAvailable) { - throw new Error('RuntimeEvent store became unavailable for turn runtime events'); - } - return await this.input.runtimeEventStore.readRuntimeEvents(this.sessionId, this.runId); - } - recordSemanticCompactBlock(block: SemanticCompactBlock): void { if (!this.input.runStore || !this.runStoreAvailable) return; this.enqueueRunStore('append semantic compact block', async () => { diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 0d59201e4b..eedb128494 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -35,7 +35,6 @@ import type { SessionEvent, CompleteEvent, - ContextBudgetExhaustedDetail, AbortEvent, ErrorEvent, TextCompleteEvent, @@ -146,13 +145,11 @@ import { ARCHIVED_TOOL_RESULT_REWRITE_VERSION, applyRuntimeEventContextBudget, buildContextBudgetDiagnosticShell, - buildHistoryCompactBlockFromSummary, buildHistorySearchSource, buildPromptSegmentEstimates, collectStaleToolResultArchiveCandidates, evaluateHistoryCompactCheckpointReplay, estimateRuntimeEventsTokens, - isHistoryCompactContentEvent, mergeContextBudgetDiagnostic, mergeContextBudgetDiagnosticPatches, mergeRuntimeEventsInOriginalOrder, @@ -183,12 +180,7 @@ import { matchHistoryCompactCheckpointPrefix, type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; -import { resolveSelectedModelContextWindow } from './context-budget-policy.js'; -import { - estimateNextRequestTokens, - exceedsHighWater, - planMidTurnCapacityCompaction, -} from './mid-turn-capacity-compact.js'; + export { DEFAULT_PERMISSION_TIMEOUT_MS, MAX_ACTIVE_SUBAGENT_TOOLS_PER_TURN, @@ -212,34 +204,12 @@ export type { export const INVALID_TOOL_NAME = 'invalid'; -/** - * Deterministic prepareStep pipeline over ONE provider-visible projection. - * Order is a contract: mid-turn capacity compaction runs first among the - * message-shaping hooks so every later mechanism operates on (and re-converges - * onto) its projection — active tool-result pruning re-archives large tool - * results in the rebuilt tail, and semantic/active-full compaction sees the - * already-compacted messages. On a step where the capacity hook replaced the - * request, semantic/active-full compaction yields (see send()) so two - * summarizers never run for one step. - * - * Every hook here only SHAPES the projection. The pass/terminate capacity - * verdict is issued once, after the whole pipeline, by the final-request - * estimate owner (buildMidTurnFinalRequestVerdict) over the actual outgoing - * (messages, tools) payload — never by an individual hook over an intermediate - * projection that a later hook could still rescue. - */ export function composePrepareStep( toolAvailability: PrepareStepFunctionLike | undefined, - midTurnCapacityCompact: PrepareStepFunctionLike | undefined, activeToolResultPrune: PrepareStepFunctionLike | undefined, activeFullCompact?: PrepareStepFunctionLike | undefined, ): PrepareStepFunctionLike | undefined { - const hooks = [ - toolAvailability, - midTurnCapacityCompact, - activeToolResultPrune, - activeFullCompact, - ].filter(Boolean) as PrepareStepFunctionLike[]; + const hooks = [toolAvailability, activeToolResultPrune, activeFullCompact].filter(Boolean) as PrepareStepFunctionLike[]; if (hooks.length === 0) return undefined; return async (options: PrepareStepLike): Promise => { let result: PrepareStepResultLike | undefined; @@ -303,126 +273,6 @@ function projectAcceptedActiveFullCompactMessages( ]; } -// ============================================================================ -// Mid-turn capacity compaction — per-send trigger state -// ============================================================================ - -/** - * Per-send() state for the mid-turn capacity invariant. The coverage pool is - * NOT mirrored here: every trigger reads the current turn's persisted - * RuntimeEvents through the injected durable-read seam, so coverage can only - * span events the ledger already replays. This class keeps only the trigger's - * cursor state between steps. - */ -class MidTurnCapacityCompactState { - /** - * Chars of the final (system prompt + messages + active tool schema) - * payload of the LAST prepared request, recorded by the final-request - * estimate owner at the end of every prepareStep pipeline run. All capacity estimates are signed - * deltas against this number, so they are anchored to the request the - * provider actually saw — a compacted projection, a pruned tail, or a - * same-turn tool-schema expansion all move the delta the same way. - */ - lastRequestPayloadChars: number | undefined; - /** - * The last request's REAL input size: the inputTokens the provider reported - * for the last finished step. Never input+output — the signed payload delta - * already carries the step's freshly generated output (assistant text/tool - * calls) and its tool results, so an output-inclusive baseline would count - * them twice. Undefined when the last step's usage is missing or unusable - * (no positive input count); estimates then fall back to the whole-payload - * cold-start path — an unusable sample is unknown, never zero. - */ - lastRequestInputTokens: number | undefined; - /** Latest durable checkpoint (loaded or written) for roll-forward summaries. */ - previousCheckpoint: HistoryCompactCheckpoint | undefined; - /** Set when the turn must end with a context_budget_exhausted outcome. */ - exhaustedDetail: ContextBudgetExhaustedDetail | undefined; - /** - * Step whose request the capacity hook replaced. Semantic/active-full - * compaction yields on that exact step so one step never runs two - * summarizers or double-projects. - */ - replacedStepNumber: number | undefined; - /** - * finish-step boundaries the event pump has flushed into the session-event - * queue. The capacity hook's durability wait needs it: only after the pump - * has flushed step N's boundary are that step's thinking/text completion - * events enqueued at all. - */ - flushedSteps = 0; - /** - * Set by the final-request estimate owner to force one capacity re-entry on - * the current step, bypassing the (deliberately approximate) high-water - * trigger. Consumed by the capacity hook on its next invocation. - */ - forcedTriggerEstimate: number | undefined; - /** - * The capacity hook's most recent shaping failure. The owner reads it (for - * the same step only) to pick the terminal detail and diagnostic reason - * when the final payload is over the window, and to avoid re-entering a - * shaper that already attempted and failed this step. - */ - lastShapeFailure: { - stepNumber: number; - detail: ContextBudgetExhaustedDetail; - diagnosticReason: string; - } | undefined; - - constructor( - readonly headAnchor: RuntimeEvent, - readonly priorContentEvents: readonly RuntimeEvent[], - readonly contextWindow: number, - ) {} -} - -/** - * Char measure of the FULL provider-visible request input: the system prompt - * (sent through the separate `system` field), the (projected) messages, and - * the serialized schemas of the active tool subset. The capacity trigger and - * the final-request estimate owner both measure with this ONE function, so - * their deltas against `lastRequestPayloadChars` are commensurable and - * same-turn tool-schema growth (a `load_tools` activation) is counted like - * any other payload growth. The system prompt is constant between adjacent - * requests — signed deltas cancel it — but the cold-start estimate (no usable - * usage sample) is the whole payload, so omitting it would under-estimate by - * exactly the system prompt and let an over-window request stream. - */ -function midTurnRequestPayloadChars( - messages: readonly ModelMessage[], - providerTools: readonly MakaTool[], - activeTools: readonly string[], - systemPromptChars: number, -): number { - return ( - Math.max(0, Math.floor(systemPromptChars)) - + JSON.stringify(messages).length - + toolSchemaCharsForDiagnostics(providerTools, activeTools) - ); -} - -/** - * Event-driven wait for seq-ack progress: resolves when the queue reports any - * push/ack/close/wake, or immediately on abort. The caller loops and re-checks - * its condition — a condition variable, not a poll. - */ -function waitForQueueProgressOrAbort( - queue: AsyncEventQueue, - abortSignal: AbortSignal | undefined, -): Promise { - return new Promise((resolve) => { - let settled = false; - const settle = (): void => { - if (settled) return; - settled = true; - abortSignal?.removeEventListener('abort', settle); - resolve(); - }; - abortSignal?.addEventListener('abort', settle, { once: true }); - void queue.waitForProgress().then(settle); - }); -} - function joinPromptFragments(fragments: readonly (string | undefined)[]): string | undefined { const joined = fragments .map((fragment) => fragment?.trim()) @@ -670,19 +520,6 @@ export interface AiSdkBackendInput { summarizeHistoryCompact?: HistoryCompactSummarizer; /** Best-effort durable recorder for accepted V2 checkpoints. */ recordHistoryCompactCheckpoint?: HistoryCompactCheckpointRecorder; - /** - * Durable read of the given turn's persisted RuntimeEvents from the - * authoritative run ledger (same injection seam as the checkpoint - * loader/recorder). Mid-turn capacity compaction derives its coverage pool - * from this read: covered events are persisted by construction before the - * checkpoint that folds them, and their bytes are exactly what recovery - * replays. A lagging read is NOT fail-safe here — the replacement - * projection replaces the whole message list, so a missing completed-step - * event would be silently dropped from the next request; the capacity hook - * therefore reads only after its seq-ack durability boundary (all enqueued - * session events processed by the consumer) is satisfied. - */ - loadTurnRuntimeEvents?: (turnId: string) => Promise; /** Optional best-effort durable recorder for accepted active full compact blocks. */ recordActiveFullCompactBlock?: ActiveFullCompactBlockRecorder; /** Optional best-effort durable recorder for accepted semantic compact blocks. */ @@ -913,7 +750,6 @@ export class AiSdkBackend implements AgentBackend { this.toolRuntime.beginTurn(turnId); this.abortController = new AbortController(); - const midTurnState = this.buildMidTurnCapacityCompactState(input); const queue = new AsyncEventQueue(); this.currentQueue = queue; @@ -983,15 +819,6 @@ export class AiSdkBackend implements AgentBackend { }; let tokenUsage: NormalizedAiSdkUsage | undefined; let tokenUsageCostUsd: number | undefined; - // Per-send sum of every COMPLETED step's usage, merged at each finish-step - // boundary. When the send aborts (mid-turn exhaust, user stop, stream - // error) the SDK's `totalUsage` promise never resolves, but this sum is - // real provider-reported evidence for the steps that did finish — IF every - // completed step produced a usable sample. One unusable sample makes the - // sum a partial cost, and LlmCallRecord has no partial marker, so the flag - // fails the whole fallback closed (#972: incomplete usage is no usage). - let completedStepUsage: NormalizedAiSdkUsage | undefined; - let sawUnusableStepUsage = false; let streamStatus: LlmCallRecord['status'] = 'success'; let streamErrorClass: string | undefined; let rawFinishReason: string | undefined; @@ -1073,11 +900,6 @@ export class AiSdkBackend implements AgentBackend { // --- Build messages from RuntimeEvent history and its compatibility projection. --- const priorReplay = await this.buildPriorMessages(input); - if (midTurnState) { - // Roll-forward seed: the latest durable checkpoint (loaded or written at - // turn start) so a mid-turn summary only re-reads the newly folded span. - midTurnState.previousCheckpoint = priorReplay.latestHistoryCompactCheckpoint; - } // --- Background pump: streamText → fullStream → normalize → queue --- const pumpDone: Promise = (async () => { @@ -1196,94 +1018,37 @@ export class AiSdkBackend implements AgentBackend { activeTools: activeToolsForStep ?? plan.activeTools, priorMessages: stepMessages, }, priorShapeBaseline).requestShapeHash; - const activeCompactHook = this.buildSemanticCompactPrepareStep( - turnId, - model, - input.runtimeContext, - (messagesForStep, activeToolsForStep) => stepRequestShapeHash(messagesForStep, activeToolsForStep), - (patch) => { - activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( - activeCompactDiagnosticPatch, - patch, - ); - }, - ) ?? this.buildActiveFullCompactPrepareStep( - turnId, - input.runtimeContext, - (messagesForStep, activeToolsForStep) => stepRequestShapeHash(messagesForStep, activeToolsForStep), - (patch) => { - activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( - activeCompactDiagnosticPatch, + const prepareStep = composePrepareStep( + plan.prepareStep, + this.buildActiveToolResultPrunePrepareStep(turnId, (patch) => { + activeToolResultPruneDiagnosticPatch = mergeActiveToolResultPruneDiagnosticPatches( + activeToolResultPruneDiagnosticPatch, patch, ); - }, - ); - // Deterministic priority on a capacity-replaced step: the hard window - // invariant owns the projection, so semantic/active-full compaction - // yields for that step (recorded as a decision) instead of running a - // second summarizer over the same request. - const activeCompactAfterMidTurn = activeCompactHook && midTurnState - ? (options: PrepareStepLike) => { - if (midTurnState.replacedStepNumber === options.stepNumber) { - activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( - activeCompactDiagnosticPatch, - compactionDecisionDiagnosticPatch({ - stage: 'activeStep', - sourceKind: 'providerMessages', - decision: 'unchanged', - boundaryKind: 'historyCompact', - reason: 'mid_turn_capacity_precedence', - skippedReasonCounts: { mid_turn_capacity_precedence: 1 }, - }), - ); - return undefined; - } - return activeCompactHook(options); - } - : activeCompactHook; - const onMidTurnDiagnosticPatch = (patch: Partial): void => { - activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( - activeCompactDiagnosticPatch, - patch, - ); - }; - const midTurnSystemPromptChars = systemPrompt?.length ?? 0; - const midTurnCapacityHook = this.buildMidTurnCapacityCompactPrepareStep( - turnId, - midTurnState, - queue, - providerTools, - () => currentRepairToolNames(), - turnTailPrompt, - midTurnSystemPromptChars, - onMidTurnDiagnosticPatch, - ); - const activeToolResultPruneHook = this.buildActiveToolResultPrunePrepareStep(turnId, (patch) => { - activeToolResultPruneDiagnosticPatch = mergeActiveToolResultPruneDiagnosticPatches( - activeToolResultPruneDiagnosticPatch, - patch, - ); - }); - const shapedPrepareStep = composePrepareStep( - plan.prepareStep, - midTurnCapacityHook, - activeToolResultPruneHook, - activeCompactAfterMidTurn, + }), + this.buildSemanticCompactPrepareStep( + turnId, + model, + input.runtimeContext, + (messagesForStep, activeToolsForStep) => stepRequestShapeHash(messagesForStep, activeToolsForStep), + (patch) => { + activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( + activeCompactDiagnosticPatch, + patch, + ); + }, + ) ?? this.buildActiveFullCompactPrepareStep( + turnId, + input.runtimeContext, + (messagesForStep, activeToolsForStep) => stepRequestShapeHash(messagesForStep, activeToolsForStep), + (patch) => { + activeCompactDiagnosticPatch = mergeContextBudgetDiagnosticPatches( + activeCompactDiagnosticPatch, + patch, + ); + }, + ), ); - // The verdict owner wraps the WHOLE shaping pipeline: hooks shape, one - // owner measures the final payload and decides pass/terminate. - const prepareStep = midTurnState && midTurnCapacityHook && shapedPrepareStep - ? this.buildMidTurnFinalRequestVerdict({ - shaped: shapedPrepareStep, - reentry: composePrepareStep(undefined, midTurnCapacityHook, activeToolResultPruneHook)!, - state: midTurnState, - providerTools, - fallbackActiveTools: () => currentRepairToolNames(), - charsPerToken: this.input.contextBudget?.charsPerToken ?? 4, - systemPromptChars: midTurnSystemPromptChars, - onDiagnosticPatch: onMidTurnDiagnosticPatch, - }) - : shapedPrepareStep; const result = await this.modelAdapter.startStream({ model, @@ -1317,9 +1082,7 @@ export class AiSdkBackend implements AgentBackend { if (isStepFinishChunk) { runtimeSteps += 1; const stepUsage = normalizeAiSdkUsage(chunk.usage, { rawFinishReason: chunk.finishReason }); - if (!stepUsage) sawUnusableStepUsage = true; if (stepUsage) { - completedStepUsage = mergeNormalizedUsage(completedStepUsage, stepUsage); this.cumulativeUsageCheckpoint = mergeNormalizedUsage(this.cumulativeUsageCheckpoint, stepUsage); await this.input.recordUsageCheckpoint?.({ ...this.cumulativeUsageCheckpoint, @@ -1345,14 +1108,6 @@ export class AiSdkBackend implements AgentBackend { if (isStepFinishChunk) { await flushStep(); this.currentStepMessageId = this.newId(); - if (midTurnState) { - // Durability clock: step N's thinking/text completion events are - // enqueued by flushStep just above, so only after this boundary - // can a seq-ack wait for step N mean anything. Wake waiters AFTER - // the increment or they would re-check a stale count and sleep. - midTurnState.flushedSteps += 1; - queue.wake(); - } } } @@ -1364,16 +1119,6 @@ export class AiSdkBackend implements AgentBackend { throw Object.assign(new Error('aborted'), { name: 'AbortError' }); } - // Mid-turn exhaustion aborts the SDK stream, but streamText ends - // gracefully on abort instead of throwing; route to the explicit - // outcome regardless of how the stream wound down. - if (midTurnState?.exhaustedDetail) { - throw Object.assign( - new Error(`mid-turn context budget exhausted: ${midTurnState.exhaustedDetail}`), - { name: 'MidTurnContextBudgetExhaustedError' }, - ); - } - // Catch-all: flush any residual step content if the provider closed the // stream without a trailing `finish-step` for the last step. await flushStep(); @@ -1532,20 +1277,7 @@ export class AiSdkBackend implements AgentBackend { // BOTH exits — user stop and provider error / watchdog timeout — so // partialOutputRetained reflects what the user actually saw. await flushStep().catch(() => {}); - if (!this.aborted && midTurnState?.exhaustedDetail) { - // Mid-turn compaction could not produce a provider-safe request: end - // the turn with the explicit first-class outcome, not a raw error. - streamErrorClass = 'ContextBudgetExhausted'; - trace.modelStreamCompleted('context_budget_exhausted'); - queue.push({ - type: 'complete', - id: this.newId(), - turnId, - ts: this.now(), - stopReason: 'context_budget_exhausted', - contextBudgetExhaustedDetail: midTurnState.exhaustedDetail, - } satisfies CompleteEvent); - } else if (this.aborted) { + if (this.aborted) { queue.push({ type: 'abort', id: this.newId(), @@ -1581,39 +1313,25 @@ export class AiSdkBackend implements AgentBackend { activeToolResultPruneDiagnosticPatch, activeCompactDiagnosticPatch, ); - // The terminal record is fail-closed on usage evidence: no evidence, - // no record. An aborted send has no `totalUsage`, but when EVERY - // completed step produced a usable sample their accumulated usage IS - // the complete evidence — record it, carrying the real cost of the - // steps that ran plus the diagnostics riding this record. Otherwise - // (no finish-step at all, or any unusable sample) the record is - // skipped: a partial sum posed as the whole call would violate the - // #972 no-fabrication invariant. The terminal outcome itself does not - // depend on this record — stopReason and the exhausted detail are - // durable on the CompleteEvent either way. - if (!tokenUsage && completedStepUsage && !sawUnusableStepUsage) { - tokenUsage = completedStepUsage; - tokenUsageCostUsd = this.computeTokenUsageCostUsd(tokenUsage); - } - if (tokenUsage) this.input.recordLlmCall?.({ + this.input.recordLlmCall?.({ sessionId: this.sessionId, turnId, connectionSlug: this.input.connection.slug, providerId: this.input.connection.providerType, modelId: this.input.modelId, - inputTokens: tokenUsage.inputTokens, - outputTokens: tokenUsage.outputTokens, - cacheHitInputTokens: tokenUsage.cacheHitInputTokens, - cacheMissInputTokens: tokenUsage.cacheMissInputTokens, - ...(tokenUsage.cacheMissInputSource !== undefined + inputTokens: tokenUsage?.inputTokens ?? 0, + outputTokens: tokenUsage?.outputTokens ?? 0, + cacheHitInputTokens: tokenUsage?.cacheHitInputTokens ?? 0, + cacheMissInputTokens: tokenUsage?.cacheMissInputTokens ?? 0, + ...(tokenUsage?.cacheMissInputSource !== undefined ? { cacheMissInputSource: tokenUsage.cacheMissInputSource } : {}), - cachedInputTokens: tokenUsage.cachedInputTokens, - cacheWriteInputTokens: tokenUsage.cacheWriteInputTokens, - reasoningTokens: tokenUsage.reasoningTokens, - totalTokens: tokenUsage.totalTokens, - ...(tokenUsage.rawFinishReason !== undefined ? { rawFinishReason: tokenUsage.rawFinishReason } : {}), - ...(tokenUsage.raw !== undefined ? { rawUsage: tokenUsage.raw } : {}), + cachedInputTokens: tokenUsage?.cachedInputTokens ?? 0, + cacheWriteInputTokens: tokenUsage?.cacheWriteInputTokens ?? 0, + reasoningTokens: tokenUsage?.reasoningTokens ?? 0, + totalTokens: tokenUsage?.totalTokens, + ...(tokenUsage?.rawFinishReason !== undefined ? { rawFinishReason: tokenUsage.rawFinishReason } : {}), + ...(tokenUsage?.raw !== undefined ? { rawUsage: tokenUsage.raw } : {}), latencyMs: Math.max(0, this.now() - startedAt), status: streamStatus, ...(streamErrorClass ? { errorClass: streamErrorClass } : {}), @@ -1640,9 +1358,7 @@ export class AiSdkBackend implements AgentBackend { })(); try { - // drain() carries the seq-ack semantics (consumer pull = processed ack); - // every consumer-facing path must go through it. - yield* this.drain(queue); + for await (const ev of queue) yield ev; } finally { await pumpDone.catch(() => {}); this.cleanupAfterTurn(turnId); @@ -1741,8 +1457,6 @@ export class AiSdkBackend implements AgentBackend { diagnostics: RuntimeEventModelReplayPlan['diagnostics']; runtimeEventCount?: number; contextBudget?: ContextBudgetDiagnostic; - /** Latest durable checkpoint (loaded or written this turn) for mid-turn roll-forward. */ - latestHistoryCompactCheckpoint?: HistoryCompactCheckpoint; }> { const projectedMessages = await this.materializePriorMessages( input.context.filter((message) => message.turnId !== input.turnId), @@ -1757,7 +1471,6 @@ export class AiSdkBackend implements AgentBackend { let runtimeContext = budgeted?.events ?? priorRuntimeContext; let contextBudgetDiagnostic = budgeted?.diagnostic; - let latestHistoryCompactCheckpoint = contextBudget?.historyCompact?.checkpoint; if (preparedContextBudget.diagnosticPatch) { contextBudgetDiagnostic = mergeContextBudgetDiagnostic( contextBudgetDiagnostic ?? buildContextBudgetDiagnosticShell(priorRuntimeContext, runtimeContext, contextBudget), @@ -1781,7 +1494,6 @@ export class AiSdkBackend implements AgentBackend { abortSignal: this.abortController?.signal, }); if (writePatch.replacementCheckpoint) { - latestHistoryCompactCheckpoint = writePatch.replacementCheckpoint; runtimeContext = [ historyCompactCheckpointToRuntimeEvent(writePatch.replacementCheckpoint), ...runtimeContext.filter((event) => !event.id.startsWith('history-compact:')), @@ -1948,7 +1660,6 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), }; } @@ -1959,7 +1670,6 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), }; } @@ -1970,7 +1680,6 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), }; } @@ -1981,7 +1690,6 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), }; } @@ -1991,7 +1699,6 @@ export class AiSdkBackend implements AgentBackend { diagnostics: plan.diagnostics, runtimeEventCount: runtimeContext.length, ...(contextBudgetDiagnostic ? { contextBudget: contextBudgetDiagnostic } : {}), - ...(latestHistoryCompactCheckpoint ? { latestHistoryCompactCheckpoint } : {}), }; } @@ -2240,514 +1947,6 @@ export class AiSdkBackend implements AgentBackend { }; } - /** - * Mid-turn capacity compaction eligibility (issue #882 PR 1). Explicit - * opt-in via `historyCompact.midTurn.enabled`; requires the checkpoint - * writer seams plus the durable turn-ledger read, the persisted head anchor - * for this turn, and a known model context window. - */ - private buildMidTurnCapacityCompactState(input: BackendSendInput): MidTurnCapacityCompactState | undefined { - const policy = this.input.contextBudget; - if (policy?.historyCompact?.enabled !== true || policy.historyCompact.midTurn?.enabled !== true) { - return undefined; - } - if ( - !this.input.summarizeHistoryCompact - || !this.input.recordHistoryCompactCheckpoint - || !this.input.loadTurnRuntimeEvents - ) { - return undefined; - } - const headAnchor = input.headAnchorRuntimeEvent; - if ( - !headAnchor - || headAnchor.sessionId !== this.sessionId - || headAnchor.turnId !== input.turnId - || headAnchor.role !== 'user' - || headAnchor.author !== 'user' - || !isHistoryCompactContentEvent(headAnchor) - ) { - return undefined; - } - const contextWindow = resolveSelectedModelContextWindow(this.input.connection, this.input.modelId); - if (contextWindow === undefined) return undefined; - const priorContentEvents = (input.runtimeContext ?? []) - .filter((event) => event.turnId !== input.turnId) - .filter(isHistoryCompactContentEvent); - return new MidTurnCapacityCompactState(headAnchor, priorContentEvents, contextWindow); - } - - /** - * prepareStep SHAPING hook for the mid-turn capacity invariant: between - * steps of one turn, estimate the next provider request (last step's real - * usage + a signed char/4 payload delta, tool schemas included) against - * `contextWindow - reserve`; over the high-water, fold a safe completed - * prefix into a durable mid_turn checkpoint and continue the same turn on - * `[compact block, verbatim head anchor, preserved tail]`. - * - * This hook never terminates the turn: every failure fails open with a - * diagnostic and records itself for the final-request estimate owner, which - * re-measures the payload after ALL shaping (including active tool-result - * pruning, which runs later and can still rescue the step) and issues the - * context_budget_exhausted verdict only when the request that would really - * go out exceeds the window. The trigger threshold here is deliberately - * approximate — a missed or spurious trigger is recoverable; the verdict is - * not, so it does not live here. - */ - private buildMidTurnCapacityCompactPrepareStep( - turnId: string, - state: MidTurnCapacityCompactState | undefined, - queue: AsyncEventQueue, - providerTools: readonly MakaTool[], - fallbackActiveTools: () => readonly string[], - turnTailPrompt: string | undefined, - systemPromptChars: number, - onDiagnosticPatch: (patch: Partial) => void, - ): PrepareStepFunctionLike | undefined { - if (!state) return undefined; - const summarizer = this.input.summarizeHistoryCompact!; - const recorder = this.input.recordHistoryCompactCheckpoint!; - const loadTurnRuntimeEvents = this.input.loadTurnRuntimeEvents!; - const policy = this.input.contextBudget!; - const compactPolicy = policy.historyCompact!; - const midTurn = compactPolicy.midTurn!; - const charsPerToken = policy.charsPerToken ?? 4; - const reserveTokens = midTurn.reserveTokens ?? 16_384; - const maxSummaryEstimatedTokens = compactPolicy.maxBlockEstimatedTokens - ?? compactPolicy.maxSummaryEstimatedTokens - ?? 1_024; - let acceptedProjection: ActiveFullCompactPrepareStepProjection | undefined; - - return async (options) => { - const incomingMessages = options.messages; - const projectedMessages = projectAcceptedActiveFullCompactMessages(incomingMessages, acceptedProjection); - const keepProjection = (): PrepareStepResultLike | undefined => - projectedMessages ? { messages: projectedMessages } : undefined; - // Step 0 is shaped by the pre_turn path; the mid-turn trigger only runs - // between steps, once completed-step usage and events exist. - if (options.stepNumber < 1 || state.exhaustedDetail) return keepProjection(); - - // Real usage for the last finished step, read synchronously from the - // SDK's own step results (the same numbers the finish-step chunk - // carries) — no coupling to how far the stream consumer has advanced. - // Baseline = the last request's INPUT tokens only (see the state field - // doc: the payload delta already carries the step's output). The - // adapter fails closed on missing token counts (undefined, #972), and a - // provider can still report a zero input outright — either way a - // non-positive input count is unusable for estimation, so clear the - // baseline and let the estimate fall back to the whole-payload cold - // start instead of "0 + delta". - const lastStepInputTokens = normalizeAiSdkUsage(options.steps.at(-1)?.usage)?.inputTokens; - state.lastRequestInputTokens = - lastStepInputTokens !== undefined && Number.isFinite(lastStepInputTokens) && lastStepInputTokens > 0 - ? lastStepInputTokens - : undefined; - - // A skipped trigger is never silent: every failure-driven skip records a - // failedOpen decision. Recorder counters are attached ONLY on the tiers - // where the recorder was actually invoked — the diagnostics must never - // claim a write that did not happen. - const failOpen = ( - failOpenReason: string, - recorderCounters: Partial = {}, - ): PrepareStepResultLike | undefined => { - onDiagnosticPatch({ - historyCompactEnabled: true, - historyCompactMode: 'read_write', - ...recorderCounters, - ...compactionDecisionDiagnosticPatch({ - stage: 'activeStep', - sourceKind: 'runtimeEvents', - decision: 'failedOpen', - phase: 'mid_turn', - boundaryKind: 'historyCompact', - reason: 'context_limit', - failOpenReason, - }), - }); - return keepProjection(); - }; - // A shaping failure additionally records itself for the final-request - // estimate owner: when the final payload is still over the window, the - // owner turns this step's failure into the terminal detail instead of - // re-entering a shaper that already attempted and failed. - const shapeFailure = ( - detail: ContextBudgetExhaustedDetail, - diagnosticReason: string, - recorderCounters: Partial = {}, - ): PrepareStepResultLike | undefined => { - state.lastShapeFailure = { stepNumber: options.stepNumber, detail, diagnosticReason }; - return failOpen(diagnosticReason, recorderCounters); - }; - - // Trigger estimate: the last request's input tokens plus a SIGNED char/4 delta of - // this step's payload (system prompt + projected messages + active tool - // schemas) against the previous request's measured payload. Measured synchronously from - // the SDK's own projection — no ledger dependency — so a same-turn - // `load_tools` schema expansion or a large tool result both count. This - // position measures BEFORE later shapers (prune) run, so it can - // over-trigger; that is the recoverable direction, and the verdict owner - // re-measures the post-shaping payload. - const measuredMessages = projectedMessages ?? incomingMessages; - const activeToolsForStep = options.activeTools ?? fallbackActiveTools(); - const payloadChars = midTurnRequestPayloadChars( - measuredMessages, - providerTools, - activeToolsForStep, - systemPromptChars, - ); - const forcedEstimate = state.forcedTriggerEstimate; - state.forcedTriggerEstimate = undefined; - const estimate = forcedEstimate ?? estimateNextRequestTokens({ - ...(state.lastRequestInputTokens !== undefined ? { priorUsageTokens: state.lastRequestInputTokens } : {}), - appendedChars: payloadChars - (state.lastRequestPayloadChars ?? payloadChars), - charsPerToken, - coldStartChars: payloadChars, - }); - if (forcedEstimate === undefined && !exceedsHighWater(estimate, state.contextWindow, reserveTokens)) { - return keepProjection(); - } - - // Coverage pool = the durable run ledger, read through the injected - // seam. Covered events are persisted by construction (no crash window - // between checkpoint and source), and their bytes are exactly what a - // recovery re-projection replays. - // - // Seq-ack durability boundary. The replacement projection REPLACES the - // whole message list, so any completed-step content event missing from - // the durable pool is silently dropped from the next request — a - // lagging ledger here is content loss (e.g. a step's already-emitted - // assistant text), not a conservative under-count. No event-kind - // predicate can close that: the wait counts the event stream itself. - // 1. The pump has flushed every finish-step boundary the SDK reports - // completed (state.flushedSteps), so ALL of the completed steps' - // session events — tool pairs AND thinking/text completions — are - // enqueued with producer-stamped sequence numbers. - // 2. The consumer has fully processed everything enqueued - // (consumedCount >= pushedCount). The consumer's pull is the ack - // (see drain()): it fires after processing, not after persisting, - // so deliberately-unpersisted events (non-terminal errors, - // partials) can never deadlock the wait. - // After both, ONE durable read (which itself re-awaits the run's - // serialized write queue) sees every event the projection may carry. - // Exits: the boundary, an abort, a detached consumer, or a read failure. - const abortSignal = this.abortController?.signal; - for (;;) { - if (abortSignal?.aborted) return shapeFailure('no_safe_completed_span', 'ledger_wait_aborted'); - if (queue.consumerDetached) return shapeFailure('no_safe_completed_span', 'ledger_wait_aborted'); - if (state.flushedSteps >= options.stepNumber && queue.consumedCount >= queue.pushedCount) break; - await waitForQueueProgressOrAbort(queue, abortSignal); - } - let turnLedger: RuntimeEvent[]; - try { - turnLedger = await loadTurnRuntimeEvents(turnId); - } catch { - return shapeFailure('no_safe_completed_span', 'ledger_read_failed'); - } - const currentTurnEvents = turnLedger - .filter((event) => event.turnId === turnId) - .filter(isHistoryCompactContentEvent); - // The head anchor is persisted before backend.send() is invoked, so - // its absence is a wiring error, not replication lag — fail open now. - if (!currentTurnEvents.some((event) => event.id === state.headAnchor.id)) { - return shapeFailure('no_safe_completed_span', 'head_anchor_not_durable'); - } - const orderedEvents = [...state.priorContentEvents, ...currentTurnEvents]; - - const plan = await planMidTurnCapacityCompaction({ - sessionId: this.sessionId, - orderedEvents, - headAnchor: { runtimeEventId: state.headAnchor.id, turnId }, - estimatedNextRequestTokens: estimate, - contextWindow: state.contextWindow, - reserveTokens, - reserveTailEvents: midTurn.reserveTailEvents ?? 1, - charsPerToken, - now: this.now(), - ...(compactPolicy.highWaterName !== undefined ? { highWaterName: compactPolicy.highWaterName } : {}), - maxSummaryEstimatedTokens, - ...(state.previousCheckpoint ? { previousCheckpoint: state.previousCheckpoint } : {}), - summarize: async ({ coveredRuntimeEvents, newlyFoldedRuntimeEvents, previousCheckpoint }) => { - const draftBlock = buildHistoryCompactBlockFromSummary({ - sessionId: this.sessionId, - foldedRuntimeEvents: coveredRuntimeEvents, - summary: 'Mid-turn capacity compaction draft.', - ...(compactPolicy.highWaterName !== undefined ? { highWaterName: compactPolicy.highWaterName } : {}), - maxSummaryEstimatedTokens, - charsPerToken, - now: this.now(), - }); - return await Promise.resolve(summarizer({ - sessionId: this.sessionId, - turnId, - source: { draftBlock, foldedRuntimeEvents: [...coveredRuntimeEvents] }, - limits: { - maxBlocks: 1, - maxBlockEstimatedTokens: maxSummaryEstimatedTokens, - maxEstimatedTokens: compactPolicy.maxEstimatedTokens ?? 2_048, - charsPerToken, - }, - ...(previousCheckpoint ? { previousCheckpoint } : {}), - newlyFoldedRuntimeEvents: [...newlyFoldedRuntimeEvents], - ...(this.abortController?.signal ? { abortSignal: this.abortController.signal } : {}), - })); - }, - }); - - if (plan.decision === 'skip') return keepProjection(); - if (plan.decision === 'fail_open') return shapeFailure(plan.reason, plan.reason); - - // Lifecycle order is validate → persist → apply, where validate = - // materializable ∧ smaller ∧ replay-admissible. Replay applies the - // session's latest checkpoint BEFORE any high-water check, so a - // checkpoint that fails ANY of the three must never be persisted — it - // would poison every later projection even though this step correctly - // refused it. - // Persistence still precedes application, so the crash-window property - // (never apply an unpersisted fold) is unchanged, and the recorder - // counters stay truthful: validation failures never reached the - // recorder, so they attach no write counters. - const replayPlan = buildRuntimeEventModelReplayPlan(plan.replacementEvents, { - toolActivityTurnIds: collectToolActivityTurnIds(orderedEvents), - }); - if ( - replayPlan.items.length === 0 - || hasBlockingReplayDiagnostics(replayPlan) - || (replayPlan.hasProviderNativeSemantics && !this.canReplayProviderNative(replayPlan)) - ) { - return shapeFailure('no_safe_completed_span', 'replacement_unmaterializable'); - } - // The head anchor must render exactly like the raw projection's current - // user message: the initial request decorates it with the volatile turn - // tail (cwd, shell context, task state — see send()), which is not part - // of the durable anchor bytes. Reuse the same decoration owner - // (appendTurnTailPrompt) on the anchor's replay item so a replacement - // never silently drops that context — and never counts the drop as - // shrinkage in the guard below. - const replayItemsWithAnchorTail = replayPlan.items.map((item) => - item.kind === 'text' && item.role === 'user' && item.eventId === state.headAnchor.id - ? { ...item, content: this.appendTurnTailPrompt(item.content, turnTailPrompt) as string } - : item, - ); - const replacementMessages = await this.materializeRuntimeReplayPlan({ - ...replayPlan, - items: replayItemsWithAnchorTail, - }); - // Apply the shape only when it actually shrinks the request: a - // materialized replacement that is not smaller than the current payload - // (e.g. a runaway summary block) would hand the verdict owner a WORSE - // request than the raw projection it just refused to improve. A - // non-shrinking fold proves the summarizer's OUTPUT is unusable — not - // that the irreducible remainder exceeds capacity — so over the window - // the owner reports it as summarizer_failed, with the precise - // replacement_not_smaller diagnostic reason. - const replacedPayloadChars = midTurnRequestPayloadChars( - replacementMessages, - providerTools, - activeToolsForStep, - systemPromptChars, - ); - if (replacedPayloadChars >= payloadChars) { - return shapeFailure('summarizer_failed', 'replacement_not_smaller'); - } - // Replay admissibility, through the SAME single gate the recovery path - // runs (max block / max total / prefix budget) with the same policy — - // one acceptance standard, not two. A checkpoint accepted here but - // rejected at replay would still become the session's latest checkpoint - // and poison recovery: the next projection would refuse it and - // re-inject the covered raw span. Block-size rejections mean the - // summarizer's output is unusable (summarizer_failed); a prefix over - // the history budget means the irreducible remainder is too large. - const replayFit = evaluateHistoryCompactCheckpointReplay( - plan.checkpoint, - plan.replacementEvents.slice(1), - policy, - ); - if (!replayFit.fits) { - return shapeFailure( - replayFit.reason === 'prefix_over_budget' ? 'head_anchor_exceeds_capacity' : 'summarizer_failed', - `replay_rejected_${replayFit.reason}`, - ); - } - - // The replacement is valid: durably persist the checkpoint BEFORE - // applying the projection — the same order as the pre_turn path — so a - // recovery re-projection never re-injects the replaced raw span. A - // persistence failure keeps raw messages and records write_failed in - // the durable diagnostics; if the final payload is then over the - // window, the verdict owner maps this failure to the terminal - // summarizer_failed detail. - const writeFailedCounters: Partial = { - historyCompactWritesAttempted: 1, - historyCompactWriteFailures: 1, - }; - try { - await Promise.resolve(recorder(plan.checkpoint, turnId)); - } catch { - return shapeFailure('summarizer_failed', 'write_failed', writeFailedCounters); - } - state.previousCheckpoint = plan.checkpoint; - acceptedProjection = { - sourceSignatures: incomingMessages.map(modelMessageSignature), - projectedMessages: replacementMessages, - }; - state.replacedStepNumber = options.stepNumber; - onDiagnosticPatch({ - historyCompactEnabled: true, - historyCompactMode: 'read_write', - historyCompactWritesAttempted: 1, - historyCompactBlocksWritten: 1, - historyCompactWrittenBlockIds: [plan.checkpoint.checkpointId], - historyCompactWriteEstimatedTokens: plan.checkpoint.estimatedTokens, - historyCompactBlockIds: [plan.checkpoint.checkpointId], - historyCompactedTurns: plan.checkpoint.coverage.turnCount, - historyCompactedEvents: plan.checkpoint.coverage.eventCount, - historyCompactedEstimatedTokensBefore: plan.estimatedTokensBefore, - historyCompactedEstimatedTokensAfter: plan.estimatedTokensAfter, - highWaterName: plan.checkpoint.highWaterName, - highWaterSeq: plan.checkpoint.highWaterSeq, - highWaterReason: 'history_compact', - ...compactionDecisionDiagnosticPatch({ - stage: 'activeStep', - sourceKind: 'runtimeEvents', - decision: 'replaced', - phase: 'mid_turn', - boundaryKind: 'historyCompact', - boundaryIds: [plan.checkpoint.checkpointId], - coverage: { bodySha256: [plan.checkpoint.coverage.sourceDigest] }, - reason: 'context_limit', - estimatedTokensBefore: plan.estimatedTokensBefore, - estimatedTokensAfter: plan.estimatedTokensAfter, - }), - }); - return { messages: replacementMessages }; - }; - } - - /** - * The single end-of-pipeline estimate owner for the mid-turn capacity - * invariant. Every prepareStep hook only shapes; this wrapper measures the - * FINAL outgoing (messages, tools) payload — the bytes the provider will - * actually see, after capacity compaction, active tool-result pruning, and - * semantic/active-full compaction have all run — and issues the one - * safety-critical verdict: - * - * - estimate = the last request's real INPUT tokens + signed char/4 delta - * against the previous request's measured payload (recorded here on - * every step, including step 0's baseline); the delta already carries - * the step's fresh output, so an output-inclusive baseline would count - * it twice, and an unusable usage sample falls back to the whole-payload - * cold start rather than a zero baseline; - * - over the window with no capacity attempt this step (the approximate - * trigger missed, e.g. growth the trigger under-weighted), force ONE - * capacity re-entry — the verdict must not terminate a turn a shaper can - * still rescue, and one bounded re-entry preserves termination; - * - still over the window → context_budget_exhausted, with the terminal - * detail taken from this step's capacity outcome: a replacement that - * remains too large is head_anchor_exceeds_capacity (the irreducible - * remainder exceeds capacity); a recorded shaping failure keeps its own - * detail and diagnostic reason. - * - * Step 0 is shaped by the pre_turn path and only records the baseline here. - */ - private buildMidTurnFinalRequestVerdict(input: { - shaped: PrepareStepFunctionLike; - reentry: PrepareStepFunctionLike; - state: MidTurnCapacityCompactState; - providerTools: readonly MakaTool[]; - fallbackActiveTools: () => readonly string[]; - charsPerToken: number; - systemPromptChars: number; - onDiagnosticPatch: (patch: Partial) => void; - }): PrepareStepFunctionLike { - const { - shaped, - reentry, - state, - providerTools, - fallbackActiveTools, - charsPerToken, - systemPromptChars, - onDiagnosticPatch, - } = input; - return async (options) => { - let result = await Promise.resolve(shaped(options)); - const finalPayloadChars = (): number => midTurnRequestPayloadChars( - result?.messages ?? options.messages, - providerTools, - result?.activeTools ?? options.activeTools ?? fallbackActiveTools(), - systemPromptChars, - ); - let payloadChars = finalPayloadChars(); - if (options.stepNumber >= 1 && !state.exhaustedDetail) { - const estimateFinal = (): number => estimateNextRequestTokens({ - ...(state.lastRequestInputTokens !== undefined ? { priorUsageTokens: state.lastRequestInputTokens } : {}), - appendedChars: payloadChars - (state.lastRequestPayloadChars ?? payloadChars), - charsPerToken, - coldStartChars: payloadChars, - }); - let estimate = estimateFinal(); - const capacityAttemptedThisStep = state.replacedStepNumber === options.stepNumber - || state.lastShapeFailure?.stepNumber === options.stepNumber; - if (estimate > state.contextWindow && !capacityAttemptedThisStep) { - // One bounded capacity re-entry: the trigger threshold is - // approximate on purpose (recoverable), so a miss must become a - // rescue attempt before it can become a terminal verdict. Re-run - // only the capacity + prune shapers over the already-shaped - // projection; a second attempt after a same-step failure is - // pointless (the failure was not a trigger miss) and would double - // recorder counters and summarizer calls. - state.forcedTriggerEstimate = estimate; - const reshaped = await Promise.resolve(reentry({ - ...options, - messages: result?.messages ?? options.messages, - ...(result?.activeTools ? { activeTools: result.activeTools } : {}), - })); - state.forcedTriggerEstimate = undefined; - if (reshaped) { - result = { - ...(result ?? {}), - ...reshaped, - activeTools: reshaped.activeTools ?? result?.activeTools, - }; - } - payloadChars = finalPayloadChars(); - estimate = estimateFinal(); - } - if (estimate > state.contextWindow) { - const failure = state.lastShapeFailure?.stepNumber === options.stepNumber - ? state.lastShapeFailure - : undefined; - const replacedThisStep = state.replacedStepNumber === options.stepNumber; - const detail: ContextBudgetExhaustedDetail = replacedThisStep - ? 'head_anchor_exceeds_capacity' - : failure?.detail ?? 'no_safe_completed_span'; - const diagnosticReason = replacedThisStep - ? 'head_anchor_exceeds_capacity' - : failure?.diagnosticReason ?? 'no_safe_completed_span'; - state.exhaustedDetail = detail; - onDiagnosticPatch({ - historyCompactEnabled: true, - historyCompactMode: 'read_write', - ...compactionDecisionDiagnosticPatch({ - stage: 'activeStep', - sourceKind: 'runtimeEvents', - decision: 'unchanged', - phase: 'mid_turn', - boundaryKind: 'historyCompact', - reason: 'context_budget_exhausted', - skippedReasonCounts: { [diagnosticReason]: 1 }, - }), - }); - this.abortController?.abort(new Error(`mid-turn context budget exhausted: ${detail}`)); - return result; - } - } - state.lastRequestPayloadChars = payloadChars; - return result; - }; - } - private recordSemanticCompactSummaryCall(input: { callId: string; turnId: string; @@ -2759,8 +1958,7 @@ export class AiSdkBackend implements AgentBackend { status: LlmCallRecord['status']; errorClass?: string; }): void { - if (!input.usage) return; - const costUsd = this.computeTokenUsageCostUsd(input.usage); + const costUsd = input.usage ? this.computeTokenUsageCostUsd(input.usage) : 0; this.input.recordLlmCall?.({ sessionId: this.sessionId, turnId: input.turnId, @@ -2769,19 +1967,19 @@ export class AiSdkBackend implements AgentBackend { connectionSlug: this.input.connection.slug, providerId: this.input.connection.providerType, modelId: input.modelId, - inputTokens: input.usage.inputTokens, - outputTokens: input.usage.outputTokens, - cacheHitInputTokens: input.usage.cacheHitInputTokens, - cacheMissInputTokens: input.usage.cacheMissInputTokens, - ...(input.usage.cacheMissInputSource !== undefined + inputTokens: input.usage?.inputTokens ?? 0, + outputTokens: input.usage?.outputTokens ?? 0, + cacheHitInputTokens: input.usage?.cacheHitInputTokens ?? 0, + cacheMissInputTokens: input.usage?.cacheMissInputTokens ?? 0, + ...(input.usage?.cacheMissInputSource !== undefined ? { cacheMissInputSource: input.usage.cacheMissInputSource } : {}), - cachedInputTokens: input.usage.cachedInputTokens, - cacheWriteInputTokens: input.usage.cacheWriteInputTokens, - reasoningTokens: input.usage.reasoningTokens, - totalTokens: input.usage.totalTokens, + cachedInputTokens: input.usage?.cachedInputTokens ?? 0, + cacheWriteInputTokens: input.usage?.cacheWriteInputTokens ?? 0, + reasoningTokens: input.usage?.reasoningTokens ?? 0, + totalTokens: input.usage?.totalTokens, ...(input.finishReason !== undefined ? { rawFinishReason: input.finishReason } : {}), - ...(input.usage.raw !== undefined ? { rawUsage: input.usage.raw } : {}), + ...(input.usage?.raw !== undefined ? { rawUsage: input.usage.raw } : {}), latencyMs: input.latencyMs, status: input.status, ...(input.errorClass ? { errorClass: input.errorClass } : {}), @@ -3589,22 +2787,7 @@ export class AiSdkBackend implements AgentBackend { } private async *drain(queue: AsyncEventQueue): AsyncIterable { - try { - for await (const ev of queue) { - yield ev; - // Generator backpressure IS the consumer's ack: this line runs only - // when the consumer's loop body finished for `ev` and pulled the next - // event, so `consumedCount` counts fully PROCESSED events. AgentRun - // persists each mapped event before continuing, so an acked event is - // either durable or deliberately skipped (partials, non-terminal - // errors) — exactly the set a durable read can ever return. - queue.ackConsumed(); - } - } finally { - // The consumer abandoned or finished the stream; wake any seq-ack waiter - // so it observes `consumerDetached` instead of blocking forever. - queue.noteConsumerDetached(); - } + for await (const ev of queue) yield ev; } private cleanupAfterTurn(turnId: string): void { diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts index 598ff2a1b1..c5138cbe6a 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -455,7 +455,7 @@ export function mapSessionEventToRuntimeEvent( actions: { endInvocation: true, stateDelta: { abortSource: event.reason } }, }; case 'complete': - return completeRuntimeEvent(base, event, memory); + return completeRuntimeEvent(base, event.stopReason, memory); default: { // Exhaustiveness guard: if SessionEvent grows a new variant, the // mapping falls through to a diagnostic event instead of dropping it. @@ -475,10 +475,9 @@ export function mapSessionEventToRuntimeEvent( function completeRuntimeEvent( base: ReturnType, - event: CompleteEvent, + stopReason: CompleteStopReason, memory: SessionEventMapMemory, ): RuntimeEvent { - const stopReason = event.stopReason; const status = memory.failureClass && stopReason !== 'user_stop' ? 'failed' : mapCompleteStopReason(stopReason); @@ -488,12 +487,6 @@ function completeRuntimeEvent( ?? failureClassFromCompleteStopReason(stopReason) ?? 'runtime_error'; } - // The context_budget_exhausted outcome carries which invariant made the turn - // unrecoverable; the durable terminal state must not collapse it to a bare - // failure class. - if (event.contextBudgetExhaustedDetail !== undefined) { - stateDelta.contextBudgetExhaustedDetail = event.contextBudgetExhaustedDetail; - } if (status === 'aborted') stateDelta.abortSource = stopReason; return { ...base, @@ -597,11 +590,6 @@ export class AiSdkFlow implements AgentFlow, AgentFlowControl { for await (const sessionEvent of this.backend.send({ runId: ctx.runId, turnId: ctx.turnId, - // The persisted head anchor: mid-turn capacity compaction keeps this - // event verbatim and needs its exact ledger identity for coverage. - ...(ctx.request.initialRuntimeEvent !== undefined - ? { headAnchorRuntimeEvent: ctx.request.initialRuntimeEvent } - : {}), text: input.text, ...(input.attachments !== undefined ? { attachments: input.attachments } : {}), context: input.context, diff --git a/packages/runtime/src/async-queue.ts b/packages/runtime/src/async-queue.ts index 2312c39e8a..9545e0b2eb 100644 --- a/packages/runtime/src/async-queue.ts +++ b/packages/runtime/src/async-queue.ts @@ -14,14 +14,6 @@ * - `error(err)` rejects the next/current waiter and marks the queue errored; * subsequent `next()` calls re-throw. * - One consumer only. Multiple consumers will race on `next()`. - * - * Seq-ack counters: `pushedCount` stamps a monotonic sequence on the producer - * side at enqueue; the consumer loop acks each event AFTER fully processing it - * via `ackConsumed()` (see AiSdkBackend.drain — the generator's pull IS the - * ack). A producer-side waiter can then await "everything enqueued before this - * boundary has been processed" with `consumedCount >= pushedCount`, using - * `waitForProgress()` as the condition-variable wake — no polling, and immune - * to event-kind predicate drift because it counts the stream itself. */ export class AsyncEventQueue implements AsyncIterable { @@ -32,51 +24,15 @@ export class AsyncEventQueue implements AsyncIterable { }> = []; private closed = false; private err: Error | null = null; - /** Monotonic count of events accepted by push(). */ - pushedCount = 0; - /** Monotonic count of events the consumer has fully processed. */ - consumedCount = 0; - /** Set when the consumer abandoned the stream; progress waiters must not block on it. */ - consumerDetached = false; - private progressWaiters: Array<() => void> = []; push(item: T): void { if (this.closed || this.err) return; - this.pushedCount += 1; const w = this.waiters.shift(); if (w) { w.resolve({ value: item, done: false }); } else { this.buf.push(item); } - this.wake(); - } - - /** Consumer-side ack: one event has been fully processed (not just received). */ - ackConsumed(): void { - this.consumedCount += 1; - this.wake(); - } - - /** The consumer stopped pulling; wake waiters so they can observe it. */ - noteConsumerDetached(): void { - this.consumerDetached = true; - this.wake(); - } - - /** Resolves on the next push/ack/close/error/wake — a condition-variable wait. */ - waitForProgress(): Promise { - return new Promise((resolve) => { - this.progressWaiters.push(resolve); - }); - } - - /** Wake all progress waiters so they re-check their condition. */ - wake(): void { - if (this.progressWaiters.length === 0) return; - const waiters = this.progressWaiters; - this.progressWaiters = []; - for (const resolve of waiters) resolve(); } close(): void { @@ -86,7 +42,6 @@ export class AsyncEventQueue implements AsyncIterable { const w = this.waiters.shift()!; w.resolve({ value: undefined as unknown as T, done: true }); } - this.wake(); } error(err: Error): void { @@ -97,7 +52,6 @@ export class AsyncEventQueue implements AsyncIterable { const w = this.waiters.shift()!; w.reject(err); } - this.wake(); } [Symbol.asyncIterator](): AsyncIterator { diff --git a/packages/runtime/src/compaction-boundary.ts b/packages/runtime/src/compaction-boundary.ts index 5010112d4c..b3774ceded 100644 --- a/packages/runtime/src/compaction-boundary.ts +++ b/packages/runtime/src/compaction-boundary.ts @@ -63,8 +63,6 @@ export interface CompactionDecision { stage: CompactionStage; sourceKind: CompactionSourceKind; decision: CompactionDecisionKind; - /** Compaction phase; absent = pre_turn. */ - phase?: 'pre_turn' | 'mid_turn'; boundaryKind?: CompactionBoundaryKind; boundaryIds?: readonly string[]; coverage?: CompactionCoverage; @@ -167,7 +165,6 @@ export function compactionDecisionToDiagnostic( stage: decision.stage, sourceKind: decision.sourceKind, decision: decision.decision, - ...(decision.phase ? { phase: decision.phase } : {}), ...(decision.boundaryKind ? { boundaryKind: decision.boundaryKind } : {}), ...(decision.boundaryIds ? { boundaryIds: [...decision.boundaryIds] } : {}), ...(decision.coverage?.turnIds ? { coveredTurns: decision.coverage.turnIds.length } : {}), diff --git a/packages/runtime/src/context-budget-policy.ts b/packages/runtime/src/context-budget-policy.ts index 8d457c65fe..b5a2e04a04 100644 --- a/packages/runtime/src/context-budget-policy.ts +++ b/packages/runtime/src/context-budget-policy.ts @@ -185,7 +185,6 @@ function buildHistoryCompactPolicy( const tailEstimatedTokens = parseOptionalPositiveInt(env.MAKA_CONTEXT_HISTORY_COMPACT_TAIL_TOKENS); const minRecentTurns = parseOptionalPositiveInt(env.MAKA_CONTEXT_HISTORY_COMPACT_MIN_RECENT_TURNS); const maxSummaryEstimatedTokens = parseOptionalPositiveInt(env.MAKA_CONTEXT_HISTORY_COMPACT_MAX_SUMMARY_TOKENS); - const midTurn = buildHistoryCompactMidTurnPolicy(env); return { enabled: true, mode: parseHistoryCompactMode(env.MAKA_CONTEXT_HISTORY_COMPACT_MODE), @@ -199,27 +198,6 @@ function buildHistoryCompactPolicy( maxEstimatedTokens: parsePositiveInt(env.MAKA_CONTEXT_HISTORY_COMPACT_MAX_TOKENS, 2048), maxBlockEstimatedTokens: parsePositiveInt(env.MAKA_CONTEXT_HISTORY_COMPACT_MAX_BLOCK_TOKENS, 1024), highWaterName: env.MAKA_CONTEXT_HISTORY_COMPACT_HIGH_WATER_NAME ?? defaultHighWaterName, - ...(midTurn !== undefined ? { midTurn } : {}), - }; -} - -// Mid-turn capacity compaction defaults OFF this PR: only an explicit -// MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN=on opts in, so a standalone revert of -// this line leaves every surface's behavior unchanged. PR 3 sinks the default on. -function buildHistoryCompactMidTurnPolicy( - env: Record, -): NonNullable['midTurn']> | undefined { - const enabled = parseOptionalBoolean( - env.MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN, - 'MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN', - ); - if (enabled !== true) return undefined; - const reserveTokens = parseOptionalPositiveInt(env.MAKA_CONTEXT_HISTORY_COMPACT_RESERVE_TOKENS) ?? 16_384; - const reserveTailEvents = parseOptionalNonNegativeInt(env.MAKA_CONTEXT_HISTORY_COMPACT_MID_TURN_TAIL_EVENTS); - return { - enabled: true, - reserveTokens, - ...(reserveTailEvents !== undefined ? { reserveTailEvents } : {}), }; } diff --git a/packages/runtime/src/context-budget.ts b/packages/runtime/src/context-budget.ts index 511b9b41b5..2afc5b7740 100644 --- a/packages/runtime/src/context-budget.ts +++ b/packages/runtime/src/context-budget.ts @@ -17,7 +17,6 @@ import type { CompactionDecisionKind } from './compaction-boundary.js'; import { historyCompactCheckpointToRuntimeEvent, matchHistoryCompactCheckpointPrefix, - midTurnHeadAnchorEvent, renderHistoryCompactCheckpoint, type HistoryCompactCheckpoint, } from './history-compact-checkpoint.js'; @@ -306,25 +305,6 @@ export interface HistoryCompactPolicy { /** Optional archive refs keyed by RuntimeEvent id for archive-before-project validation. */ sourceArchiveRefs?: readonly HistoryCompactSourceArchiveRef[] | Readonly>; highWaterName?: string; - /** - * Optional mid-turn capacity compaction, layered on the same V2 checkpoint - * protocol. Defaults off (explicit opt-in this PR); when enabled the backend - * measures the next provider request between steps and folds a safe completed - * prefix before crossing the model context window. - */ - midTurn?: HistoryCompactMidTurnPolicy; -} - -export interface HistoryCompactMidTurnPolicy { - enabled: boolean; - /** - * Tokens kept free below the selected model context window. The proactive - * high-water threshold is `contextWindow - reserveTokens`. Defaults to the - * shared history-compact reserve (16384). - */ - reserveTokens?: number; - /** Trailing events kept verbatim as the continuation tail. Defaults to 1. */ - reserveTailEvents?: number; } export interface HistoryCompactSourceArchiveRef { @@ -672,66 +652,6 @@ export function applyRuntimeEventHistoryCompact( } const compactableEvents = events.filter(isHistoryCompactContentEvent); - - // A mid_turn checkpoint's coverage reaches into the compacted turn's own - // completed steps, so it can extend past what tail selection would retain - // and must not require multiple prior turns. Match it against the full - // content projection BEFORE every size-based guard — including the - // below-high-water skip: replaying an accepted mid_turn checkpoint is a - // correctness invariant (the covered raw span must never be re-injected), - // not a capacity optimization, so a small raw projection does not bypass - // it. Replay is the deterministic [block, verbatim head anchor, tail]. - const midTurnCheckpoint = compactPolicy.checkpoint?.phase === 'mid_turn' ? compactPolicy.checkpoint : undefined; - if (midTurnCheckpoint) { - const match = matchHistoryCompactCheckpointPrefix(midTurnCheckpoint, compactableEvents); - if (match.reason) { - increment(skippedReasonCounts, match.reason); - } else { - const headAnchor = midTurnHeadAnchorEvent(midTurnCheckpoint, match.coveredRuntimeEvents); - const replayTail = headAnchor - ? [headAnchor, ...match.successorRuntimeEvents] - : [...match.successorRuntimeEvents]; - const fit = evaluateHistoryCompactCheckpointReplay(midTurnCheckpoint, replayTail, policy!, { - charsPerToken, - maxHistoryEstimatedTokens: maxTokens, - }); - if (!fit.fits) { - increment(skippedReasonCounts, fit.reason); - } else { - return { - events: [historyCompactCheckpointToRuntimeEvent(midTurnCheckpoint), ...replayTail], - blocks: [], - checkpoint: midTurnCheckpoint, - diagnosticPatch: { - ...basePatch, - historyCompactBlocksAvailable: 1, - historyCompactBlocksSelected: 1, - historyCompactBlockIds: [midTurnCheckpoint.checkpointId], - historyCompactedTurns: midTurnCheckpoint.coverage.turnCount, - historyCompactedEvents: midTurnCheckpoint.coverage.eventCount, - historyCompactedEstimatedTokensBefore: estimateRuntimeEventsTokens(match.coveredRuntimeEvents, charsPerToken), - historyCompactedEstimatedTokensAfter: fit.checkpointTokens, - historyCompactCoverageHashes: [midTurnCheckpoint.coverage.sourceDigest], - highWaterName: midTurnCheckpoint.highWaterName, - highWaterSeq: midTurnCheckpoint.highWaterSeq, - highWaterReason: 'history_compact', - ...compactionDecisionDiagnosticPatch({ - stage: 'priorReplay', - sourceKind: 'runtimeEvents', - decision: 'replaced', - phase: 'mid_turn', - boundaryKind: 'historyCompact', - boundaryIds: [midTurnCheckpoint.checkpointId], - coverage: { bodySha256: [midTurnCheckpoint.coverage.sourceDigest] }, - estimatedTokensBefore: estimateRuntimeEventsTokens(match.coveredRuntimeEvents, charsPerToken), - estimatedTokensAfter: fit.checkpointTokens, - }), - }, - }; - } - } - } - const estimatedTokensBefore = estimateRuntimeEventsTokens(compactableEvents, charsPerToken); const highWaterRatio = finiteRatio(compactPolicy.highWaterRatio, 0.8); const highWaterThreshold = Math.max(1, Math.floor(maxTokens * highWaterRatio)); @@ -803,8 +723,7 @@ export function applyRuntimeEventHistoryCompact( }; } - // mid_turn checkpoints were handled above against the full content projection. - const checkpoint = compactPolicy.checkpoint?.phase === 'mid_turn' ? undefined : compactPolicy.checkpoint; + const checkpoint = compactPolicy.checkpoint; if (checkpoint) { const match = matchHistoryCompactCheckpointPrefix(checkpoint, foldedEvents); if (match.reason) { @@ -2201,8 +2120,7 @@ function turnKey(event: RuntimeEvent): string { return event.turnId || ''; } -/** True when the event carries model-visible content the compact projection counts. */ -export function isHistoryCompactContentEvent(event: RuntimeEvent): boolean { +function isHistoryCompactContentEvent(event: RuntimeEvent): boolean { return estimateRuntimeEventChars(event) > 0; } diff --git a/packages/runtime/src/history-compact-checkpoint.ts b/packages/runtime/src/history-compact-checkpoint.ts index b8394d1642..76c299e055 100644 --- a/packages/runtime/src/history-compact-checkpoint.ts +++ b/packages/runtime/src/history-compact-checkpoint.ts @@ -25,26 +25,6 @@ export interface HistoryCompactCheckpointCoverage { sourceDigest: string; } -/** - * Compaction phase. Absent on legacy data and defaults to `pre_turn`, the - * turn-boundary compaction the V2 checkpoint protocol was introduced for. A - * `mid_turn` checkpoint folds a prefix that reaches into the current turn's - * completed steps, so its projection re-renders the covered head anchor (the - * current turn's user message) verbatim after the compact block. - */ -export type HistoryCompactCheckpointPhase = 'pre_turn' | 'mid_turn'; - -/** - * Reference to a covered RuntimeEvent that a `mid_turn` checkpoint re-renders - * verbatim in the replay projection. Coverage still spans this event so the - * digest math stays a contiguous prefix; the projection deterministically - * rebuilds `[compact block, verbatim head anchor, tail]` from it. - */ -export interface HistoryCompactCheckpointHeadAnchor { - runtimeEventId: string; - turnId: string; -} - export interface HistoryCompactCheckpoint { kind: 'maka.history_compact_checkpoint'; version: 2; @@ -56,10 +36,6 @@ export interface HistoryCompactCheckpoint { /** Present on evidence-spine checkpoints; omitted only on legacy V2 data. */ source?: HistoryCompactCheckpointSource; coverage: HistoryCompactCheckpointCoverage; - /** Absent = `pre_turn`. `mid_turn` checkpoints carry a `headAnchor`. */ - phase?: HistoryCompactCheckpointPhase; - /** Present only on `mid_turn` checkpoints; the covered head anchor re-rendered verbatim. */ - headAnchor?: HistoryCompactCheckpointHeadAnchor; summary: string; limitations: string[]; estimatedTokens: number; @@ -76,10 +52,6 @@ export interface BuildHistoryCompactCheckpointInput { previousCheckpointId?: string; now?: number; charsPerToken?: number; - /** Defaults to `pre_turn`. `mid_turn` requires a `headAnchor` inside coverage. */ - phase?: HistoryCompactCheckpointPhase; - /** Required when `phase` is `mid_turn`; must reference a covered RuntimeEvent. */ - headAnchor?: HistoryCompactCheckpointHeadAnchor; } export type HistoryCompactCheckpointPrefixMatch = @@ -105,45 +77,10 @@ export function buildHistoryCompactCheckpoint( if (input.coveredRuntimeEvents.some((event) => event.sessionId !== input.sessionId)) { throw new Error('History compact checkpoint source events must belong to one session'); } - // A partial streaming snapshot is later replaced or deleted in the durable - // ledger, so a digest over it can never replay: coverage must be immutable. - if (input.coveredRuntimeEvents.some((event) => event.partial === true)) { - throw new Error('History compact checkpoint coverage must not include partial events'); - } const summary = input.summary.trim(); if (summary.length === 0) { throw new Error('History compact checkpoint requires a non-empty summary'); } - // A mid_turn checkpoint folds a prefix that reaches into the current turn, so - // its head anchor MUST be one of the covered events — the projection re-renders - // that exact event verbatim after the block, and coverage stays contiguous. - const phase = input.phase === 'mid_turn' ? 'mid_turn' : undefined; - let headAnchor: HistoryCompactCheckpointHeadAnchor | undefined; - if (phase === 'mid_turn') { - if (!input.headAnchor) { - throw new Error('Mid-turn history compact checkpoint requires a head anchor'); - } - const anchored = input.coveredRuntimeEvents.find((event) => event.id === input.headAnchor!.runtimeEventId); - if (!anchored) { - throw new Error('Mid-turn history compact checkpoint head anchor must be a covered RuntimeEvent'); - } - // The anchor is re-rendered verbatim as the compacted turn's user message. - // The compacted turn is the one the coverage reaches into — the LAST - // covered event's turn — so the anchor must be that turn's user event. A - // self-consistent anchor resolving to some other covered user event (e.g. - // a prior turn's prompt) would silently drop the real current prompt from - // the replay, so the protocol fails closed at build time. - const lastCovered = input.coveredRuntimeEvents.at(-1)!; - if ( - anchored.turnId !== input.headAnchor.turnId - || anchored.turnId !== lastCovered.turnId - || anchored.role !== 'user' - || anchored.author !== 'user' - ) { - throw new Error('Mid-turn history compact checkpoint head anchor must be the compacted turn\'s user event'); - } - headAnchor = { runtimeEventId: input.headAnchor.runtimeEventId, turnId: input.headAnchor.turnId }; - } const charsPerToken = input.charsPerToken ?? 4; const maxSummaryChars = Math.max(80, (input.maxSummaryEstimatedTokens ?? 1_024) * Math.max(1, charsPerToken)); const boundedSummary = summary.length <= maxSummaryChars @@ -176,8 +113,6 @@ export function buildHistoryCompactCheckpoint( coverage, summary: boundedSummary, previousCheckpointId: input.previousCheckpointId, - // Only hash the phase/anchor when set so pre_turn checkpoint ids stay stable. - ...(phase ? { phase, headAnchor } : {}), })).slice(0, 32)}`; const checkpoint: HistoryCompactCheckpoint = { kind: 'maka.history_compact_checkpoint', @@ -189,8 +124,6 @@ export function buildHistoryCompactCheckpoint( highWaterSeq, source, coverage, - ...(phase ? { phase } : {}), - ...(headAnchor ? { headAnchor } : {}), summary: boundedSummary, limitations: [ 'Replay-time summary of the covered RuntimeEvent prefix.', @@ -260,13 +193,6 @@ export function validateHistoryCompactCheckpointShape( checkpoint.sessionId, coverage, )) - && (checkpoint.phase === undefined || checkpoint.phase === 'pre_turn' || checkpoint.phase === 'mid_turn') - && (checkpoint.phase !== 'mid_turn' - || (!!checkpoint.headAnchor - && nonEmpty(checkpoint.headAnchor.runtimeEventId) - && nonEmpty(checkpoint.headAnchor.turnId))) - && (checkpoint.headAnchor === undefined - || (nonEmpty(checkpoint.headAnchor.runtimeEventId) && nonEmpty(checkpoint.headAnchor.turnId))) && typeof checkpoint.summary === 'string' && checkpoint.summary.trim().length > 0 && Array.isArray(checkpoint.limitations) @@ -331,58 +257,12 @@ export function matchHistoryCompactCheckpointPrefix( ) { return { coveredEventCount: 0, coveredRuntimeEvents: [], successorRuntimeEvents: [], reason: 'coverage_miss' }; } - // A mid_turn checkpoint's replay re-renders the head anchor verbatim, so a - // corrupted anchor reference must fail the match closed here — otherwise the - // projection would silently drop the compacted turn's user message. - // The compacted turn is the coverage's `through` turn, so the anchor must be - // THAT turn's user event — not merely any self-consistent covered user event - // (e.g. a prior turn's prompt). - if (checkpoint.phase === 'mid_turn') { - const anchor = coveredRuntimeEvents.find( - (event) => event.id === checkpoint.headAnchor!.runtimeEventId, - ); - if ( - !anchor - || anchor.turnId !== checkpoint.headAnchor!.turnId - || anchor.turnId !== checkpoint.coverage.through.turnId - || anchor.role !== 'user' - || anchor.author !== 'user' - ) { - return { coveredEventCount: 0, coveredRuntimeEvents: [], successorRuntimeEvents: [], reason: 'coverage_miss' }; - } - } if (historyCompactSourceDigest(coveredRuntimeEvents) !== checkpoint.coverage.sourceDigest) { return { coveredEventCount: 0, coveredRuntimeEvents: [], successorRuntimeEvents: [], reason: 'source_hash_mismatch' }; } return { coveredEventCount: coveredRuntimeEvents.length, coveredRuntimeEvents, successorRuntimeEvents }; } -/** - * Deterministic replay projection for a checkpoint. `pre_turn` yields - * `[compact block, ...tail]`; `mid_turn` re-inserts the covered head anchor - * verbatim as `[compact block, head anchor, ...tail]` so the current turn's - * user message stays exact even though coverage folded it. `coveredRuntimeEvents` - * are the raw events the checkpoint covers (from `matchHistoryCompactCheckpointPrefix`). - */ -export function projectHistoryCompactCheckpointReplay( - checkpoint: HistoryCompactCheckpoint, - coveredRuntimeEvents: readonly RuntimeEvent[], - replayTail: readonly RuntimeEvent[], -): RuntimeEvent[] { - const block = historyCompactCheckpointToRuntimeEvent(checkpoint); - const anchor = midTurnHeadAnchorEvent(checkpoint, coveredRuntimeEvents); - return anchor ? [block, anchor, ...replayTail] : [block, ...replayTail]; -} - -/** The covered head anchor event for a mid_turn checkpoint, or undefined. */ -export function midTurnHeadAnchorEvent( - checkpoint: HistoryCompactCheckpoint, - coveredRuntimeEvents: readonly RuntimeEvent[], -): RuntimeEvent | undefined { - if (checkpoint.phase !== 'mid_turn' || !checkpoint.headAnchor) return undefined; - return coveredRuntimeEvents.find((event) => event.id === checkpoint.headAnchor!.runtimeEventId); -} - function historyCompactCheckpointSource( sessionId: string, events: readonly RuntimeEvent[], diff --git a/packages/runtime/src/mid-turn-capacity-compact.ts b/packages/runtime/src/mid-turn-capacity-compact.ts deleted file mode 100644 index 35136f6972..0000000000 --- a/packages/runtime/src/mid-turn-capacity-compact.ts +++ /dev/null @@ -1,329 +0,0 @@ -import type { RuntimeEvent } from '@maka/core/runtime-event'; -import { estimateRuntimeEventsTokens } from './context-budget.js'; -import { - buildHistoryCompactCheckpoint, - historyCompactCheckpointToRuntimeEvent, - matchHistoryCompactCheckpointPrefix, - projectHistoryCompactCheckpointReplay, - type HistoryCompactCheckpoint, -} from './history-compact-checkpoint.js'; - -/** - * Mid-turn capacity compaction: the pure measurement + safe-boundary engine. - * - * The runtime owns one active-turn context invariant — a long single turn must - * compact a safe completed prefix before the next provider request crosses the - * selected model's context window. This module is turn-agnostic and side-effect - * free, and it only SHAPES: it selects the largest safe covered prefix and - * builds the checkpoint + replacement projection, failing open when it cannot. - * The safety-critical pass/terminate verdict is NOT issued here — the backend's - * final-request estimate owner measures the actual outgoing (messages, tools) - * payload after every shaping hook has run and decides `context_budget_exhausted` - * there, so the verdict is always about the request that really goes out. - */ - -export interface EstimateNextRequestTokensInput { - /** - * The last request's real INPUT tokens as reported by the provider — never - * input+output, because `appendedChars` is a delta against that request's - * payload and already carries the step's freshly generated output. - * Undefined on cold start or when the sample is unusable (no positive - * input count), which falls back to a whole-payload char estimate. - */ - priorUsageTokens?: number; - /** - * SIGNED char delta of the next request's payload versus the last measured - * request payload. Negative after compaction/pruning shrank the projection — - * the estimate must credit the shrink, or a compacted request would still be - * judged by the pre-compaction usage sample. - */ - appendedChars: number; - /** Estimate conversion; defaults to 4 chars/token. */ - charsPerToken?: number; - /** Whole-payload chars, used only when `priorUsageTokens` is undefined. */ - coldStartChars?: number; -} - -/** - * Estimate the token size of the next provider request. Anchors on the last - * step's real usage plus a signed char/4 payload delta for content the provider - * has not yet counted (or no longer carries); cold-start (no usage) is a pure - * char/4 estimate of the whole payload. This mirrors how surveyed peers avoid - * pure character guessing. - */ -export function estimateNextRequestTokens(input: EstimateNextRequestTokensInput): number { - const charsPerToken = Math.max(1, input.charsPerToken ?? 4); - if (input.priorUsageTokens !== undefined && Number.isFinite(input.priorUsageTokens)) { - return Math.max( - 0, - Math.max(0, Math.floor(input.priorUsageTokens)) + estimateSignedChars(input.appendedChars, charsPerToken), - ); - } - return Math.max(0, estimateSignedChars(input.coldStartChars ?? input.appendedChars, charsPerToken)); -} - -/** Proactive threshold: the next request would cross `contextWindow - reserve`. */ -export function exceedsHighWater( - estimatedTokens: number, - contextWindow: number, - reserveTokens: number, -): boolean { - const highWater = Math.max(1, contextWindow - Math.max(0, reserveTokens)); - return estimatedTokens > highWater; -} - -/** Hard cap: the estimate exceeds the raw context window even before the reserve. */ -export function exceedsContextWindow(estimatedTokens: number, contextWindow: number): boolean { - return estimatedTokens > contextWindow; -} - -export interface MidTurnBoundaryOptions { - /** Keep at least this many trailing events uncovered as the verbatim tail. */ - reserveTailEvents?: number; -} - -export type MidTurnBoundary = - | { ok: true; coveredCount: number } - | { ok: false; reason: 'no_safe_completed_span' }; - -/** - * Select the largest contiguous covered prefix that is safe to fold: - * - * - it ends on an immutable, non-partial event (a partial streaming snapshot is - * later replaced/deleted, so a digest over it can never replay); - * - it never straddles a tool call/result pair (a provider protocol unit); - * - it leaves at least `reserveTailEvents` trailing events as the verbatim tail. - * - * Returns `no_safe_completed_span` when no such cut exists (e.g. the remaining - * pool is a single atomic call/result pair), which the caller surfaces as an - * explicit `context_budget_exhausted` outcome rather than a provider error. - */ -export function selectMidTurnSafeBoundary( - events: readonly RuntimeEvent[], - options: MidTurnBoundaryOptions = {}, -): MidTurnBoundary { - const reserveTail = Math.max(0, Math.floor(options.reserveTailEvents ?? 0)); - // A partial anywhere in the covered prefix (not just at the cut) poisons the - // digest — its snapshot is later replaced or deleted — so the boundary - // retreats to strictly before the first partial in the pool. - const firstPartialIndex = events.findIndex((event) => event.partial === true); - const maxCut = Math.min( - events.length - reserveTail, - firstPartialIndex === -1 ? events.length : firstPartialIndex, - ); - const pairSpans = toolPairSpans(events); - for (let cut = maxCut; cut >= 1; cut -= 1) { - if (straddlesToolPair(pairSpans, cut)) continue; - return { ok: true, coveredCount: cut }; - } - return { ok: false, reason: 'no_safe_completed_span' }; -} - -interface ToolPairSpan { - callIndex?: number; - responseIndex?: number; -} - -function toolPairSpans(events: readonly RuntimeEvent[]): ToolPairSpan[] { - const byCallId = new Map(); - events.forEach((event, index) => { - const content = event.content; - if (content?.kind === 'function_call') { - const span = byCallId.get(content.id) ?? {}; - span.callIndex = index; - byCallId.set(content.id, span); - } else if (content?.kind === 'function_response') { - const span = byCallId.get(content.id) ?? {}; - span.responseIndex = index; - byCallId.set(content.id, span); - } - }); - return [...byCallId.values()]; -} - -/** - * A cut at exclusive index `cut` straddles a pair if exactly one side is - * covered. A call whose response is not in the pool yet is an OPEN span: - * covering it would orphan the response that arrives later (a result with no - * call in the projection), so any cut past the call is unsafe. A response - * without a call is inert — its call lives before the pool, so no cut inside - * the pool can split that pair. - */ -function straddlesToolPair(spans: readonly ToolPairSpan[], cut: number): boolean { - for (const span of spans) { - if (span.callIndex !== undefined && span.responseIndex === undefined) { - if (span.callIndex < cut) return true; - continue; - } - if (span.callIndex === undefined || span.responseIndex === undefined) continue; - const callCovered = span.callIndex < cut; - const responseCovered = span.responseIndex < cut; - if (callCovered !== responseCovered) return true; - } - return false; -} - -function estimateSignedChars(chars: number | undefined, charsPerToken: number): number { - const value = Math.trunc(chars ?? 0); - if (!Number.isFinite(value) || value === 0) return 0; - const magnitude = Math.ceil(Math.abs(value) / charsPerToken); - return value > 0 ? magnitude : -magnitude; -} - -// ============================================================================ -// Orchestration: engine + checkpoint protocol + injected summarizer → decision -// ============================================================================ - -export type MidTurnSummarizer = (input: { - coveredRuntimeEvents: readonly RuntimeEvent[]; - newlyFoldedRuntimeEvents: readonly RuntimeEvent[]; - previousCheckpoint?: HistoryCompactCheckpoint; -}) => Promise | string | undefined; - -export interface PlanMidTurnCapacityCompactionInput { - sessionId: string; - /** - * Full ordered content-event projection for the compaction pool: - * `[...prior turns, head anchor, ...current-turn completed steps]`. - */ - orderedEvents: readonly RuntimeEvent[]; - /** The current turn's user message; must be one of `orderedEvents`. */ - headAnchor: { runtimeEventId: string; turnId: string }; - /** Estimated size of the next provider request (see estimateNextRequestTokens). */ - estimatedNextRequestTokens: number; - contextWindow: number; - reserveTokens: number; - reserveTailEvents?: number; - charsPerToken?: number; - now?: number; - highWaterName?: string; - highWaterSeq?: number; - maxSummaryEstimatedTokens?: number; - previousCheckpoint?: HistoryCompactCheckpoint; - summarize: MidTurnSummarizer; -} - -export type PlanMidTurnCapacityCompactionResult = - | { decision: 'skip'; reason: 'below_high_water' } - | { decision: 'fail_open'; reason: MidTurnFailReason } - | { - decision: 'compacted'; - checkpoint: HistoryCompactCheckpoint; - /** Deterministic `[block, head anchor, tail]` replacement projection. */ - replacementEvents: RuntimeEvent[]; - coveredRuntimeEvents: RuntimeEvent[]; - tailRuntimeEvents: RuntimeEvent[]; - estimatedTokensBefore: number; - estimatedTokensAfter: number; - }; - -export type MidTurnFailReason = - | 'no_safe_completed_span' - | 'summarizer_failed'; - -/** - * Decide, deterministically, how a long active turn compacts before the next - * provider request. This plan is a pure shaper: when it cannot fold a safe - * completed prefix it FAILS OPEN (keep the raw projection + diagnostic) and - * never terminates the turn itself. The two failure tiers — fail open under - * the window, explicit `context_budget_exhausted` over it — are applied by the - * backend's final-request estimate owner, which re-measures the actual outgoing - * payload after all shaping (including this fold) has been applied. - */ -export async function planMidTurnCapacityCompaction( - input: PlanMidTurnCapacityCompactionInput, -): Promise { - const charsPerToken = Math.max(1, input.charsPerToken ?? 4); - const highWater = Math.max(1, input.contextWindow - Math.max(0, input.reserveTokens)); - if (input.estimatedNextRequestTokens <= highWater) { - return { decision: 'skip', reason: 'below_high_water' }; - } - - const boundary = selectMidTurnSafeBoundary(input.orderedEvents, { - reserveTailEvents: input.reserveTailEvents ?? 1, - }); - const headAnchorIndex = input.orderedEvents.findIndex( - (event) => event.id === input.headAnchor.runtimeEventId, - ); - // Coverage must include the head anchor and at least one other event, since the - // anchor is re-rendered verbatim — folding only the anchor saves nothing. - if ( - !boundary.ok - || headAnchorIndex < 0 - || boundary.coveredCount <= headAnchorIndex - || boundary.coveredCount < 2 - ) { - return { decision: 'fail_open', reason: 'no_safe_completed_span' }; - } - - const coveredRuntimeEvents = input.orderedEvents.slice(0, boundary.coveredCount); - const tailRuntimeEvents = input.orderedEvents.slice(boundary.coveredCount); - - // Roll forward from a previous checkpoint when it is an exact prefix of the - // covered events, so the summary only re-reads the newly folded span. - const checkpointMatch = input.previousCheckpoint - ? matchHistoryCompactCheckpointPrefix(input.previousCheckpoint, coveredRuntimeEvents) - : undefined; - const previousCheckpoint = checkpointMatch && !checkpointMatch.reason ? input.previousCheckpoint : undefined; - const newlyFoldedRuntimeEvents = previousCheckpoint - ? checkpointMatch!.successorRuntimeEvents - : coveredRuntimeEvents; - - let summary: string | undefined; - try { - summary = (await Promise.resolve(input.summarize({ - coveredRuntimeEvents, - newlyFoldedRuntimeEvents, - ...(previousCheckpoint ? { previousCheckpoint } : {}), - })))?.trim(); - } catch { - summary = undefined; - } - if (!summary) { - return { decision: 'fail_open', reason: 'summarizer_failed' }; - } - - const checkpoint = buildHistoryCompactCheckpoint({ - sessionId: input.sessionId, - coveredRuntimeEvents, - summary, - phase: 'mid_turn', - headAnchor: input.headAnchor, - ...(input.highWaterName !== undefined ? { highWaterName: input.highWaterName } : {}), - ...(input.highWaterSeq !== undefined ? { highWaterSeq: input.highWaterSeq } : {}), - ...(input.maxSummaryEstimatedTokens !== undefined - ? { maxSummaryEstimatedTokens: input.maxSummaryEstimatedTokens } - : {}), - ...(previousCheckpoint ? { previousCheckpointId: previousCheckpoint.checkpointId } : {}), - charsPerToken, - ...(input.now !== undefined ? { now: input.now } : {}), - }); - - const replacementEvents = projectHistoryCompactCheckpointReplay( - checkpoint, - coveredRuntimeEvents, - tailRuntimeEvents, - ); - const estimatedTokensBefore = estimateRuntimeEventsTokens(coveredRuntimeEvents, charsPerToken); - const estimatedTokensAfter = estimateRuntimeEventsTokens( - [historyCompactCheckpointToRuntimeEvent(checkpoint)], - charsPerToken, - ); - - // No post-fold verdict here: any re-estimate over the raw ledger span is - // wrong once the previous request was itself a compacted projection (the - // raw covered span was never in that request, so subtracting it - // over-credits the fold). The backend applies the shape only when the - // materialized replacement payload actually shrinks the request, and its - // final-request estimate owner measures the outgoing payload for the - // window verdict. - return { - decision: 'compacted', - checkpoint, - replacementEvents, - coveredRuntimeEvents, - tailRuntimeEvents, - estimatedTokensBefore, - estimatedTokensAfter, - }; -} diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 4cafa4c9e4..ee65afe157 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -51,19 +51,10 @@ export interface ModelAdapterInput { export interface PrepareStepLike { steps: ReadonlyArray<{ toolCalls?: ReadonlyArray<{ toolCallId?: string; toolName: string; input?: unknown }>; - /** Real provider usage the SDK recorded for this finished step. */ - usage?: AiSdkUsageLike; }>; stepNumber: number; model: unknown; messages: ModelMessage[]; - /** - * Active tool subset for this step. The SDK does not pass it; the composed - * prepareStep pipeline threads an earlier hook's `activeTools` result through - * so later hooks (and the final-request estimate owner) can measure the - * provider-visible tool schema for the step. - */ - activeTools?: readonly string[]; experimental_context: unknown; } @@ -418,7 +409,6 @@ export interface AiSdkUsageLike { reasoningTokens?: number; }; outputTokenDetails?: { - textTokens?: number; reasoningTokens?: number; }; raw?: AiSdkRawUsageFields; @@ -444,28 +434,19 @@ export function normalizeAiSdkUsage( options: { rawFinishReason?: unknown } = {}, ): NormalizedAiSdkUsage | undefined { if (!usage) return undefined; - const reportedInputTokens = + const inputTokens = finiteTokenFromValueOrBreakdown(usage.inputTokens, 'total') - ?? finiteTokenBreakdownSum(usage.inputTokens, ['noCache', 'cacheRead', 'cacheWrite']) ?? finiteToken(usage.promptTokens) ?? finiteToken(usage.raw?.prompt_tokens) ?? finiteToken(usage.prompt_tokens) - ?? finiteTokenSum([ - usage.inputTokenDetails?.noCacheTokens, - usage.inputTokenDetails?.cacheReadTokens, - usage.inputTokenDetails?.cacheWriteTokens, - ]); - const reportedOutputTokens = + ?? 0; + const outputTokens = finiteTokenFromValueOrBreakdown(usage.outputTokens, 'total') - ?? finiteTokenBreakdownSum(usage.outputTokens, ['text', 'reasoning']) ?? finiteToken(usage.completionTokens) ?? finiteToken(usage.raw?.completion_tokens) ?? finiteToken(usage.completion_tokens) - ?? finiteTokenSum([ - usage.outputTokenDetails?.textTokens, - usage.outputTokenDetails?.reasoningTokens, - ]); - const reportedCacheHitInputTokens = + ?? 0; + const cacheHitInputTokens = finiteToken(usage.cacheHitInputTokens) ?? finiteToken(usage.cachedInputTokens) ?? finiteToken(usage.cacheReadInputTokens) @@ -475,12 +456,14 @@ export function normalizeAiSdkUsage( ?? finiteToken(usage.prompt_tokens_details?.cached_tokens) ?? finiteTokenFromBreakdown(usage.inputTokens, 'cacheRead') ?? finiteToken(usage.inputTokenDetails?.cacheReadTokens) - ?? finiteToken(usage.inputTokenDetails?.cachedTokens); - const reportedCacheWriteInputTokens = + ?? finiteToken(usage.inputTokenDetails?.cachedTokens) + ?? 0; + const cacheWriteInputTokens = finiteToken(usage.cacheWriteInputTokens) ?? finiteToken(usage.cacheCreationInputTokens) ?? finiteTokenFromBreakdown(usage.inputTokens, 'cacheWrite') - ?? finiteToken(usage.inputTokenDetails?.cacheWriteTokens); + ?? finiteToken(usage.inputTokenDetails?.cacheWriteTokens) + ?? 0; const explicitCacheMissInputTokens = finiteToken(usage.cacheMissInputTokens) ?? finiteToken(usage.raw?.prompt_cache_miss_tokens) @@ -488,42 +471,24 @@ export function normalizeAiSdkUsage( ?? finiteTokenFromBreakdown(usage.inputTokens, 'noCache') ?? finiteToken(usage.inputTokenDetails?.noCacheTokens) ?? finiteToken(usage.inputTokenDetails?.cacheMissTokens); - const reportedReasoningTokens = + const cacheMissInputTokens = + explicitCacheMissInputTokens + ?? Math.max(0, inputTokens - cacheHitInputTokens - cacheWriteInputTokens); + const cacheMissInputSource: CacheMissInputSource = + explicitCacheMissInputTokens !== undefined ? 'explicit' : 'derived'; + const reasoningTokens = finiteToken(usage.reasoningTokens) ?? finiteTokenFromBreakdown(usage.outputTokens, 'reasoning') ?? finiteToken(usage.outputTokenDetails?.reasoningTokens) ?? finiteToken(usage.raw?.completion_tokens_details?.reasoning_tokens) ?? finiteToken(usage.completion_tokens_details?.reasoning_tokens) - ?? finiteToken(usage.inputTokenDetails?.reasoningTokens); - const reportedTotalTokens = + ?? finiteToken(usage.inputTokenDetails?.reasoningTokens) + ?? 0; + const totalTokens = finiteToken(usage.totalTokens) ?? finiteToken(usage.raw?.total_tokens) - ?? finiteToken(usage.total_tokens); - const inputTokens = reportedInputTokens ?? ( - reportedTotalTokens !== undefined - && reportedOutputTokens !== undefined - && reportedTotalTokens >= reportedOutputTokens - ? reportedTotalTokens - reportedOutputTokens - : undefined - ); - const outputTokens = reportedOutputTokens ?? ( - reportedTotalTokens !== undefined - && reportedInputTokens !== undefined - && reportedTotalTokens >= reportedInputTokens - ? reportedTotalTokens - reportedInputTokens - : undefined - ); - if (inputTokens === undefined || outputTokens === undefined) return undefined; - const cacheHitInputTokens = reportedCacheHitInputTokens ?? 0; - const cacheWriteInputTokens = reportedCacheWriteInputTokens ?? 0; - const cacheMissInputTokens = - explicitCacheMissInputTokens - ?? Math.max(0, inputTokens - cacheHitInputTokens - cacheWriteInputTokens); - const cacheMissInputSource: CacheMissInputSource = - explicitCacheMissInputTokens !== undefined ? 'explicit' : 'derived'; - const reasoningTokens = reportedReasoningTokens ?? 0; - const totalTokens = - reportedTotalTokens ?? inputTokens + outputTokens; + ?? finiteToken(usage.total_tokens) + ?? inputTokens + outputTokens; const raw = rawUsageFields(usage); const rawFinishReason = rawFinishReasonString(options.rawFinishReason); return { @@ -560,24 +525,6 @@ function finiteTokenFromValueOrBreakdown( return finiteToken(value) ?? finiteTokenFromBreakdown(value, key); } -function finiteTokenBreakdownSum( - value: number | TokenCountBreakdown | undefined, - keys: readonly (keyof TokenCountBreakdown)[], -): number | undefined { - if (!value || typeof value !== 'object') return undefined; - const parts = keys.map((key) => finiteToken(value[key])); - return parts.every((part) => part === undefined) - ? undefined - : parts.reduce((sum, part) => sum + (part ?? 0), 0); -} - -function finiteTokenSum(values: readonly unknown[]): number | undefined { - const tokens = values.map(finiteToken); - return tokens.every((token) => token === undefined) - ? undefined - : tokens.reduce((sum, token) => sum + (token ?? 0), 0); -} - function rawUsageFields(usage: AiSdkUsageLike): AiSdkRawUsageFields | undefined { const raw: AiSdkRawUsageFields = {}; const promptTokens = diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index 4d7e242e0e..7aae530ccf 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -11,7 +11,7 @@ import { anthropicV1BaseUrl, googleV1BetaBaseUrl } from './provider-urls.js'; import { resolveModelRuntime } from './model-runtime.js'; import { claudeSubscriptionHeaders, - codexSubscriptionHeaders, + openAiCodexHeaders, } from './subscription-auth.js'; export interface ModelFactoryInput { @@ -47,12 +47,12 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV3 { headers: claudeSubscriptionHeaders(), }).chat(modelId); - case 'codex-subscription': + case 'openai-codex': return createOpenAI({ apiKey, baseURL, fetch, - headers: codexSubscriptionHeaders(apiKey), + headers: openAiCodexHeaders(apiKey), }).responses(modelId); case 'github-copilot': { @@ -281,7 +281,7 @@ export function buildProviderOptions( : { effort: level } : {}, }; - case 'codex-subscription': + case 'openai-codex': return { openai: { store: false, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index 99ffd096e9..4ec0284584 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -677,13 +677,6 @@ export class RuntimeKernel implements RuntimeKernelLike { const run = runId ? active?.activeRuns.get(runId) : undefined; return this.recordHistoryCompactCheckpoint(sessionId, checkpoint, run); }, - loadTurnRuntimeEvents: (turnId: string) => { - const active = this.active.get(sessionId); - const runId = active?.turnToRunId.get(turnId); - const run = runId ? active?.activeRuns.get(runId) : undefined; - if (!run) return Promise.reject(new Error('No active AgentRun for turn runtime events')); - return run.loadTurnRuntimeEvents(); - }, } : {}), recordActiveFullCompactBlock: (block) => { const active = this.active.get(sessionId); @@ -744,13 +737,6 @@ export class RuntimeKernel implements RuntimeKernelLike { const run = runId ? active?.activeRuns.get(runId) : undefined; return this.recordHistoryCompactCheckpoint(sessionId, checkpoint, run); }, - // loadTurnRuntimeEvents is deliberately NOT injected for child - // sessions: a child run has no top-level prior context, so a mid-turn - // checkpoint built from its child-only ledger would claim to cover a - // session-scoped projection prefix and poison the session-global - // checkpoint cache/CAS for the parent projection. Child mid-turn - // compaction stays disabled (the backend requires this seam) until - // checkpoint streams are partitioned by lineage. } : {}), recordActiveFullCompactBlock: (block) => { const active = this.childActive.get(activeKey); diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index dae82b1a90..097014569d 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -213,13 +213,6 @@ export interface BackendFactoryContext { recordRunTrace?: RunTraceRecorder; loadHistoryCompactCheckpoint?: () => Promise; recordHistoryCompactCheckpoint?: (checkpoint: HistoryCompactCheckpoint, turnId: string) => Promise; - /** - * Durable read of the given turn's persisted RuntimeEvents from the - * authoritative run ledger. Mid-turn capacity compaction derives its - * coverage pool from this read, so covered events are persisted by - * construction before any checkpoint that folds them. - */ - loadTurnRuntimeEvents?: (turnId: string) => Promise; recordActiveFullCompactBlock?: (block: ActiveFullCompactBlock) => void; recordSemanticCompactBlock?: (block: SemanticCompactBlock) => void; shellRunContextSummary?: () => Promise; diff --git a/packages/runtime/src/subscription-auth.ts b/packages/runtime/src/subscription-auth.ts index 2cffec79bd..2733904cb3 100644 --- a/packages/runtime/src/subscription-auth.ts +++ b/packages/runtime/src/subscription-auth.ts @@ -12,7 +12,7 @@ export function claudeSubscriptionHeaders(): Record { }; } -export function codexSubscriptionHeaders(accessToken: string): Record { +export function openAiCodexHeaders(accessToken: string): Record { const accountId = extractCodexAccountId(accessToken); return { ...(accountId ? { diff --git a/packages/runtime/src/subscription-credentials.ts b/packages/runtime/src/subscription-credentials.ts index 4e40941702..b9e0152f74 100644 --- a/packages/runtime/src/subscription-credentials.ts +++ b/packages/runtime/src/subscription-credentials.ts @@ -1,7 +1,7 @@ import type { ProviderType } from '@maka/core/llm-connections'; import { TOKEN_REFRESH_SKEW_MS } from '@maka/core'; -export type OAuthSubscriptionProvider = Extract; +export type OAuthSubscriptionProvider = Extract; export interface OAuthSubscriptionTokens { access_token: string; @@ -17,7 +17,7 @@ export interface OAuthSubscriptionTokens { export function isOAuthSubscriptionProvider(providerType: ProviderType): providerType is OAuthSubscriptionProvider { return providerType === 'claude-subscription' - || providerType === 'codex-subscription' + || providerType === 'openai-codex' || providerType === 'github-copilot'; } @@ -114,8 +114,8 @@ async function refreshOAuthSubscriptionTokens(input: { switch (input.providerType) { case 'claude-subscription': return refreshClaudeSubscriptionTokens(input.tokens, input.now, input.fetchFn); - case 'codex-subscription': - return refreshCodexSubscriptionTokens(input.tokens, input.now, input.fetchFn); + case 'openai-codex': + return refreshOpenAiCodexTokens(input.tokens, input.now, input.fetchFn); case 'github-copilot': return input.tokens; } @@ -181,7 +181,7 @@ async function refreshClaudeSubscriptionTokens( }; } -async function refreshCodexSubscriptionTokens( +async function refreshOpenAiCodexTokens( tokens: OAuthSubscriptionTokens, now: () => number, fetchFn: typeof fetch, diff --git a/packages/runtime/src/subscription-model-fetch.ts b/packages/runtime/src/subscription-model-fetch.ts index 80d5fefef8..14f95d059f 100644 --- a/packages/runtime/src/subscription-model-fetch.ts +++ b/packages/runtime/src/subscription-model-fetch.ts @@ -22,8 +22,8 @@ export function buildSubscriptionModelFetch(input: SubscriptionModelFetchInput): if (input.claude?.cloakEnabled === false) return undefined; return buildClaudeSubscriptionCloakedFetch(input, requireClaudeCloakMetadata(input.claude)); } - if (input.connection.providerType === 'codex-subscription') { - return buildCodexSubscriptionFetch(input.sessionId, input.fetchFn ?? fetch); + if (input.connection.providerType === 'openai-codex') { + return buildOpenAiCodexFetch(input.sessionId, input.fetchFn ?? fetch); } if (input.connection.providerType === 'github-copilot') { return buildGitHubCopilotFetch(input.fetchFn ?? fetch); @@ -109,7 +109,7 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } -function buildCodexSubscriptionFetch(sessionId: string, fetchFn: typeof fetch): typeof fetch { +function buildOpenAiCodexFetch(sessionId: string, fetchFn: typeof fetch): typeof fetch { return async (url: Parameters[0], init?: Parameters[1]) => { const headers = new Headers(init?.headers); headers.set('OpenAI-Beta', 'responses=experimental'); @@ -120,21 +120,21 @@ function buildCodexSubscriptionFetch(sessionId: string, fetchFn: typeof fetch): const rawBody = init?.body; if (typeof rawBody !== 'string') { - return checkedCodexSubscriptionFetch(fetchFn, url, { ...init, headers }); + return checkedOpenAiCodexFetch(fetchFn, url, { ...init, headers }); } let parsedBody: Record; try { const parsed = JSON.parse(rawBody) as unknown; if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { - return checkedCodexSubscriptionFetch(fetchFn, url, { ...init, headers }); + return checkedOpenAiCodexFetch(fetchFn, url, { ...init, headers }); } parsedBody = parsed as Record; } catch { - return checkedCodexSubscriptionFetch(fetchFn, url, { ...init, headers }); + return checkedOpenAiCodexFetch(fetchFn, url, { ...init, headers }); } - return checkedCodexSubscriptionFetch(fetchFn, url, { + return checkedOpenAiCodexFetch(fetchFn, url, { ...init, headers, body: JSON.stringify({ @@ -159,7 +159,7 @@ function buildCodexSubscriptionFetch(sessionId: string, fetchFn: typeof fetch): }; } -async function checkedCodexSubscriptionFetch( +async function checkedOpenAiCodexFetch( fetchFn: typeof fetch, url: Parameters[0], init?: Parameters[1], @@ -167,7 +167,7 @@ async function checkedCodexSubscriptionFetch( const response = await fetchFn(url, init); if (!response.ok) { const detail = await response.clone().text().catch(() => ''); - throw new Error(formatCodexSubscriptionHttpError(response.status, detail)); + throw new Error(formatOpenAiCodexHttpError(response.status, detail)); } return response; } @@ -203,7 +203,7 @@ function codexInstructionsFromBody(body: Record): string { return 'You are Maka, a helpful AI assistant.'; } -function formatCodexSubscriptionHttpError(statusCode: number, detail: string): string { +function formatOpenAiCodexHttpError(statusCode: number, detail: string): string { const compact = redactSecrets(detail).replace(/\s+/g, ' ').trim().slice(0, 240); return compact ? `Codex OAuth request failed: HTTP ${statusCode} ${compact}` diff --git a/packages/runtime/src/test-connection.ts b/packages/runtime/src/test-connection.ts index 19b809d5f1..6c15406425 100644 --- a/packages/runtime/src/test-connection.ts +++ b/packages/runtime/src/test-connection.ts @@ -41,7 +41,7 @@ export async function testConnection( return /^gpt-5/i.test(testModel) ? await probeOpenAIResponses(baseUrl, secret, testModel, t0) : await probeOpenAI(connection, baseUrl, secret, testModel, t0); - case 'codex-subscription': + case 'openai-codex': case 'openai-compatible': return await probeOpenAI(connection, baseUrl, secret, testModel, t0); case 'github-copilot': @@ -174,7 +174,7 @@ async function probeOpenAI( model: string, t0: number, ): Promise { - if (connection.providerType === 'codex-subscription') { + if (connection.providerType === 'openai-codex') { // Codex Subscription credentials are ChatGPT account-scoped OAuth // tokens. A live `/responses` probe is not a stable readiness test: // the backend can hold or reject small synthetic requests even when diff --git a/packages/ui/src/__tests__/chat-model-helpers.test.ts b/packages/ui/src/__tests__/chat-model-helpers.test.ts index fcb6d46fda..743bac702b 100644 --- a/packages/ui/src/__tests__/chat-model-helpers.test.ts +++ b/packages/ui/src/__tests__/chat-model-helpers.test.ts @@ -34,7 +34,7 @@ test('cross-provider same model name stays in separate, distinguishable groups', // the user must be able to tell which connection a row belongs to. const groups = modelMenuGroups([ choice('openai-main', 'openai', 'gpt-5.5'), - choice('codex-sub', 'codex-subscription', 'gpt-5.5'), + choice('codex-sub', 'openai-codex', 'gpt-5.5'), ]); assert.equal(groups.length, 2); assert.equal(new Set(groups.map((g) => g.heading)).size, 2); diff --git a/packages/ui/src/__tests__/live-turn-projection.test.ts b/packages/ui/src/__tests__/live-turn-projection.test.ts index ced6d1dbcb..854b09120a 100644 --- a/packages/ui/src/__tests__/live-turn-projection.test.ts +++ b/packages/ui/src/__tests__/live-turn-projection.test.ts @@ -538,18 +538,6 @@ describe('reconcileTerminalLiveTurn', () => { ]), undefined); }); - it('keeps a non-terminal projection armed once persisted history covers all steps', () => { - const inFlight: LiveTurnProjection = { - turnId: 'turn-1', - phase: 'streamed', - steps: toolOnly.steps, - }; - assert.deepEqual(reconcileTerminalLiveTurn(inFlight, [ - { type: 'tool_call', id: 'tool-1', turnId: 'turn-1', stepId: 'step-1', ts: 1, toolName: 'Bash', args: {} }, - { type: 'tool_result', id: 'result-1', turnId: 'turn-1', ts: 2, toolUseId: 'tool-1', isError: false, content: { kind: 'text', text: 'ok' } }, - ]), { turnId: 'turn-1', phase: 'streamed', steps: [] }); - }); - it('retains terminal evidence while persisted history does not cover it', () => { assert.equal(reconcileTerminalLiveTurn(toolOnly, []), toolOnly); }); diff --git a/packages/ui/src/__tests__/picker-trigger.test.ts b/packages/ui/src/__tests__/picker-trigger.test.ts deleted file mode 100644 index b306aab46a..0000000000 --- a/packages/ui/src/__tests__/picker-trigger.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { pickerTriggerClasses } from '../ui.js'; - -test('picker triggers separate field chrome from quiet toolbar chrome', () => { - const field = pickerTriggerClasses('field'); - const quiet = pickerTriggerClasses('quiet'); - - assert.match(field, /\bmin-h-9\b/); - assert.match(field, /\bw-full\b/); - assert.match(field, /\bshadow-sm\b/); - - assert.doesNotMatch(quiet, /\bmin-h-9\b/); - assert.doesNotMatch(quiet, /\bw-full\b/); - assert.doesNotMatch(quiet, /\bshadow-/); - assert.doesNotMatch(quiet, /\bborder-input\b/); - assert.match(quiet, /focus-visible:ring-2/); - assert.match(quiet, /disabled:pointer-events-none/); -}); diff --git a/packages/ui/src/chat-model-helpers.ts b/packages/ui/src/chat-model-helpers.ts index bb7fdf25b9..0fa1726d0c 100644 --- a/packages/ui/src/chat-model-helpers.ts +++ b/packages/ui/src/chat-model-helpers.ts @@ -32,7 +32,7 @@ export interface ChatModelChoice { * `none` auth), where `connection.name` is a plain label the user typed in * Settings when adding the connection (e.g. "OpenRouter", "My Together AI * key"). Must stay `undefined` for `claude-subscription` / - * `codex-subscription` / `gemini-cli`, whose `connection.name` embeds the + * `openai-codex` / `gemini-cli`, whose `connection.name` embeds the * OAuth account email (PR-CHAT-CHROME-FIX-0) — those three keep falling * back to the leak-safe provider label in `modelMenuGroups`. Callers * populate this field; `@maka/ui` doesn't know about `LlmConnection` and @@ -60,7 +60,7 @@ const PROVIDER_SHORT_LABEL: Partial> = { 'MiniMax-cn': 'MiniMax 中国站', 'openai-compatible': '自定义', 'claude-subscription': 'Claude 订阅', - 'codex-subscription': 'OpenAI OAuth', + 'openai-codex': 'OpenAI OAuth', 'gemini-cli': 'Gemini CLI', }; @@ -87,7 +87,7 @@ export interface ModelMenuGroup { * the SAME provider are present and neither supplied a name (e.g. two OpenAI * keys) — the slug is a safe `[a-z0-9-]` identifier, never the OAuth * account email `connection.name` carries for `claude-subscription` / - * `codex-subscription` / `gemini-cli`. + * `openai-codex` / `gemini-cli`. */ export function modelMenuGroups(choices: ChatModelChoice[]): ModelMenuGroup[] { const bySlug = new Map(); diff --git a/packages/ui/src/chat-model-switcher.tsx b/packages/ui/src/chat-model-switcher.tsx index 33bc104bcf..bf9d3c7b97 100644 --- a/packages/ui/src/chat-model-switcher.tsx +++ b/packages/ui/src/chat-model-switcher.tsx @@ -179,7 +179,6 @@ export function ChatModelSwitcher(props: { aria-busy={pending ? 'true' : undefined} > { void props.onPermissionModeChange?.(mode); diff --git a/packages/ui/src/live-turn-projection.ts b/packages/ui/src/live-turn-projection.ts index 4284e6ca01..7b754e15dc 100644 --- a/packages/ui/src/live-turn-projection.ts +++ b/packages/ui/src/live-turn-projection.ts @@ -384,6 +384,6 @@ export function reconcileTerminalLiveTurn( return !toolsCovered; }); if (steps.length === current.steps.length) return current; - if (steps.length === 0 && current.terminal) return undefined; + if (steps.length === 0) return undefined; return { ...current, steps }; } diff --git a/packages/ui/src/model-picker.tsx b/packages/ui/src/model-picker.tsx index 98d6ddc2fe..7caabf3c88 100644 --- a/packages/ui/src/model-picker.tsx +++ b/packages/ui/src/model-picker.tsx @@ -18,7 +18,7 @@ import { type ModelPickerPinnedItem, } from './model-picker-internals.js'; import { cn } from './utils.js'; -import { pickerTriggerClasses, type PickerTriggerAppearance } from './ui.js'; +import { inputClasses } from './primitives/input.js'; export interface ModelPickerProps { groups: ModelMenuGroup[]; @@ -31,7 +31,6 @@ export interface ModelPickerProps { searchPlaceholder?: string; emptyMessage?: string; triggerClassName?: string; - triggerAppearance?: PickerTriggerAppearance; popupClassName?: string; ariaLabel: string; title?: string; @@ -41,17 +40,14 @@ export interface ModelPickerProps { children: ReactNode; } -const ModelPickerTrigger = forwardRef< - HTMLButtonElement, - React.ComponentPropsWithoutRef & { appearance?: PickerTriggerAppearance } ->(function ModelPickerTrigger( - { appearance = 'field', className, children, ...props }, +const ModelPickerTrigger = forwardRef>(function ModelPickerTrigger( + { className, children, ...props }, ref, ) { return ( {children} @@ -204,7 +200,6 @@ export function ModelPicker(props: ModelPickerProps) { disabled={props.disabled} > & { appearance?: PickerTriggerAppearance } ->(function SelectTrigger( - { appearance = 'field', className, children, ...props }, +export const SelectTrigger = forwardRef>(function SelectTrigger( + { className, children, ...props }, ref, ) { return ( From b5a34bfe459a8f7e1222149c286689eb612d5150 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 15 Jul 2026 08:36:42 +0800 Subject: [PATCH 2/3] feat(providers): add gpt-5.6-sol to OpenAI Codex OAuth with accurate context windows Add gpt-5.6-sol as the lead fallback model for the openai-codex (ChatGPT/ Codex OAuth) provider, and align the OAuth model metadata's context windows with the OpenAI codex CLI models.json - the authoritative source for the chatgpt.com/backend-api/codex path. Slug: the ChatGPT/Codex OAuth backend serves the 5.6 generation as gpt-5.6-sol/-terra/-luna, never as a bare gpt-5.6 (per codex CLI models.json and hermes DEFAULT_CODEX_MODELS). gpt-5.5-pro is dropped: it is a platform-API slug the OAuth backend does not serve. Context window: contextWindow here is the max input/prompt limit (context-budget's maxHistoryEstimatedTokens = contextWindow - reserve), not the total window. The previous 400k override (and the snapshot's 400k for gpt-5.4-mini) overstated gpt-5.5/5.4/5.4-mini by 47% (real 272k), letting the budget send history past the actual input limit. The models.dev snapshot is the platform-API view (1.05M) and does not apply to the OAuth path - pi confirms this ("not fetched from models.dev; we keep a small, explicit list") and opencode hardcodes the same split. Aligned values (matching pi's openai-codex.models.ts): - gpt-5.6-sol: 372k (was 400k) - gpt-5.5 / gpt-5.4 / gpt-5.4-mini: 272k (was 400k / 400k / snapshot 400k) - gpt-5.3-codex-spark: 128k (snapshot already correct) gpt-5.4 / gpt-5.4-mini (hidden but served) and gpt-5.3-codex-spark (served despite supported_in_api:false, per hermes PR #12994) stay in the fallback list. --- .../__tests__/context-budget-policy.test.ts | 4 +- .../core/src/__tests__/model-catalog.test.ts | 38 +++++++++++---- .../core/src/__tests__/model-metadata.test.ts | 4 +- packages/core/src/model-metadata.ts | 47 ++++--------------- packages/core/src/provider-registry.ts | 2 +- 5 files changed, 45 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/main/__tests__/context-budget-policy.test.ts b/apps/desktop/src/main/__tests__/context-budget-policy.test.ts index 6a880f37d4..1412d36624 100644 --- a/apps/desktop/src/main/__tests__/context-budget-policy.test.ts +++ b/apps/desktop/src/main/__tests__/context-budget-policy.test.ts @@ -105,14 +105,14 @@ describe('desktop activeToolResultPrune policy', () => { test('uses provider-specific metadata for Codex subscription models', () => { const policy = buildDefaultContextBudgetPolicy({ - providerType: 'codex-subscription', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', models: [{ id: 'gpt-5.5' }], } as unknown as LlmConnection, { name: 'desktop-default-history-budget', modelId: 'gpt-5.5', }); - assert.equal(policy?.maxHistoryEstimatedTokens, 400_000 - 16_384); + assert.equal(policy?.maxHistoryEstimatedTokens, 272_000 - 16_384); }); test('uses metadata for known DeepSeek models but keeps unknown DeepSeek models unbounded', () => { diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index d7bd005c03..d5f7b4049b 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -632,7 +632,7 @@ describe('ModelCatalogEntry', () => { it('keeps OpenAI OAuth limits provider-specific instead of reusing OpenAI API context', () => { const [[openaiEntry], [oauthEntry]] = ([ ['openai', 'gpt-5.5'], - ['codex-subscription', 'gpt-5.5'], + ['openai-codex', 'gpt-5.5'], ] as const).map(([providerType, model]) => buildModelCatalogEntries({ providerType, defaultModel: model, @@ -641,7 +641,7 @@ describe('ModelCatalogEntry', () => { })); assert.equal(openaiEntry?.contextWindow, 1_050_000); - assert.equal(oauthEntry?.contextWindow, 400_000); + assert.equal(oauthEntry?.contextWindow, 272_000); assert.notEqual(oauthEntry?.contextWindow, openaiEntry?.contextWindow); assert.equal(oauthEntry?.maxOutputTokens, 128_000); }); @@ -955,7 +955,7 @@ describe('ModelCatalogEntry', () => { it('carries display names separately from stable model ids', () => { const [fetchedEntry] = buildModelCatalogEntries({ - providerType: 'codex-subscription', + providerType: 'openai-codex', defaultModel: 'gpt-5.5', models: [{ id: 'gpt-5.5', displayName: 'GPT 5.5' }], modelSource: 'fetched', @@ -967,14 +967,36 @@ describe('ModelCatalogEntry', () => { const [fallbackEntry] = buildConnectionModelCatalogEntries({ connection: { - slug: 'codex-subscription', - providerType: 'codex-subscription', + slug: 'openai-codex', + providerType: 'openai-codex', defaultModel: '', }, }); - assert.equal(fallbackEntry?.id, 'gpt-5.5'); - assert.equal(fallbackEntry?.displayName, 'GPT-5.5'); + assert.equal(fallbackEntry?.id, 'gpt-5.6-sol'); + assert.equal(fallbackEntry?.displayName, 'GPT-5.6 Sol'); + }); + + it('exposes gpt-5.6-sol as the lead OpenAI Codex OAuth fallback model with the access-path context window', () => { + // gpt-5.6-sol is the current ChatGPT/Codex flagship slug served by the + // chatgpt.com/backend-api/codex OAuth backend (per the OpenAI codex CLI + // models.json and hermes DEFAULT_CODEX_MODELS). It must lead the OAuth + // fallback list, and its context window is the access-path limit (372k), + // not the platform-API limit (1.05M) from the models.dev snapshot. + // gpt-5.6-luna/terra and the bare gpt-5.6 platform-API slug are + // intentionally excluded: they are not selectable through the + // ChatGPT/Codex OAuth path. + const [lead] = buildConnectionModelCatalogEntries({ + connection: { + slug: 'openai-codex', + providerType: 'openai-codex', + defaultModel: '', + }, + }); + + assert.equal(lead?.id, 'gpt-5.6-sol'); + assert.equal(lead?.displayName, 'GPT-5.6 Sol'); + assert.equal(lead?.contextWindow, 372_000); }); it('enriches provider model ids with models.dev display names', () => { @@ -988,7 +1010,7 @@ describe('ModelCatalogEntry', () => { ['gemini-cli', 'gemini-2.5-pro'], ['deepseek', 'deepseek-v4-flash'], ['zai-coding-plan', 'glm-5.2'], - ['codex-subscription', 'gpt-5.3-codex-spark'], + ['openai-codex', 'gpt-5.3-codex-spark'], ] as Array<[ProviderType, string]>).map(([providerType, model]) => { const [entry] = buildModelCatalogEntries({ providerType, diff --git a/packages/core/src/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 9ddb63f581..286105b4b1 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -65,8 +65,8 @@ describe('model-metadata vision capability', () => { }); it('keeps complete metadata for every Codex subscription model alias', () => { - for (const modelId of [...PROVIDER_DEFAULTS['codex-subscription'].fallbackModels, 'gpt-5.5-pro']) { - const metadata = lookupModelMetadata('codex-subscription', modelId); + for (const modelId of PROVIDER_DEFAULTS['openai-codex'].fallbackModels) { + const metadata = lookupModelMetadata('openai-codex', modelId); assert.ok(metadata.displayName); assert.equal(metadata.capabilities?.vision, true); } diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index 95bb46e306..0b2b3a6302 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -104,10 +104,10 @@ const OPENAI_MODEL_OVERRIDES: Record = { }; const OPENAI_OAUTH_MODEL_METADATA: Record = { - 'gpt-5.5': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.5']!, ...OPENAI_MODEL_OVERRIDES['gpt-5.5']!, contextWindow: 400_000 }, - 'gpt-5.5-pro': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.5-pro']!, contextWindow: 400_000 }, - 'gpt-5.4': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.4']!, contextWindow: 400_000 }, - 'gpt-5.4-mini': GENERATED_MODELS_DEV_METADATA.openai['gpt-5.4-mini']!, + 'gpt-5.6-sol': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.6-sol']!, contextWindow: 372_000 }, + 'gpt-5.5': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.5']!, ...OPENAI_MODEL_OVERRIDES['gpt-5.5']!, contextWindow: 272_000 }, + 'gpt-5.4': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.4']!, contextWindow: 272_000 }, + 'gpt-5.4-mini': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.4-mini']!, contextWindow: 272_000 }, 'gpt-5.3-codex-spark': GENERATED_MODELS_DEV_METADATA.openai['gpt-5.3-codex-spark']!, }; @@ -133,32 +133,6 @@ const VOLCENGINE_CODING_PLAN_MODEL_METADATA: Record = { 'kimi-k2.7-code': planModel('Kimi-K2.7-Code', true, 256_000, 32_000), }; -/** - * Thinking-capable Ollama Cloud models, derived from the generated models.dev - * snapshot so new reasoning models are picked up automatically on the next sync. - * The endpoint globally accepts `reasoning_effort` (none/low/medium/high/max), - * but GPT-OSS only accepts low/medium/high and cannot be fully disabled. - * Deprecated models are filtered — Ollama publishes concrete retirement dates. - */ -const OLLAMA_CLOUD_STANDARD_THINKING_OPTIONS: ThinkingOptions = { - efforts: ['none', 'low', 'medium', 'high', 'max'], - toggle: true, -}; - -const OLLAMA_CLOUD_GPT_OSS_THINKING_OPTIONS: ThinkingOptions = { - efforts: ['low', 'medium', 'high'], -}; - -const ollamaCloudThinkingModels: Record = Object.fromEntries( - Object.entries(GENERATED_MODELS_DEV_METADATA['ollama-cloud']) - .filter(([id, m]) => m.capabilities?.reasoning && m.lifecycle !== 'deprecated') - .map(([id]) => [id, { - thinkingOptions: id.startsWith('gpt-oss') - ? OLLAMA_CLOUD_GPT_OSS_THINKING_OPTIONS - : OLLAMA_CLOUD_STANDARD_THINKING_OPTIONS, - }]), -); - // Facts that models.dev cannot express: provider wire controls and // access-path-specific aliases/limits. Standard model facts stay generated. const STATIC_MODEL_METADATA: Partial>> = { @@ -195,7 +169,7 @@ const STATIC_MODEL_METADATA: Partial Date: Wed, 15 Jul 2026 08:38:12 +0800 Subject: [PATCH 3/3] feat(providers): live-discover OpenAI Codex OAuth models with three-state sync Switch the openai-codex provider from static fallback to live /models discovery: modelDiscovery: { kind: 'protocol', auth: 'openai-codex' } probes chatgpt.com/backend-api/codex/models with the OAuth bearer token, filtering unsupported slugs and sorting by the backend's priority field. The fetched context_window is authoritative; OPENAI_OAUTH_MODEL_METADATA is now only the offline fallback. fetchOpenAiCodexModels reuses openAiCodexHeaders(accessToken) so the ChatGPT-Account-Id routing header is set consistently. Discovery errors throw OpenAiCodexDiscoveryError (carrying the HTTP status) so callers can classify without string-matching; fetchProviderModels passes it through unchanged and only wraps unknown errors for display. syncOpenAiCodexConnection handles three discovery outcomes: - empty token / 401 / 403 -> needs_reauth (the token is unusable) - empty or all-filtered / 4xx -> error, disabled, models cleared (persisted as models: [] + modelSource: 'fetched' so a later transient failure does not revive a stale list) - 5xx / timeout / unknown -> keep the cached fetched list (or the curated fallback), so the connection stays usable Only a previously fetched list is cached; a persisted fallback snapshot is rebuilt from the current registry so renamed/added models (e.g. gpt-5.6-sol) reach existing users instead of being shadowed by a stale on-disk copy. --- ...uth-model-connections-openai-codex.test.ts | 207 ++++++++++++++++++ .../src/main/oauth-model-connections-main.ts | 109 ++++++++- packages/core/src/provider-registry.ts | 4 +- .../src/__tests__/model-fetcher.test.ts | 75 ++++++- packages/runtime/src/index.ts | 23 +- packages/runtime/src/model-fetcher.ts | 79 ++++++- 6 files changed, 457 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/oauth-model-connections-openai-codex.test.ts diff --git a/apps/desktop/src/main/__tests__/oauth-model-connections-openai-codex.test.ts b/apps/desktop/src/main/__tests__/oauth-model-connections-openai-codex.test.ts new file mode 100644 index 0000000000..7b3ba2835f --- /dev/null +++ b/apps/desktop/src/main/__tests__/oauth-model-connections-openai-codex.test.ts @@ -0,0 +1,207 @@ +import { strict as assert } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + createOAuthModelConnectionsMainService, + CODEX_SUBSCRIPTION_CONNECTION_SLUG, +} from '../oauth-model-connections-main.js'; +import { PROVIDER_REGISTRY, PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { OpenAiCodexDiscoveryError } from '@maka/runtime'; +import type { LlmConnection } from '@maka/core/llm-connections'; + +// syncOpenAiCodexConnection live-discovers the account's Codex model list +// from chatgpt.com/backend-api/codex/models. These behavior tests inject fake +// deps (connectionStore / openAiCodex / fetchModels) so the three discovery +// outcomes - fetched, empty, failed - and the OAuth-token-failure path can be +// asserted directly, instead of grepping source. + +type ModelInfo = NonNullable[number]; + +function makeExisting(overrides: Partial = {}): LlmConnection { + return { + slug: CODEX_SUBSCRIPTION_CONNECTION_SLUG, + name: 'Codex OAuth', + providerType: 'openai-codex', + baseUrl: PROVIDER_DEFAULTS['openai-codex'].baseUrl, + defaultModel: 'gpt-5.6-sol', + enabled: true, + models: [{ id: 'gpt-5.6-sol' }], + modelSource: 'fetched', + createdAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +function makeService(opts: { + existing?: LlmConnection | null; + token?: string | null; + fetchModels?: (conn: LlmConnection, token: string) => Promise; + accountState?: { runtimeState: string }; +}): { sync: () => Promise; getSaved: () => LlmConnection | null } { + let saved: LlmConnection | null = null; + const existing = opts.existing ?? null; + const connectionStore = { + get: async () => existing, + list: async () => (existing ? [existing] : []), + save: async (v: LlmConnection) => { + saved = v; + return v; + }, + update: async (_slug: string, patch: Partial) => { + saved = { ...(existing as LlmConnection), ...patch } as LlmConnection; + return saved; + }, + create: async () => { + throw new Error('not used'); + }, + delete: async () => {}, + remove: async () => {}, + getDefault: async () => null, + setDefault: async () => {}, + }; + const service = createOAuthModelConnectionsMainService({ + connectionStore, + credentialStore: { + getSecret: async () => null, + setSecret: async () => {}, + deleteSecret: async () => {}, + }, + claudeSubscription: {} as never, + openAiCodex: { + getAccountState: async () => ({ + provider: 'openai-codex', + runtimeState: opts.accountState?.runtimeState ?? 'authenticated', + }), + getAccessTokenInternal: async () => opts.token ?? null, + }, + githubCopilotSubscription: {} as never, + fetchModels: opts.fetchModels, + } as never); + return { sync: () => service.syncOpenAiCodexConnection(), getSaved: () => saved }; +} + +describe('syncOpenAiCodexConnection live discovery behavior', () => { + it('declares protocol live discovery for the openai-codex provider', () => { + assert.deepEqual( + PROVIDER_REGISTRY['openai-codex'].modelDiscovery, + { kind: 'protocol', auth: 'openai-codex' }, + ); + }); + + it('stamps modelSource=fetched and persists discovered models on success', async () => { + const { sync, getSaved } = makeService({ + token: 'tok', + fetchModels: async () => [{ id: 'gpt-5.6-sol', contextWindow: 372000 }, { id: 'gpt-5.5' }], + }); + await sync(); + const saved = getSaved()!; + assert.equal(saved.modelSource, 'fetched'); + assert.deepEqual(saved.models, [{ id: 'gpt-5.6-sol', contextWindow: 372000 }, { id: 'gpt-5.5' }]); + assert.equal(saved.enabled, true); + assert.equal(saved.lastTestStatus, 'verified'); + }); + + it('disables the connection with lastTestStatus=error when /models returns an empty list', async () => { + const { sync, getSaved } = makeService({ + existing: makeExisting(), + token: 'tok', + fetchModels: async () => [], + }); + await sync(); + const saved = getSaved()!; + assert.equal(saved.enabled, false); + assert.equal(saved.lastTestStatus, 'error'); + }); + + it('disables the connection with lastTestStatus=needs_reauth when the access token is unavailable', async () => { + const { sync, getSaved } = makeService({ + existing: makeExisting(), + token: null, + }); + await sync(); + const saved = getSaved()!; + assert.equal(saved.enabled, false); + assert.equal(saved.lastTestStatus, 'needs_reauth'); + }); + + it('rebuilds the fallback list from the registry on discovery failure, not the stale persisted copy', async () => { + // Existing connection carries an old fallback snapshot (pre-gpt-5.6-sol). + // A transient discovery failure must not reuse that stale copy; it must + // rebuild from the current registry fallbackModels so gpt-5.6-sol appears. + const { sync, getSaved } = makeService({ + existing: makeExisting({ models: [{ id: 'gpt-5.4' }], modelSource: 'fallback' }), + token: 'tok', + fetchModels: async () => { + throw new Error('offline'); + }, + }); + await sync(); + const saved = getSaved()!; + assert.deepEqual( + saved.models!.map((m) => m.id), + PROVIDER_DEFAULTS['openai-codex'].fallbackModels, + ); + assert.ok( + saved.models!.some((m) => m.id === 'gpt-5.6-sol'), + 'fallback must include gpt-5.6-sol from the current registry', + ); + assert.equal(saved.modelSource, 'fallback'); + assert.equal(saved.enabled, true); + assert.equal(saved.lastTestStatus, 'verified'); + }); + + it('keeps the last fetched list as a cache on transient discovery failure', async () => { + const { sync, getSaved } = makeService({ + existing: makeExisting({ models: [{ id: 'gpt-5.6-sol' }], modelSource: 'fetched' }), + token: 'tok', + fetchModels: async () => { + throw new Error('offline'); + }, + }); + await sync(); + const saved = getSaved()!; + assert.deepEqual(saved.models, [{ id: 'gpt-5.6-sol' }]); + assert.equal(saved.modelSource, 'fetched'); + assert.equal(saved.enabled, true); + }); + + it('disables with needs_reauth when /models rejects with 401', async () => { + const { sync, getSaved } = makeService({ + existing: makeExisting(), + token: 'tok', + fetchModels: async () => { + throw new OpenAiCodexDiscoveryError(401); + }, + }); + await sync(); + const saved = getSaved()!; + assert.equal(saved.enabled, false); + assert.equal(saved.lastTestStatus, 'needs_reauth'); + }); + + it('disables with error when all discovered models are filtered as unsupported', async () => { + const { sync, getSaved } = makeService({ + existing: makeExisting(), + token: 'tok', + fetchModels: async () => [{ id: 'gpt-5-codex' }], + }); + await sync(); + const saved = getSaved()!; + assert.equal(saved.enabled, false); + assert.equal(saved.lastTestStatus, 'error'); + }); + + it('clears stale models when /models returns empty', async () => { + const { sync, getSaved } = makeService({ + existing: makeExisting({ models: [{ id: 'gpt-5.6-sol' }], modelSource: 'fetched' }), + token: 'tok', + fetchModels: async () => [], + }); + await sync(); + const saved = getSaved()!; + assert.equal(saved.enabled, false); + assert.equal(saved.lastTestStatus, 'error'); + assert.deepEqual(saved.models, []); + assert.equal(saved.modelSource, 'fetched'); + }); +}); diff --git a/apps/desktop/src/main/oauth-model-connections-main.ts b/apps/desktop/src/main/oauth-model-connections-main.ts index ba6fea1c64..279dda50d0 100644 --- a/apps/desktop/src/main/oauth-model-connections-main.ts +++ b/apps/desktop/src/main/oauth-model-connections-main.ts @@ -2,13 +2,14 @@ import { CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS, PROVIDER_DEFAULTS, type LlmConnection, + type ModelDiscoverySource, } from '@maka/core/llm-connections'; import type { ConnectionStore, CredentialStore } from '@maka/storage'; import type { ClaudeSubscriptionService } from './oauth/claude-subscription-service.js'; import { isSubscriptionExperimentalEnabled } from './oauth/claude-subscription-helpers.js'; import type { OpenAiCodexService } from './oauth/openai-codex-service.js'; import { isOpenAiCodexExperimentalEnabled } from './oauth/openai-codex-helpers.js'; -import { fetchProviderModels } from '@maka/runtime'; +import { fetchProviderModels, OpenAiCodexDiscoveryError } from '@maka/runtime'; import type { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js'; export const CLAUDE_SUBSCRIPTION_CONNECTION_SLUG = 'claude-subscription'; @@ -181,14 +182,107 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio const defaults = PROVIDER_DEFAULTS['openai-codex']; const fallbackModels = defaults.fallbackModels.map((id) => ({ id })); - const normalizedModels = normalizeOpenAiCodexModels(existing?.models, fallbackModels); + const displayName = 'Codex OAuth'; + const now = Date.now(); + + // Only a previously fetched list is worth caching; a persisted fallback + // snapshot is rebuilt from the current registry so renamed/added models + // (e.g. gpt-5.6-sol) reach existing users instead of being shadowed by a + // stale copy on disk. + const cachedFetchedModels = + existing?.modelSource === 'fetched' && existing.models?.length + ? normalizeOpenAiCodexModels(existing.models, fallbackModels) + : fallbackModels; + + let models: NonNullable = cachedFetchedModels; + let modelSource: ModelDiscoverySource = + existing?.modelSource === 'fetched' ? 'fetched' : 'fallback'; + let modelsFetchedAt = existing?.modelsFetchedAt; + try { + const accessToken = await deps.openAiCodex.getAccessTokenInternal(); + if (!accessToken) { + // OAuth credentials unavailable (no stored token or refresh rejected). + // Surface as needs_reauth instead of masking as verified, so the user + // is prompted to re-login rather than hitting a guaranteed refresh + // failure on the next send. + if (!existing) return null; + return deps.connectionStore.update(existing.slug, { + enabled: false, + lastTestStatus: 'needs_reauth', + lastTestAt: new Date(now).toISOString(), + lastTestMessage: 'Codex OAuth 需要重新登录。', + }); + } + const discovered = await (deps.fetchModels ?? fetchProviderModels)( + { + slug: CODEX_SUBSCRIPTION_CONNECTION_SLUG, + name: existing?.name ?? displayName, + providerType: 'openai-codex', + baseUrl: defaults.baseUrl, + defaultModel: existing?.defaultModel || defaults.fallbackModels[0] || '', + enabled: true, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }, + accessToken, + ); + // Normalize before the empty check so a list that is non-empty but + // entirely filtered as unsupported (e.g. only gpt-5-codex) is also + // treated as "no usable models", not as fetched+fallback. + const normalized = normalizeOpenAiCodexModels(discovered, []); + if (normalized.length === 0) { + // /models returned no usable models (empty, or all filtered). Persist + // the empty fetched result so a later transient failure doesn't + // revive a stale cached list; mirror GitHub Copilot's failDiscovery. + if (!existing) return null; + return deps.connectionStore.update(existing.slug, { + enabled: false, + lastTestStatus: 'error', + models: [], + modelSource: 'fetched', + modelsFetchedAt: now, + lastTestAt: new Date(now).toISOString(), + lastTestMessage: '当前账号无可用 Codex 模型。', + }); + } + models = normalized; + modelSource = 'fetched'; + modelsFetchedAt = now; + } catch (error) { + if (error instanceof OpenAiCodexDiscoveryError) { + if (error.status === 401 || error.status === 403) { + // Auth rejected at /models - the token is unusable for this account. + if (!existing) return null; + return deps.connectionStore.update(existing.slug, { + enabled: false, + lastTestStatus: 'needs_reauth', + lastTestAt: new Date(now).toISOString(), + lastTestMessage: 'Codex OAuth 需要重新登录。', + }); + } + if (error.status >= 400 && error.status < 500) { + // Deterministic protocol error (4xx) - won't fix itself on retry. + if (!existing) return null; + return deps.connectionStore.update(existing.slug, { + enabled: false, + lastTestStatus: 'error', + models: [], + modelSource: 'fetched', + modelsFetchedAt: now, + lastTestAt: new Date(now).toISOString(), + lastTestMessage: 'Codex 模型列表获取失败。', + }); + } + } + // Transient network failure / 5xx / unknown - keep the cached fetched + // list or the curated fallback so the connection stays usable. + } + const normalizedDefaultModel = normalizeOpenAiCodexDefaultModel( existing?.defaultModel, - normalizedModels.map((entry) => entry.id), + models.map((entry) => entry.id), defaults.fallbackModels[0] || '', ); - const displayName = 'Codex OAuth'; - const now = Date.now(); const connection: LlmConnection = { slug: CODEX_SUBSCRIPTION_CONNECTION_SLUG, name: existing?.name ?? displayName, @@ -196,8 +290,9 @@ export function createOAuthModelConnectionsMainService(deps: OAuthModelConnectio baseUrl: defaults.baseUrl, defaultModel: normalizedDefaultModel, enabled: true, - models: normalizedModels, - modelSource: existing?.modelSource ?? 'fallback', + models, + modelSource, + modelsFetchedAt, lastTestStatus: 'verified', lastTestAt: new Date(now).toISOString(), lastTestMessage: 'Codex OAuth 已登录。', diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 399eef897c..c9e3112ab0 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -29,7 +29,7 @@ export type ProviderRuntimeAdapter = export type ProviderModelDiscovery = | { kind: 'protocol'; - auth?: 'claude-subscription' | 'github-copilot' | 'none'; + auth?: 'claude-subscription' | 'github-copilot' | 'openai-codex' | 'none'; path?: string; query?: Readonly>; responseShape?: 'array-or-data'; @@ -1260,7 +1260,7 @@ const providerRegistry = { status: 'phase3-experimental', protocol: 'openai', runtimeAdapter: { kind: 'openai-codex' }, - modelDiscovery: { kind: 'fallback' }, + modelDiscovery: { kind: 'protocol', auth: 'openai-codex' }, category: 'oauth', catalogBadge: 'Account', }, diff --git a/packages/runtime/src/__tests__/model-fetcher.test.ts b/packages/runtime/src/__tests__/model-fetcher.test.ts index 0b34fef0f6..5d5c73eead 100644 --- a/packages/runtime/src/__tests__/model-fetcher.test.ts +++ b/packages/runtime/src/__tests__/model-fetcher.test.ts @@ -530,25 +530,84 @@ describe('fetchProviderModels', () => { assert.deepEqual(models, [{ id: 'MiniMax-M3' }, { id: 'MiniMax-M2.7-highspeed' }]); }); - test('Codex subscription model fetch uses the pinned subscription model list', async () => { + test('Codex OAuth discovers models from the chatgpt.com/backend-api/codex/models endpoint', async () => { + const requests: Array<{ url: string; authorization: string | undefined }> = []; + const server = await startJsonServer((request, response) => { + requests.push({ url: request.url ?? '', authorization: request.headers.authorization }); + respondJson(response, 200, { + models: [ + { slug: 'hidden-model', visibility: 'hide', priority: 0 }, + { slug: 'gpt-5.6-sol', priority: 1, context_window: 372000 }, + { slug: 'gpt-5.5', priority: 2, context_window: 272000 }, + { slug: 'gpt-5.4-mini', priority: 3 }, + { slug: '', priority: 4 }, + ], + }); + }); + const models = await fetchProviderModels({ - slug: 'codex-subscription', + slug: 'openai-codex', name: 'Codex OAuth', - providerType: 'codex-subscription', - defaultModel: 'gpt-5.5', + providerType: 'openai-codex', + baseUrl: server.url, + defaultModel: 'gpt-5.6-sol', enabled: true, createdAt: 1, updatedAt: 1, - }, 'oauth-access-token'); + }, 'codex-oauth-token'); + assert.deepEqual(requests, [ + { url: '/models?client_version=1.0.0', authorization: 'Bearer codex-oauth-token' }, + ]); assert.deepEqual(models, [ - { id: 'gpt-5.5' }, - { id: 'gpt-5.4' }, + { id: 'gpt-5.6-sol', contextWindow: 372000 }, + { id: 'gpt-5.5', contextWindow: 272000 }, { id: 'gpt-5.4-mini' }, - { id: 'gpt-5.3-codex-spark' }, ]); }); + test('Codex OAuth discovery sends ChatGPT-Account-Id for account routing', async () => { + const payload = Buffer.from( + JSON.stringify({ 'https://api.openai.com/auth': { chatgpt_account_id: 'acct-42' } }), + ).toString('base64url'); + const token = `header.${payload}.sig`; + let capturedAccountId: string | string[] | undefined; + const server = await startJsonServer((request, response) => { + capturedAccountId = request.headers['chatgpt-account-id']; + respondJson(response, 200, { models: [] }); + }); + await fetchProviderModels({ + slug: 'openai-codex', + name: 'Codex OAuth', + providerType: 'openai-codex', + baseUrl: server.url, + defaultModel: 'gpt-5.6-sol', + enabled: true, + createdAt: 1, + updatedAt: 1, + }, token); + assert.equal(capturedAccountId, 'acct-42'); + }); + + test('Codex OAuth discovery surfaces the HTTP status on auth failure for caller classification', async () => { + const server = await startJsonServer((_request, response) => { + respondJson(response, 401, { error: 'unauthorized' }); + }); + await assert.rejects( + fetchProviderModels({ + slug: 'openai-codex', + name: 'Codex OAuth', + providerType: 'openai-codex', + baseUrl: server.url, + defaultModel: 'gpt-5.6-sol', + enabled: true, + createdAt: 1, + updatedAt: 1, + }, 'codex-oauth-token'), + (err: unknown) => (err as { status?: number }).status === 401, + ); + }); + test('successful empty provider responses stay fetched-empty instead of falling back', async () => { const server = await startJsonServer((_request, response) => { respondJson(response, 200, { data: [] }); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 61fc415667..a1376a2f28 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -502,8 +502,6 @@ export { canReplaceHistoryCompactCheckpoint, historyCompactCheckpointToRuntimeEvent, matchHistoryCompactCheckpointPrefix, - midTurnHeadAnchorEvent, - projectHistoryCompactCheckpointReplay, renderHistoryCompactCheckpoint, validateHistoryCompactCheckpointShape, } from './history-compact-checkpoint.js'; @@ -511,27 +509,9 @@ export type { BuildHistoryCompactCheckpointInput, HistoryCompactCheckpoint, HistoryCompactCheckpointCoverage, - HistoryCompactCheckpointHeadAnchor, - HistoryCompactCheckpointPhase, HistoryCompactCheckpointPrefixMatch, HistoryCompactCheckpointSource, } from './history-compact-checkpoint.js'; -export { - estimateNextRequestTokens, - exceedsContextWindow, - exceedsHighWater, - planMidTurnCapacityCompaction, - selectMidTurnSafeBoundary, -} from './mid-turn-capacity-compact.js'; -export type { - EstimateNextRequestTokensInput, - MidTurnBoundary, - MidTurnBoundaryOptions, - MidTurnFailReason, - MidTurnSummarizer, - PlanMidTurnCapacityCompactionInput, - PlanMidTurnCapacityCompactionResult, -} from './mid-turn-capacity-compact.js'; export { cleanupLegacyHistoryCompactArtifacts } from './history-compact-cleanup.js'; export type { HistoryCompactCleanupDiagnostic, @@ -578,7 +558,6 @@ export type { ArchiveRetrievalResult, HistoryCompactBlock, HistoryCompactCoverage, - HistoryCompactMidTurnPolicy, HistoryCompactPolicy, HistoryCompactReplayResult, HistoryCompactSourceArchiveRef, @@ -661,7 +640,7 @@ export type { SemanticCompactSummaryRequest, } from './semantic-compact.js'; export { testConnection } from './test-connection.js'; -export { fetchGitHubCopilotModels, fetchProviderModels } from './model-fetcher.js'; +export { fetchGitHubCopilotModels, fetchOpenAiCodexModels, fetchProviderModels, OpenAiCodexDiscoveryError } from './model-fetcher.js'; export { materializeSession, diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index 449f4cca31..60707b860f 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -8,7 +8,7 @@ import { import { generalizedErrorMessage } from '@maka/core/redaction'; import { proxiedFetch } from './bots/proxied-fetch.js'; import { anthropicV1Url, googleApiUrl } from './provider-urls.js'; -import { claudeSubscriptionHeaders } from './subscription-auth.js'; +import { claudeSubscriptionHeaders, openAiCodexHeaders } from './subscription-auth.js'; import { GITHUB_COPILOT_API_VERSION, GITHUB_COPILOT_COMPAT_HEADERS, @@ -87,6 +87,9 @@ export async function fetchProviderModels( try { return await fetchProviderModelsStrict(connection, apiKey); } catch (error) { + // Preserve status-bearing discovery errors so the sync layer can classify + // auth/protocol/network failures; only wrap unknown errors for display. + if (error instanceof OpenAiCodexDiscoveryError) throw error; throw new Error(generalizedErrorMessage(error, 'Failed to fetch provider models')); } } @@ -123,6 +126,9 @@ async function fetchProviderModelsStrict( if (discovery.auth === 'github-copilot') { return fetchGitHubCopilotModels(baseUrl, apiKey); } + if (discovery.auth === 'openai-codex') { + return fetchOpenAiCodexModels(baseUrl, apiKey); + } switch (definition.protocol) { case 'anthropic': { @@ -196,6 +202,77 @@ export async function fetchGitHubCopilotModels( return payload.data.flatMap(toGitHubCopilotModelInfo); } +type RawOpenAiCodexModel = { + slug?: unknown; + visibility?: unknown; + priority?: unknown; + context_window?: unknown; +}; + +/** + * Discovery error carrying the HTTP status, so callers (syncOpenAiCodexConnection) + * can classify auth failures (401/403) vs protocol errors (4xx) vs transient + * network failures without string-matching the message. + */ +export class OpenAiCodexDiscoveryError extends Error { + constructor(public readonly status: number) { + super(`HTTP ${status}`); + this.name = 'OpenAiCodexDiscoveryError'; + } +} + +/** + * Discover models from the ChatGPT/Codex OAuth backend + * (`chatgpt.com/backend-api/codex/models`). Unlike the public OpenAI API + * `/v1/models`, this endpoint reports the slugs the signed-in ChatGPT account + * can actually use over the Codex backend, including OAuth-only slugs such + * as `gpt-5.3-codex-spark`. Entries with `visibility: hide|hidden` are + * dropped; the rest are sorted by `priority` (ascending) to match the + * ChatGPT/Codex picker order. + */ +export async function fetchOpenAiCodexModels( + baseUrl: string, + accessToken: string, + fetchFn?: typeof fetch, +): Promise { + const response = await (fetchFn ?? proxiedFetch)( + `${stripTrailing(baseUrl)}/models?client_version=1.0.0`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + ...openAiCodexHeaders(accessToken), + 'content-type': 'application/json', + }, + ...(fetchFn ? { signal: AbortSignal.timeout(MODEL_FETCH_TIMEOUT_MS) } : { timeoutMs: MODEL_FETCH_TIMEOUT_MS }), + }, + ); + if (!response.ok) throw new OpenAiCodexDiscoveryError(response.status); + const payload = await response.json() as { models?: RawOpenAiCodexModel[] }; + if (!Array.isArray(payload?.models)) throw new Error('Invalid OpenAI Codex models response'); + const visible = payload.models.filter((model) => { + if (!model || typeof model.slug !== 'string' || !model.slug.trim()) return false; + const visibility = typeof model.visibility === 'string' ? model.visibility.trim().toLowerCase() : ''; + return visibility !== 'hide' && visibility !== 'hidden'; + }); + visible.sort((a, b) => priorityOfOpenAiCodexModel(a) - priorityOfOpenAiCodexModel(b)); + return visible.map((model) => { + const entry: ModelInfo = { id: (model.slug as string).trim() }; + const contextWindow = contextWindowOfOpenAiCodexModel(model); + if (contextWindow !== undefined) entry.contextWindow = contextWindow; + return entry; + }); +} + +function priorityOfOpenAiCodexModel(model: RawOpenAiCodexModel): number { + return typeof model.priority === 'number' && Number.isFinite(model.priority) ? model.priority : 10_000; +} + +function contextWindowOfOpenAiCodexModel(model: RawOpenAiCodexModel): number | undefined { + return typeof model.context_window === 'number' && Number.isFinite(model.context_window) && model.context_window > 0 + ? model.context_window + : undefined; +} + function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] { if ( !model.id