From 1bf30670d4868dcaa2315d34b212fd7ab4aa84dd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 7 Sep 2026 05:48:19 -0400 Subject: [PATCH 01/28] fix(relay-ops): let the rehome trust probe approve the asia-east2 cells (#19275) --- .../dev/scripts/probe-relay-rehome-trust.mjs | 4 +++- .../scripts/probe-relay-rehome-trust.test.mjs | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.mjs index 9a7d505d4bb..2208131e58a 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.mjs @@ -1,7 +1,9 @@ import { pathToFileURL } from 'node:url' import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs' -const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26)$/ +// Every general cell that carries the rehome identity: the sixteen US cells and the +// three asia-east2 cells that drain mis-homed hosts back the other way. +const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29)$/ const DIRECTOR_ORIGIN = 'https://relay.onorca.dev' export function parseRehomeTrustProbeArguments(argv, environment = process.env) { diff --git a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs index 7d7b2cd95ac..789d33c40b6 100644 --- a/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs +++ b/cloud/dev/scripts/probe-relay-rehome-trust.test.mjs @@ -111,3 +111,23 @@ test('fails when both trust-probe attempts return a transient 503', async () => ) assert.equal(calls, 2) }) + +test('approves the asia-east2 rehome sources and still rejects unlisted cells', () => { + for (const cellId of ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']) { + const parsed = parseRehomeTrustProbeArguments( + argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), + environment + ) + assert.equal(parsed.cellId, cellId) + } + for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c30']) { + assert.throws( + () => + parseRehomeTrustProbeArguments( + argv.map((value) => (value === 'production-gce-c7' ? cellId : value)), + environment + ), + /--cell-id is not approved/ + ) + } +}) From 9d29e6878e7092ea5b5c74864d3f14e0fb0d8026 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 7 Sep 2026 03:28:33 -0700 Subject: [PATCH 02/28] fix(codex): distinguish personal and enterprise accounts sharing an email (#19279) * fix(codex): distinguish same-email accounts in the switcher * fix(codex): scope switcher disambiguation to the visible runtime group Review follow-ups: wrap labels at word boundaries instead of mid-word, disambiguate against the accounts a group actually renders, and tolerate a missing email arriving from persisted settings or a remote summary. --- .../codex-accounts/codex-auth-identity.ts | 33 ++++- .../codex-auth-workspace-identity.test.ts | 106 ++++++++++++++++ .../runtime-home-per-account-homes.test.ts | 115 +++++++++--------- .../service-add-account-from-home.test.ts | 70 ++++++++++- .../accounts-pane-codex-account-row.tsx | 14 ++- .../status-bar/CodexSwitcherMenu.tsx | 4 +- .../status-bar/codex-status-sign-in.test.tsx | 36 ++++++ .../status-bar/status-bar-codex-accounts.ts | 7 +- .../status-bar-runtime-groups.test.ts | 73 +++++++++++ .../lib/codex-account-display-label.test.ts | 60 +++++++++ .../src/lib/codex-account-display-label.ts | 47 +++++++ src/renderer/src/lib/codex-session-restart.ts | 19 ++- .../codex-stale-pane-account-identity.test.ts | 4 +- 13 files changed, 504 insertions(+), 84 deletions(-) create mode 100644 src/main/codex-accounts/codex-auth-workspace-identity.test.ts create mode 100644 src/renderer/src/lib/codex-account-display-label.test.ts create mode 100644 src/renderer/src/lib/codex-account-display-label.ts diff --git a/src/main/codex-accounts/codex-auth-identity.ts b/src/main/codex-accounts/codex-auth-identity.ts index 0455b5e481f..105dbdbdb88 100644 --- a/src/main/codex-accounts/codex-auth-identity.ts +++ b/src/main/codex-accounts/codex-auth-identity.ts @@ -182,10 +182,10 @@ export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | nul readStringClaim(authClaims, 'chatgpt_account_id') ?? readStringClaim(payload, 'chatgpt_account_id') ), - workspaceLabel: normalizeField( - readStringClaim(authClaims, 'workspace_name') ?? - readStringClaim(profileClaims, 'workspace_name') - ), + workspaceLabel: + normalizeField(readStringClaim(authClaims, 'workspace_name')) ?? + normalizeField(readStringClaim(profileClaims, 'workspace_name')) ?? + readPlanWorkspaceLabel(authClaims), workspaceAccountId: normalizeField( readStringClaim(authClaims, 'workspace_account_id') ?? tokenAccountId ?? @@ -194,6 +194,31 @@ export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | nul } } +function readPlanWorkspaceLabel(authClaims: Record | null): string | null { + // Codex tokens commonly omit workspace_name but identify the account's plan. + switch (normalizeField(readStringClaim(authClaims, 'chatgpt_plan_type'))?.toLowerCase()) { + case 'free': + return 'Personal (Free)' + case 'go': + return 'Personal (Go)' + case 'plus': + return 'Personal (Plus)' + case 'pro': + return 'Personal (Pro)' + case 'team': + return 'Team' + case 'business': + return 'Business' + case 'enterprise': + return 'Enterprise' + case 'edu': + return 'Education' + case undefined: + default: + return null + } +} + function readFreshnessFromAuthContents(contents: string): number | null { const raw = parseJsonRecord(contents) if (!raw) { diff --git a/src/main/codex-accounts/codex-auth-workspace-identity.test.ts b/src/main/codex-accounts/codex-auth-workspace-identity.test.ts new file mode 100644 index 00000000000..8a1a18ffc81 --- /dev/null +++ b/src/main/codex-accounts/codex-auth-workspace-identity.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import type { CodexManagedAccount } from '../../shared/managed-account-types' +import { + codexAuthMatchesManagedAccount, + codexAuthMatchesSystemDefaultIdentity, + readCodexAuthIdentity +} from './codex-auth-identity' + +const email = 'same@example.com' + +function auth( + accountId: string, + claims: Record, + profileClaims: Record = {} +): string { + const payload = Buffer.from( + JSON.stringify({ + email, + 'https://api.openai.com/auth': { chatgpt_account_id: accountId, ...claims }, + 'https://api.openai.com/profile': profileClaims + }) + ).toString('base64url') + return JSON.stringify({ + tokens: { account_id: accountId, id_token: `header.${payload}.signature` } + }) +} + +describe('Codex personal and organization workspace identity', () => { + it.each([ + ['free', 'Personal (Free)'], + ['go', 'Personal (Go)'], + ['plus', 'Personal (Plus)'], + ['pro', 'Personal (Pro)'], + ['team', 'Team'], + ['business', 'Business'], + ['enterprise', 'Enterprise'], + ['edu', 'Education'] + ])('uses the %s plan when the token omits the workspace name', (plan, label) => { + expect(readCodexAuthIdentity(auth('provider-1', { chatgpt_plan_type: plan }))).toEqual({ + email, + providerAccountId: 'provider-1', + workspaceAccountId: 'provider-1', + workspaceLabel: label + }) + }) + + it.each([undefined, null, '', 'future-plan', 42])( + 'does not infer personal membership from an unknown plan %s', + (plan) => { + expect( + readCodexAuthIdentity(auth('provider-1', { chatgpt_plan_type: plan }))?.workspaceLabel + ).toBeNull() + } + ) + + it('preserves an explicit organization name over the plan label', () => { + expect( + readCodexAuthIdentity( + auth('provider-1', { workspace_name: ' Acme ', chatgpt_plan_type: 'enterprise' }) + )?.workspaceLabel + ).toBe('Acme') + }) + + it('uses the profile workspace name when the auth workspace name is blank', () => { + expect( + readCodexAuthIdentity( + auth( + 'provider-1', + { workspace_name: ' ', chatgpt_plan_type: 'enterprise' }, + { workspace_name: 'Acme' } + ) + )?.workspaceLabel + ).toBe('Acme') + }) + + it('keeps same-email personal and enterprise credentials isolated in both directions', () => { + const personal = auth('personal-provider', { chatgpt_plan_type: 'plus' }) + const enterprise = auth('enterprise-provider', { chatgpt_plan_type: 'enterprise' }) + for (const [selectedAuth, otherAuth] of [ + [personal, enterprise], + [enterprise, personal] + ]) { + const identity = readCodexAuthIdentity(selectedAuth)! + const account: CodexManagedAccount = { + ...identity, + id: 'orca-account', + email, + managedHomePath: 'managed-home', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + expect(codexAuthMatchesManagedAccount(selectedAuth, account, selectedAuth)).toBe(true) + expect(codexAuthMatchesManagedAccount(otherAuth, account, selectedAuth)).toBe(false) + expect(codexAuthMatchesSystemDefaultIdentity(otherAuth, selectedAuth)).toBe(false) + } + expect(readCodexAuthIdentity(personal)?.workspaceLabel).toBe('Personal (Plus)') + expect(readCodexAuthIdentity(enterprise)?.workspaceLabel).toBe('Enterprise') + }) + + it('does not treat a matching plan label as proof of account ownership', () => { + const first = auth('enterprise-a', { chatgpt_plan_type: 'enterprise' }) + const second = auth('enterprise-b', { chatgpt_plan_type: 'enterprise' }) + expect(codexAuthMatchesSystemDefaultIdentity(first, second)).toBe(false) + }) +}) diff --git a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts index 7877482f355..dd948433962 100644 --- a/src/main/codex-accounts/runtime-home-per-account-homes.test.ts +++ b/src/main/codex-accounts/runtime-home-per-account-homes.test.ts @@ -87,64 +87,67 @@ describe('CodexRuntimeHomeService', () => { expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(managedHomePath) }) - it('gives two managed accounts distinct homes without racing one auth.json', async () => { - writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') - const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - shellStartupEnvProbeSupported: true, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: home2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // A pane for account-1 launches, then the user switches and a second pane - // for account-2 launches concurrently — each gets its OWN CODEX_HOME. - expect(service.prepareForCodexLaunch()).toBe(home1) - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - expect(service.prepareForCodexLaunch()).toBe(home2) - expect( - service.prepareForCodexLaunch(undefined, undefined, { - unavailableManagedHomePath: home1 + it.each(['two@example.com', 'one@example.com'])( + 'isolates account homes when the second email is %s', + async (secondEmail) => { + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') + const account2Auth = createCodexAuthJson(secondEmail, 'acct-2', 'two') + const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const settings = createSettings({ + shellStartupEnvProbeSupported: true, + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: home1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: secondEmail, + managedHomePath: home2, + providerAccountId: 'acct-2', + workspaceLabel: null, + workspaceAccountId: 'acct-2', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } }) - ).toBe(home2) - expect(store.updateSettings).not.toHaveBeenCalled() + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) - // Nothing is hot-swapped, so the still-running account-1 pane keeps seeing - // account-1's credentials — the single-auth.json race (GAP-5) is gone. - expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(account1Auth) - expect(readFileSync(join(home2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) - }) + // A pane for account-1 launches, then the user switches and a second pane + // for account-2 launches concurrently — each gets its OWN CODEX_HOME. + expect(service.prepareForCodexLaunch()).toBe(home1) + settings.activeCodexManagedAccountId = 'account-2' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } + expect(service.prepareForCodexLaunch()).toBe(home2) + expect( + service.prepareForCodexLaunch(undefined, undefined, { + unavailableManagedHomePath: home1 + }) + ).toBe(home2) + expect(store.updateSettings).not.toHaveBeenCalled() + + // Nothing is hot-swapped, so the still-running account-1 pane keeps seeing + // account-1's credentials — the single-auth.json race (GAP-5) is gone. + expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(account1Auth) + expect(readFileSync(join(home2, 'auth.json'), 'utf-8')).toBe(account2Auth) + expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) + } + ) it('materializes resources and config into the per-account home on launch', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') diff --git a/src/main/codex-accounts/service-add-account-from-home.test.ts b/src/main/codex-accounts/service-add-account-from-home.test.ts index 16c060c8a55..8247e5fadd0 100644 --- a/src/main/codex-accounts/service-add-account-from-home.test.ts +++ b/src/main/codex-accounts/service-add-account-from-home.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { @@ -29,6 +29,74 @@ vi.mock('node:os', async () => { describe('CodexAccountService.addAccountFromHome', () => { registerCodexAccountsTestHomes() + it('imports and switches personal and enterprise accounts sharing an email independently', async () => { + vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) + const sourceHomes = [ + mkdtempSync(join(tmpdir(), 'orca-codex-personal-')), + mkdtempSync(join(tmpdir(), 'orca-codex-enterprise-')) + ] + const email = 'same@example.com' + const credentials = ['plus', 'enterprise'].map((plan) => { + const parsed = JSON.parse(createCodexAuthJson(email, `provider-${plan}`, `refresh-${plan}`)) + const payload = Buffer.from( + JSON.stringify({ + email, + 'https://api.openai.com/auth': { + chatgpt_account_id: `provider-${plan}`, + chatgpt_plan_type: plan + } + }) + ).toString('base64url') + parsed.tokens.id_token = `header.${payload}.signature` + return JSON.stringify(parsed) + }) + + try { + sourceHomes.forEach((home, index) => { + writeFileSync(join(home, 'auth.json'), credentials[index], 'utf-8') + }) + const store = createStore(createSettings()) + const runtimeHome = createRuntimeHome() + const { CodexAccountService } = await import('./service') + const service = new CodexAccountService( + store as never, + createRateLimits() as never, + runtimeHome as never + ) + + await service.addAccountFromHome(sourceHomes[0]) + const result = await service.addAccountFromHome(sourceHomes[1]) + const accounts = store.getSettings().codexManagedAccounts + expect(result.accounts).toHaveLength(2) + expect(new Set(accounts.map((account) => account.id)).size).toBe(2) + expect(new Set(accounts.map((account) => account.managedHomePath)).size).toBe(2) + expect(accounts.map((account) => account.email)).toEqual([email, email]) + expect(accounts.map((account) => account.workspaceLabel)).toEqual([ + 'Personal (Plus)', + 'Enterprise' + ]) + expect(accounts.map((account) => account.providerAccountId)).toEqual([ + 'provider-plus', + 'provider-enterprise' + ]) + + for (const account of accounts) { + const selected = await service.selectAccount(account.id) + expect(selected.activeAccountId).toBe(account.id) + expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe(account.id) + accounts.forEach((entry, index) => { + expect(readFileSync(join(entry.managedHomePath, 'auth.json'), 'utf-8')).toBe( + credentials[index] + ) + }) + } + expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(4) + } finally { + sourceHomes.forEach((home) => rmSync(home, { recursive: true, force: true })) + vi.doUnmock('../codex-cli/command') + } + }) + it('registers a managed Codex account by importing an authenticated CODEX_HOME', async () => { vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-')) diff --git a/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx b/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx index 21bef8fb17c..182b8de5aee 100644 --- a/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx +++ b/src/renderer/src/components/settings/accounts-pane-codex-account-row.tsx @@ -1,6 +1,7 @@ import { Loader2, RefreshCw, Trash2 } from 'lucide-react' import type { CodexRateLimitAccountsState } from '../../../../shared/managed-account-types' import { translate } from '@/i18n/i18n' +import { getCodexAccountDisplayDetail } from '@/lib/codex-account-display-label' import { selectCodexProviderAccount } from '@/runtime/runtime-provider-accounts-client' import { Badge } from '../ui/badge' import { Button } from '../ui/button' @@ -48,6 +49,7 @@ export function renderCodexAccountRow( accountId: account.id }) const needsReauthentication = Boolean(accountAuthWarning) + const accountDetail = getCodexAccountDisplayDetail(account, codexAccounts.accounts) const isReauthing = codexAction === `reauth:${account.id}` const isRemoving = codexAction === `remove:${account.id}` const isBusy = codexAction !== 'idle' || accountRuntimeUnavailable @@ -111,6 +113,12 @@ export function renderCodexAccountRow( needsReauthentication ? 'text-destructive' : 'text-muted-foreground' }`} > + {accountDetail ? ( + <> + {accountDetail} + • + + ) : null} {needsReauthentication ? ( {translate( @@ -118,12 +126,8 @@ export function renderCodexAccountRow( 'Codex reported this sign-in is out of date' )} - ) : account.workspaceLabel ? ( - {account.workspaceLabel} - ) : null} - {needsReauthentication || account.workspaceLabel ? ( - • ) : null} + {needsReauthentication ? • : null} {formatAccountTimestamp(account.lastAuthenticatedAt)} diff --git a/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx b/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx index 0a4bccdc29a..33818340d24 100644 --- a/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx +++ b/src/renderer/src/components/status-bar/CodexSwitcherMenu.tsx @@ -239,7 +239,9 @@ export function CodexSwitcherMenu({ >
- {target.label} + + {target.label} + {target.active ? ( {translate( diff --git a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx index 446019b86be..86912a2e50e 100644 --- a/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx +++ b/src/renderer/src/components/status-bar/codex-status-sign-in.test.tsx @@ -207,6 +207,42 @@ describe('status bar Codex sign-in action', () => { cleanup() }) + it.each([null, 'Enterprise'])( + 'selects the exact same-email account when workspace labels collide: %s', + async (workspaceLabel) => { + storeSettings.codexManagedAccounts = storeSettings.codexManagedAccounts.map((account) => ({ + ...account, + email: 'same@example.com', + workspaceLabel + })) + const { selectCodexProviderAccount } = + await import('@/runtime/runtime-provider-accounts-client') + vi.mocked(selectCodexProviderAccount).mockResolvedValueOnce({ + accounts: storeSettings.codexManagedAccounts, + activeAccountId: 'account-2', + activeAccountIdsByRuntime: { host: 'account-2', wsl: {} } + }) + + await renderSwitcherAndOpenAccounts('System default') + const detail = workspaceLabel ? `${workspaceLabel} · ` : '' + expect(screen.getByText(`same@example.com (${detail}account-1)`)).toBeTruthy() + fireEvent.click(screen.getByText(`same@example.com (${detail}account-2)`)) + + await waitFor(() => + expect(selectCodexProviderAccount).toHaveBeenCalledWith(storeSettings, { + accountId: 'account-2', + runtime: 'host', + wslDistro: null + }) + ) + expect(markLiveCodexSessionsForRestart).toHaveBeenCalledWith( + expect.objectContaining({ + nextAccountId: 'account-2' + }) + ) + } + ) + it('activates the signed-in account and runs the same restart workflow a switch runs', async () => { reauthenticate.mockResolvedValue(codexSnapshot('account-2')) diff --git a/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts b/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts index 62720392825..d72e4d1702d 100644 --- a/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts +++ b/src/renderer/src/components/status-bar/status-bar-codex-accounts.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { CodexRateLimitAccountsState } from '../../../../shared/managed-account-types' import { translate } from '@/i18n/i18n' +import { getCodexAccountDisplayLabel } from '@/lib/codex-account-display-label' import { getCodexStatusRuntimeKey, getCodexStatusRuntimeLabel, @@ -12,10 +13,6 @@ import { type CodexStatusAccount = CodexRateLimitAccountsState['accounts'][number] -function getCodexAccountDisplayLabel(account: CodexStatusAccount): string { - return account.workspaceLabel ? `${account.email} (${account.workspaceLabel})` : account.email -} - function getSingleConcreteCodexWslDistro(state: CodexRateLimitAccountsState): string | null { const keys = new Set() for (const [key, accountId] of Object.entries(state.activeAccountIdsByRuntime?.wsl ?? {})) { @@ -96,7 +93,7 @@ export function buildCodexStatusSwitchGroups( }, ...accountsForTarget.map((account) => ({ id: account.id, - label: getCodexAccountDisplayLabel(account), + label: getCodexAccountDisplayLabel(account, accountsForTarget), active: account.id === activeId, runtimeTarget: target })) diff --git a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts index 5e0c4a162de..c323861f85f 100644 --- a/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-runtime-groups.test.ts @@ -15,6 +15,79 @@ import { const hostLabel = navigator.userAgent.includes('Windows') ? 'Windows' : 'This device' describe('status bar runtime switch groups', () => { + it.each(['host', 'wsl'] as const)( + 'keeps same-email accounts independently selectable in the %s runtime', + (runtime) => { + const state: CodexRateLimitAccountsState = { + accounts: ['account-a', 'account-b'].map((id) => ({ + id, + email: 'same@example.com', + managedHomeRuntime: runtime, + wslDistro: runtime === 'wsl' ? 'Ubuntu' : null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + })), + activeAccountId: runtime === 'host' ? 'account-b' : null, + activeAccountIdsByRuntime: { + host: runtime === 'host' ? 'account-b' : null, + wsl: runtime === 'wsl' ? { Ubuntu: 'account-b' } : {} + } + } + const target = { runtime, wslDistro: runtime === 'wsl' ? 'Ubuntu' : null } + const group = buildCodexStatusSwitchGroups(state, target).find( + (entry) => entry.runtimeTarget.runtime === runtime + )! + expect(group.targets.slice(1)).toEqual([ + { + id: 'account-a', + label: 'same@example.com (account-a)', + active: false, + runtimeTarget: target + }, + { + id: 'account-b', + label: 'same@example.com (account-b)', + active: true, + runtimeTarget: target + } + ]) + } + ) + + it('keeps one email plain when its only same-email peer sits in another runtime group', () => { + const state: CodexRateLimitAccountsState = { + accounts: [ + { + id: 'account-host', + email: 'same@example.com', + managedHomeRuntime: 'host', + wslDistro: null, + workspaceLabel: 'Personal (Plus)', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-wsl', + email: 'same@example.com', + managedHomeRuntime: 'wsl', + wslDistro: 'Ubuntu', + workspaceLabel: 'Personal (Plus)', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeAccountId: null, + activeAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + } + const groups = buildCodexStatusSwitchGroups(state, { runtime: 'host', wslDistro: null }) + expect(groups.flatMap((group) => group.targets.slice(1).map((target) => target.label))).toEqual( + ['same@example.com (Personal (Plus))', 'same@example.com (Personal (Plus))'] + ) + }) + it('collapses WSL default into the single concrete Codex distro', () => { const state: CodexRateLimitAccountsState = { accounts: [ diff --git a/src/renderer/src/lib/codex-account-display-label.test.ts b/src/renderer/src/lib/codex-account-display-label.test.ts new file mode 100644 index 00000000000..64c99a48f0e --- /dev/null +++ b/src/renderer/src/lib/codex-account-display-label.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + getCodexAccountDisplayLabel, + type CodexDisplayAccount +} from './codex-account-display-label' + +const email = 'same@example.com' +const labels = (accounts: CodexDisplayAccount[]) => + accounts.map((account) => getCodexAccountDisplayLabel(account, accounts)) + +describe('Codex account display labels', () => { + it('names personal and enterprise workspaces sharing an email', () => { + expect( + labels([ + { id: 'personal', email, workspaceLabel: 'Personal (Plus)' }, + { id: 'enterprise', email, workspaceLabel: 'Enterprise' } + ]) + ).toEqual([`${email} (Personal (Plus))`, `${email} (Enterprise)`]) + }) + + it.each([null, 'Enterprise'])( + 'disambiguates missing or duplicate workspace names: %s', + (workspaceLabel) => { + const accounts = [ + { id: '12345678-a', email, workspaceLabel }, + { id: '12345678-b', email, workspaceLabel } + ] + const result = labels(accounts) + expect(new Set(result).size).toBe(2) + expect(result[0]).toContain('12345678-a') + expect(result[1]).toContain('12345678-b') + expect(labels(accounts.toReversed())).toEqual(result.toReversed()) + } + ) + + it('handles legacy and remote summaries without workspace metadata', () => { + expect( + labels([ + { id: 'account-a', email }, + { id: 'account-b', email: email.toUpperCase() } + ]) + ).toEqual([`${email} (account-a)`, `${email.toUpperCase()} (account-b)`]) + }) + + it('keeps unambiguous accounts concise', () => { + expect(labels([{ id: 'account-a', email }])).toEqual([email]) + expect(labels([{ id: 'account-a', email, workspaceLabel: 'Acme' }])).toEqual([ + `${email} (Acme)` + ]) + }) + + it('does not collide with a workspace name that looks like an ID suffix', () => { + const result = labels([ + { id: '12345678-a', email }, + { id: '87654321-b', email }, + { id: 'abcdefgh-c', email, workspaceLabel: '12345678' } + ]) + expect(new Set(result).size).toBe(3) + }) +}) diff --git a/src/renderer/src/lib/codex-account-display-label.ts b/src/renderer/src/lib/codex-account-display-label.ts new file mode 100644 index 00000000000..01b7b701026 --- /dev/null +++ b/src/renderer/src/lib/codex-account-display-label.ts @@ -0,0 +1,47 @@ +export type CodexDisplayAccount = { + id: string + email: string + workspaceLabel?: string | null +} + +// Emails round-trip through persisted settings and remote summaries; tolerate a missing one. +export function normalizeCodexAccountEmail(email: string | null | undefined): string { + return (email ?? '').trim().toLowerCase() +} + +export function getCodexAccountDisplayDetail( + account: CodexDisplayAccount, + accounts: readonly CodexDisplayAccount[] +): string | null { + const workspace = account.workspaceLabel?.trim() || null + const email = normalizeCodexAccountEmail(account.email) + const peers = accounts.filter( + (entry) => entry.id !== account.id && normalizeCodexAccountEmail(entry.email) === email + ) + const workspaces = [workspace, ...peers.map((entry) => entry.workspaceLabel?.trim() || null)] + if ( + peers.length === 0 || + (workspaces.every(Boolean) && new Set(workspaces).size === workspaces.length) + ) { + return workspace + } + + // Extend the stored account ID prefix until even same-prefix accounts are distinguishable. + let length = Math.min(8, account.id.length) + while ( + length < account.id.length && + peers.some((entry) => entry.id.slice(0, length) === account.id.slice(0, length)) + ) { + length += 1 + } + const identifier = account.id.slice(0, length) + return workspace ? `${workspace} · ${identifier}` : identifier +} + +export function getCodexAccountDisplayLabel( + account: CodexDisplayAccount, + accounts: readonly CodexDisplayAccount[] +): string { + const detail = getCodexAccountDisplayDetail(account, accounts) + return detail ? `${account.email} (${detail})` : account.email +} diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts index d3a111e86db..c2f1119ca54 100644 --- a/src/renderer/src/lib/codex-session-restart.ts +++ b/src/renderer/src/lib/codex-session-restart.ts @@ -6,6 +6,10 @@ import { type RuntimeTerminalProcessInspection } from '@/runtime/runtime-terminal-inspection' import { translate } from '@/i18n/i18n' +import { + getCodexAccountDisplayLabel, + normalizeCodexAccountEmail +} from './codex-account-display-label' import { isShellProcess } from '../../../shared/shell-process-detection' import { isCodexForegroundProcess, @@ -326,13 +330,7 @@ export async function markRestoredStaleCodexSessionsForRestart(args?: { return scans.map((scan) => (notifiedPtyIds.has(scan.ptyId) ? { ...scan, notified: true } : scan)) } -/** - * Names an account for the restart prompt. - * - * Why the collision check: one OpenAI login added under two ChatGPT workspaces - * gives both accounts the same email, and "switch from x@y to x@y" names - * neither. The workspace is appended only when it is what tells them apart. - */ +// Same-email accounts need the same workspace or ID distinction as the switcher. export function resolveCodexRestartPromptAccountLabel( accounts: readonly { id: string; email: string; workspaceLabel?: string | null }[], accountId: string | null | undefined @@ -344,12 +342,11 @@ export function resolveCodexRestartPromptAccountLabel( if (!account) { return translate('auto.lib.codex.session.restart.9f0b1c2d3e', 'Codex account') } + const email = normalizeCodexAccountEmail(account.email) const sharesEmail = accounts.some( - (entry) => entry.id !== account.id && entry.email === account.email + (entry) => entry.id !== account.id && normalizeCodexAccountEmail(entry.email) === email ) - return sharesEmail && account.workspaceLabel - ? `${account.email} (${account.workspaceLabel})` - : account.email + return sharesEmail ? getCodexAccountDisplayLabel(account, accounts) : account.email } async function createCodexAccountLabelResolver(): Promise<(accountId: string | null) => string> { diff --git a/src/renderer/src/lib/codex-stale-pane-account-identity.test.ts b/src/renderer/src/lib/codex-stale-pane-account-identity.test.ts index aeeb1c674c9..1389d88cd89 100644 --- a/src/renderer/src/lib/codex-stale-pane-account-identity.test.ts +++ b/src/renderer/src/lib/codex-stale-pane-account-identity.test.ts @@ -80,7 +80,7 @@ describe('stale Codex panes are decided by account id, not label', () => { } }) - it('keeps the prompt when the two accounts resolve to the same label', async () => { + it('distinguishes same-email accounts even without workspace names', async () => { vi.mocked(window.api.codexAccounts.listStalePanes).mockResolvedValue([ { ptyId: 'pty-1', launchAccountId: 'account-a', activeAccountId: 'account-b' } ]) @@ -88,6 +88,8 @@ describe('stale Codex panes are decided by account id, not label', () => { const scans = await markRestoredStaleCodexSessionsForRestart() expect(noticeFor('pty-1')).toMatchObject({ + previousAccountLabel: `${SHARED_EMAIL} (account-a)`, + nextAccountLabel: `${SHARED_EMAIL} (account-b)`, previousAccountId: 'account-a', nextAccountId: 'account-b' }) From 8cd0abf76acd830bd96b5b7df2f266e2f4480fb3 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:39:38 -0400 Subject: [PATCH 03/28] test(relay): prove the capability header reaches acceptControl over a real upgrade (#19274) The unit tests cover parseRelayHostCapabilities, the sendHelloAck gating, and the header literal separately, but nothing joined them: a typo in the header name read off the upgrade request passed the entire suite. This drives a real control upgrade carrying the header, leaves an invite connection pending, and asserts the rebound control's ack. Renaming the header the server reads fails it. --- cloud/apps/relay/src/relay.blackbox.test.ts | 75 ++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/cloud/apps/relay/src/relay.blackbox.test.ts b/cloud/apps/relay/src/relay.blackbox.test.ts index 38134213e76..0202d964ee7 100644 --- a/cloud/apps/relay/src/relay.blackbox.test.ts +++ b/cloud/apps/relay/src/relay.blackbox.test.ts @@ -9,7 +9,9 @@ import { fileURLToPath } from 'node:url' import { exportJWK, generateKeyPair, jwtVerify, SignJWT } from 'jose' import { buildHostProofMacInput, - HOST_CHALLENGE_PLAINTEXT_DOMAIN + HOST_CHALLENGE_PLAINTEXT_DOMAIN, + RELAY_HOST_CAPABILITIES_HEADER, + RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS } from '@orca-cloud/relay-contract' import nacl from 'tweetnacl' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -282,11 +284,17 @@ async function openHostControl(input?: { previousGeneration?: number keyPair?: nacl.BoxKeyPair assignmentEpoch?: number + capabilities?: string }): Promise<{ socket: WebSocket; ack: Record; keyPair: nacl.BoxKeyPair }> { const keyPair = input?.keyPair ?? nacl.box.keyPair() const hostId = createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16) const socket = new WebSocket(`${relayUrl.replace('http:', 'ws:')}/v1/host/control`, { - headers: { authorization: `Bearer ${await relayToken('orca-relay', hostId)}` }, + headers: { + authorization: `Bearer ${await relayToken('orca-relay', hostId)}`, + ...(input?.capabilities + ? { [RELAY_HOST_CAPABILITIES_HEADER]: input.capabilities } + : {}) + }, perMessageDeflate: false }) await new Promise((resolveOpen, reject) => { @@ -653,6 +661,69 @@ describe('served relay URL', () => { expect(result.reason).not.toContain('http') }) + it('restates a pending connection to the rebound control, detailed only when advertised', async () => { + // The one link the unit tests cannot reach: an upgrade that really carries + // x-orca-host-capabilities must reach acceptControl and change the ack. A + // typo in the header name here passes every other test in the suite. + const host = await openHostControl() + const hostId = createHash('sha256') + .update(host.keyPair.publicKey) + .digest('base64url') + .slice(0, 16) + const inviteResponse = nextMessage(host.socket) + host.socket.send( + JSON.stringify({ + type: 'invite-create', + reqId: 'capability-invite', + relayDeviceId: 'capability-device' + }) + ) + const invite = await inviteResponse + const phone = new WebSocket(`${relayUrl.replace('http:', 'ws:')}/v1/connect/${hostId}`, { + headers: forwardedHeaders() + }) + await new Promise((resolveOpen, reject) => { + phone.once('open', resolveOpen) + phone.once('error', reject) + }) + const connectionPromise = nextMessage(host.socket) + phone.send( + JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: invite.inviteToken }) + ) + // Never attached: the connection stays pending, which is what the ack restates. + const connection = await connectionPromise + expect(connection.type).toBe('conn-open') + + const capable = await openHostControl({ + keyPair: host.keyPair, + controlResumeSecret: String(host.ack.controlResumeSecret), + previousGeneration: 1, + capabilities: RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS + }) + expect(capable.ack.pendingConns).toEqual([ + { + connId: connection.connId, + connTicket: connection.connTicket, + kind: 'invite', + relayDeviceId: 'capability-device' + } + ]) + + const legacy = await openHostControl({ + keyPair: host.keyPair, + controlResumeSecret: String(capable.ack.controlResumeSecret), + previousGeneration: 1 + }) + // A shipped host parses these entries strictly, so an unannounced key would + // fail the whole ack and kill a control that was working. + expect(legacy.ack.pendingConns).toEqual([ + { connId: connection.connId, connTicket: connection.connTicket } + ]) + + phone.close() + legacy.socket.close() + }) + it('keeps a pending attach usable after a bad ticket and rejects ticket replay', async () => { const host = await openHostControl() const hostId = createHash('sha256') From a899f92402859440cff772e1babde707002f607c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:18:38 -0700 Subject: [PATCH 04/28] feat(windows): enable structured Codex chat on native Windows (#18519) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): enable Windows structured sessions * fix(codex): prove native Windows process identity * style(codex): format Windows session seam * fix Windows structured Codex admission * fix(windows): reprobe missing process identity capability * fix(windows): decide folder-workspace WSL routing before the click Review found pathUsesWslUnc exported but unused, and the folder composer hardcoding worktreeUsesWslPath:false. Together those meant a folder picked under a \\wsl.localhost\ parent routed to structured chat, then got refused by the host and fell back AFTER the click -- which defeats the lane's own design goal that create cannot fail after the click. The group's parentPath is in scope at submit and the workspace is created under it, so the parent decides WSL-ness pre-click. Wires pathUsesWslUnc there and adds tests for the helper, including the unhydrated-store case that previously threw. * fix(windows): collapse the gate derivation to one call, restoring max-lines CI static analysis failed: launch-agent-in-new-tab.ts crossed the 300-line oxlint ceiling. Adding a max-lines disable is forbidden, so the two gate derivations collapse into one readWindowsStructuredGateInputs() call -- a store-backed site now adds one line and one import name instead of two. Better shape anyway: one derivation entry point rather than two reads a call site must remember to pair. * fix(windows): engage the legacy fallback when the host THROWS a refusal Review found a P1 this merge composes: neither parent could reach it. At the lane head the only structured entry was launch-agent-in-new-tab (full store-backed WSL check); on main all win32 was refused. The merge enables win32 in creation flows that pass no projectRuntime, so a WSL folder workspace, a WSL-configured repo, or a repair-required runtime now routes structured -- and the host refuses correctly, but by THROWING rather than returning {ok:false, refusal}. Callers engage their legacy-terminal fallback on the refusal CLASS, so an unmapped throw arrives as a generic RPC rejection: no fallback, empty workspace, error toast, prompt stranded in the launch outbox. Pre-merge the same action opened a legacy terminal agent. Map the host's thrown definitive refusals onto the refusal class at the launch boundary, so every creation flow -- present and future -- degrades to the legacy terminal instead of stranding. Narrow predicate: unrelated failures (ECONNRESET, empty message, non-Error) still propagate untouched. Ablation-proven: removing the mapping reddens the fallback test. * fix(windows): teach the mobile RPC double the status probe the lane added CI's first-ever run on this lane caught a pre-existing lane defect. The lane changed status.get to resolve through runtime.getStatusAfterWindowsProcessStartTimeProbe(), but never taught the mobile-surface runtime double about it, so status.get failed for mobile clients with "not a function". The lane's own test list did not include this file and the lane had zero CI, so nothing ever ran it. The real runtime always implements the method; the double omitted it. * chore: merge current main and regenerate the localization runtime catalog CI static analysis failed on a stale en-runtime-required.json: main added onboarding integration-capability keys, and the generated catalog is checked against the PR MERGE result, not the branch alone -- so it read clean locally while failing in CI. Merging current main (90780acb85) and regenerating. Gates after the merge: pnpm tc 0, oxlint 0, changed-code quality 0/56, 7 gate/lane test files 69 tests green. * fix: route structured launches by execution host platform * fix: recover paired structured session mirror on host swap * Revert "fix: recover paired structured session mirror on host swap" This reverts commit 81bfca0007dbbbc150a9cfcc9a24e3d060c70850. * Revert "fix: route structured launches by execution host platform" This reverts commit 47abbd354acf45fd6acc69589ad55f6341493281. * fix(windows): refuse structured chat in a paired web client Reverts the two review-loop commits (restoring a tree byte-identical to the validated head) and closes the hole they were aiming at, without their cost. A paired web client's `platform` describes the browser's machine, not the host that will run the agent, so the Windows gate cannot be evaluated there. Before this, a browser on macOS driving a Windows runtime read "not win32", skipped the creation-time proof entirely and allowed structured chat — fail-OPEN, the dangerous direction, bypassing the guarantee this lane is built on. `isWebClient` is a required input like the other gate fields, so the compiler enumerated all seven call sites. Refusal is synchronous and fail-closed: no async round-trip, no null window, no cache to invalidate — unlike keying on an asynchronously-fetched host platform, which would have made every desktop launch wait on a round-trip to fix a paired-web-only hole. Paired web therefore gets the legacy chat until the host publishes eligibility itself; that is the proper fix and belongs in its own PR. Ablation-proven: removing the guard reddens both refusal tests; the desktop-unaffected test is a preservation check and passes either way. Gates: tc 0, oxlint 0. Known open: repos-onboarding-folder-startup.test.ts fails on this branch and passes on plain main — under investigation, NOT caused by this commit. * test(onboarding): mock the web-client check the store path now reaches The web-client refusal added `isWebClientLocation()` to the launch-route inputs, which this suite's store path reaches while adding the FIRST folder. The suite stubs `window` as `{ api }` with no `location`, so the function cleared its `typeof window === 'undefined'` guard and then threw on `window.location.pathname`. That threw inside addNonGitFolder's own catch, so folder-1 never activated; folder-2 then returned early (a project already existed) before reaching the call at all, leaving exactly one activation with no startup seed. Test artifact, not a product defect: a real renderer always has `window.location`, so the seeding path is intact for users. Mocking the module is the convention 7 other suites already use, and keeps product code free of defensive branches that only exist to satisfy a stub. Ablation-proven: removing the mock reproduces the original failure exactly. * fix(renderer): make the web-client check total over a partial window isWebClientLocation() guarded `typeof window === 'undefined'` and then assumed `window.location` existed. A window stubbed without a location cleared the guard and threw on `.pathname`. That matters because this branch put the call on the launch-routing path, where the throw is swallowed by the caller's catch and silently becomes a FAILED LAUNCH rather than a visible error. CI caught it as 9 failures in launch-work-item-direct.test.ts. I previously "fixed" this by mocking the module in the one suite I knew about. That was whack-a-mole against an unbounded set, and it missed this one. The defect is the partial-window assumption, so fix it there: the mock is removed from the onboarding suite and both suites now pass on the hardening alone. Ablation-proven: reverting to the unguarded form reddens 11 tests across the new unit suite and launch-work-item-direct. Gates: tc 0, oxlint 0, changed-code quality 0/58. * Move Codex's Windows structured-chat eligibility onto the host createSupport probe The renderer no longer decides Codex win32 eligibility: launchStructuredAgentSession probes agentSession.createSupport for both providers, the host answers via supportsCodexStructuredLocation (process start-time proof + WSL refusal), and the create path re-checks live. Deletes the client-side windows gate module and its routing inputs (windowsProcessStartTime, worktreeUsesWslPath, isWebClient, platform) from six call sites. Splits killCodexAppServerProcessTree out of codex-app-server-session to hold the max-lines ceiling without a disable. * fix(ci): keep pnpm lockfile stable * test(windows): align foreground snapshot flags * Restore main's pane-snapshot flag contract Main asks for CreationTime on both projections; this branch's hot-path isolation went away with the async probe it served. --------- Co-authored-by: Orca Worker Co-authored-by: Merge Sim Co-authored-by: Merge Sim --- .../rebuild-native-deps-node-pty.test.mjs | 22 +++ .../rebuild-native-deps-test-fixtures.mjs | 39 +++- .../windows-process-tree-gyp-rebuild.mjs | 42 ++++ .../windows-process-tree-gyp-rebuild.test.mjs | 47 +++++ config/tsconfig.cli.json | 1 + .../codex/codex-app-server-client.test.ts | 3 +- src/main/codex/codex-app-server-client.ts | 10 +- .../codex-app-server-process-tree-kill.ts | 76 +++++++ src/main/codex/codex-app-server-session.ts | 73 +------ ...codex-structured-launch-resolution.test.ts | 22 ++- .../codex-structured-launch-resolution.ts | 10 + .../codex-structured-location-support.test.ts | 47 +++++ .../codex-structured-location-support.ts | 8 +- .../codex/codex-structured-session-adapter.ts | 3 +- .../codex/codex-structured-session-state.ts | 2 + .../structured-agent-session-acquisition.ts | 78 ++++++++ .../structured-agent-session-attach-flow.ts | 132 ++++++------- ...uctured-agent-session-host-handoff.test.ts | 186 ++++++++++++++++++ .../structured-agent-session-host-handoff.ts | 10 + ...nt-session-processless-reservation.test.ts | 145 ++++++++++++++ ...ructured-agent-session-provider-support.ts | 32 ++- src/main/own-chromium-tree-kill-guard.test.ts | 2 +- ...refused-tree-kill-root-termination.test.ts | 2 +- ...ocess-identity-probe-windows-batch.test.ts | 42 ++++ .../agent-session-process-identity-probe.ts | 22 +++ src/main/runtime/orca-runtime-get-status.ts | 6 + ...lve-recovered-structured-tui-transcript.ts | 23 ++- .../orchestration-worker-start-mode.test.ts | 14 +- .../orchestration-worker-start-mode.ts | 3 - .../methods/orchestration/worker/workers.ts | 3 +- .../methods/structured-agent-session-gate.ts | 5 +- .../structured-agent-session-runtime.test.ts | 31 +++ ...ctured-agent-session-support-probe.test.ts | 39 ++++ ...-vault-session-resume-in-chat-workspace.ts | 2 - .../folder-workspace-composer-submit.ts | 7 +- .../composer-state/full-creation-execution.ts | 3 +- .../quick-creation-execution.ts | 2 - .../src/lib/agent-launch-routing.test.ts | 68 ++----- src/renderer/src/lib/agent-launch-routing.ts | 2 - .../src/lib/launch-agent-in-new-tab.ts | 1 - .../launch-structured-agent-session.test.ts | 182 ++++++++--------- .../lib/launch-structured-agent-session.ts | 11 +- ...unch-work-item-direct-route-preparation.ts | 1 - .../lib/onboarding-folder-agent-startup.ts | 1 - ...nt-session-launch-refusal-fallback.test.ts | 3 + ...ent-session-launch-resume-identity.test.ts | 15 +- .../src/lib/web-client-location.test.ts | 43 ++++ src/renderer/src/lib/web-client-location.ts | 7 +- ...windows-terminal-capabilities-race.test.ts | 80 ++++++++ .../lib/windows-terminal-capabilities.test.ts | 13 +- .../src/lib/windows-terminal-capabilities.ts | 2 + .../lib/windows-terminal-capability-read.ts | 12 +- ...indows-terminal-capability-reprobe.test.ts | 34 +++- .../windows-terminal-capability-reprobe.ts | 14 +- .../child-process-import-allowlist.txt | 1 - .../child-process-import-boundary.test.ts | 2 +- src/shared/runtime-session-contracts.ts | 2 + ...tructured-native-chat-launch-route.test.ts | 13 +- .../structured-native-chat-launch-route.ts | 8 - 59 files changed, 1324 insertions(+), 385 deletions(-) create mode 100644 src/main/codex/codex-app-server-process-tree-kill.ts create mode 100644 src/main/codex/codex-structured-location-support.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts create mode 100644 src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts create mode 100644 src/renderer/src/lib/web-client-location.test.ts create mode 100644 src/renderer/src/lib/windows-terminal-capabilities-race.test.ts diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs index 871732dd53d..c3a8f9bbd83 100644 --- a/config/scripts/rebuild-native-deps-node-pty.test.mjs +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -173,6 +173,28 @@ describe('rebuild-native-deps patched node-pty rebuild', () => { } }) + it('refuses a Windows rebuild when the process creation-time patch is missing', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { creationTimePatchApplied: false }) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('process creation-time patch') + } finally { + removeTreeSync(projectDir) + } + }) + it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { const projectDir = mkTempProject() diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs index 2cb7d8ba8b4..db5af45a454 100644 --- a/config/scripts/rebuild-native-deps-test-fixtures.mjs +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -374,13 +374,18 @@ export function writeFakeWindowsProcessTree(projectDir) { export function writeFakeWindowsProcessTreeWithNodeAddonApi( projectDir, - { commandLinePatchApplied = true } = {} + { commandLinePatchApplied = true, creationTimePatchApplied = true } = {} ) { const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api') mkdirSync(nodeAddonApiDir, { recursive: true }) writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n') - writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(processTreeDir, 'index.js'), + creationTimePatchApplied + ? 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }\n' + : 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2 }\n' + ) mkdirSync(join(processTreeDir, 'src'), { recursive: true }) writeFileSync( join(processTreeDir, 'src', 'process_commandline.cc'), @@ -388,6 +393,36 @@ export function writeFakeWindowsProcessTreeWithNodeAddonApi( ? '// kProcessCommandLineInformation = 60\n' : unpatchedWindowsProcessTreeCommandLineSource() ) + writeFileSync( + join(processTreeDir, 'src', 'process.h'), + creationTimePatchApplied + ? 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2, CREATIONTIME = 4 };\nULONGLONG creationTimeMs;\n' + : 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2 };\n' + ) + writeFileSync( + join(processTreeDir, 'src', 'process.cc'), + creationTimePatchApplied + ? 'GetProcessCreationTime(pinfo);\nGetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime);\n' + : 'GetProcessMemoryUsage(pinfo);\n' + ) + writeFileSync( + join(processTreeDir, 'src', 'process_worker.cc'), + creationTimePatchApplied ? 'object.Set("creationTimeMs", process.creationTimeMs);\n' : '\n' + ) + mkdirSync(join(processTreeDir, 'lib'), { recursive: true }) + writeFileSync( + join(processTreeDir, 'lib', 'index.js'), + creationTimePatchApplied ? 'exports.ProcessDataFlag["CreationTime"] = 4;\n' : '\n' + ) + writeFileSync( + join(processTreeDir, 'lib', 'index.ts'), + creationTimePatchApplied ? 'export enum ProcessDataFlag { CreationTime = 4 }\n' : '\n' + ) + mkdirSync(join(processTreeDir, 'typings'), { recursive: true }) + writeFileSync( + join(processTreeDir, 'typings', 'windows-process-tree.d.ts'), + creationTimePatchApplied ? 'creationTimeMs?: number\n' : '\n' + ) writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n') writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n') writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n') diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs index 20d91e55497..6f21fb2a153 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -33,6 +33,17 @@ export const WINDOWS_PROCESS_TREE_PATCH_PATH = join( /** Only the patched reader defines this; the upstream one walks the PEB. */ const COMMAND_LINE_PATCH_MARKER = 'kProcessCommandLineInformation' +const CREATION_TIME_PATCH_MARKERS = [ + ['src/process.h', 'CREATIONTIME = 4'], + ['src/process.h', 'ULONGLONG creationTimeMs'], + ['src/process.cc', 'GetProcessCreationTime(pinfo)'], + ['src/process.cc', 'GetProcessTimes(hProcess, &creationTime'], + ['src/process_worker.cc', 'object.Set("creationTimeMs"'], + ['lib/index.js', '["CreationTime"] = 4'], + ['lib/index.ts', 'CreationTime = 4'], + ['typings/windows-process-tree.d.ts', 'creationTimeMs?: number'] +] + export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [ 'napi.h', 'napi-inl.h', @@ -83,6 +94,36 @@ export function inspectWindowsProcessTreeAddon(addonPath) { return readFileSync(addonPath).includes(FLAGGED_IMPORT) ? 'unpatched' : 'clean' } +export function assertWindowsProcessTreeCreationTimePatch( + packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR +) { + for (const [relativePath, expected] of CREATION_TIME_PATCH_MARKERS) { + const filePath = join(packageDir, relativePath) + if (!existsSync(filePath)) { + throw new Error( + `${filePath} is missing, so the process creation-time patch cannot be verified. ` + + 'Run pnpm install.' + ) + } + if (!readFileSync(filePath, 'utf8').includes(expected)) { + throw new Error( + `${relativePath} does not contain the process creation-time patch (${expected}). ` + + 'Run pnpm install.' + ) + } + } +} + +export function assertWindowsProcessTreeRuntimeCreationTime(windowsProcessTree) { + if (windowsProcessTree?.ProcessDataFlag?.CreationTime !== 4) { + throw new Error( + '@vscode/windows-process-tree does not expose ProcessDataFlag.CreationTime, so native ' + + 'Windows structured agent-session process ownership cannot be PID-reuse safe. Rebuild it ' + + '(pnpm run rebuild:electron) rather than using the published prebuild.' + ) + } +} + /** * Refuse to compile or load the upstream command-line reader. * @@ -159,6 +200,7 @@ export function ensureWindowsProcessTreeCommandLinePatch( rmSync(windowsProcessTreeAddonPath(packageDir), { force: true }) repaired = true } + assertWindowsProcessTreeCreationTimePatch(packageDir) return repaired } diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs index f2939b71179..56bd9a385c7 100644 --- a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -12,12 +12,15 @@ import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { + assertWindowsProcessTreeCreationTimePatch, + assertWindowsProcessTreeRuntimeCreationTime, inspectWindowsProcessTreeAddon, nodeGypRebuildInvocation, stageWindowsProcessTreeNodeAddonApiHeaders, WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS, WINDOWS_PROCESS_TREE_PACKAGE_DIR } from './windows-process-tree-gyp-rebuild.mjs' +import { writeFakeWindowsProcessTreeWithNodeAddonApi } from './rebuild-native-deps-test-fixtures.mjs' describe('windows-process-tree node-gyp rebuild', () => { it("resolves node-addon-api's gyp target from the rebuild cwd", () => { @@ -97,3 +100,47 @@ describe('inspecting a compiled windows-process-tree addon', () => { expect(inspectWindowsProcessTreeAddon(staged)).toBe('unpatched') }) }) + +describe('windows-process-tree CreationTime patch assertion', () => { + let dir + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-creation-time-')) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('accepts a package whose source and JS surfaces expose process creation time', () => { + writeFakeWindowsProcessTreeWithNodeAddonApi(dir) + + expect(() => + assertWindowsProcessTreeCreationTimePatch( + join(dir, 'node_modules', '@vscode', 'windows-process-tree') + ) + ).not.toThrow() + }) + + it('rejects a package missing the process creation-time patch', () => { + writeFakeWindowsProcessTreeWithNodeAddonApi(dir, { creationTimePatchApplied: false }) + + expect(() => + assertWindowsProcessTreeCreationTimePatch( + join(dir, 'node_modules', '@vscode', 'windows-process-tree') + ) + ).toThrow('process creation-time patch') + }) + + it('requires the runtime ProcessDataFlag.CreationTime enum', () => { + expect(() => + assertWindowsProcessTreeRuntimeCreationTime({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 } + }) + ).not.toThrow() + expect(() => + assertWindowsProcessTreeRuntimeCreationTime({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 } + }) + ).toThrow('ProcessDataFlag.CreationTime') + }) +}) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 80cf4a511f2..a23d90e6a1e 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -32,6 +32,7 @@ "../src/main/codex/codex-app-server-capability-cache.ts", "../src/main/codex/codex-app-server-capability-signal.ts", "../src/main/codex/codex-app-server-client.ts", + "../src/main/codex/codex-app-server-process-tree-kill.ts", "../src/main/codex/codex-app-server-record-reader.ts", "../src/main/codex/codex-app-server-session.ts", "../src/main/codex/codex-config-mirror.ts", diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index b845d3a9694..ce98288bdf0 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -11,7 +11,8 @@ import { runCodexHookTrustGrantSession, type CodexHookTrustGrantRequest } from './codex-app-server-client' -import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session' +import { killCodexAppServerProcessTree } from './codex-app-server-process-tree-kill' +import { runCodexAppServerSession } from './codex-app-server-session' // Stub codex app-server speaking the same JSONL protocol: initialize → // initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven diff --git a/src/main/codex/codex-app-server-client.ts b/src/main/codex/codex-app-server-client.ts index 8c95562e66c..efdee5de8bf 100644 --- a/src/main/codex/codex-app-server-client.ts +++ b/src/main/codex/codex-app-server-client.ts @@ -1,4 +1,5 @@ -import { spawn } from 'node:child_process' +import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec' +import { spawnProcess } from '../../shared/child-process/run-process' import { normalizeHookTrustKeyForLookup } from './config-toml-trust' import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session' @@ -105,7 +106,12 @@ function collectHookListings(result: unknown): CodexHookListing[] { */ export async function runCodexHookTrustGrantSession( request: CodexHookTrustGrantRequest, - spawnImpl: typeof spawn = spawn + spawnImpl: ( + program: string, + args: string[], + options: Record + ) => ChildProcessHandle = (program, args, options) => + spawnProcess({ program, args, ...options } as ProcessSpec) ): Promise { return runCodexAppServerSession( request.invocation, diff --git a/src/main/codex/codex-app-server-process-tree-kill.ts b/src/main/codex/codex-app-server-process-tree-kill.ts new file mode 100644 index 00000000000..315246aaa9f --- /dev/null +++ b/src/main/codex/codex-app-server-process-tree-kill.ts @@ -0,0 +1,76 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec' +import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate' + +/** Spawn seam for tests; production always goes through the hardened spawnProcess wrapper. */ +export type CodexAppServerSpawn = ( + program: string, + args: string[], + options: Record +) => ChildProcessHandle + +export const spawnCodexAppServerProcess: CodexAppServerSpawn = (program, args, options) => + spawnProcess({ program, args, ...options } as ProcessSpec) + +export function killCodexAppServerProcessTree( + child: Pick, + options: { platform?: NodeJS.Platform; spawnImpl?: CodexAppServerSpawn } = {} +): void { + const platform = options.platform ?? process.platform + const spawnImpl = options.spawnImpl ?? spawnCodexAppServerProcess + if (platform === 'win32' && child.pid) { + if ( + !admitProcessTreeKill({ + pid: child.pid, + site: 'codex-app-server-session-deadline', + scope: 'win-taskkill-tree' + }) + ) { + // Refusal blocks the tree walk, not the termination: the root kill is + // handle-addressed, so it cannot reach the recycled pid we refused. + child.kill('SIGKILL') + return + } + try { + // Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper + // leaves the app-server child alive after a timeout or failed shutdown. + const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + let fellBack = false + const killDirectChild = (): void => { + if (!fellBack) { + fellBack = true + child.kill('SIGKILL') + } + } + killer.on('error', killDirectChild) + killer.on('exit', (code) => { + if (code !== 0) { + killDirectChild() + } + }) + killer.unref() + return + } catch { + // Fall through to the direct-child best effort when taskkill cannot start. + } + } + if (child.pid) { + try { + // npm/package-manager launchers insert a shim child on POSIX. Reap its + // direct descendants before signalling the wrapper itself. + const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], { + stdio: 'ignore' + }) + // A missing pkill surfaces as an async 'error' event, and an unhandled one + // takes down the main process. + descendants.on('error', () => undefined) + descendants.unref() + } catch { + // The direct kill below remains the fallback when pkill is unavailable. + } + } + child.kill('SIGKILL') +} diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index cef7f40c66b..6db33b4c85d 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -1,9 +1,13 @@ -import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' import { waitForProcessExitUntil } from './codex-process-exit-deadline' import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' +import { + killCodexAppServerProcessTree, + spawnCodexAppServerProcess, + type CodexAppServerSpawn +} from './codex-app-server-process-tree-kill' import { createCodexAppServerRecordReader } from './codex-app-server-record-reader' -import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate' // Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned // state (hook trust hashes, the sqlite thread index). This module owns the @@ -68,69 +72,6 @@ export type CodexAppServerRpc = { const JSON_RPC_METHOD_NOT_FOUND = -32601 const STDERR_TAIL_MAX_BYTES = 8192 -export function killCodexAppServerProcessTree( - child: Pick, - options: { platform?: NodeJS.Platform; spawnImpl?: typeof spawn } = {} -): void { - const platform = options.platform ?? process.platform - const spawnImpl = options.spawnImpl ?? spawn - if (platform === 'win32' && child.pid) { - if ( - !admitProcessTreeKill({ - pid: child.pid, - site: 'codex-app-server-session-deadline', - scope: 'win-taskkill-tree' - }) - ) { - // Refusal blocks the tree walk, not the termination: the root kill is - // handle-addressed, so it cannot reach the recycled pid we refused. - child.kill('SIGKILL') - return - } - try { - // Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper - // leaves the app-server child alive after a timeout or failed shutdown. - const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) - let fellBack = false - const killDirectChild = (): void => { - if (!fellBack) { - fellBack = true - child.kill('SIGKILL') - } - } - killer.on('error', killDirectChild) - killer.on('exit', (code) => { - if (code !== 0) { - killDirectChild() - } - }) - killer.unref() - return - } catch { - // Fall through to the direct-child best effort when taskkill cannot start. - } - } - if (child.pid) { - try { - // npm/package-manager launchers insert a shim child on POSIX. Reap its - // direct descendants before signalling the wrapper itself. - const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], { - stdio: 'ignore' - }) - // A missing pkill surfaces as an async 'error' event, and an unhandled one - // takes down the main process. - descendants.on('error', () => undefined) - descendants.unref() - } catch { - // The direct kill below remains the fallback when pkill is unavailable. - } - } - child.kill('SIGKILL') -} - /** Codex answering "no such method" is the only response that proves the RPC * surface is absent rather than temporarily failing. */ export function isCodexMethodNotFoundError(error: unknown): boolean { @@ -152,7 +93,7 @@ export function isCodexMethodNotFoundError(error: unknown): boolean { export async function runCodexAppServerSession( invocation: CodexAppServerInvocation, body: (rpc: CodexAppServerRpc) => Promise, - spawnImpl: typeof spawn = spawn + spawnImpl: CodexAppServerSpawn = spawnCodexAppServerProcess ): Promise { // Why: a default-home grant must run against the real ~/.codex, so strip an // inherited CODEX_HOME (envToDelete) after applying the overlay, not before. diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 484f7c1ee80..de83e74c6c8 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -44,7 +44,8 @@ function resolverFor( store: { getRecord: () => value } as unknown as AgentSessionRecordStore, resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', - resolveRollout + resolveRollout, + isWindowsProcessStartTimeAvailable: () => true }) } @@ -68,7 +69,8 @@ describe('codex structured launch resolution', () => { const resolveLaunch = createCodexStructuredLaunchResolver({ store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, - resolveCommand: () => command + resolveCommand: () => command, + isWindowsProcessStartTimeAvailable: () => true }) await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ @@ -78,6 +80,22 @@ describe('codex structured launch resolution', () => { }) }) + it('fails closed before resolving a Windows launch without creation-time proof', async () => { + await withPlatform('win32', async () => { + const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`) + const resolveLaunch = createCodexStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath, + isWindowsProcessStartTimeAvailable: () => false + }) + + await expect(resolveLaunch({ identity: IDENTITY })).rejects.toThrow( + 'Windows process creation-time proof' + ) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + }) + }) + it('resumes the last thread this session actually proved, not one a caller names', async () => { const launch = await resolverFor( record({ diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index b1cc7854808..d395ee87c12 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -13,6 +13,7 @@ import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' export type CodexStructuredLaunchResolverDeps = { store: AgentSessionRecordStore @@ -24,6 +25,8 @@ export type CodexStructuredLaunchResolverDeps = { /** Fresh shell/configured environment for this spawn; never written to the session record. */ resolveEnvironment?: () => Promise resolveRollout?: typeof resolvePinnedCodexRolloutProof + /** Test seam for the host capability; production uses the native process table. */ + isWindowsProcessStartTimeAvailable?: () => boolean } export function createCodexStructuredLaunchResolver( @@ -46,6 +49,13 @@ export function createCodexStructuredLaunchResolver( `codex structured sessions run on the local host, not ${location.executionHostId}` ) } + // Refuse before resolving launch data; a PID alone cannot prove Windows ownership. + if ( + process.platform === 'win32' && + !(deps.isWindowsProcessStartTimeAvailable ?? isWindowsProcessStartTimeAvailable)() + ) { + throw new Error('codex structured sessions require Windows process creation-time proof') + } if (accountHome.variable !== 'CODEX_HOME') { throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) } diff --git a/src/main/codex/codex-structured-location-support.test.ts b/src/main/codex/codex-structured-location-support.test.ts new file mode 100644 index 00000000000..324568956d8 --- /dev/null +++ b/src/main/codex/codex-structured-location-support.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { supportsCodexStructuredLocation } from './codex-structured-location-support' + +const LOCAL_WINDOWS_LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' +} + +const WSL_WINDOWS_LOCATION: AgentSessionExecutionLocation = { + ...LOCAL_WINDOWS_LOCATION, + wslDistro: 'Ubuntu' +} + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +describe('Codex structured location support', () => { + it('uses the injected Windows identity capability for location admission', () => { + let proofAvailable = false + withPlatform('win32', () => { + expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => proofAvailable)).toBe( + false + ) + proofAvailable = true + expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => proofAvailable)).toBe( + true + ) + }) + }) + + it('rejects WSL locations while retaining native folder support on Windows', () => { + withPlatform('win32', () => { + expect(supportsCodexStructuredLocation(WSL_WINDOWS_LOCATION, () => true)).toBe(false) + expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => true)).toBe(true) + }) + }) +}) diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts index 915d9edaa83..ad0bbefa4d3 100644 --- a/src/main/codex/codex-structured-location-support.ts +++ b/src/main/codex/codex-structured-location-support.ts @@ -2,10 +2,14 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' -export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean { +export function supportsCodexStructuredLocation( + location: AgentSessionExecutionLocation, + // Injected by the adapter, which owns this dep for every other Codex gate too. + hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable +): boolean { return ( location.executionHostId === LOCAL_EXECUTION_HOST_ID && location.wslDistro === null && - (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable()) + (process.platform !== 'win32' || hasWindowsProcessStartTimeProof()) ) } diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index afa881f8254..5b551c8b01e 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -74,7 +74,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap }) } - supportsLocation = supportsCodexStructuredLocation + supportsLocation = (location: Parameters[0]): boolean => + supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable) acquire = (input: StructuredAgentSessionAcquireInput): Promise => acquireCodexStructuredSession({ diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 2f805e8570f..5fd82f22ff9 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -41,6 +41,8 @@ export type CodexStructuredSessionAdapterDeps = { resolveLaunch: (input: { identity: AgentSessionJournalIdentity }) => Promise + /** Host capability seam; production uses the native Windows process table. */ + isWindowsProcessStartTimeAvailable?: () => boolean onEvent?: (event: CodexStructuredSessionEvent) => void openConnection?: typeof openCodexAppServerConnection readProcessStartTime?: (pid: number) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts new file mode 100644 index 00000000000..cbaafa5ff32 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -0,0 +1,78 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' +import { journalIdentityFor } from './structured-agent-session-attach' +import type { AttachFlowInput } from './structured-agent-session-attach-flow' +import { readNativeSessionOptions } from './structured-agent-session-option-restoration' + +/** A reservation with no process behind it is only a promise to spawn; the + * adapter makes it real and the store then grants the writer. */ +export async function acquireOwner( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> { + const fence = record.lease.runtimeFence + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + // Pre-spawn proof is single-use: this retry may create a child after the durable clear. + try { + try { + record = await input.store.setReservationProcesslessProof({ + sessionId: record.sessionId, + fence, + spawnToken, + processlessAt: null, + now: input.now() + }) + await input.onAcquiring?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + const acquired = await input.adapter.acquire({ + identity: journalIdentityFor(record, input.params), + fence, + // Retries must recover the original reservation, not mint a second child. + spawnToken, + ...(record.options ? { options: record.options } : {}), + ...(input.eventSink ? { events: input.eventSink } : {}) + }) + const options = await readNativeSessionOptions({ + adapter: input.adapter, + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + if (record.lease.ownerProcess === null) { + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + throw new Error('agent_session_ownership_unknown') + } + const proved = await input.store.proveOwner({ + sessionId: record.sessionId, + fence, + link: acquired.link, + now: input.now(), + ...(options ? { options } : {}) + }) + return { + record: proved, + acquisitionGeneration: acquired.acquisitionGeneration ?? null + } + } catch (error) { + if (isAgentSessionPreSpawnError(error)) { + throw error + } + return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 8697e76ba3b..4bbdd51cdf9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -5,7 +5,6 @@ // the decisions that must not be client-supplied — the spawn token, the claim // key, the owner probe — and passes them in. -import { isDeepStrictEqual } from 'node:util' import type { AgentSessionAttachResult, AgentSessionMutationResult @@ -16,7 +15,6 @@ import { admitAttachOrRefuse, attachJournal, classifyStoreFailure, - journalIdentityFor, reserveRequestFor, type AgentSessionAttachAuthority, type AgentSessionAttachParams, @@ -24,18 +22,18 @@ import { } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRootExitObservedError, AgentSessionAcquisitionRefusal, - AgentSessionPreSpawnError, isAgentSessionPreSpawnError, rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { acquireOwner } from './structured-agent-session-acquisition' import { importAdoptedTranscript, prepareAdoptedTranscript @@ -72,15 +70,27 @@ export async function performAttach( input: AttachFlowInput ): Promise> { const { params, store } = input + const unsupported = (): AgentSessionMutationResult => ({ + ok: false, + refusal: { + code: 'structured_agent_session_unsupported', + message: 'This execution host cannot create the requested structured agent session.' + } + }) const sessionId = params.envelope.sessionId const admitted = admitAttachOrRefuse(params) if (!admitted.ok) { return admitted } + // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. + if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + return unsupported() + } let record: AgentSessionRecord let acquisitionGeneration: string | null = null let reservedRecord: AgentSessionRecord | null = null + let unsupportedReservationSettlementAttempted = false let replayed = false const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } @@ -101,6 +111,21 @@ export async function performAttach( ) record = reserved.record replayed = reserved.disposition === 'replayed' + // Capability can change while the durable reservation is in flight. Recheck + // every reservation at its effect boundary so it cannot bypass the support + // gate, and release a pending reservation that support drift invalidated. + reservedRecord = record + if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { + if ( + record.lease.claimStatus === 'reserved' && + record.lease.handoffStage === 'new-owner-proving' && + record.lease.reservedSpawnToken + ) { + unsupportedReservationSettlementAttempted = true + await settleUnsupportedReservation(input, record) + } + return unsupported() + } if ( replayed && reserved.operationRow.outcome.status !== 'pending' && @@ -115,7 +140,6 @@ export async function performAttach( return { ok: false, refusal: replay.refusal } } } - reservedRecord = record if (!agentSessionLeaseAdmitsWriter(record.lease)) { const acquired = await acquireOwner(input, record) record = acquired.record @@ -123,7 +147,7 @@ export async function performAttach( } } catch (error) { const spawnToken = reservedRecord?.lease.reservedSpawnToken - if (reservedRecord && spawnToken) { + if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) { // A pre-spawn failure is its own processless proof; the settlement records the // evidence and the failed operation in one durable transaction. const exitProof = isAgentSessionPreSpawnError(error) @@ -217,6 +241,34 @@ export async function performAttach( } } +async function settleUnsupportedReservation( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + return + } + try { + await input.store.settleFailedAcquisition({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken, + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'structured_agent_session_unsupported', + message: 'Structured session support changed before the provider could start.' + }, + exitProof: 'processless', + now: input.now() + }) + } catch (error) { + throw new AggregateError([error], 'agent session unsupported reservation settlement failed') + } +} + async function settlePostAcquisitionAttachFailure( input: AttachFlowInput, record: AgentSessionRecord, @@ -261,71 +313,3 @@ async function settlePostAcquisitionAttachFailure( } throw cleanupError } - -/** A reservation with no process behind it is only a promise to spawn; the - * adapter makes it real and the store then grants the writer. */ -async function acquireOwner( - input: AttachFlowInput, - record: AgentSessionRecord -): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> { - const fence = record.lease.runtimeFence - const spawnToken = record.lease.reservedSpawnToken - if (!spawnToken) { - throw new Error('agent_session_ownership_unknown') - } - // Pre-spawn proof is single-use: this retry may create a child after the durable clear. - try { - try { - record = await input.store.setReservationProcesslessProof({ - sessionId: record.sessionId, - fence, - spawnToken, - processlessAt: null, - now: input.now() - }) - await input.onAcquiring?.() - } catch (error) { - throw new AgentSessionPreSpawnError(error) - } - const acquired = await input.adapter.acquire({ - identity: journalIdentityFor(record, input.params), - fence, - // Retries must recover the original reservation, not mint a second child. - spawnToken, - ...(record.options ? { options: record.options } : {}), - ...(input.eventSink ? { events: input.eventSink } : {}) - }) - const options = await readNativeSessionOptions({ - adapter: input.adapter, - sessionId: record.sessionId, - fence, - ...(record.options ? { priorOptions: record.options } : {}) - }) - if (record.lease.ownerProcess === null) { - await input.store.commitProcessIdentity({ - sessionId: record.sessionId, - fence, - process: acquired.process, - now: input.now() - }) - } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { - throw new Error('agent_session_ownership_unknown') - } - const proved = await input.store.proveOwner({ - sessionId: record.sessionId, - fence, - link: acquired.link, - now: input.now(), - ...(options ? { options } : {}) - }) - return { - record: proved, - acquisitionGeneration: acquired.acquisitionGeneration ?? null - } - } catch (error) { - if (isAgentSessionPreSpawnError(error)) { - throw error - } - return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts index 9bf27a11106..bf2a1381b3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts @@ -11,6 +11,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { acquireNativeHandoffOwner, createStructuredAgentSessionHostHandoff, @@ -202,6 +203,191 @@ describe('native handoff acquisition', () => { expect(order).toEqual(['append-entered', 'append-complete', 'unbind', 'acquire']) }) + + it('refuses an unsupported adapter before unbinding the TUI owner', async () => { + const location: AgentSessionExecutionLocation = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-unsupported', + workspaceKind: 'folder' + } + const operationId = `${now}-00000000000000000000000000000011` + const reserved = await store.reserveOwner({ + sessionId: 'session-handoff-unsupported', + location, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'unsupported-spawn', + claimKeyId: 'key-1', + handoffOperationId: operationId, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId, fingerprint: 'unsupported' }, + now + }) + const journal = await journals.open({ + identity: { + sessionId: 'session-handoff-unsupported', + workspaceId: location.workspaceId, + hostId: location.executionHostId, + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'unsupported-thread' } + }, + journalDir: join(root, 'unsupported-journal') + }) + const eventSink = createDeferredStructuredAgentSessionEventSink() + eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined }) + const unbind = vi.spyOn(eventSink, 'unbind') + const acquire = vi.fn>() + const adapter = { + supportsLocation: vi.fn(() => false), + acquire + } + const session = { + journal, + params: { + envelope: { + sessionId: 'session-handoff-unsupported', + clientOperationId: `${now}-00000000000000000000000000000012`, + expectedRuntimeFence: reserved.record.lease.runtimeFence, + payloadFingerprint: 'unsupported' + }, + location, + provider: 'codex' as const, + agent: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: 'unsupported-thread' } + }, + fence: reserved.record.lease.runtimeFence, + hasProviderChild: false, + acquisitionGeneration: null + } + + await expect( + acquireNativeHandoffOwner( + { + store, + adapter: adapter as never, + journalRoot: root, + claimKeyId: 'key-1' + }, + { + session: () => session, + findSession: () => session, + eventSink: () => eventSink, + flush: async () => undefined, + serialize: async (_sessionId, task) => task(), + subscribers: { + publish: vi.fn(), + reset: vi.fn(), + handoff: vi.fn(), + snapshot: vi.fn() + } as never, + now: () => now + }, + { + sessionId: 'session-handoff-unsupported', + fence: reserved.record.lease.runtimeFence, + spawnToken: 'unsupported-spawn' + } + ) + ).rejects.toThrow('structured_agent_session_unsupported') + expect(unbind).not.toHaveBeenCalled() + expect(acquire).not.toHaveBeenCalled() + }) + + it('rechecks adapter support immediately before handoff acquisition', async () => { + const sessionId = 'session-handoff-drift' + const location: AgentSessionExecutionLocation = { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-drift', + workspaceKind: 'folder' + } + const operationId = `${now}-00000000000000000000000000000021` + const reserved = await store.reserveOwner({ + sessionId, + location, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'drift-spawn', + claimKeyId: 'key-1', + handoffOperationId: operationId, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId, fingerprint: 'drift' }, + now + }) + const journal = await journals.open({ + identity: { + sessionId, + workspaceId: location.workspaceId, + hostId: location.executionHostId, + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'drift-thread' } + }, + journalDir: join(root, 'drift-journal') + }) + const eventSink = createDeferredStructuredAgentSessionEventSink() + eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined }) + const unbind = vi.spyOn(eventSink, 'unbind') + const supportsLocation = vi.fn(() => true) + supportsLocation.mockReturnValueOnce(true).mockReturnValueOnce(false) + const acquire = vi.fn>() + const adapter = { supportsLocation, acquire } + const session = { + journal, + params: { + envelope: { + sessionId, + clientOperationId: `${now}-00000000000000000000000000000022`, + expectedRuntimeFence: reserved.record.lease.runtimeFence, + payloadFingerprint: 'drift' + }, + location, + provider: 'codex' as const, + agent: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: 'drift-thread' } + }, + fence: reserved.record.lease.runtimeFence, + hasProviderChild: false, + acquisitionGeneration: null + } + + await expect( + acquireNativeHandoffOwner( + { + store, + adapter: adapter as never, + journalRoot: root, + claimKeyId: 'key-1' + }, + { + session: () => session, + findSession: () => session, + eventSink: () => eventSink, + flush: async () => undefined, + serialize: async (_sessionId, task) => task(), + subscribers: { + publish: vi.fn(), + reset: vi.fn(), + handoff: vi.fn(), + snapshot: vi.fn() + } as never, + now: () => now + }, + { sessionId, fence: reserved.record.lease.runtimeFence, spawnToken: 'drift-spawn' } + ) + ).rejects.toThrow('structured_agent_session_unsupported') + expect(supportsLocation).toHaveBeenCalledTimes(2) + expect(unbind).toHaveBeenCalledOnce() + expect(acquire).not.toHaveBeenCalled() + }) }) describe('handoff status published for a session the host no longer holds', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts index cb316850e5b..7c8c65a5292 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui import { readNativeSessionOptions } from './structured-agent-session-option-restoration' import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' +import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry' type HostHandoffAccess = { @@ -195,12 +196,21 @@ export async function acquireNativeHandoffOwner( if (!record) { throw new Error('agent_session_identity_required') } + // Native handoff bypasses attach admission; reject before unbinding TUI ownership. + if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) { + throw new Error('structured_agent_session_unsupported') + } const eventSink = host.eventSink(input.sessionId) const priorBarrier = await eventSink.drained() if (!priorBarrier.ok) { throw priorBarrier.error } eventSink.unbind() + // Recheck immediately before acquisition; capability probes may drift while + // the old TUI event sink is draining. + if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) { + throw new Error('structured_agent_session_unsupported') + } const acquired = await deps.adapter.acquire({ identity: journalIdentityFor(record, session.params), fence: input.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index a1b6b39f5e0..0f115da5ebd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -64,6 +64,151 @@ function attachParams( } describe('processless structured session reservation', () => { + it('refuses an adapter that declares no create support before reserving a lease', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const reserveOwner = vi.spyOn(store, 'reserveOwner') + const acquire = vi.fn() + const adapter = { + supportsCreate: vi.fn(() => false), + acquire, + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter + + await expect( + performAttach({ + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + }) + ).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(reserveOwner).not.toHaveBeenCalled() + expect(acquire).not.toHaveBeenCalled() + }) + + it('refuses a replay when adapter support drifts after durable reservation', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const supportsCreate = vi + .fn>() + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + const adapter = { + supportsCreate, + acquire: vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: 'link-1', + handle: { provider: 'codex' as const, threadId: 'thread-1' }, + origin: 'created' as const, + mintedAtFence: fence, + observedAt: NOW + } + })) + } as unknown as StructuredAgentSessionAdapter + const input = { + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + } + + await expect(performAttach(input)).resolves.toMatchObject({ ok: true }) + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(supportsCreate).toHaveBeenCalledTimes(3) + expect(adapter.acquire).toHaveBeenCalledOnce() + }) + + it('releases a new reservation when support drifts before acquisition', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const supportsCreate = vi + .fn>() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true) + const acquire = vi.fn() + const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter + const input = { + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-drift', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + } + + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + + expect(acquire).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + reservedSpawnToken: null, + processlessAt: null, + runtimeFence: 2, + deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } + }) + expect(store.listOperationRows()[0]?.outcome).toMatchObject({ + status: 'failed', + code: 'structured_agent_session_unsupported' + }) + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported' } + }) + expect(acquire).not.toHaveBeenCalled() + }) + it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const storeDir = join(root, 'store') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts index 99958a2bcb0..15af150c12f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -9,17 +9,35 @@ export function adapterSupportsCreate( location: AgentSessionExecutionLocation, agent: string ): boolean { - return ( - adapter.supportsCreate?.(location, agent) ?? - (agent === 'codex' && (adapter.supportsLocation?.(location) ?? false)) - ) + if (adapter.supportsCreate) { + return adapter.supportsCreate(location, agent) + } + if (agent !== 'codex') { + return false + } + // Older Codex adapters exposed only location support; absence still fails closed here. + return adapter.supportsLocation?.(location) ?? false +} + +/** Honors declared gates while retaining legacy adapters whose acquire path is authoritative. */ +export function adapterSupportsCreateIfDeclared( + adapter: StructuredAgentSessionAdapter, + location: AgentSessionExecutionLocation, + agent: string +): boolean { + if (!adapter.supportsCreate && !adapter.supportsLocation) { + return true + } + return adapterSupportsCreate(adapter, location, agent) } export function adapterSupportsRecord( adapter: StructuredAgentSessionAdapter, record: AgentSessionRecord ): boolean { - return adapter.supportsCreate - ? adapter.supportsCreate(record.location, record.provider) - : record.provider === 'codex' + if (adapter.supportsCreate) { + return adapter.supportsCreate(record.location, record.provider) + } + // Old Codex records stay readable unless the adapter explicitly rejects their location. + return record.provider === 'codex' && (adapter.supportsLocation?.(record.location) ?? true) } diff --git a/src/main/own-chromium-tree-kill-guard.test.ts b/src/main/own-chromium-tree-kill-guard.test.ts index 7b9c30687bc..5bfad815631 100644 --- a/src/main/own-chromium-tree-kill-guard.test.ts +++ b/src/main/own-chromium-tree-kill-guard.test.ts @@ -17,7 +17,7 @@ import { admitSelfInitiatedTreeKill, installMainProcessTreeKillGate } from './own-chromium-tree-kill-guard' -import { killCodexAppServerProcessTree } from './codex/codex-app-server-session' +import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill' import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate' import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log' import { diff --git a/src/main/refused-tree-kill-root-termination.test.ts b/src/main/refused-tree-kill-root-termination.test.ts index ada4b5942a9..3912d6e946e 100644 --- a/src/main/refused-tree-kill-root-termination.test.ts +++ b/src/main/refused-tree-kill-root-termination.test.ts @@ -34,7 +34,7 @@ import { terminateNotebookProcessTree } from './ipc/notebook' import { killLocalPrecheckProcessTree } from './automations/precheck-runner' import { killRecipeProcess } from '../shared/ephemeral-vm-recipe-process' import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tree-kill' -import { killCodexAppServerProcessTree } from './codex/codex-app-server-session' +import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill' import { signalProcessTree } from '../shared/child-process/process-tree-termination' import { killSourceControlAgentProcess } from './text-generation/source-control-local-process' import { terminateCodexTurnProcesses } from './codex/codex-structured-turn-processes' diff --git a/src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts b/src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts new file mode 100644 index 00000000000..5e8ef48ad62 --- /dev/null +++ b/src/main/runtime/agent-session-process-identity-probe-windows-batch.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { isWindowsProcessStartTimeAvailable, readWindowsProcessIdentityTableFresh } = vi.hoisted( + () => ({ + isWindowsProcessStartTimeAvailable: vi.fn(() => true), + readWindowsProcessIdentityTableFresh: vi.fn() + }) +) + +vi.mock('../windows/windows-process-table', async (importOriginal) => ({ + ...(await importOriginal()), + isWindowsProcessStartTimeAvailable, + readWindowsProcessIdentityTableFresh +})) + +const { readProcessStartTimesMs } = await import('./agent-session-process-identity-probe') + +const START_TIME = 1_700_000_000_000 + +afterEach(() => { + isWindowsProcessStartTimeAvailable.mockReset() + isWindowsProcessStartTimeAvailable.mockReturnValue(true) + readWindowsProcessIdentityTableFresh.mockReset() +}) + +describe('Windows owner identity batch probe', () => { + it('reads Windows start times for a batch from one process-table snapshot', async () => { + readWindowsProcessIdentityTableFresh.mockResolvedValue([ + { pid: 4242, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME }, + { pid: 4243, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME + 10 } + ]) + + await expect(readProcessStartTimesMs([4242, 4243, 4242], 'win32')).resolves.toEqual( + new Map([ + [4242, START_TIME], + [4243, START_TIME + 10] + ]) + ) + + expect(readWindowsProcessIdentityTableFresh).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts index 5d441782ca8..3fa1971b830 100644 --- a/src/main/runtime/agent-session-process-identity-probe.ts +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -131,6 +131,25 @@ async function readWindowsProcessStartTimeMs(pid: number): Promise> { + const observed = new Map(pids.map((pid) => [pid, null])) + if (pids.length === 0 || !isWindowsProcessStartTimeAvailable()) { + return observed + } + try { + const table = await readWindowsProcessIdentityTableFresh() + const startTimesByPid = new Map(table.map((row) => [row.pid, row.creationTimeMs ?? null])) + for (const pid of pids) { + observed.set(pid, startTimesByPid.get(pid) ?? null) + } + } catch { + // A missing process table is unknown, never evidence that every owner exited. + } + return observed +} + /** * Process start time is the cross-platform PID-reuse guard when no provider hook can echo the * spawn token back to the owner probe. @@ -160,6 +179,9 @@ export async function readProcessStartTimesMs( const table = await readDarwinProcessStartTimesMs(uniquePids) return new Map(uniquePids.map((pid) => [pid, table.get(pid) ?? null])) } + if (platform === 'win32') { + return readWindowsProcessStartTimesMs(uniquePids) + } return new Map( await Promise.all( uniquePids.map(async (pid) => [pid, await readProcessStartTimeMs(pid, platform)] as const) diff --git a/src/main/runtime/orca-runtime-get-status.ts b/src/main/runtime/orca-runtime-get-status.ts index bd378ea2bf7..d177c8fe64d 100644 --- a/src/main/runtime/orca-runtime-get-status.ts +++ b/src/main/runtime/orca-runtime-get-status.ts @@ -20,6 +20,7 @@ import { browserUnavailableMessage } from '../../shared/runtime-types' import { runtimeTerminalDegradation } from './native-terminal-availability' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' import type { RuntimeWorktreeLifecycleEvent } from './orca-runtime-core' import { WORKTREE_CREATE_RESULT_TTL_MS } from './orca-runtime-core' import type { RuntimePtyController } from './runtime-pty-controller-contract' @@ -56,6 +57,10 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend) const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless() const canBrowse = hasRenderer || hasOffscreen + // This field reports current Windows process-identity proof. Structured RPC + // support itself stays advertised; agentSession.createSupport owns current eligibility. + const windowsProcessStartTimeAvailable = + process.platform === 'win32' && isWindowsProcessStartTimeAvailable() const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter( (capability) => (capability !== 'browser.screencast.v1' || canBrowse) && @@ -110,6 +115,7 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId { capabilities, ...(degradations.length > 0 ? { degradations } : {}), worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS }, + ...(windowsProcessStartTimeAvailable ? { windowsProcessStartTimeAvailable } : {}), hostPlatform: process.platform, terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false, diff --git a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts index 37752d207e6..6448cc3911d 100644 --- a/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts +++ b/src/main/runtime/orca-runtime-resolve-recovered-structured-tui-transcript.ts @@ -22,6 +22,8 @@ import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentS import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { homedir } from 'node:os' import { join } from 'node:path' +import { parseWslUncPath } from '../../shared/wsl-paths' +import { parseWorkspaceKey } from '../../shared/workspace-scope' export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess { protected async resolveRecoveredStructuredTuiTranscript(input: { @@ -95,14 +97,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) { const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) - // WSL routing describes *this* machine; no remote or runtime host may inherit it. - const wslDistro = - repo && target.executionHostId === LOCAL_EXECUTION_HOST_ID + const folderScope = parseWorkspaceKey(target.worktree.id) + const folderWorkspace = folderScope?.type === 'folder' + // WSL routing describes *this* machine; no remote or runtime host may inherit + // it. Both branches key on executionHostId: the target no longer carries a + // connectionId, which used to spell remote, unresolved and local alike. + const isLocalHost = target.executionHostId === LOCAL_EXECUTION_HOST_ID + const configuredWslDistro = + repo && isLocalHost ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) : null - const folderWorkspace = this.store - ?.getFolderWorkspaces?.() - .some((workspace) => workspace.id === target.worktree.id) + // Folder workspaces have no repo Git options, so a WSL UNC path is the only + // durable signal that native Windows structured Codex cannot safely use it. + const wslDistro = + configuredWslDistro ?? + (folderWorkspace && isLocalHost + ? (parseWslUncPath(target.worktree.path)?.distro ?? null) + : null) return { executionHostId: target.executionHostId, wslDistro, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts index 7eafe9c86d4..23ed5b5a549 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.test.ts @@ -23,13 +23,11 @@ function decide( overrides: { params?: Parameters[0]['params'] settings?: Parameters[0]['settings'] - platform?: NodeJS.Platform } = {} ): WorkerStartModeReceipt { return decideWorkerStartMode({ params: { agent: 'claude', ...overrides.params }, - settings: overrides.settings === undefined ? STRUCTURED_DEFAULT : overrides.settings, - platform: overrides.platform ?? 'darwin' + settings: overrides.settings === undefined ? STRUCTURED_DEFAULT : overrides.settings }) } @@ -90,12 +88,10 @@ describe('a structured default this dispatch cannot honour', () => { ).toMatchObject({ mode: 'terminal', reason: 'tui_launch_customization' }) }) - it('keeps Codex terminal-backed on Windows and leaves Claude to the host', () => { - expect(decide({ params: { agent: 'codex' }, platform: 'win32' })).toMatchObject({ - mode: 'terminal', - reason: 'codex_on_windows' - }) - expect(decide({ params: { agent: 'claude' }, platform: 'win32' }).mode).toBe('structured') + // Neither provider is refused here on the client's platform: only the executing host knows + // whether it can read a provider child's start time, and it answers at create time. + it.each(['claude', 'codex'] as const)('leaves a Windows %s worker to the host', (agent) => { + expect(decide({ params: { agent } }).mode).toBe('structured') }) }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts index 7c02c2a688f..c1f22a2c3f4 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode.ts @@ -87,7 +87,6 @@ const BLOCKER_REASON: Record< 'floating-workspace': 'structured_unsupported_on_host', 'tui-launch-customization': 'tui_launch_customization', 'remote-execution-host': 'remote_execution_host', - 'codex-on-windows': 'codex_on_windows', 'project-runtime': 'wsl_execution_runtime', 'runtime-capability': 'structured_sessions_unavailable' } @@ -105,7 +104,6 @@ const HOST_SUPPORT_REASON: Record< export function decideWorkerStartMode(args: { params: WorkerStartModePlacement settings: WorkerStartModeSettings | null | undefined - platform: NodeJS.Platform }): WorkerStartModeReceipt { const { params, settings } = args if (!prefersStructuredNativeChatByDefault(settings)) { @@ -125,7 +123,6 @@ export function decideWorkerStartMode(args: { agent, // Set only by --on, which the placement check above already turned into a fallback. executionHostId: 'local', - platform: args.platform, hostCapabilities: RUNTIME_CAPABILITIES, // Orchestration resolves a managed worktree or folder workspace; a floating terminal is never // a worker placement. WSL is left to the executing host's own create-support probe, which diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index 6ccac1dea9e..8b14ec044cf 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -51,8 +51,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ await assertWorkerStartTaskSpecWithinPromptBudget(params.spec ?? existingTask!.spec) const mode = decideWorkerStartMode({ params, - settings: readWorkerStartModeSettings(runtime), - platform: process.platform + settings: readWorkerStartModeSettings(runtime) }) if (params.on) { // A remote worker is always a terminal agent; the mode receipt rides along so the diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index de83820e9c3..60b28425057 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -80,7 +80,10 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { // Gated first: a client that cannot read structured sessions must not be able // to make the host exist, which is an observable side effect of the surface. - if (!supportsStructuredSessions(ctx) || getStructuredAgentSessionHost()) { + if (!supportsStructuredSessions(ctx)) { + return + } + if (getStructuredAgentSessionHost()) { return } await ctx.runtime.ensureStructuredAgentSessionHost() diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 3b69a0a4be3..2ce51b1c29b 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -8,9 +8,11 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import type { AgentSessionClaimStatus, + AgentSessionExecutionLocation, AgentSessionProcessIdentity, AgentSessionRecord } from '../../shared/agent-session-record' +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -270,6 +272,35 @@ describe('structured agent-session runtime install', () => { ) ) }) + + it('does not infer Windows process identity support from an injected reader', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) + const originalPlatform = process.platform + const location: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + } + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsProcessTreeLoaderForTests(() => null) + try { + const host = await ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + readProcessStartTime: async () => 1_700_000_000_000 + }) + + expect(host.supportsCreate(location, 'codex')).toBe(false) + } finally { + __setWindowsProcessTreeLoaderForTests() + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) }) // A stop whose teardown fails must not forget the runtime it was tearing down. diff --git a/src/main/runtime/structured-agent-session-support-probe.test.ts b/src/main/runtime/structured-agent-session-support-probe.test.ts index e393e41f3a4..f55a802e979 100644 --- a/src/main/runtime/structured-agent-session-support-probe.test.ts +++ b/src/main/runtime/structured-agent-session-support-probe.test.ts @@ -6,6 +6,21 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +const { isWindowsProcessStartTimeAvailable } = vi.hoisted(() => ({ + isWindowsProcessStartTimeAvailable: vi.fn(() => true) +})) + +vi.mock('../windows/windows-process-table', async (importOriginal) => ({ + ...(await importOriginal()), + isWindowsProcessStartTimeAvailable +})) + +const originalPlatform = process.platform + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + type InstallEffects = { storeOpened: boolean writeGateAttached: boolean @@ -94,6 +109,9 @@ async function expectSupportWithoutInstall(input: { describe('structured agent-session create-support probe', () => { afterEach(() => { + setPlatform(originalPlatform) + isWindowsProcessStartTimeAvailable.mockReset() + isWindowsProcessStartTimeAvailable.mockReturnValue(true) setStructuredAgentSessionHost(null) agentSessionPtyWriteGate.detachRecordLookup() vi.restoreAllMocks() @@ -111,6 +129,27 @@ describe('structured agent-session create-support probe', () => { } ) + it.each([ + ['codex', true, { supported: true }], + ['codex', false, { supported: false, reason: 'agent' }], + ['claude', true, { supported: true }], + ['claude', false, { supported: false, reason: 'agent' }] + ] as const)( + 'requires native Windows process identity proof before answering %s support (%s)', + async (agent, proofAvailable, expected) => { + setPlatform('win32') + isWindowsProcessStartTimeAvailable.mockReturnValue(proofAvailable) + + await expectSupportWithoutInstall({ + agent, + location: { executionHostId: 'local', wslDistro: null }, + expected + }) + + expect(isWindowsProcessStartTimeAvailable).toHaveBeenCalled() + } + ) + it.each(['codex', 'claude'] as const)( 'still reports an unsupported remote %s location without installing the host', async (agent) => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index adedc94b22a..a1f2a296748 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -3,7 +3,6 @@ import { type AgentLaunchRoutingInput } from '@/lib/agent-launch-routing' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' import { useAppStore } from '@/store' @@ -47,7 +46,6 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { useAppStore.getState(), targetWorkspaceId as string ), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: (targetWorkspaceId as string).startsWith('folder:') ? 'folder' diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index ca685dded64..c016538cac9 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -1,8 +1,4 @@ -import { - CLIENT_PLATFORM, - ensureAgentStartupInTerminal, - type LinkedWorkItemSummary -} from '@/lib/new-workspace' +import { ensureAgentStartupInTerminal, type LinkedWorkItemSummary } from '@/lib/new-workspace' import { seedNativeChatLaunchDraftForAgentTab } from '@/lib/agent-launch-prompt-delivery' import { createBrowserUuid } from '@/lib/browser-uuid' import { buildAgentStartupPlan } from '@/lib/tui-agent-startup' @@ -151,7 +147,6 @@ export async function submitFolderWorkspaceCreate({ executionHostId: runtimeEnvironmentId ? `runtime:${encodeURIComponent(runtimeEnvironmentId)}` : (projectGroup.connectionId ?? 'local'), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: 'folder', promptDelivery: launchDraftPrompt ? 'draft' : 'auto-submit', diff --git a/src/renderer/src/hooks/composer-state/full-creation-execution.ts b/src/renderer/src/hooks/composer-state/full-creation-execution.ts index f199ca66f0c..0118f6c2236 100644 --- a/src/renderer/src/hooks/composer-state/full-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/full-creation-execution.ts @@ -33,7 +33,7 @@ import type { PendingSmartGitHubSubmitResolution } from './source-selection-deci import { translate } from '@/i18n/i18n' import { settleComposerSubmit } from '@/lib/composer-submit-cancellation' import { toFolderWorkspaceLinkedTask } from '@/components/sidebar/folder-workspace-composer-helpers' -import { CLIENT_PLATFORM, ensureAgentStartupInTerminal } from '@/lib/new-workspace' +import { ensureAgentStartupInTerminal } from '@/lib/new-workspace' import { createBrowserUuid } from '@/lib/browser-uuid' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' @@ -140,7 +140,6 @@ export function useFullCreationExecution(input: FullCreationExecutionInput) { agent: tuiAgent, settings, executionHostId: selectedRepoExecutionHostId ?? 'local', - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', promptDelivery: startupPlan?.draftPrompt ? 'draft' : 'auto-submit', diff --git a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts index 7160cb48b4c..a25afd9106c 100644 --- a/src/renderer/src/hooks/composer-state/quick-creation-execution.ts +++ b/src/renderer/src/hooks/composer-state/quick-creation-execution.ts @@ -51,7 +51,6 @@ import { resolveAgentLaunchRoute } from '@/lib/agent-launch-routing' import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' -import { CLIENT_PLATFORM } from '@/lib/new-workspace' export function useQuickCreationExecution(input: QuickCreationExecutionInput) { const { @@ -206,7 +205,6 @@ export function useQuickCreationExecution(input: QuickCreationExecutionInput) { executionHostId: ephemeralVmRecipe ? 'runtime:pending-ephemeral-vm' : (workspaceRunContext?.hostId ?? selectedRepoExecutionHostId ?? 'local'), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: selectedRepoIsGit ? 'git-worktree' : 'folder', promptDelivery: quickDraftPrompt ? 'draft' : 'auto-submit', diff --git a/src/renderer/src/lib/agent-launch-routing.test.ts b/src/renderer/src/lib/agent-launch-routing.test.ts index af219bab633..cb3a2b70b00 100644 --- a/src/renderer/src/lib/agent-launch-routing.test.ts +++ b/src/renderer/src/lib/agent-launch-routing.test.ts @@ -19,7 +19,6 @@ function route(overrides: Partial[0]> agent: 'codex', settings, executionHostId: 'local', - platform: 'darwin', hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], workspaceKind: 'git-worktree', nativeChatTranscriptIsLocalReadable: true, @@ -41,57 +40,16 @@ describe('resolveAgentLaunchRoute', () => { } ) - /** Boundary guard between this lane and the one that owns Windows Codex. Codex's win32 refusal is - * deliberate, so it is asserted against whatever currently lets Claude through rather than - * against one host answer — a future gate swap must not be able to flip Codex on quietly. */ - describe("Codex's Windows refusal", () => { - it('holds in the exact situation that routes Claude to structured', () => { - const onWindows = { platform: 'win32' } as const - expect(route({ ...onWindows, agent: 'claude' })).toBe('structured-native-chat') - expect(route({ ...onWindows, agent: 'codex' })).toBe('legacy-native-chat') - }) - - it('holds for every host capability set, including ones that carry extra gates', () => { - for (const hostCapabilities of [ - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.claude.v1'], - [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, 'agent-session.structured.hold.v1'] - ]) { - expect(route({ agent: 'codex', platform: 'win32', hostCapabilities })).toBe( - 'legacy-native-chat' - ) - } - }) - - it('holds for prompted and folder-workspace launches too', () => { - expect( - route({ - agent: 'codex', - platform: 'win32', - launchText: 'go', - promptDelivery: 'auto-submit' - }) - ).toBe('legacy-native-chat') - expect(route({ agent: 'codex', platform: 'win32', workspaceKind: 'folder' })).toBe( - 'legacy-native-chat' - ) - }) - }) - - /** Pins Codex's whole platform answer, not just win32, so no platform silently changes here. */ - it.each([ - ['darwin', 'structured-native-chat'], - ['linux', 'structured-native-chat'], - ['win32', 'legacy-native-chat'] - ] as const)('leaves Codex routing on %s unchanged', (platform, expected) => { - expect(route({ agent: 'codex', platform })).toBe(expected) - }) - - /** Claude's Windows answer is not a client-side platform guess: the route lets it through and the - * executing host settles it with agentSession.createSupport at create time. */ - it('lets a Windows Claude launch reach the host-measured create support check', () => { - expect(route({ agent: 'claude', platform: 'win32' })).toBe('structured-native-chat') - }) + /** Windows eligibility is no client-side platform guess for either provider: the route lets the + * launch through and the executing host settles it with agentSession.createSupport at create + * time. A stale caller still passing the removed `platform` input must not flip Codex off the + * structured route — the field is gone, not reinterpreted. */ + it.each(['claude', 'codex'] as const)( + 'routes %s to structured even when the caller claims a win32 client platform', + (agent) => { + expect(route({ agent, ...({ platform: 'win32' } as object) })).toBe('structured-native-chat') + } + ) it('routes a supported local Codex launch to structured native chat', () => { expect(route()).toBe('structured-native-chat') @@ -134,15 +92,13 @@ describe('resolveAgentLaunchRoute', () => { it.each(['git-worktree', 'folder'] as const)( 'supports a local %s without widening floating-terminal scope', (workspaceKind) => { - expect(route({ workspaceKind, platform: 'linux' })).toBe('structured-native-chat') + expect(route({ workspaceKind })).toBe('structured-native-chat') } ) it('keeps floating, WSL, and repair-required launches terminal-backed', () => { expect(route({ workspaceKind: 'floating' })).toBe('legacy-native-chat') - expect(route({ agent: 'claude', workspaceKind: 'floating', platform: 'win32' })).toBe( - 'legacy-native-chat' - ) + expect(route({ agent: 'claude', workspaceKind: 'floating' })).toBe('legacy-native-chat') expect( route({ projectRuntime: { diff --git a/src/renderer/src/lib/agent-launch-routing.ts b/src/renderer/src/lib/agent-launch-routing.ts index 2bca72ba3ae..090ef3c9108 100644 --- a/src/renderer/src/lib/agent-launch-routing.ts +++ b/src/renderer/src/lib/agent-launch-routing.ts @@ -30,7 +30,6 @@ export type AgentLaunchRoutingInput = { | null | undefined executionHostId: string - platform: NodeJS.Platform hostCapabilities: readonly string[] workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null @@ -68,7 +67,6 @@ export function structuredAgentLaunchSupported( resolveStructuredNativeChatSupport({ agent: input.agent, executionHostId: input.executionHostId, - platform: input.platform, hostCapabilities: input.hostCapabilities, workspaceKind: input.workspaceKind, projectRuntime: input.projectRuntime, diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 118fcdbeda8..cb3187878ef 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -213,7 +213,6 @@ function launchAgentInNewTabInternal( agent, settings: store.settings, executionHostId: getExecutionHostIdForWorktree(store, worktreeId), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind, projectRuntime: getLocalProjectExecutionRuntimeContext(store, worktreeId), diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index d9a75ee2827..1d7dec2cdf3 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -19,37 +19,43 @@ describe('structured agent session launch', () => { }) it('creates a native session with a host-verifiable launch intent', async () => { - vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 0 }, - value: { - sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, - fence: 1, - page: { - sessionId: 'session-1', - epoch: 'epoch-1', - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [], - window: { - oldest: null, - newest: null, - nextCursor: { epoch: 'epoch-1', sequence: 0 } - }, - liveCursor: { epoch: 'epoch-1', sequence: 0 }, - hasOlder: false, - hasNewer: false - }, - unconfirmedClientMessageIds: [] - } - })) + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method, params) => + method === 'agentSession.createSupport' + ? { supported: true } + : { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 0 }, + value: { + sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, + fence: 1, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-1', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } + } + ) const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'codex') const receipt = await launchStructuredAgentSession(intent) - const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as { + const params = vi + .mocked(callStructuredAgentSession) + .mock.calls.find(([, method]) => method === 'agentSession.create')?.[2] as { envelope: { sessionId: string; payloadFingerprint: string } worktree: string agent: 'codex' @@ -87,40 +93,46 @@ describe('structured agent session launch', () => { ) }) - it('asks the executing host for create support before creating a Claude session', async () => { - vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => - method === 'agentSession.createSupport' - ? { supported: true } - : { ok: true, replayed: false, value: { sessionId: 'claude_1', fence: 1 } } - ) + it.each(['claude', 'codex'] as const)( + 'asks the executing host for create support before creating a %s session', + async (agent) => { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => + method === 'agentSession.createSupport' + ? { supported: true } + : { ok: true, replayed: false, value: { sessionId: `${agent}_1`, fence: 1 } } + ) - const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') - await launchStructuredAgentSession(intent) + const intent = createStructuredAgentSessionLaunchIntent('workspace-1', agent) + await launchStructuredAgentSession(intent) - expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ - 'agentSession.createSupport', - 'agentSession.create' - ]) - expect(callStructuredAgentSession).toHaveBeenNthCalledWith( - 1, - { kind: 'local' }, - 'agentSession.createSupport', - { worktree: 'id:workspace-1', agent: 'claude' } - ) - }) + expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.createSupport', + 'agentSession.create' + ]) + expect(callStructuredAgentSession).toHaveBeenNthCalledWith( + 1, + { kind: 'local' }, + 'agentSession.createSupport', + { worktree: 'id:workspace-1', agent } + ) + } + ) - it('refuses a Claude launch the host says it cannot support, without creating', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' }) + it.each(['claude', 'codex'] as const)( + 'refuses a %s launch the host says it cannot support, without creating', + async (agent) => { + vi.mocked(callStructuredAgentSession).mockResolvedValue({ supported: false, reason: 'agent' }) - const intent = createStructuredAgentSessionLaunchIntent('workspace-1', 'claude') + const intent = createStructuredAgentSessionLaunchIntent('workspace-1', agent) - await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf( - StructuredAgentSessionCreateRefusalError - ) - expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ - 'agentSession.createSupport' - ]) - }) + await expect(launchStructuredAgentSession(intent)).rejects.toBeInstanceOf( + StructuredAgentSessionCreateRefusalError + ) + expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ + 'agentSession.createSupport' + ]) + } + ) it('fails closed when the create support probe cannot be answered', async () => { vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('runtime unreachable')) @@ -212,46 +224,40 @@ describe('structured agent session launch', () => { expect(callStructuredAgentSession).toHaveBeenCalledOnce() }) - /** Codex's support answer is settled by the launch route and owned elsewhere; this pins that the - * Claude probe did not change Codex's wire traffic. */ - it('does not probe create support for Codex', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ - ok: true, - replayed: false, - value: { sessionId: 'codex_1', fence: 1 } - }) - - await launchStructuredAgentSession( - createStructuredAgentSessionLaunchIntent('workspace-1', 'codex') + /** The probe now runs for Codex too, so create-outcome tests script it to say yes. */ + function mockSupportedCreate(create: () => unknown): void { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, method) => + method === 'agentSession.createSupport' ? { supported: true } : create() ) - - expect(vi.mocked(callStructuredAgentSession).mock.calls.map(([, method]) => method)).toEqual([ - 'agentSession.create' - ]) - }) + } it('replays the exact create envelope when an unknown outcome is retried', async () => { const intent = createStructuredAgentSessionLaunchIntent('workspace-retry', 'codex') - vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost')) + mockSupportedCreate(() => { + throw new Error('response lost') + }) await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost') await expect(launchStructuredAgentSession(intent)).rejects.toThrow('response lost') - const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] - const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2] + const createCalls = vi + .mocked(callStructuredAgentSession) + .mock.calls.filter(([, method]) => method === 'agentSession.create') + const first = createCalls[0]?.[2] + const second = createCalls[1]?.[2] expect(first).toBe(intent.params) expect(second).toBe(first) expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/) }) it('preserves an unknown refusal code without classifying it as fallback-safe', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ + mockSupportedCreate(() => ({ ok: false, refusal: { code: 'agent_session_operation_unknown', message: 'The chat may already exist.' } - }) + })) const error = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-unknown', 'codex') @@ -266,13 +272,13 @@ describe('structured agent session launch', () => { /** The class is the verdict, so a refusal message that happens to end in a definitive token * must not be re-read into one by the transport-error matcher. */ it('keeps an unknown outcome unknown even when its message ends in a definitive token', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ + mockSupportedCreate(() => ({ ok: false, refusal: { code: 'agent_session_ownership_unknown', message: 'Owner check failed: method_not_found' } - }) + })) const error = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-unknown-token', 'codex') @@ -283,13 +289,13 @@ describe('structured agent session launch', () => { }) it('preserves a definitive refusal code for the fallback path', async () => { - vi.mocked(callStructuredAgentSession).mockResolvedValue({ + mockSupportedCreate(() => ({ ok: false, refusal: { code: 'structured_agent_session_unsupported', message: 'Structured chat is unavailable.' } - }) + })) const error = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-unsupported', 'codex') @@ -303,9 +309,9 @@ describe('structured agent session launch', () => { it.each(['method_not_found', 'structured_agent_session_unsupported'])( 'turns an old-host %s error into a definitive transport refusal', async (code) => { - vi.mocked(callStructuredAgentSession).mockRejectedValueOnce( - Object.assign(new Error(code), { code }) - ) + mockSupportedCreate(() => { + throw Object.assign(new Error(code), { code }) + }) const oldHostError = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent(`workspace-old-host-${code}`, 'codex') ).catch((caught: unknown) => caught) @@ -316,9 +322,9 @@ describe('structured agent session launch', () => { ) it('keeps an unclassified transport failure outcome unknown', async () => { - vi.mocked(callStructuredAgentSession).mockRejectedValueOnce( - Object.assign(new Error('Connection lost'), { code: 'runtime_error' }) - ) + mockSupportedCreate(() => { + throw Object.assign(new Error('Connection lost'), { code: 'runtime_error' }) + }) const transportError = await launchStructuredAgentSession( createStructuredAgentSessionLaunchIntent('workspace-offline', 'codex') ).catch((caught: unknown) => caught) diff --git a/src/renderer/src/lib/launch-structured-agent-session.ts b/src/renderer/src/lib/launch-structured-agent-session.ts index 503ae771419..0694090fc5c 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.ts @@ -174,17 +174,10 @@ async function hostSupportsCreate(intent: StructuredAgentSessionLaunchIntent): P /** * Only the host that will execute the session can answer whether it supports creating one there — * on Windows that means reading the provider child's process start time, which a client cannot - * observe. - * - * Codex is absent on purpose: its answer is settled by the launch route and owned elsewhere, so - * probing here would change Codex's wire traffic. Note that this early return is also why the - * unresolvable-selector race above has never been able to refuse a Codex launch — the race is - * identical for Codex, nothing asks. Whoever gives Codex a probe inherits it. + * observe. Both providers ask: the host classifies per agent, and Codex inherits the + * unresolvable-selector retry above along with the probe. */ async function requireHostCreateSupport(intent: StructuredAgentSessionLaunchIntent): Promise { - if (intent.agent !== 'claude') { - return - } if (!(await hostSupportsCreate(intent))) { abandonStructuredAgentSessionLaunchIntent(intent) throw new StructuredAgentSessionCreateRefusalError( diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 55aa77bddbe..1fdc3b6fa69 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -97,7 +97,6 @@ export async function prepareDirectWorkItemAgentLaunch(args: { agent: effectiveAgent, settings: args.settings, executionHostId: getExecutionHostIdForWorktree(args.latestStore, args.worktreeId), - platform: CLIENT_PLATFORM, hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: 'git-worktree', projectRuntime: getLocalProjectExecutionRuntimeContext( diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index 958f43eda28..a4341a4dc87 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -135,7 +135,6 @@ export function resolveDismissedOnboardingFolderAgentLaunch(args: { agent, settings: args.settings, executionHostId: args.executionHostId, - platform: getClientPlatform(), hostCapabilities: readLocalRuntimeCapabilities(), workspaceKind: 'folder', nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable, diff --git a/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts index 45c41bd111e..9139b1c122a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-refusal-fallback.test.ts @@ -58,6 +58,9 @@ type CreateReply = { ok: boolean; refusal?: { code: string; message: string } } function replyToCreates(...replies: CreateReply[]): void { let index = 0 mocks.call.mockImplementation(async (_target: unknown, method: string, params: unknown) => { + if (method === 'agentSession.createSupport') { + return { supported: true } + } if (method !== 'agentSession.create') { return { ok: true, page: { fence: 1 } } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts b/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts index 7e99ff73fe6..f93437bc088 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-resume-identity.test.ts @@ -63,11 +63,16 @@ describe('a launch that adopts a conversation is its own identity', () => { vi.clearAllMocks() localStorage.clear() mocks.refresh.mockResolvedValue([]) - mocks.call.mockImplementation(async (_target: unknown, method: string) => - method === 'agentSession.create' - ? new Promise(() => {}) - : { ok: true, value: { submission: { dispatchState: 'accepted' } } } - ) + mocks.call.mockImplementation(async (_target: unknown, method: string) => { + if (method === 'agentSession.create') { + return new Promise(() => {}) + } + // Both providers now ask the executing host before creating. + if (method === 'agentSession.createSupport') { + return { supported: true } + } + return { ok: true, value: { submission: { dispatchState: 'accepted' } } } + }) }) it('does not hand a resume the blank launch already pending for the same worktree', async () => { diff --git a/src/renderer/src/lib/web-client-location.test.ts b/src/renderer/src/lib/web-client-location.test.ts new file mode 100644 index 00000000000..9ea2886e533 --- /dev/null +++ b/src/renderer/src/lib/web-client-location.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isWebClientLocation } from './web-client-location' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('isWebClientLocation', () => { + it('reports false when there is no window at all', () => { + vi.stubGlobal('window', undefined) + expect(isWebClientLocation()).toBe(false) + }) + + // Why: this runs on the launch-routing path, where a throw is swallowed and + // silently becomes a failed launch. A window without a usable `location` + // must answer the question, not throw. + it('does not throw when window exists without a location', () => { + vi.stubGlobal('window', { api: {} }) + expect(() => isWebClientLocation()).not.toThrow() + expect(isWebClientLocation()).toBe(false) + }) + + it('does not throw when location exists without a pathname', () => { + vi.stubGlobal('window', { location: {} }) + expect(() => isWebClientLocation()).not.toThrow() + expect(isWebClientLocation()).toBe(false) + }) + + it('detects the web client by its entry path', () => { + vi.stubGlobal('window', { location: { pathname: '/web-index.html' } }) + expect(isWebClientLocation()).toBe(true) + }) + + it('detects the web client by its global marker', () => { + vi.stubGlobal('window', { __ORCA_WEB_CLIENT__: true, location: { pathname: '/' } }) + expect(isWebClientLocation()).toBe(true) + }) + + it('reports false for a normal desktop renderer path', () => { + vi.stubGlobal('window', { location: { pathname: '/index.html' } }) + expect(isWebClientLocation()).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/web-client-location.ts b/src/renderer/src/lib/web-client-location.ts index 26c7e70bb21..94d751b8ab1 100644 --- a/src/renderer/src/lib/web-client-location.ts +++ b/src/renderer/src/lib/web-client-location.ts @@ -2,8 +2,13 @@ export function isWebClientLocation(): boolean { if (typeof window === 'undefined') { return false } + // Why the pathname guard: `window` can exist without a usable `location` + // (partial test doubles, and any embedder that stubs the global), and this + // runs on the launch-routing path where a throw is swallowed and silently + // turns into a failed launch rather than a visible error. + const pathname = (window as { location?: { pathname?: unknown } }).location?.pathname return ( Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) || - window.location.pathname.endsWith('/web-index.html') + (typeof pathname === 'string' && pathname.endsWith('/web-index.html')) ) } diff --git a/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts b/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts new file mode 100644 index 00000000000..0439e5c76bf --- /dev/null +++ b/src/renderer/src/lib/windows-terminal-capabilities-race.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getCachedWindowsTerminalCapabilities, + loadWindowsTerminalCapabilities, + resetWindowsTerminalCapabilitiesForTests +} from './windows-terminal-capabilities' +import { resetWindowsTerminalCapabilityReprobeForTests } from './windows-terminal-capability-reprobe' + +describe('Windows terminal capability probe ordering', () => { + afterEach(() => { + resetWindowsTerminalCapabilitiesForTests() + resetWindowsTerminalCapabilityReprobeForTests() + vi.unstubAllGlobals() + }) + + it('does not let an older forced probe overwrite a newer identity proof', async () => { + let resolveOlderStatus!: (status: { hostPlatform: NodeJS.Platform }) => void + let resolveNewerStatus!: (status: { + hostPlatform: NodeJS.Platform + windowsProcessStartTimeAvailable: boolean + }) => void + const olderStatus = new Promise<{ hostPlatform: NodeJS.Platform }>((resolve) => { + resolveOlderStatus = resolve + }) + const newerStatus = new Promise<{ + hostPlatform: NodeJS.Platform + windowsProcessStartTimeAvailable: boolean + }>((resolve) => { + resolveNewerStatus = resolve + }) + const runtimeGetStatus = vi + .fn<() => Promise>() + .mockReturnValueOnce(olderStatus) + .mockReturnValueOnce(newerStatus) + vi.stubGlobal('window', { + api: { + wsl: { + isAvailable: vi.fn().mockResolvedValue(false), + listDistros: vi.fn().mockResolvedValue([]) + }, + pwsh: { isAvailable: vi.fn().mockResolvedValue(false) }, + gitBash: { isAvailable: vi.fn().mockResolvedValue(false) }, + runtime: { getStatus: runtimeGetStatus } + } + }) + + const olderProbe = loadWindowsTerminalCapabilities({ + ownerKey: 'local', + force: true, + now: 1_000 + }) + const newerProbe = loadWindowsTerminalCapabilities({ + ownerKey: 'local', + force: true, + now: 2_000 + }) + + resolveNewerStatus({ hostPlatform: 'win32', windowsProcessStartTimeAvailable: true }) + await expect(newerProbe).resolves.toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + + resolveOlderStatus({ hostPlatform: 'win32' }) + await expect(olderProbe).resolves.toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + expect(getCachedWindowsTerminalCapabilities('local')).toMatchObject({ + hostPlatform: 'win32', + windowsProcessStartTimeAvailable: true + }) + }) +}) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.test.ts b/src/renderer/src/lib/windows-terminal-capabilities.test.ts index 1f1a83dec9e..d1ad22b463d 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.test.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.test.ts @@ -70,6 +70,7 @@ function stubTerminalCapabilityApi(args: { wslDistros?: string[] gitBashAvailable?: boolean hostPlatform?: NodeJS.Platform | null + windowsProcessStartTimeAvailable?: boolean }): { wslIsAvailable: ReturnType wslListDistros: ReturnType @@ -81,9 +82,12 @@ function stubTerminalCapabilityApi(args: { const wslListDistros = vi.fn().mockResolvedValue(args.wslDistros ?? []) const pwshIsAvailable = vi.fn().mockResolvedValue(args.pwshAvailable) const isGitBashAvailable = vi.fn().mockResolvedValue(args.gitBashAvailable ?? false) - const runtimeGetStatus = vi - .fn() - .mockResolvedValue({ hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32' }) + const runtimeGetStatus = vi.fn().mockResolvedValue({ + hostPlatform: 'hostPlatform' in args ? args.hostPlatform : 'win32', + ...(args.windowsProcessStartTimeAvailable !== undefined + ? { windowsProcessStartTimeAvailable: args.windowsProcessStartTimeAvailable } + : {}) + }) vi.stubGlobal('window', { api: { @@ -583,7 +587,8 @@ describe('windows terminal capabilities', () => { const { wslIsAvailable, wslListDistros } = stubTerminalCapabilityApi({ wslAvailable: false, pwshAvailable: true, - wslDistros: [] + wslDistros: [], + windowsProcessStartTimeAvailable: true }) wslIsAvailable.mockResolvedValueOnce(false).mockResolvedValue(true) wslListDistros.mockResolvedValueOnce([]).mockResolvedValue(['Ubuntu']) diff --git a/src/renderer/src/lib/windows-terminal-capabilities.ts b/src/renderer/src/lib/windows-terminal-capabilities.ts index c759567df15..4bc5d6d7b6e 100644 --- a/src/renderer/src/lib/windows-terminal-capabilities.ts +++ b/src/renderer/src/lib/windows-terminal-capabilities.ts @@ -11,6 +11,8 @@ export type WindowsTerminalCapabilities = { pwshAvailable: boolean gitBashAvailable: boolean hostPlatform: NodeJS.Platform | null + /** Host-owned PID-reuse proof; absent means the host did not advertise it. */ + windowsProcessStartTimeAvailable?: boolean isLoading: boolean } diff --git a/src/renderer/src/lib/windows-terminal-capability-read.ts b/src/renderer/src/lib/windows-terminal-capability-read.ts index 3c9a7edc6bc..9a77538cefc 100644 --- a/src/renderer/src/lib/windows-terminal-capability-read.ts +++ b/src/renderer/src/lib/windows-terminal-capability-read.ts @@ -49,16 +49,13 @@ export async function readWindowsTerminalCapabilities( } if (target.kind === 'local') { - const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, hostPlatform] = + const [wslAvailable, wslDistros, pwshAvailable, gitBashAvailable, runtimeStatus] = await Promise.all([ window.api.wsl.isAvailable().catch(() => false), window.api.wsl.listDistros().catch(() => []), window.api.pwsh.isAvailable().catch(() => false), window.api.gitBash.isAvailable().catch(() => false), - window.api.runtime - .getStatus() - .then((status) => status.hostPlatform ?? null) - .catch(() => null) + window.api.runtime.getStatus().catch(() => null) ]) const reconciledWslAvailable = await reconcileWslAvailability(wslAvailable, wslDistros, () => window.api.wsl.isAvailable() @@ -68,7 +65,10 @@ export async function readWindowsTerminalCapabilities( wslDistros, pwshAvailable, gitBashAvailable, - hostPlatform, + hostPlatform: runtimeStatus?.hostPlatform ?? null, + ...(runtimeStatus?.windowsProcessStartTimeAvailable !== undefined + ? { windowsProcessStartTimeAvailable: runtimeStatus.windowsProcessStartTimeAvailable } + : {}), isLoading: false } } diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts index ad35839ba3d..3d3d476753e 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.test.ts @@ -40,6 +40,36 @@ afterEach(() => { }) describe('windows terminal capability re-probe', () => { + it('reprobes usable WSL until Windows process identity is proved', async () => { + vi.useFakeTimers() + let current: WindowsTerminalCapabilities = USABLE_WSL + const probe = vi.fn(async () => { + current = { ...current, windowsProcessStartTimeAvailable: true } + return current + }) + const readCached = () => current + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(1) + expect(readCached().windowsProcessStartTimeAvailable).toBe(true) + + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('resets the backoff when only process identity capability changes', async () => { + vi.useFakeTimers() + const identityAvailable = { ...ABSENT_WSL, windowsProcessStartTimeAvailable: true } + const { probe, readCached } = createWatcher([identityAvailable, identityAvailable]) + startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(30_000) + expect(probe).toHaveBeenCalledTimes(2) + }) + it('backs off to a five-minute ceiling on a stable answer', async () => { vi.useFakeTimers() const { probe, readCached } = createWatcher() @@ -55,7 +85,9 @@ describe('windows terminal capability re-probe', () => { it('still re-checks a transient absent answer, then stops once WSL answers', async () => { vi.useFakeTimers() - const { probe, readCached } = createWatcher([USABLE_WSL]) + const { probe, readCached } = createWatcher([ + { ...USABLE_WSL, windowsProcessStartTimeAvailable: true } + ]) startWindowsTerminalCapabilityReprobe({ ownerKey: 'local', probe, readCached }) await vi.advanceTimersByTimeAsync(30_000) diff --git a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts index 674adc565d7..f9b9d44b025 100644 --- a/src/renderer/src/lib/windows-terminal-capability-reprobe.ts +++ b/src/renderer/src/lib/windows-terminal-capability-reprobe.ts @@ -31,13 +31,21 @@ function capabilitySignature(capabilities: WindowsTerminalCapabilities): string capabilities.wslDistros.join('\u0000'), capabilities.pwshAvailable, capabilities.gitBashAvailable, - capabilities.hostPlatform ?? '' + capabilities.hostPlatform ?? '', + capabilities.windowsProcessStartTimeAvailable ].join('|') } -/** The answer #11295 waits for: a usable WSL. Nothing further to watch for. */ +/** A usable WSL is settled only after Windows hosts also prove PID identity. */ function isSettled(capabilities: WindowsTerminalCapabilities): boolean { - return capabilities.wslAvailable && capabilities.wslDistros.length > 0 + if (!capabilities.wslAvailable || capabilities.wslDistros.length === 0) { + return false + } + if (capabilities.hostPlatform === 'win32') { + return capabilities.windowsProcessStartTimeAvailable === true + } + // A missing platform means the status probe may have failed; keep checking until it recovers. + return capabilities.hostPlatform !== null } function clearRunnerTimer(runner: CapabilityReprobeRunner): void { diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index fd8502d8913..d7a503bbaf2 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -57,7 +57,6 @@ src/main/codex-accounts/legacy-wsl-runtime-auth-drain-recovery-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-harness.ts src/main/codex-accounts/legacy-wsl-runtime-auth-drain-script-interference-shims.ts src/main/codex-accounts/service.ts -src/main/codex/codex-app-server-client.ts src/main/codex/codex-app-server-posix-supervisor.ts src/main/codex/codex-app-server-session.ts src/main/codex/codex-state-db-backfill-recovery.ts diff --git a/src/shared/child-process/child-process-import-boundary.test.ts b/src/shared/child-process/child-process-import-boundary.test.ts index 3abdf8023c4..ac4a02f6ee5 100644 --- a/src/shared/child-process/child-process-import-boundary.test.ts +++ b/src/shared/child-process/child-process-import-boundary.test.ts @@ -29,7 +29,7 @@ const CHILD_PROCESS_IMPORT_ALLOWLIST: readonly string[] = readFileSync( * May only ever be DECREASED, and only by migrating a file off * `node:child_process`. Raising it is never the fix. */ -const DIRECT_IMPORTER_PIN = 156 +const DIRECT_IMPORTER_PIN = 155 const IMPORT_PATTERN = /(?:from\s+['"]node:child_process['"]|from\s+['"]child_process['"]|require\(\s*['"]node:child_process['"]|require\(\s*['"]child_process['"])/ diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9b7bf2ee0cd..99b4fbe4d6f 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -78,6 +78,8 @@ export type RuntimeStatus = { worktreeCreateIdempotency?: { dedupeTtlMs: number } + /** True only when this Windows host can prove process creation times for PID ownership. */ + windowsProcessStartTimeAvailable?: boolean /** * Optional for mixed-version peers. Absence means the host predates structured * degradation reporting, not that the host proved every optional feature available. diff --git a/src/shared/structured-native-chat-launch-route.test.ts b/src/shared/structured-native-chat-launch-route.test.ts index 48cb117fdf5..ee13a8fb590 100644 --- a/src/shared/structured-native-chat-launch-route.test.ts +++ b/src/shared/structured-native-chat-launch-route.test.ts @@ -22,7 +22,6 @@ function support(overrides: Partial = {}) { return resolveStructuredNativeChatSupport({ agent: 'claude', executionHostId: 'local', - platform: 'darwin', hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], workspaceKind: 'git-worktree', ...overrides @@ -64,7 +63,6 @@ describe('per-launch structured feasibility', () => { ['a floating workspace', { workspaceKind: 'floating' }, 'floating-workspace'], ['a custom TUI launch', { requiresTuiLaunchCustomization: true }, 'tui-launch-customization'], ['an SSH host', { executionHostId: 'ssh:host-a' }, 'remote-execution-host'], - ['Codex on Windows', { agent: 'codex', platform: 'win32' }, 'codex-on-windows'], ['a missing capability', { hostCapabilities: [] }, 'runtime-capability'] ] as [string, Partial, string][])( 'names %s as the blocker', @@ -73,9 +71,14 @@ describe('per-launch structured feasibility', () => { } ) - it('leaves a Windows Claude launch to the executing host', () => { - expect(support({ agent: 'claude', platform: 'win32' })).toEqual({ supported: true }) - }) + // The client cannot see whether the host can read a provider child's start time, so neither + // provider is refused here on platform; agentSession.createSupport answers that at create time. + it.each(['claude', 'codex'] as const)( + 'leaves a Windows %s launch to the executing host', + (agent) => { + expect(support({ agent })).toEqual({ supported: true }) + } + ) it('blocks a WSL or repair-required project runtime', () => { expect( diff --git a/src/shared/structured-native-chat-launch-route.ts b/src/shared/structured-native-chat-launch-route.ts index b97ffcc0dac..8498fe674ce 100644 --- a/src/shared/structured-native-chat-launch-route.ts +++ b/src/shared/structured-native-chat-launch-route.ts @@ -26,7 +26,6 @@ export type StructuredNativeChatBlocker = | 'floating-workspace' | 'tui-launch-customization' | 'remote-execution-host' - | 'codex-on-windows' | 'project-runtime' | 'runtime-capability' @@ -37,7 +36,6 @@ export type StructuredNativeChatSupport = export type StructuredNativeChatSupportInput = { agent: TuiAgent executionHostId: string - platform: NodeJS.Platform hostCapabilities: readonly string[] workspaceKind?: 'git-worktree' | 'folder' | 'floating' projectRuntime?: ProjectExecutionRuntimeResolution | null @@ -82,12 +80,6 @@ export function resolveStructuredNativeChatSupport( if (input.executionHostId !== 'local') { return { supported: false, blocker: 'remote-execution-host' } } - // Codex's Windows refusal is deliberate and settled elsewhere, so it stays a client-side answer. - // Claude's is measured by the executing host at create time (agentSession.createSupport) because - // only that host knows whether it can read a provider child's start time. - if (input.agent === 'codex' && input.platform === 'win32') { - return { supported: false, blocker: 'codex-on-windows' } - } const projectRuntime = input.projectRuntime if (projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') { return { supported: false, blocker: 'project-runtime' } From bcb703fb4ca433b5077d170ad908c25a069db00d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:32:14 -0700 Subject: [PATCH 05/28] test(ssh): isolate the MFA fixture from the developer's real ~/.ssh (#19300) The multi-stage cases pass `resolved: null`, so `resolvePrivateKeys` falls through to `findDefaultKeyFile`, which reads `~/.ssh/id_*` via `homedir()`. On a machine with an encrypted default key ssh2 rejects with "Cannot parse privateKey" before authentication is exercised, so two cases failed locally while staying green on hosted CI, which has no key. Point home at the existing fixture directory so default-key discovery stays in the test's control. Co-authored-by: Merge Sim --- .../ssh-multi-factor-authentication.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/main/ssh/ssh-multi-factor-authentication.test.ts b/src/main/ssh/ssh-multi-factor-authentication.test.ts index 275ea2e3247..2ee643dea7a 100644 --- a/src/main/ssh/ssh-multi-factor-authentication.test.ts +++ b/src/main/ssh/ssh-multi-factor-authentication.test.ts @@ -186,9 +186,19 @@ function connectWithOrcaConfig( describe('multi-stage SSH authentication', () => { let tempDir: string let keyPaths: string[] + let homeEnv: { HOME?: string; USERPROFILE?: string } beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'orca-mfa-')) + // Why: the cases below pass `resolved: null`, so `resolvePrivateKeys` falls through to + // `findDefaultKeyFile`, which reads `~/.ssh/id_*` through `homedir()`. On a developer + // machine that picks up a real key, and an encrypted one makes ssh2 reject with + // "Cannot parse privateKey" before authentication is exercised at all. Hosted CI has no + // key, so this only ever failed locally. Pointing home at the fixture directory keeps + // default-key discovery inside the test's control on every machine. + homeEnv = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + process.env.HOME = tempDir + process.env.USERPROFILE = tempDir keyPaths = ['id_a', 'id_b'].map((name) => { const path = join(tempDir, name) writeFileSync(path, utils.generateKeyPairSync('ecdsa', { bits: 256 }).private) @@ -197,6 +207,14 @@ describe('multi-stage SSH authentication', () => { }) afterEach(() => { + for (const key of ['HOME', 'USERPROFILE'] as const) { + const previous = homeEnv[key] + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } + } rmSync(tempDir, { recursive: true, force: true }) }) From 6ae5418a890a7d410952c9e7e34ed51c9e20e3d4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 7 Sep 2026 09:33:23 -0700 Subject: [PATCH 06/28] Add localization for activity view and sidebar (#18589) * i18n: add localization for activity view and sidebar Wrap activity thread state labels, interrupted status, and sidebar title in translate() calls. Add localization keys to all five locale catalogs (en, es, ja, ko, zh) to enable translation support. * i18n: refactor to static keys for activity and sidebar Convert dynamic translation key construction to static literal keys, enabling proper i18n catalog registration. This ensures activity state labels and sidebar strings are bundled in the boot catalog with their complete translations. * i18n: change permission state label to 'Needs attention' - Rename state label for semantic clarity across all locales - Remove strings now using static keys (per i18n refactor to static keys) --------- Co-authored-by: m4air Co-authored-by: m4air --- .../activity/activity-thread-presentation.ts | 42 +++++++++++++++++-- .../src/components/sidebar/SidebarHeader.tsx | 5 ++- src/renderer/src/i18n/locales/en.json | 18 +++++++- src/renderer/src/i18n/locales/es.json | 28 ++++++++++++- src/renderer/src/i18n/locales/ja.json | 28 ++++++++++++- src/renderer/src/i18n/locales/ko.json | 28 ++++++++++++- src/renderer/src/i18n/locales/zh.json | 28 ++++++++++++- 7 files changed, 163 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/activity/activity-thread-presentation.ts b/src/renderer/src/components/activity/activity-thread-presentation.ts index 93d69c7688a..f1262082345 100644 --- a/src/renderer/src/components/activity/activity-thread-presentation.ts +++ b/src/renderer/src/components/activity/activity-thread-presentation.ts @@ -1,4 +1,4 @@ -import { agentStateLabel, type AgentDotState } from '@/components/AgentStateDot' +import type { AgentDotState } from '@/components/AgentStateDot' import { formatAgentTypeLabel } from '@/lib/agent-status' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' import { showsAgentToolPreview } from '@/lib/agent-row-tool-preview' @@ -8,6 +8,7 @@ import { resolveActivityThreadStatusPreview } from '@/lib/activity-thread-display' import { formatUiRelativeTime } from '@/i18n/relative-time-format' +import { translate } from '@/i18n/i18n' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { ActivityEvent, AgentPaneThread } from './activity-thread-types' @@ -109,9 +110,44 @@ export function threadAgentState(thread: AgentPaneThread): AgentDotState { export function threadAgentStateLabel(thread: AgentPaneThread): string { const state = threadAgentState(thread) if (!thread.currentAgentState && state === 'done' && thread.latestEvent?.entry.interrupted) { - return 'Interrupted' + return translate('auto.components.activity.ActivityPrototypePage.interrupted', 'Interrupted') + } + // Literal keys with literal fallbacks: a dynamic key registers no catalog reference + // and forces every state string into the boot bundle. + switch (state) { + case 'working': + return translate('auto.components.activity.ActivityPrototypePage.state.working', 'Working') + case 'monitoring': + return translate( + 'auto.components.activity.ActivityPrototypePage.state.monitoring', + 'Monitoring background tasks' + ) + case 'blocked': + return translate('auto.components.activity.ActivityPrototypePage.state.blocked', 'Blocked') + case 'waiting': + return translate( + 'auto.components.activity.ActivityPrototypePage.state.waiting', + 'Waiting for input' + ) + case 'interrupted': + return translate('auto.components.activity.ActivityPrototypePage.interrupted', 'Interrupted') + case 'failed': + return translate('auto.components.activity.ActivityPrototypePage.state.failed', 'Failed') + case 'done': + return translate('auto.components.activity.ActivityPrototypePage.state.done', 'Done') + case 'idle': + return translate('auto.components.activity.ActivityPrototypePage.state.idle', 'Idle') + case 'unverifiable': + return translate( + 'auto.components.activity.ActivityPrototypePage.state.unverifiable', + 'No recent update' + ) + case 'permission': + return translate( + 'auto.components.activity.ActivityPrototypePage.state.permission', + 'Needs attention' + ) } - return agentStateLabel(state) } export type ActivityThreadStatusKind = 'tool' | 'message' | 'state' | 'none' diff --git a/src/renderer/src/components/sidebar/SidebarHeader.tsx b/src/renderer/src/components/sidebar/SidebarHeader.tsx index fafd7094034..413558ff9c7 100644 --- a/src/renderer/src/components/sidebar/SidebarHeader.tsx +++ b/src/renderer/src/components/sidebar/SidebarHeader.tsx @@ -36,7 +36,10 @@ const SidebarHeader = React.memo(function SidebarHeader({ const acknowledgeIntro = React.useCallback(() => { void updateSettings?.({ agentsSidebarIntroShown: true }) }, [updateSettings]) - const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces' + const sidebarTitle = + groupBy === 'repo' + ? translate('dashboard.sidebar.projects', 'Projects') + : translate('dashboard.sidebar.workspaces', 'Workspaces') const activityLabel = translate( agentsViewActive ? 'dashboard.sidebar.closeActivity' : 'dashboard.sidebar.openActivity', agentsViewActive ? 'Turn off activity view' : 'View activity' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b23161d3887..e3198b9c45a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16225,7 +16225,19 @@ "showUnreadOnly": "Show unread only", "showChildAgents": "Show child agents", "activityOptions": "Activity options", - "threadListOptionsFiltered": "Thread list options, filters active" + "threadListOptionsFiltered": "Thread list options, filters active", + "interrupted": "Interrupted", + "state": { + "working": "Working", + "monitoring": "Monitoring background tasks", + "blocked": "Blocked", + "waiting": "Waiting for input", + "failed": "Failed", + "done": "Done", + "idle": "Idle", + "unverifiable": "No recent update", + "permission": "Needs attention" + } }, "clearCompleted": { "clearedOne": "Cleared 1 completed agent", @@ -17617,7 +17629,9 @@ "label": "Agents", "dashboardLabel": "Agent Dashboard", "openActivity": "View activity", - "closeActivity": "Turn off activity view" + "closeActivity": "Turn off activity view", + "projects": "Projects", + "workspaces": "Workspaces" } }, "runtimeRpc": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3c2881e16f2..e7fc5a60e42 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14186,7 +14186,27 @@ "beb2c19173": "No leído", "5651b216c6": "Proyecto desconocido", "22b22034bc": "Terminal independiente no disponible en Actividad.", - "afdc2139a8": "Terminal de Agent cerrada. Abre una nueva terminal en este workspace para continuar." + "afdc2139a8": "Terminal de Agent cerrada. Abre una nueva terminal en este workspace para continuar.", + "compactModeDescription": "Muestra filas de hilo más cortas con títulos de una línea y mensajes de estado de dos líneas.", + "unreadOnlyDescription": "Filtra la lista de actividad para mostrar solo hilos con actualizaciones sin leer.", + "clearCompleted": "Borrar completados", + "none": "Ninguno", + "search": "Buscar", + "showUnreadOnly": "Mostrar solo no leídos", + "showChildAgents": "Mostrar agentes secundarios", + "activityOptions": "Opciones de actividad", + "interrupted": "Interrumpido", + "state": { + "working": "Trabajando", + "monitoring": "Supervisando tareas en segundo plano", + "blocked": "Bloqueado", + "waiting": "Esperando entrada", + "failed": "Fallido", + "done": "Completado", + "idle": "Inactivo", + "unverifiable": "Sin actualizaciones recientes", + "permission": "Requiere atención" + } }, "ActivityScopeFilterControls": { "resetScope": "Mostrar todos los hosts y proyectos" @@ -14842,7 +14862,11 @@ "dashboard": { "sidebar": { "label": "Agentes", - "dashboardLabel": "Panel de agentes" + "dashboardLabel": "Panel de agentes", + "openActivity": "Ver actividad", + "closeActivity": "Cerrar vista de actividad", + "projects": "Proyectos", + "workspaces": "Espacios de trabajo" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c46c27ddf5a..1dcf28f78fa 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14186,7 +14186,27 @@ "beb2c19173": "未読", "5651b216c6": "不明なプロジェクト", "22b22034bc": "スタンドアロンターミナルはアクティビティでは使用できません。", - "afdc2139a8": "Agent ターミナルが閉じられました。続行するには、このワークスペースで新規ターミナルを開いてください。" + "afdc2139a8": "Agent ターミナルが閉じられました。続行するには、このワークスペースで新規ターミナルを開いてください。", + "compactModeDescription": "1 行のタイトルと 2 行のステータスメッセージで短いスレッド行を表示します。", + "unreadOnlyDescription": "未読の更新があるスレッドのみをアクティビティ一覧に表示します。", + "clearCompleted": "完了済みをクリア", + "none": "なし", + "search": "検索", + "showUnreadOnly": "未読のみ表示", + "showChildAgents": "子 Agent を表示", + "activityOptions": "アクティビティのオプション", + "interrupted": "中断", + "state": { + "working": "作業中", + "monitoring": "バックグラウンドタスクを監視中", + "blocked": "ブロック", + "waiting": "入力待ち", + "failed": "失敗", + "done": "完了", + "idle": "アイドル", + "unverifiable": "最近の更新なし", + "permission": "要対応" + } }, "ActivityScopeFilterControls": { "resetScope": "すべてのホストとプロジェクトを表示" @@ -14877,7 +14897,11 @@ "dashboard": { "sidebar": { "label": "Agent", - "dashboardLabel": "Agent ダッシュボード" + "dashboardLabel": "Agent ダッシュボード", + "openActivity": "アクティビティを表示", + "closeActivity": "アクティビティビューを閉じる", + "projects": "プロジェクト", + "workspaces": "ワークスペース" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8314abe56c8..710df014665 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14264,7 +14264,27 @@ "beb2c19173": "읽지 않음", "5651b216c6": "알 수 없는 프로젝트", "22b22034bc": "활동에서는 독립형 terminal을 사용할 수 없습니다.", - "afdc2139a8": "Agent terminal이 닫혔습니다. 계속하려면 이 워크스페이스에서 새 terminal을 여세요." + "afdc2139a8": "Agent terminal이 닫혔습니다. 계속하려면 이 워크스페이스에서 새 terminal을 여세요.", + "compactModeDescription": "한 줄 제목과 두 줄 상태 메시지로 더 짧은 스레드 행을 표시합니다.", + "unreadOnlyDescription": "읽지 않은 업데이트가 있는 스레드만 활동 목록에 표시합니다.", + "clearCompleted": "완료된 항목 지우기", + "none": "없음", + "search": "검색", + "showUnreadOnly": "읽지 않은 항목만 표시", + "showChildAgents": "하위 에이전트 표시", + "activityOptions": "활동 옵션", + "interrupted": "중단됨", + "state": { + "working": "작업 중", + "monitoring": "백그라운드 작업 모니터링 중", + "blocked": "차단됨", + "waiting": "입력 대기 중", + "failed": "실패", + "done": "완료", + "idle": "유휴", + "unverifiable": "최근 업데이트 없음", + "permission": "주의 필요" + } }, "ActivityScopeFilterControls": { "resetScope": "모든 호스트 및 프로젝트 표시" @@ -15016,7 +15036,11 @@ "dashboard": { "sidebar": { "label": "에이전트", - "dashboardLabel": "에이전트 대시보드" + "dashboardLabel": "에이전트 대시보드", + "openActivity": "활동 보기", + "closeActivity": "활동 보기 닫기", + "projects": "프로젝트", + "workspaces": "워크스페이스" } }, "browser": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 17f60476de0..7d60495e3b5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14264,7 +14264,27 @@ "beb2c19173": "未读", "5651b216c6": "未知项目", "22b22034bc": "独立终端在活动中不可用。", - "afdc2139a8": "智能体终端关闭。在此工作区中打开一个新终端以继续。" + "afdc2139a8": "智能体终端关闭。在此工作区中打开一个新终端以继续。", + "compactModeDescription": "以单行标题和两行状态消息显示更短的线程行。", + "unreadOnlyDescription": "将活动列表筛选为仅显示有未读更新的线程。", + "clearCompleted": "清除已完成", + "none": "无", + "search": "搜索", + "showUnreadOnly": "仅显示未读", + "showChildAgents": "显示子智能体", + "activityOptions": "活动选项", + "interrupted": "已中断", + "state": { + "working": "工作中", + "monitoring": "监控后台任务", + "blocked": "受阻", + "waiting": "等待输入", + "failed": "失败", + "done": "完成", + "idle": "空闲", + "unverifiable": "暂无近期更新", + "permission": "需注意" + } }, "ActivityScopeFilterControls": { "resetScope": "显示所有主机和项目" @@ -14981,7 +15001,11 @@ "dashboard": { "sidebar": { "label": "智能体", - "dashboardLabel": "智能体仪表盘" + "dashboardLabel": "智能体仪表盘", + "openActivity": "查看活动", + "closeActivity": "关闭活动视图", + "projects": "项目", + "workspaces": "工作区" } }, "browser": { From bffdad9f05f61f3a6f3b961148a3f31304543dc9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:36:14 -0700 Subject: [PATCH 07/28] fix(native-chat): make structured chat tabs renameable (#19153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): let a structured chat tab be renamed Renaming a native chat tab accepted the text and silently did nothing: setTabCustomTitle only scanned terminal tabs and only bridged to unified tabs whose contentType was 'terminal', so the agent-session tab it was keyed to never matched. Any label that did land was then re-nulled by the next host snapshot, which preserved color/createdAt/isPinned but not customLabel. Also routes both placeholder sites through one helper so a Claude chat stops falling back to 'Codex Chat'. * test(native-chat): cover structured chat tab rename and label fallback * chore: drop the local @pnpm/exe lockfile artifact Swept in accidentally; running pnpm here adds @pnpm/exe to the root lockfile, which fails CI's frozen-lockfile guard. * fix(native-chat): reach the rename shortcut and tab color too Review found the first fix covered only the context-menu path. The tab.rename shortcut gated on activeTabType === 'terminal', so on a structured chat tab it stayed the silent no-op this branch set out to fix. setTabColor carried the identical terminal-only lookup one function below the one that was fixed. Both lookups now share one resolver instead of two copies. * fix(native-chat): stop unknown agents reading as Codex, cover the terminal path Review found the placeholder helper encoded "unknown means Codex": its signature accepts null/undefined and Tab.agentSessionAgent is the open AgentType, so the first caller passing a Tab would label gemini or grok as "Codex Chat". Routed through the shared agent-name table instead. Also adds the missing regression test that a terminal rename still resolves through its entityId now that both rename and color share one resolver, and a guard on a test that passed with the fix reverted. * fix(native-chat): degrade instead of throwing on a null tab title A stacked branch can publish title: null when a conversation name is cleared. The wire type says string, so this consumer trusted it and threw inside the store patch that applies the snapshot. Fall back to the placeholder — the producer bug is fixed separately, but a consumer of wire data should not crash on a contract violation. * fix(native-chat): rename the focused structured tab, not a background terminal * fix(native-chat): cycle terminals from the structured tab, not a stale terminal --------- Co-authored-by: Merge Sim --- ...tore-structured-agent-session-tabs-once.ts | 3 +- .../app-command-handlers-tab-rename.test.ts | 165 ++++++++++++++++++ .../src/app-shell/app-command-handlers.ts | 34 +++- .../components/tab-bar/tab-bar-item-model.ts | 3 + .../src/components/terminal/tab-type-cycle.ts | 15 +- ...c-tab-switch-group-order-hydration.test.ts | 15 +- ...pc-tab-switch-structured-tab-cycle.test.ts | 142 +++++++++++++++ src/renderer/src/hooks/ipc-tab-switch.test.ts | 13 +- src/renderer/src/hooks/ipc-tab-switch.ts | 9 +- .../mirrored-agent-tab-label.test.ts | 85 +++++++++ .../terminal-surfaces.ts | 9 +- .../store/terminals/renamable-unified-tab.ts | 15 ++ .../structured-chat-tab-rename.test.ts | 114 ++++++++++++ .../store/terminals/terminal-tab-attention.ts | 9 +- src/shared/agent-session-chat-label.ts | 9 + 15 files changed, 617 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts create mode 100644 src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts create mode 100644 src/renderer/src/store/terminals/renamable-unified-tab.ts create mode 100644 src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts create mode 100644 src/shared/agent-session-chat-label.ts diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 536f00723f7..e912cc6b665 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { defaultAgentChatLabel } from '../../shared/agent-session-chat-label' import { OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript } from './orca-runtime-resolve-recovered-structured-tui-transcript' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' @@ -132,7 +133,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const tab: RuntimeMobileSessionAgentTab = { type: 'agent-session', id, - title: input.agent === 'claude' ? 'Claude Chat' : 'Codex Chat', + title: defaultAgentChatLabel(input.agent), sessionId: input.sessionId, ...(input.replacesSessionId ? { replacesSessionId: input.replacesSessionId } : {}), agent: input.agent, diff --git a/src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts b/src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts new file mode 100644 index 00000000000..80e43afddd3 --- /dev/null +++ b/src/renderer/src/app-shell/app-command-handlers-tab-rename.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { AppState } from '@/store/types' +import { createTabsFocusActions } from '../store/slices/tabs/tabs-focus-actions' +import type { TabsSliceGet, TabsSliceSet } from '../store/slices/tabs/tabs-slice-contract' +import { buildActiveSurfacePatch } from '../store/slices/tabs/tabs-surface' +import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers' + +const mocks = vi.hoisted(() => ({ + requestTerminalTabRename: vi.fn(), + store: {} as AppState +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store }) +})) + +vi.mock('../components/tab-bar/terminal-tab-rename-request', () => ({ + requestTerminalTabRename: mocks.requestTerminalTabRename +})) + +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelFocused: () => false +})) + +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: vi.fn() +})) + +import { createAppCommandHandlers } from './app-command-handlers' + +const WORKTREE_ID = 'repo::/feature' +const GROUP_ID = 'group-1' +const TERMINAL_ENTITY_ID = 'terminal-1' +const TERMINAL_UNIFIED_ID = 'unified-terminal' +const CHAT_UNIFIED_ID = 'unified-chat' + +function unifiedTab(overrides: Partial & Pick): Tab { + return { + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + label: overrides.id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +/** + * Builds the store the real app has when `activeGroupTabId` is focused: the raw group/tab state + * plus the active-surface fields derived from it by the same code the store runs. That derivation + * is what leaves `activeTabId` pointing at a background terminal while a structured tab is active, + * so stubbing those fields instead would hide exactly the half under test. + */ +function storeForActiveTab(activeGroupTabId: string): AppState { + const groups: TabGroup[] = [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: activeGroupTabId, + tabOrder: [TERMINAL_UNIFIED_ID, CHAT_UNIFIED_ID] + } + ] + const rawState = { + activeBrowserTabIdByWorktree: {}, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }, + // The user focused this terminal before switching to the structured tab. + activeTabIdByWorktree: { [WORKTREE_ID]: TERMINAL_ENTITY_ID }, + activeTabTypeByWorktree: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { [WORKTREE_ID]: groups }, + layoutByWorktree: {}, + openFiles: [], + tabsByWorktree: { + [WORKTREE_ID]: [{ id: TERMINAL_ENTITY_ID, worktreeId: WORKTREE_ID } as TerminalTab] + }, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + unifiedTab({ + id: TERMINAL_UNIFIED_ID, + entityId: TERMINAL_ENTITY_ID, + contentType: 'terminal' + }), + unifiedTab({ id: CHAT_UNIFIED_ID, entityId: 'session-1', contentType: 'agent-session' }) + ] + } + } as unknown as AppState + const store = { + ...rawState, + ...buildActiveSurfacePatch(rawState, WORKTREE_ID) + } as AppState + const noopSet = (() => {}) as unknown as TabsSliceSet + store.getActiveTab = createTabsFocusActions(noopSet, (() => store) as TabsSliceGet).getActiveTab + return store +} + +function shortcutState(): AppShortcutState { + return { + activeView: 'terminal', + activeWorktreeId: WORKTREE_ID, + actions: {} as AppShortcutState['actions'], + creationLayoutActive: false, + floatingTerminalEnabled: false, + floatingTerminalOpen: false, + floatingVisibleTabCount: 0, + keybindings: {}, + openFloatingWorkspaceMaximized: vi.fn(), + pluginCommands: [], + setFloatingTerminalOpen: vi.fn(), + terminalShortcutPolicy: 'orca-first', + workspaceChromeActive: true + } +} + +function shortcutInput(): ShortcutDispatchInput { + return { target: null, defaultPrevented: false, preventDefault: vi.fn() } +} + +function runRename(state: AppShortcutState = shortcutState()): boolean | undefined { + return createAppCommandHandlers(state, shortcutInput(), 'terminal').get('tab.rename')?.() +} + +describe('tab.rename shortcut', () => { + beforeEach(() => vi.clearAllMocks()) + + it('leaves activeTabId on a background terminal while a structured tab is active', () => { + // Guards the premise of the test below: without this the structured case proves nothing. + mocks.store = storeForActiveTab(CHAT_UNIFIED_ID) + expect(mocks.store.activeTabType).toBe('agent-session') + expect(mocks.store.activeTabId).toBe(TERMINAL_ENTITY_ID) + }) + + it('renames the structured chat tab, not the stale background terminal', () => { + mocks.store = storeForActiveTab(CHAT_UNIFIED_ID) + expect(runRename()).toBe(true) + expect(mocks.requestTerminalTabRename).toHaveBeenCalledWith(CHAT_UNIFIED_ID) + expect(mocks.requestTerminalTabRename).not.toHaveBeenCalledWith(TERMINAL_ENTITY_ID) + }) + + it('still renames the terminal tab by its backing terminal id', () => { + mocks.store = storeForActiveTab(TERMINAL_UNIFIED_ID) + expect(mocks.store.activeTabType).toBe('terminal') + expect(runRename()).toBe(true) + expect(mocks.requestTerminalTabRename).toHaveBeenCalledWith(TERMINAL_ENTITY_ID) + }) + + it('does not claim the chord for a tab type that has no inline rename', () => { + mocks.store = { + ...storeForActiveTab(CHAT_UNIFIED_ID), + activeTabType: 'browser' + } as AppState + expect(runRename()).toBe(false) + expect(mocks.requestTerminalTabRename).not.toHaveBeenCalled() + }) + + it('does not claim the chord for a structured tab with no active worktree', () => { + mocks.store = storeForActiveTab(CHAT_UNIFIED_ID) + expect(runRename({ ...shortcutState(), activeWorktreeId: null })).toBe(false) + expect(mocks.requestTerminalTabRename).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index bb60f898130..1a7264efb5e 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -75,6 +75,24 @@ export function getKeybindingContext(target: EventTarget | null): KeybindingCont : 'app' } +/** + * The tab id the inline rename editor listens on, which differs per tab kind: a terminal tab is + * addressed by its backing terminal id (`activeTabId`), a structured chat tab by its unified tab + * id. `activeTabId` is terminal-only state and never moves for a structured tab, so reading it + * there targets whichever terminal was last active. Mirrors TabGroupPanel's tab-strip resolution. + */ +function resolveRenameTargetTabId(activeWorktreeId: string | null): string | null { + const store = useAppStore.getState() + if (store.activeTabType === 'terminal') { + return store.activeTabId + } + if (store.activeTabType !== 'agent-session' || !activeWorktreeId) { + return null + } + const activeTab = store.getActiveTab(activeWorktreeId) + return activeTab?.contentType === 'agent-session' ? activeTab.id : null +} + /** * Builds the app-level handlers for every keybindable action. Each returns whether it claimed * the chord, so an unavailable surface (settings view, closed floating panel) falls through to @@ -172,16 +190,16 @@ export function createAppCommandHandlers( [ 'tab.rename', () => { - const store = useAppStore.getState() - if ( - !workspaceChromeActive || - floatingWorkspaceFocused || - store.activeTabType !== 'terminal' || - !store.activeTabId - ) { + if (!workspaceChromeActive || floatingWorkspaceFocused) { return false } - return claim('tab.rename', () => requestTerminalTabRename(store.activeTabId!)) + // Why: a structured chat tab is renamed through the same inline editor, so gating on + // 'terminal' alone left the shortcut a silent no-op there. + const tabId = resolveRenameTargetTabId(activeWorktreeId) + if (!tabId) { + return false + } + return claim('tab.rename', () => requestTerminalTabRename(tabId)) } ], [ diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts index d5b00ada161..3789d89b7de 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts @@ -234,6 +234,9 @@ export function findActiveVisibleTabId( return active.activeTabType === 'simulator' && item.id === active.activeSimulatorTabId } if (item.type === 'agent-session') { + // Reachable only from TabGroupPanel, which passes the structured tab's own id; the store's + // `activeTabId` names a background terminal here (cf. TerminalTitlebarTabs, which resolves + // `getActiveTab(...)?.id` for 'simulator' and never renders agent-session items). return active.activeTabType === 'agent-session' && item.id === active.activeTabId } return ( diff --git a/src/renderer/src/components/terminal/tab-type-cycle.ts b/src/renderer/src/components/terminal/tab-type-cycle.ts index 051c2533518..3de13076c15 100644 --- a/src/renderer/src/components/terminal/tab-type-cycle.ts +++ b/src/renderer/src/components/terminal/tab-type-cycle.ts @@ -18,11 +18,19 @@ type GetNextTabWithinActiveTypeParams = { direction: number } +/** + * The backing entity id of the active tab, in the same id domain the cyclable entries use. + * + * `activeAgentSessionEntityId` is optional because a caller that only compares type-matched + * entries stays correct without it; a caller that searches a pre-filtered single-type list must + * pass it, or a structured tab resolves to a live background terminal (see the branch below). + */ export function getActiveEntityIdForTabType( activeTabType: TabCycleType, activeTabId: string | null, activeFileId: string | null, - activeBrowserTabId: string | null + activeBrowserTabId: string | null, + activeAgentSessionEntityId: string | null = null ): string | null { if (activeTabType === 'editor') { return activeFileId @@ -30,6 +38,11 @@ export function getActiveEntityIdForTabType( if (activeTabType === 'browser') { return activeBrowserTabId } + // Why: `activeTabId` is terminal-only state that keeps naming a live background terminal while a + // structured tab is active, so falling through here cycles from a tab the user is not on. + if (activeTabType === 'agent-session') { + return activeAgentSessionEntityId + } if (activeTabType === 'simulator') { return activeTabId } diff --git a/src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts b/src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts index 2f8f1131559..7711d795403 100644 --- a/src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts +++ b/src/renderer/src/hooks/ipc-tab-switch-group-order-hydration.test.ts @@ -9,6 +9,9 @@ const { getStateMock } = vi.hoisted(() => ({ getStateMock: vi.fn() })) vi.mock('../store', () => ({ useAppStore: { getState: getStateMock } })) +import { createTabsFocusActions } from '../store/slices/tabs/tabs-focus-actions' +import type { TabsSliceGet, TabsSliceSet } from '../store/slices/tabs/tabs-slice-contract' + import { handleSwitchTab, handleSwitchTabAcrossAllTypes, @@ -39,7 +42,7 @@ function stateWithGroupOrder(tabOrder: string[]) { terminalTab('tab-2', 'term-2', 1), terminalTab('tab-3', 'term-3', 2) ] - return { + const store = { activeWorktreeId: WT, activeTabType: 'terminal' as const, activeTabId: 'term-1', @@ -58,8 +61,16 @@ function stateWithGroupOrder(tabOrder: string[]) { setActiveFile: vi.fn(), setActiveBrowserTab: vi.fn(), setActiveTabType: vi.fn(), - activateTab: vi.fn() + activateTab: vi.fn(), + getActiveTab: (_worktreeId: string): unknown => null } + // Why the real resolver: a hand-written stub would decide the group-scoped answer the code + // under test is meant to exercise. + store.getActiveTab = createTabsFocusActions( + (() => {}) as unknown as TabsSliceSet, + (() => store) as unknown as TabsSliceGet + ).getActiveTab + return store } describe('tab-cycle chord against a group whose tabOrder is still hydrating', () => { diff --git a/src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts b/src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts new file mode 100644 index 00000000000..1bdb41bd5c4 --- /dev/null +++ b/src/renderer/src/hooks/ipc-tab-switch-structured-tab-cycle.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { AppState } from '@/store/types' +import { createTabsFocusActions } from '../store/slices/tabs/tabs-focus-actions' +import type { TabsSliceGet, TabsSliceSet } from '../store/slices/tabs/tabs-slice-contract' +import { buildActiveSurfacePatch } from '../store/slices/tabs/tabs-surface' + +const mocks = vi.hoisted(() => ({ store: {} as AppState })) + +vi.mock('../store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store }) +})) + +import { handleSwitchTerminalTab } from './ipc-tab-switch' + +const WORKTREE_ID = 'wt-1' +const GROUP_ID = 'group-1' +const SESSION_ID = 'sess-1' +const CHAT_UNIFIED_ID = `structured-agent-session-${SESSION_ID}` + +function unifiedTab(overrides: Partial & Pick): Tab { + return { + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + label: overrides.id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + ...overrides + } +} + +/** + * The store the app really has with a structured tab focused: raw group state plus the + * active-surface fields the store derives from it. That derivation is what leaves `activeTabId` + * naming a live background terminal, so stubbing it would hide the half under test. + */ +function storeWithStructuredTabActive({ + terminalIds, + lastFocusedTerminalId, + activeGroupTabId = CHAT_UNIFIED_ID +}: { + terminalIds: string[] + lastFocusedTerminalId: string + activeGroupTabId?: string +}): AppState { + const terminalTabs = terminalIds.map((id) => + unifiedTab({ id: `unified-${id}`, entityId: id, contentType: 'terminal' }) + ) + const chatTab = unifiedTab({ + id: CHAT_UNIFIED_ID, + entityId: SESSION_ID, + contentType: 'agent-session' + }) + const groups: TabGroup[] = [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: activeGroupTabId, + tabOrder: [...terminalTabs.map((tab) => tab.id), chatTab.id] + } + ] + const rawState = { + activeBrowserTabIdByWorktree: {}, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }, + activeTabIdByWorktree: { [WORKTREE_ID]: lastFocusedTerminalId }, + activeTabTypeByWorktree: {}, + activeWorktreeId: WORKTREE_ID, + browserTabsByWorktree: {}, + groupsByWorktree: { [WORKTREE_ID]: groups }, + layoutByWorktree: {}, + openFiles: [], + tabBarOrderByWorktree: {}, + tabsByWorktree: { + [WORKTREE_ID]: terminalIds.map((id) => ({ id, worktreeId: WORKTREE_ID }) as TerminalTab) + }, + unifiedTabsByWorktree: { [WORKTREE_ID]: [...terminalTabs, chatTab] }, + setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + activateTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveBrowserTab: vi.fn() + } as unknown as AppState + const store = { + ...rawState, + ...buildActiveSurfacePatch(rawState, WORKTREE_ID) + } as AppState + const noopSet = (() => {}) as unknown as TabsSliceSet + store.getActiveTab = createTabsFocusActions(noopSet, (() => store) as TabsSliceGet).getActiveTab + return store +} + +describe('handleSwitchTerminalTab with a structured chat tab active', () => { + beforeEach(() => vi.clearAllMocks()) + + it('leaves activeTabId naming a live background terminal', () => { + // Guards the premise: without a stale id that is really in the terminal list, the tests + // below would pass with the bug present. + mocks.store = storeWithStructuredTabActive({ + terminalIds: ['term-1', 'term-2', 'term-3'], + lastFocusedTerminalId: 'term-2' + }) + expect(mocks.store.activeTabType).toBe('agent-session') + expect(mocks.store.activeTabId).toBe('term-2') + }) + + it('jumps to the first terminal instead of cycling from the background terminal', () => { + mocks.store = storeWithStructuredTabActive({ + terminalIds: ['term-1', 'term-2', 'term-3'], + lastFocusedTerminalId: 'term-2' + }) + expect(handleSwitchTerminalTab(1)).toBe(true) + // Stepping from the stale 'term-2' would land on 'term-3'. + expect(mocks.store.setActiveTab).toHaveBeenCalledWith('term-1') + expect(mocks.store.setActiveTab).not.toHaveBeenCalledWith('term-3') + expect(mocks.store.setActiveTabType).toHaveBeenCalledWith('terminal') + }) + + it('still reaches the sole terminal rather than reading as already focused', () => { + mocks.store = storeWithStructuredTabActive({ + terminalIds: ['term-1'], + lastFocusedTerminalId: 'term-1' + }) + // The stale id matched the only terminal, so the single-terminal guard swallowed the chord. + expect(handleSwitchTerminalTab(1)).toBe(true) + expect(mocks.store.setActiveTab).toHaveBeenCalledWith('term-1') + }) + + it('still cycles normally from a focused terminal tab', () => { + mocks.store = storeWithStructuredTabActive({ + terminalIds: ['term-1', 'term-2', 'term-3'], + lastFocusedTerminalId: 'term-2', + activeGroupTabId: 'unified-term-2' + }) + expect(mocks.store.activeTabType).toBe('terminal') + expect(handleSwitchTerminalTab(1)).toBe(true) + expect(mocks.store.setActiveTab).toHaveBeenCalledWith('term-3') + }) +}) diff --git a/src/renderer/src/hooks/ipc-tab-switch.test.ts b/src/renderer/src/hooks/ipc-tab-switch.test.ts index 0cdc5276539..8cc6fc3afaa 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.test.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.test.ts @@ -15,6 +15,8 @@ vi.mock('@/components/tab-bar/group-tab-order', () => ({ getActiveTabNavOrder: getActiveTabNavOrderMock })) +import { createTabsFocusActions } from '../store/slices/tabs/tabs-focus-actions' +import type { TabsSliceGet, TabsSliceSet } from '../store/slices/tabs/tabs-slice-contract' import { handleSwitchRecentTab, handleSwitchTab, @@ -54,10 +56,11 @@ type MockStore = { setActiveBrowserTab: ReturnType activateTab: ReturnType setActiveTabType: ReturnType + getActiveTab: (worktreeId: string) => unknown } function makeStore(activeTabType: ActiveTabType, overrides: Partial = {}): MockStore { - return { + const store: MockStore = { activeWorktreeId: 'wt-1', activeTabType, activeTabId: 'term-1', @@ -72,8 +75,16 @@ function makeStore(activeTabType: ActiveTabType, overrides: Partial = setActiveBrowserTab: vi.fn(), activateTab: vi.fn(), setActiveTabType: vi.fn(), + getActiveTab: () => null, ...overrides } + // Why the real resolver: the group-scoped active tab is what the code under test reads, so a + // hand-written stub here would decide the answer instead of exercising it. + store.getActiveTab = createTabsFocusActions( + (() => {}) as unknown as TabsSliceSet, + (() => store) as unknown as TabsSliceGet + ).getActiveTab + return store } describe('handleSwitchTerminalTab', () => { diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index e88f9070703..2fee76386c2 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -343,13 +343,18 @@ export function handleSwitchTerminalTab(direction: number): boolean { if (terminalTabs.length === 0) { return false } + // Why: this list is pre-filtered to terminals, so the index search below has no type check to + // reject a stale terminal id — a structured tab must resolve to its own entity or the chord + // cycles from whichever terminal was last active. + const activeTab = store.getActiveTab(worktreeId) const currentId = getActiveEntityIdForTabType( store.activeTabType, store.activeTabId, store.activeFileId, - store.activeBrowserTabId + store.activeBrowserTabId, + activeTab?.contentType === 'agent-session' ? activeTab.entityId : null ) - // Why: when an editor/browser tab is active, jump to the first terminal on + // Why: when an editor/browser/structured tab is active, jump to the first terminal on // forward navigation instead of skipping to index 1. const idx = terminalTabs.findIndex((t) => t.id === currentId) // Why: only no-op when the sole terminal is already focused. With one terminal diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts new file mode 100644 index 00000000000..7d5ef3c25f3 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' +import { buildMirroredAgentTabs } from './terminal-surfaces' + +const WORKTREE = 'repo-1::worktree-1' +const GROUP = 'group-1' + +function snapshotWith(agent: 'claude' | 'codex', title: string): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: GROUP, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'host-tab-1', + title, + sessionId: `${agent}-1`, + agent, + isActive: false + } + ] + } as RuntimeMobileSessionTabsResult +} + +function build( + snapshot: RuntimeMobileSessionTabsResult, + currentUnifiedTabs: readonly Tab[] = [] +): Tab { + const [mirrored] = buildMirroredAgentTabs( + snapshot, + new Map(), + GROUP, + 0, + currentUnifiedTabs, + 1_000 + ) + return mirrored.unifiedTab +} + +describe('buildMirroredAgentTabs', () => { + it('falls back to the agent-specific placeholder when the host publishes no title', () => { + expect(build(snapshotWith('claude', '')).label).toBe('Claude Chat') + expect(build(snapshotWith('codex', ' ')).label).toBe('Codex Chat') + }) + + it('prefers the host title over the placeholder', () => { + expect(build(snapshotWith('claude', 'Flaky retry test')).label).toBe('Flaky retry test') + }) + + it('keeps a manual rename across host snapshots', () => { + const snapshot = snapshotWith('codex', 'Codex Chat') + const renamed = build(snapshot) + const existing: Tab = { ...renamed, customLabel: 'My rename' } + expect(build(snapshot, [existing]).customLabel).toBe('My rename') + }) + + it('leaves customLabel null when the tab was never renamed', () => { + // Guard: assert the row is actually built, so this cannot pass on an empty + // result the way a bare null-check would. + const tab = build(snapshotWith('codex', 'Codex Chat')) + expect(tab.label).toBe('Codex Chat') + expect(tab.customLabel).toBeNull() + }) + + it('degrades to the placeholder when the host violates the string contract', () => { + const snapshot = snapshotWith('claude', 'Named') + // The wire type says `string`, but a host clearing a name can send null. + ;(snapshot.tabs[0] as { title: unknown }).title = null + expect(() => build(snapshot)).not.toThrow() + expect(build(snapshot).label).toBe('Claude Chat') + }) + + it('names an agent this build does not know after itself, not Codex', () => { + const snapshot = snapshotWith('codex', '') + // Cast: the wire union is claude|codex today, but Tab.agentSessionAgent is + // the open AgentType, so a future agent can reach this label. + ;(snapshot.tabs[0] as { agent: string }).agent = 'gemini' + expect(build(snapshot).label).toBe('Gemini Chat') + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts index 3c43eec8d5c..50a1558533c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/terminal-surfaces.ts @@ -3,6 +3,7 @@ import type { RuntimeMobileSessionAgentTab } from '../../../../shared/runtime-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' +import { defaultAgentChatLabel } from '../../../../shared/agent-session-chat-label' import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization' import { resolveTerminalLayoutRoot } from '../remote-terminal-layout-resolution' import { getRemoteRuntimePtyEnvironmentId } from '../runtime-terminal-stream' @@ -113,8 +114,12 @@ export function buildMirroredAgentTabs( worktreeId: snapshot.worktree, contentType: 'agent-session', agentSessionAgent: tab.agent, - label: tab.title.trim() || 'Codex Chat', - customLabel: null, + // Why: `title` is wire data typed `string`; a host that violates that must + // degrade to the placeholder, not throw inside the snapshot patch. + label: tab.title?.trim() || defaultAgentChatLabel(tab.agent), + // Why: a manual rename lives only on the client; re-nulling it here made + // every host snapshot silently discard the user's title. + customLabel: existing?.customLabel ?? null, color: tab.color !== undefined ? tab.color : (existing?.color ?? null), sortOrder: sortOffset + index, createdAt: existing?.createdAt ?? now + sortOffset + index, diff --git a/src/renderer/src/store/terminals/renamable-unified-tab.ts b/src/renderer/src/store/terminals/renamable-unified-tab.ts new file mode 100644 index 00000000000..74d3eee0234 --- /dev/null +++ b/src/renderer/src/store/terminals/renamable-unified-tab.ts @@ -0,0 +1,15 @@ +import type { Tab } from '../../../../shared/tab-types' + +/** Resolves the unified tab a per-tab presentation action (rename, color) targets. + * Terminal tabs are addressed by their backing terminal's entityId; a structured + * chat has no TerminalTab record and is addressed by the unified tab id itself. */ +export function findRenamableUnifiedTab( + unifiedTabsByWorktree: Record, + tabId: string +): Tab | undefined { + const unified = Object.values(unifiedTabsByWorktree).flat() + return ( + unified.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) ?? + unified.find((entry) => entry.contentType === 'agent-session' && entry.id === tabId) + ) +} diff --git a/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts b/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts new file mode 100644 index 00000000000..5d1e6343f74 --- /dev/null +++ b/src/renderer/src/store/terminals/structured-chat-tab-rename.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' +import { createTestStore, makeWorktree, seedStore } from '../slices/store-test-helpers' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +const WORKTREE = 'local-repo::/tmp/app' +const STRUCTURED_TAB_ID = 'structured-agent-session-codex-1' + +function structuredTab(): Tab { + return { + id: STRUCTURED_TAB_ID, + entityId: 'codex-1', + groupId: 'group-1', + worktreeId: WORKTREE, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +const TERMINAL_TAB_ID = 'terminal-1' +const TERMINAL_UNIFIED_ID = 'unified-terminal-1' + +function terminalTab(): Tab { + return { + id: TERMINAL_UNIFIED_ID, + entityId: TERMINAL_TAB_ID, + groupId: 'group-1', + worktreeId: WORKTREE, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } +} + +function storeWithStructuredTab(): ReturnType { + const store = createTestStore() + seedStore(store, { + repos: [{ id: 'local-repo', path: '/tmp/app', name: 'app' }] as never, + worktreesByRepo: { + 'local-repo': [makeWorktree({ id: WORKTREE, repoId: 'local-repo', path: '/tmp/app' })] + }, + unifiedTabsByWorktree: { [WORKTREE]: [structuredTab()] } + }) + return store +} + +function labelOf(store: ReturnType): string | null | undefined { + return store + .getState() + .unifiedTabsByWorktree[WORKTREE]?.find((tab) => tab.id === STRUCTURED_TAB_ID)?.customLabel +} + +function colorOf(store: ReturnType): string | null | undefined { + return store + .getState() + .unifiedTabsByWorktree[WORKTREE]?.find((tab) => tab.id === STRUCTURED_TAB_ID)?.color +} + +describe('renaming a terminal tab still resolves', () => { + it('routes a terminal rename through its entityId, not the unified id', () => { + const store = createTestStore() + seedStore(store, { + repos: [{ id: 'local-repo', path: '/tmp/app', name: 'app' }] as never, + worktreesByRepo: { + 'local-repo': [makeWorktree({ id: WORKTREE, repoId: 'local-repo', path: '/tmp/app' })] + }, + unifiedTabsByWorktree: { [WORKTREE]: [terminalTab(), structuredTab()] } + }) + + // Keyed by the TERMINAL's entityId — the structured tab must not absorb it. + store.getState().setTabCustomTitle(TERMINAL_TAB_ID, 'Build logs') + + const tabs = store.getState().unifiedTabsByWorktree[WORKTREE] ?? [] + expect(tabs.find((t) => t.id === TERMINAL_UNIFIED_ID)?.customLabel).toBe('Build logs') + expect(tabs.find((t) => t.id === STRUCTURED_TAB_ID)?.customLabel).toBeNull() + }) +}) + +describe('recoloring a structured chat tab', () => { + it('writes the color onto the agent-session tab', () => { + const store = storeWithStructuredTab() + store.getState().setTabColor(STRUCTURED_TAB_ID, 'red') + expect(colorOf(store)).toBe('red') + }) +}) + +describe('renaming a structured chat tab', () => { + it('writes the custom label onto the agent-session tab', () => { + const store = storeWithStructuredTab() + store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, 'Flaky retry test') + expect(labelOf(store)).toBe('Flaky retry test') + }) + + it('clears the custom label when the rename is emptied', () => { + const store = storeWithStructuredTab() + store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, 'Flaky retry test') + // Guard: without the intermediate assertion this case passes on a rename + // that never wrote anything, since the label starts out null too. + expect(labelOf(store)).toBe('Flaky retry test') + store.getState().setTabCustomTitle(STRUCTURED_TAB_ID, null) + expect(labelOf(store)).toBeNull() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-tab-attention.ts b/src/renderer/src/store/terminals/terminal-tab-attention.ts index 062043b546e..3ba84961763 100644 --- a/src/renderer/src/store/terminals/terminal-tab-attention.ts +++ b/src/renderer/src/store/terminals/terminal-tab-attention.ts @@ -1,6 +1,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { findRenamableUnifiedTab } from './renamable-unified-tab' export function createTerminalTabAttentionActions( set: TerminalStoreSet, @@ -87,9 +88,7 @@ export function createTerminalTabAttentionActions( scheduleRuntimeGraphSync() return { tabsByWorktree: next } }) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + const item = findRenamableUnifiedTab(get().unifiedTabsByWorktree, tabId) if (item) { get().setTabCustomLabel(item.id, title, opts) } @@ -102,9 +101,7 @@ export function createTerminalTabAttentionActions( } return { tabsByWorktree: next } }) - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId) + const item = findRenamableUnifiedTab(get().unifiedTabsByWorktree, tabId) if (item) { get().setUnifiedTabColor(item.id, color) // Why: tab color is host-authoritative for remote-server tabs; mirror it so it persists instead of reverting on the next snapshot. diff --git a/src/shared/agent-session-chat-label.ts b/src/shared/agent-session-chat-label.ts new file mode 100644 index 00000000000..131285b0626 --- /dev/null +++ b/src/shared/agent-session-chat-label.ts @@ -0,0 +1,9 @@ +import type { AgentType } from './agent-status-types' +import { formatAgentTypeLabel } from './agent-type-label' + +/** Placeholder tab label for a structured chat that has no conversation name yet. + * Routed through the shared agent-name table so an agent this build does not + * know reads as itself rather than silently as Codex. */ +export function defaultAgentChatLabel(agent: AgentType | null | undefined): string { + return `${formatAgentTypeLabel(agent)} Chat` +} From 0252fe5c36da38e02f2f950bccecf9cda2fd029c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:38:16 -0700 Subject: [PATCH 08/28] feat(native-chat): show Codex subagent activity instead of opcode rows (#18773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(native-chat): show Codex subagent activity instead of opcode rows Codex spawns subagents and reports their lifecycle, but Orca rendered only gray `codex · item:subAgentActivity` opcode rows. Build the real display: one summary row per spawn group with a live working count and token usage. State is accumulated from `subAgentActivity.kind` alone. A live probe against app-server 0.152.1 showed `agentsStates` arrives empty even in a real subagent run, and that every activity item is delivered twice (item/started and item/completed), so every transition is idempotent and terminal states latch. Children never receive `thread/started`, so there is no nickname, role, or depth to read; the row labels from the trailing segment of `agentPath`. Two sweeps keep a row from claiming work forever: the parent turn's terminal event settles still-running children, and session start marks a pre-restart roster unverifiable rather than exited, since Codex resume replays no non-message items and no event can ever settle them. The roster rides a new NativeChatBlock variant paired with a plain-text twin. A journal item kind could not be used: that union is closed, and an unknown kind parses as malformed, which is the corrupt-journal class that can hide the chat tab. Block types are explicitly admissible when unknown, so an older client drops the block and renders the sentence. MessageRow moves out of NativeChatMessageList to keep both files under the max-lines budget without a disable. * feat(native-chat): give the subagent summary row its bot glyph The row led with a glyph that swapped on state — a check once every child completed, a group icon otherwise — so a group appeared to change identity the moment it settled. Per the approved mock, the glyph names the category and never moves: state is carried by the status dot and the tone of the words beside it. Use lucide `bot`, the same glyph the individual `subAgentActivity` rows take in the eight-category vocabulary, so the summary reads as their parent. Slot and glyph are the mock's 16px/14px, muted by default, and the svg is `aria-hidden` — the headline is what a screen reader announces, so the icon never stands alone. * fix(native-chat): correct the Codex subagent roster's build, journal write, and failure reporting * Restore the exhaustive block handling that adding `subagent-group` to `NativeChatBlock` broke. `formatWorkerTranscriptMessage` and `boundBlock` both fell through to `image-ref` field access, so `tsc -p` failed for the CLI and node projects and `build:cli` could not emit. Both now guard on `image-ref` explicitly and give the roster block its own branch. * Stop the roster's publish from evicting its own append. The sink queue coalesces by `coalescingKey` alone with no op-kind check, so passing the append's key to `tryPublish` spliced the queued append out and the row never reached the journal — permanently, since `lastSerialized` was already set. `tryPublish()` now takes no argument, matching every other call site. The regression test's fake sink honours the key, which the previous fake did not. * Keep `collabAgentToolCall` substantive. Only the MultiAgentV2 path emits `subAgentActivity`, so a V1 turn has no roster row; suppressing its collab tool calls too would have left a V1 fan-out showing nothing at all. * Surface a settled failure while siblings still work. The summary now reports the worst adverse outcome independently of the group verdict, so the row shows `3 working +1 failed` with a failed-coloured dot instead of a neutral pulsing dot. The plain-text twin names it too. * Treat `/morpheus` as a child. Only `/root` is the turn itself; the old segment-count test silently dropped a valid single-segment agent. * Refresh token-usage recency on update so an active thread is not evicted as the oldest entry, and scope the `agentsStates` comment to the V2 path. * fix(native-chat): stop the subagent roster announcing a new duration every second The roster row is an `aria-live="polite"` region and it contains the elapsed clock, which reticks once a second for as long as the fan-out runs. A screen reader therefore reads out a fresh duration every second, burying the state changes the live region exists to report — the headline, the verdict, and the `+1 failed` alert. No other live region in the transcript does this. `NativeChatToolRun`'s live button holds only the active tool label, and in `NativeChatWorkingStatus` the variant that shows a duration is precisely the one with no `aria-live`. Hide the clock from the accessibility tree only while it is moving. Once the group settles the duration is fixed, so it stays readable and costs no announcements. * fix(native-chat): retry a refused roster publish, and stop two wrong readings Four defects from a third review pass over the Codex subagent roster. `write()` set `lastSerialized` before the append and rolled it back only when the APPEND was refused. A refused PUBLISH left it set, so an identical replay short-circuited and the revision was never published again. The repo's own pattern is the opposite: `codex-structured-item-streams.ts` advances `checkpointLengths` only once the append AND the publish are both accepted. Roll back on either half. That alone did not cover the sweep, which is the LAST event a group ever gets: its `changed` guard skips the write on a retry because every child has already latched, stranding the settled roster's final revision. Write when the previous attempt was refused part-way, too. `formatWorkerTranscriptMessage` read `block.agents` as its exhaustive fallback. The journal schema deliberately admits block types this build does not know and `client.call` casts the RPC result instead of validating it, so a newer remote host's block reached that line and threw `agents is not iterable`, taking down the whole `worker read`. It printed a harmless `[image omitted]` before. Match `subagent-group` explicitly and degrade the unknown case. The elapsed clock measured to `now` whenever no child carried a terminal timestamp. That is exactly the roster restored from the journal after the host died: the reconciler latches `unverifiable` without a `settledAt`, so a child that ran four seconds reported the time since the crash as its run length, on a row that is not even counting. Show no duration when none is known. Also restores package.json to origin/main: the merge had deleted one of main's two duplicate `bench:terminal-partial-escape-tail` keys. Behaviour-preserving (JSON is last-wins and the deleted line was the dead one), but unrelated to this PR and better left to its own change. No gate rejects duplicate JSON keys. The new refusal tests also cover the append-side rollback, which had none. * fix(native-chat): stop the subagent roster vanishing from every settled turn `NativeChatToolRun` bailed out for a completed turn whose activity disclosure is collapsed before it reached the branch that draws a roster-only run. That guard exists to push TOOL activity behind the turn-status disclosure, and it fires on exactly the shape a spawn group has: a roster message carries no tool blocks, so `selectActiveToolCall` returns null and `isSettled` is true, while the list passes `expandOverride={expandedTurnIds.has(turnKey)}` — false until the reader opens that turn — and `activeTurnIsWorking={false}`. That is the default state of every finished turn in the transcript, so the one compact row this feature exists to leave behind ("Ran 3 subagents") disappeared the moment its turn ended. Worse, `MessageRow` counts a spawn group as renderable specifically so the row survives, then rendered a wrapper around a component that returned null — the empty ghost bubble its own guard is written to prevent. Order the roster branch before the disclosure guard. A roster has no tool activity to hide, and the guard's reasoning ("a failed child command looked like the whole response was still running") does not reach it. Runs that do carry tool blocks still fall through to the guard unchanged, and in practice a roster never shares a message with them: it is its own `role: 'system'` journal row and `isToolOnlyMessage` is false for it, so `foldToolMessages` never merges tool blocks into it. Also drop childless groups when building the rows, so `subagentRows.length` stays an honest test of "something will draw" — the roster-only branch returns a margin-bearing wrapper on the strength of it, and a group with no children renders null. Both tests fail with their fix reverted; the existing NativeChatToolRun suite still passes, so the completed-turn disclosure behaviour is unchanged. * test(native-chat): cover the subagent roster at the message-list level Every defect this feature has shipped so far lived in the assembly between rows, and the row-level suites kept passing through all of them. Loop 4's regression — a settled roster swallowed by the completed-turn disclosure — was found by reading the code, not by a test, and an independent visual-proof run observed the same symptom in the real UI and routed around it rather than reporting it. `NativeChatToolRun` rendered alone is handed `expandOverride` and `activeTurnIsWorking` by the test author, so it agrees with whatever the caller was assumed to pass. Drive the real component instead. The roster is its own `role: 'system'` journal row carrying the producer's two blocks (structured + plain-text twin), so what reaches the DOM depends on `foldToolMessages`, the turn-key mapping and the disclosure state `NativeChatMessageList` owns — none of which a row test exercises. Three cases, on one assembled transcript that holds tool calls AND a roster: - a settled turn with activity collapsed, the resting state of the whole transcript, still shows the row (fails with loop 4's reorder reverted); - tool activity stays behind that disclosure and appears only on expand, and expanding draws no second roster (fails with the guard removed); - a working turn reads as a live spawn. The first also pins that the plain-text twin is dropped rather than printed beside the row it stands in for. Timestamps are explicit and ascending: the list re-sorts by (timestamp, id), so rows sharing a millisecond tie-break alphabetically and the user turn can sort last, stranding the roster outside its own turn and reconciling live children to `unverifiable`. No production code changed. * fix(native-chat): make "counts as renderable" and "actually draws" agree for a spawn group `MessageRow` counts any `subagent-group` block as renderable, but `NativeChatSubagentRun` renders null for a childless roster. A group with `agents: []` therefore mounted a row that drew nothing — an empty div that still costs the transcript one `gap-5` slot. The Codex producer never writes one (every `write()` call site operates on a group that already holds an entry), but the block schema admits `agents: []` with no `.min(1)`, and the wire is where such a shape would arrive. Narrow `subagentGroupBlocks` — whose only production caller IS that renderable check — to the groups that will draw, behind a named `isRenderableSubagentGroup` that `NativeChatToolRun` now shares in place of its own copy of the predicate, so the two guards cannot drift apart again. A childless group carrying its plain-text twin now prints the twin, which is what the twin is for; a bare one skips the row entirely. Also correct four comments that had stopped describing the code: - the roster header called `agentsStates` "always empty", contradicting the probe note in `codex-subagent-activity.ts` — it is empty on the MultiAgentV2 path that emits these items, and the V1 path does populate it; - `tokensByThread` was documented "retained UNCONDITIONALLY" while `handleTokenUsage` LRU-caps it 65 lines below; - the sweep is not "the LAST event a group ever gets": neither `settleTurn` nor `settleSession` removes the group, so a later `thread/tokenUsage/updated` naming a swept child still writes it. The retry condition is right; only its stated reason was wrong; - the `subAgentActivity` classification is not reached "for every event — and every one of them arrives twice". `handleSubagentItem` intercepts those items before `items.handle`, so the live path never consults the catalog; `restoreThread` replays them straight through, and is the real consumer. Comment-only apart from the childless-group guard. * fix(cli): stop `worker read` printing the subagent roster sentence twice The producer ALWAYS writes a roster block beside a plain-text twin carrying the same sentence, for clients that cannot draw the block. The renderer honours that contract from one side — it draws the block and drops the twin. The CLI honoured neither side: it printed the twin as prose AND rendered the block as `[subagents] `, so a real roster message read [system] Ran 2 subagents (1 failed) [subagents] Ran 2 subagents (1 failed) Take the mirror of the renderer's rule, which is the cleaner half for a text client: the twin IS the sentence, so print it and drop the block it stands in for. A block that arrives WITHOUT its twin — a shape the wire admits and no producer writes — still stands in for itself, because dropping it unconditionally would lose the roster entirely. Either way the sentence prints exactly once, off the same `subagentGroupFallbackText` helper both sides use. Unreachable through `readWorkerTranscript` today, whose provider rollout decoder never emits a `subagent-group` block — but the formatter is the CLI's contract for any transcript source, and the shape is already producible. The test pinned a TWIN-LESS group, a body `codexSubagentGroupBody` never writes: it asserted the exact double-print this fixes was correct output, and would have blessed either behaviour. Rebuild the fixture as the producer's real two-block row, with the sentence taken from the shared helper rather than hardcoded so it cannot drift, and assert the sentence appears exactly once. The twin-less shape keeps a test of its own, labelled as the wire-only fallback it is. Also record why `settleTurn` keys on the RAW `turnId` while `groupFor` remaps off-primary activity onto the primary's active turn. The asymmetry is load-bearing, not an oversight: were `settleTurn` to remap, a child thread ending its own turn would sweep the parent group and settle every still-working sibling to `unverifiable`. The lookup missing is the intended no-op. * fix(native-chat): add the subagent roster's localization keys and narrow its twin filters The roster row called 16 `components.native-chat.subagents.*` keys that were never added to the catalog, failing the localization gate. Synced en.json; the English strings are the component's own inline fallbacks, so nothing renders differently. Also tightens the twin/block handoff on both readers. The renderer dropped every text block once a roster was present, which is safe only because Codex writes a roster as its own message — the block is provider-agnostic, so a lane folding prose in beside one would have lost it on desktop while mobile kept it. And both readers decided "the twin is already printing" by recomputing the sentence and comparing bytes, which a roster from a newer build never matches: its unknown state normalizes to `unverifiable` here, so the CLI printed the roster twice with two different verdicts. Both now recognize a twin by shape. * test(native-chat): pin the roster twin recognizer against prose Both readers use it to decide the twin is already printing, so a false positive eats a message's real prose and a false negative prints the roster twice. * docs(codex): restore the roster's evictionated trigger to its KNOWN LIMITATION The previous rewrite dropped both triggers the old comment named and kept only the restart one, but eviction is the reachable half: `groupFor` caps `groups` at MAX_CODEX_SUBAGENT_GROUPS and drops the oldest-INSERTED entry (it returns an existing group without re-inserting, so this is not LRU), which can evict a still-live group in-process. The row identity is keyed on the group id alone, so the next activity item rebuilds that row from one child — the same N-to-1 rewrite, with no restart, and with the sweep skipped so the children never latch `unverifiable`. Also softens "every real turn id is freshly minted" to the provider assumption it is: turn ids are read verbatim off provider frames and nothing in this repo mints or asserts them. * docs(codex): justify the subagent wire notes from the live probe alone The roster and disposition comments explained themselves in terms of a provider-internal path taxonomy rather than anything this repo can observe. Restate them from the evidence Orca actually has: the live app-server probe saw `agentsStates` arrive empty, so nothing reads it; and `collabAgentToolCall` stays substantive because nothing guarantees a session reports subagent work as `subAgentActivity` at all — one that only emits the collab tool call gets no roster row, and suppressing that too would leave its fan-out blank. Same behaviour, same tests; comments and one test name only. * fix(native-chat): stop the roster's durable twin from claiming live subagents The spawn-group row is written once and revised in place, but the row itself is durable and replayed on every reconnect. Its plain-text twin — the only thing a client that cannot draw the block ever sees — froze a live count into that row: `Kicked off 4 subagents — 2 working`. The desktop renderer never shows it, and reconciles the block's `working` to `unverifiable` outside the live turn. A text-only reader does neither. When the writing process dies mid-flight the turn-end sweep never runs, so the sentence keeps asserting two running children forever, with nothing left that could re-check them. That is the collapse `docs/reference/ssh-execution-boundary.md` forbids: loss of contact reported as a live state. Fix it at the source rather than per client: the durable sentence now states only what survives its process — that the group was spawned, plus whatever outcome had latched. `Kicked off` vs `Ran` stays, because it reports whether an outcome was recorded at write time; saying `Ran` while children were in flight would assert they exited, the same error inverted. The adverse count stays so a failing fan-out still reads as failing. Reconciliation stays in the renderer, where the block still needs it. The twin recognizer keeps matching the legacy `— N working` shape: journals already hold those sentences and their rows replay forever, so dropping the branch would print every one of them twice, once as the block and once as prose the reader meant to drop. Also align the two functions that read `agentPath`. The root check compared the raw string while the label normalized separators, so `/root/` was both the turn itself and a child of it — a phantom row labelled `root` inflating the group by one. Compare normalized segments instead, keeping `/morpheus` a child. And a trailing segment with nothing visible in it survives the empty-segment filter and would draw a nameless row, so it now reads as no label and falls back to the placeholder. * fix(codex): key the subagent label collision ordinal on what the row draws `codexSubagentLabel` tested the trailing segment trimmed but returned it untrimmed, and `claimLabel` keys its collision ordinal on that string. Two children at `/root/read` and `/root/ read ` therefore both drew as `read` with no ordinal — the one thing the ordinal exists to prevent. Return the trimmed segment so labels that render identically collide. Also correct the legacy-clause note on the twin recognizer. It claimed shipped journals hold the old `— N working` sentence; the feature is unreleased, so the only journals holding one are dev worktrees of this branch. The branch still earns its place — those rows replay too, and it adds no false-positive surface the bare shape does not already carry — but the stated reason was wrong. * test(native-chat): retire the subagent-visibility guards now the roster renders Two tests from the sibling item-coverage PR asserted that subagent items stay on the generic gray row, explicitly gated on "until a real renderer exists". This branch is that renderer, so both guards fire on merge — the handoff they were written to mark rather than a regression. They now pin the other side of it: subAgentActivity is suppressed because the spawn-group roster renders it, and collabAgentToolCall deliberately stays visible, since nothing guarantees a session reports subagent work as subAgentActivity at all. Git merged both files without conflict; only running the suite surfaced this. * fix(native-chat): let a subagent swept at turn end still report what it did The turn-end sweep marks still-running children `unverifiable`, and the producer latched on any state that was not `working` — so `unverifiable` latched too. A subagent that outlived its turn then reported `completed`, the latch refused it, and a child that finished successfully read as one we never saw finish, permanently. One predicate was doing two jobs. `isTerminalSubagentState` is right for counting — `unverifiable` is not working — and wrong for latching, because `unverifiable` records that we stopped being able to see the child, not what it did. Split them: a child's own verdict latches, the sweep's guess does not. The reverse stays refused. Nothing returns to `working` once we have given up on it, so a straggler progress tick cannot re-light a settled row. Neither the latch nor the sweep was wrong alone, and both were tested; the defect lived only in their interaction, and only when a subagent outlives its turn — which the probe that drove this design never produced, because the parent it captured waited on its child. * fix: drop the @pnpm/exe lockfile drift a merge staged `git add -A` swept up the pnpm-lock.yaml mutation that every pnpm invocation leaves in this repo. Nineteen lines, thirteen of them @pnpm/exe, and it fails sixteen unrelated CI checks — native smoke, typecheck, packaging, xterm patch sync — none of which name the lockfile. * fix(native-chat): restore the item fall-through an inline dropped Inlining the subagent routing helper lost its null check: the roster returning null means it did not claim the item, and the translator must keep looking. Returning unconditionally once any thread item parsed swallowed every ordinary item — twelve settlement tests, none of them about subagents. * fix(orchestration): rebind the subagent block arm to the renamed bound state Main renamed clipMetadata's second parameter from a warnings set to a TranscriptBoundState. The subagent-group arm still passed `warnings`, and git merged both sides without a conflict because the lines never overlapped — the rename and the new arm are in different hunks. Typecheck was the only thing that could catch it, and did. * fix(codex): publish the turn tail for a subagent item the roster claims Main's #19055 added a `subAgentActivity` arm to the provider activity table, which is reached only through `publishActivity`. The roster's admission returned above that call, so every `subAgentActivity` item bypassed it and a fan-out that reports nothing else left the turn tail stuck on the previous frame's text. `publishActivity` already no-ops on a refused admission and on a non-primary thread, so routing the roster's admission through it is safe. Also corrects a docstring the frames extraction copy-pasted onto `settleOversizedNotification`. * fix(native-chat): bound the subagent roster on every boundary that carries it The spawn-group arm was the one collection in the worker-transcript payload with no cap, and the one block type mobile's `sanitizeBlock` forwarded verbatim. The producer's `MAX_CODEX_SUBAGENTS_PER_GROUP` does not reach either boundary: the journal schema declares no maximum on `agents`, and a remote host may run a build with a different cap. Both transports now cap the roster and bound `id`, `label` and the open `state` string; `label` and `id` also take the standard inline bound on the journal write path, where every other provider string already does. A token count is now persisted onto its entry at write time. `write` rebuilt `tokens` from the LRU-capped thread map on every write, so an eviction silently retracted a count the durable row had already shown. Adds the first coverage of the three roster caps, including the group eviction that rewrites a row from N children down to one. * fix(native-chat): keep the roster drawn beside tool calls and its clock honest The roster-only escape is keyed on `blocks.length === 0`, so a spawn group sharing its message with tool-call blocks fell through to the settled-turn guard, which returned bare null and took the roster with it — the exact regression the escape above was written to avoid, after the message row had already counted the group as renderable. Unreachable for Codex today; the block type is deliberately provider-agnostic, so it is live for the Claude lane. The elapsed clock also froze at a sibling's timestamp on a partial sweep: in a group where one child completed and another is unaccounted for, the ended turn left `working === 0` with the completed child's `settledAt`, and the row showed that child's duration as the group's run length. No clock is drawn while any child is `unverifiable` with no terminal timestamp. * perf(native-chat): bound the roster's provider strings without digesting them `boundInlineText` computes a sha256 and a Buffer BEFORE it checks the length, so the roster paid two digests per child on every write even when nothing was truncated — and `write()` runs on every claimed activity item (each delivered twice) and again from `handleTokenUsage`, which streams. A same-process A/B over a 64-child group: 76.5 us/write before, 2.0 us/write after (plain, unbounded row is 1.2 us). The cap changes with the mechanism. 16 KB is the tool-output bound; both readers of this row already clip the same fields to 512, so the producer was admitting ~2 MB per durable roster row for consumers to throw ~97% of away. One `MAX_SUBAGENT_FIELD_CHARS` now serves the producer and both readers, and the marker is an ellipsis rather than the tool-output truncation sentence — `id` is the roster key and the renderer's React key. Also raises the orchestration arm's per-group bound from 20 to the producer's 64, matching the mobile arm: a 21-64 child group is routinely producible here, so that arm clipped children and warned while its sibling clipped none. The slice and warning stay as the transport's own defence against a remote host with a larger cap. * fix(orchestration): suppress one roster block per twin, not all of them `hasTwin` was a single boolean over the whole message, so a message carrying two `subagent-group` blocks and one plain-text twin printed one sentence and dropped the second roster with no marker. Count the twins and claim one per group instead. Not reachable from this branch's producer, which writes one group per journal item, but the surrounding reasoning is explicitly about wire shapes the producer never writes and this is the adjacent one it missed. * fix(native-chat): loop-3 fixes to the Codex subagent worklog Five defects loop 2's own fixes introduced. Twin claiming was order-blind: the count-based claim silenced whichever roster block came first, so a lone twin belonging to a LATER group erased an earlier group's roster and printed the later sentence twice. Exact-text claims are now settled for every group before any leftover twin is claimed by position; the positional fallback stays for a newer build's frozen twin, which can never equal a recomputed sentence. `boundSubagentField` sliced UTF-16 units and could leave a lone high surrogate in a durable row, and the clip removed exactly the tail that told two children apart — `id` is the renderer's React key and `claimLabel` writes its repeat ordinal at the end. It now backs off a split pair and reserves the child index inside the bound, so both readers' re-clip cannot cut the disambiguator off again. `MAX_SUBAGENT_FIELD_CHARS`'s doc claimed a `groupId` bound the producer never applies; the doc now says so and why. The worker-transcript metadata cap is a separate literal again: it governs message ids, turn ids, tool-call names and image urls, so a roster-motivated change must not move it. * fix(native-chat): never infer a lost subagent from a turn boundary QA drove a real Codex session with three live `spawn_agent` children and sent a mid-turn correction. The roster row immediately read "Ran 3 subagents / 3 unverifiable" with no clock, while all three were still running — they reported `completed` 57-87s after that turn ended. Both sites rested on the same false premise: that a turn ending means no event will ever settle a child. Children outlive their turn and keep reporting into the same group. - Renderer: drop `reconcileSubagentRoster`. Nothing plumbed to the component distinguishes a row written by a dead host from a turn that merely ended — journal render items carry no epoch, and a new epoch deletes the rows of the one it supersedes — so the row now draws the state the journal recorded. Under-claiming beats over-claiming. - Main: stop sweeping on `turn/completed`. That sweep wrote `unverifiable` into the DURABLE journal, which mobile reads with no reconciliation. `turn/completed` is Codex's only turn-end notification, so an abort cannot be told apart from a clean finish; the safe default is not to sweep. `settleSession` — the provider actually being gone — is unchanged and is now the only sweep. `unverifiable` stays non-latching so a late verdict still lands. * test(native-chat): pin the roster at the seam the QA defect came from The mid-turn correction opens a new turn, so the fan-out's row stops being the current turn and the list hands the roster `activeTurnIsWorking={false}`. Asserted through the list, not the component, because that prop is what carried the wrong claim. * fix(native-chat): settle a roster the dying host never got to sweep `settleSession` only fires when the provider goes away while this process is alive. If the host itself dies, nothing sweeps and nothing reconciles on restore, so a `subagent-group` row persisted as `working` claimed live children forever — the mirror of the defect the previous commit fixed, and the same `ssh-execution-boundary.md` violation in the other direction. Reconciled host-side, at journal open, not in the renderer: mobile shows only the durable text twin and reconciles nothing, so a renderer-only fix would leave it claiming live children indefinitely. Opening the journal is also the one moment a host can honestly say the previous writer is gone. - `staleSubagentRosterRevisions` rewrites every child still reading `working` to `unverifiable` and regenerates the twin from the same summary, so the block and the sentence cannot disagree. - No terminal timestamp: the child stopped being observable at an unknown moment, and stamping the reopen would report the downtime as its run length. - Revises in place under the parsed identity, so a reopen upserts the row rather than appending a duplicate, and a second reopen writes nothing. - Skipped on a corrupt load: that journal is still owed a rebuild from provider history, and content past the repair's free sequence retires the demand. Reconciles journal ROWS, not roster state — the producer's in-process group map is untouched, so the roster's known seeding limitation is unchanged, as is `canReplaceSubagentState`: `unverifiable` still does not latch. --------- Co-authored-by: Merge Sim --- .../orchestration/worker-output.test.ts | 210 +++++ .../codex-structured-item-translation.test.ts | 7 +- .../codex/codex-structured-journal-limits.ts | 7 + ...x-structured-journal-translation-frames.ts | 43 + ...ured-journal-translation-subagents.test.ts | 168 ++++ .../codex-structured-journal-translation.ts | 76 +- src/main/codex/codex-subagent-activity.ts | 140 ++++ src/main/codex/codex-subagent-roster.test.ts | 769 ++++++++++++++++++ src/main/codex/codex-subagent-roster.ts | 347 ++++++++ .../journal-store-open.ts | 45 +- .../journal-store-restore.ts | 3 +- .../journal-subagent-liveness.test.ts | 202 +++++ .../journal-subagent-liveness.ts | 101 +++ .../provider-frame-activity.test.ts | 10 + .../provider-frame-disposition.test.ts | 49 +- .../provider-frame-disposition.ts | 16 +- .../worker-transcript-payload.test.ts | 75 ++ .../worker-transcript-payload.ts | 48 +- .../methods/native-chat-rpc-block-sanitize.ts | 132 +++ .../runtime/rpc/methods/native-chat.test.ts | 33 + src/main/runtime/rpc/methods/native-chat.ts | 110 +-- .../NativeChatMessageList.test.tsx | 295 +++++++ .../native-chat/NativeChatMessageRow.tsx | 35 +- .../NativeChatSubagentRun.test.tsx | 318 ++++++++ .../native-chat/NativeChatSubagentRun.tsx | 277 +++++++ .../native-chat/NativeChatToolRun.tsx | 38 +- .../native-chat/native-chat-tool-fold.test.ts | 49 ++ src/renderer/src/i18n/locales/en.json | 20 + src/shared/agent-session-journal-schemas.ts | 24 +- .../native-chat-subagent-summary.test.ts | 228 ++++++ src/shared/native-chat-subagent-summary.ts | 216 +++++ src/shared/native-chat-tool-fold.ts | 13 +- src/shared/native-chat-types.ts | 46 ++ src/shared/worker-transcript-text.ts | 66 +- 34 files changed, 4058 insertions(+), 158 deletions(-) create mode 100644 src/main/codex/codex-structured-journal-translation-frames.ts create mode 100644 src/main/codex/codex-structured-journal-translation-subagents.test.ts create mode 100644 src/main/codex/codex-subagent-activity.ts create mode 100644 src/main/codex/codex-subagent-roster.test.ts create mode 100644 src/main/codex/codex-subagent-roster.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts create mode 100644 src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatSubagentRun.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx create mode 100644 src/shared/native-chat-subagent-summary.test.ts create mode 100644 src/shared/native-chat-subagent-summary.ts diff --git a/src/cli/handlers/orchestration/worker-output.test.ts b/src/cli/handlers/orchestration/worker-output.test.ts index 44da2d67f93..895e9909d05 100644 --- a/src/cli/handlers/orchestration/worker-output.test.ts +++ b/src/cli/handlers/orchestration/worker-output.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' import type { OrchestrationFleetWorker } from '../../../shared/orchestration-fleet-projection' +import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../shared/native-chat-types' import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output' import { formatWorkerRead, formatWorkerStart } from './worker-output' @@ -288,3 +294,207 @@ function workerReadResult( type WorkerReadResultWithoutContext = T extends unknown ? Omit : never + +function transcriptRead( + blocks: NativeChatBlock[], + role: NativeChatMessage['role'] = 'assistant' +): OrchestrationWorkerReadResult { + const message: NativeChatMessage = { + id: 'm1', + role, + blocks, + timestamp: 1, + source: 'transcript' + } + return { + dispatchId: 'd1', + source: 'transcript', + sourceIdentity: 'pane:1', + provider: 'codex', + transcript: { messages: [message], nextCursor: '1', limited: false, returnedMessageCount: 1 }, + cursor: '1', + status: { worker: 'running', terminal: 'running' }, + fallbackReason: null, + warnings: [] + } +} + +const ROSTER: readonly NativeChatSubagentEntry[] = [ + { id: 'child-1', label: 'read', state: 'working' }, + { id: 'child-2', label: 'edit', state: 'failed' } +] + +function occurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1 +} + +describe('formatWorkerRead', () => { + // The replay case this row is durable for: SQLite-backed, re-sent on every + // reconnect, and read here by a client that draws no roster block, runs no + // reconciliation, and cannot re-check whether those children still exist. A + // sentence frozen mid-flight outlives the process that wrote it, so it must + // not keep asserting a liveness only that process could have observed — + // `docs/reference/ssh-execution-boundary.md` calls that loss of contact + // reported as a live state. + it('replays a mid-flight roster row without claiming a child is still working', () => { + const midFlight: readonly NativeChatSubagentEntry[] = [ + { id: 'child-1', label: 'read', state: 'working' }, + { id: 'child-2', label: 'search', state: 'working' }, + { id: 'child-3', label: 'edit', state: 'failed' } + ] + + const output = formatWorkerRead( + transcriptRead([ + { type: 'text', text: subagentGroupFallbackText(midFlight) }, + { type: 'subagent-group', groupId: 'thread:turn-1', agents: [...midFlight] } + ]) + ) + + expect(output).toContain('[assistant] Kicked off 3 subagents (1 failed)') + expect(output).not.toMatch(/\bworking\b/) + }) + + // The body `codexSubagentGroupBody` actually writes: the plain-text twin, then + // the block it stands in for. The twin exists for clients that cannot draw the + // block, so a client printing the block must not print the twin beside it — + // the renderer drops the twin for the same reason, from the other side. + it('prints the roster sentence once for the two-block row the producer writes', () => { + const sentence = subagentGroupFallbackText(ROSTER) + const output = formatWorkerRead( + transcriptRead( + [ + { type: 'text', text: sentence }, + { type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] } + ], + 'system' + ) + ) + + expect(output).toContain(`[system] ${sentence}`) + expect(occurrences(output, sentence)).toBe(1) + }) + + // Suppression is per twin, not per message. One twin beside two roster blocks + // silenced BOTH groups and printed one sentence, so the second roster vanished + // with no marker — the same silent drop the missing-twin case above avoids. + it('stands in for the second roster block when only one twin accompanies two', () => { + const other: readonly NativeChatSubagentEntry[] = [ + { id: 'child-3', label: 'plan', state: 'completed' } + ] + const output = formatWorkerRead( + transcriptRead( + [ + { type: 'text', text: subagentGroupFallbackText(ROSTER) }, + { type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }, + { type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] } + ], + 'system' + ) + ) + + expect(occurrences(output, subagentGroupFallbackText(ROSTER))).toBe(1) + expect(output).toContain(`[subagents] ${subagentGroupFallbackText(other)}`) + }) + + // Which group a lone twin belongs to is decided by its TEXT, not its position. + // Claiming positionally silenced whichever group came first, so a twin + // belonging to a LATER group erased the earlier group's roster and printed the + // later one's sentence twice — the same silent drop, one permutation over. + it('claims a lone twin for the group it names, not the first group in the message', () => { + const other: readonly NativeChatSubagentEntry[] = [ + { id: 'child-3', label: 'plan', state: 'completed' } + ] + const second = subagentGroupFallbackText(other) + const output = formatWorkerRead( + transcriptRead( + [ + { type: 'text', text: second }, + { type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }, + { type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] } + ], + 'system' + ) + ) + + expect(occurrences(output, second)).toBe(1) + expect(output).toContain(`[subagents] ${subagentGroupFallbackText(ROSTER)}`) + }) + + // The same claim, with the twin written after both blocks: nothing about the + // ORDER of a twin and its group is guaranteed by the block schema. + it('claims a trailing twin for the group it names', () => { + const other: readonly NativeChatSubagentEntry[] = [ + { id: 'child-3', label: 'plan', state: 'completed' } + ] + const second = subagentGroupFallbackText(other) + const output = formatWorkerRead( + transcriptRead( + [ + { type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }, + { type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] }, + { type: 'text', text: second } + ], + 'system' + ) + ) + + expect(occurrences(output, second)).toBe(1) + expect(output).toContain(`[subagents] ${subagentGroupFallbackText(ROSTER)}`) + }) + + // A group with no twin beside it is a shape the block schema admits and no + // producer writes. Dropping it would lose the roster entirely, so the block + // itself carries the sentence when nothing else does. + it('stands in for a roster block that arrived without its twin', () => { + const output = formatWorkerRead( + transcriptRead([{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }]) + ) + + expect(output).toContain(`[assistant] [subagents] ${subagentGroupFallbackText(ROSTER)}`) + }) + + // A roster from a newer build holds a state this build does not know, which + // `summarizeSubagentGroup` reads as `unverifiable`. Recomputing the sentence + // to compare it against the frozen twin therefore produced a DIFFERENT string, + // and the CLI printed the roster twice: the twin's own wording plus a + // `[subagents]` line contradicting it. + it('prints the roster once when the twin names a state this build cannot reproduce', () => { + const frozenTwin = 'Ran 2 subagents (1 cancelled)' + const output = formatWorkerRead( + transcriptRead( + [ + { type: 'text', text: frozenTwin }, + { + type: 'subagent-group', + groupId: 'thread:turn-1', + agents: [ + { id: 'child-1', label: 'read', state: 'completed' }, + { id: 'child-2', label: 'edit', state: 'cancelled' } + ] as unknown as NativeChatSubagentEntry[] + } + ], + 'system' + ) + ) + + expect(output).toContain(`[system] ${frozenTwin}`) + expect(output).not.toContain('[subagents]') + expect(output).not.toContain('unverifiable') + }) + + // The journal admits block types this build does not know, and `client.call` + // casts the RPC result rather than validating it — so a newer remote host's + // block reaches this formatter as-is. Reading fields off it threw a TypeError + // and took down the whole `worker read`. + it('degrades an unknown block type from a newer host instead of throwing', () => { + const output = formatWorkerRead( + transcriptRead([ + { type: 'text', text: 'before' }, + { type: 'plan-step', title: 'ship it' } as unknown as NativeChatBlock, + { type: 'text', text: 'after' } + ]) + ) + + expect(output).toContain('[assistant] before\n[unsupported block]\nafter') + }) +}) diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 2558f4b60de..0c47919f59d 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -784,7 +784,7 @@ describe('codex item bodies', () => { } }) - it('leaves subagent items on the generic row until a real renderer exists', () => { + it('drops the raw subagent item now the roster row renders it', () => { expect( codexJournalItem({ type: 'subAgentActivity', @@ -793,10 +793,7 @@ describe('codex item bodies', () => { agentThreadId: 'thread-child', agentPath: '/root/list_directory' }) - ).toMatchObject({ - handled: false, - body: { kind: 'status', providerFrame: { kind: 'item:subAgentActivity' } } - }) + ).toMatchObject({ handled: true, body: null }) }) it('drops the sleep item, which codex itself renders as nothing', () => { diff --git a/src/main/codex/codex-structured-journal-limits.ts b/src/main/codex/codex-structured-journal-limits.ts index d741a9e86d2..5137ea8dd16 100644 --- a/src/main/codex/codex-structured-journal-limits.ts +++ b/src/main/codex/codex-structured-journal-limits.ts @@ -7,3 +7,10 @@ export const MAX_CODEX_PENDING_PROMPTS = 128 export const MAX_CODEX_IDENTITY_ENTRIES = 512 export const MAX_CODEX_DETAIL_ENTRIES = 512 export const MAX_CODEX_DETAIL_BYTES = 64 * 1024 +/** Spawn-group rows kept live per session, and children per row. Both bound an + * event-accumulated map that no provider snapshot ever prunes. */ +export const MAX_CODEX_SUBAGENT_GROUPS = 32 +export const MAX_CODEX_SUBAGENTS_PER_GROUP = 64 +/** Threads whose latest token total is retained. Usage frames arrive for + * threads that are not yet (or never become) roster children. */ +export const MAX_CODEX_TOKEN_USAGE_THREADS = 256 diff --git a/src/main/codex/codex-structured-journal-translation-frames.ts b/src/main/codex/codex-structured-journal-translation-frames.ts new file mode 100644 index 00000000000..22dc516b210 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation-frames.ts @@ -0,0 +1,43 @@ +/** + * The translator's provider-frame arms. + * + * Each returns null for a frame it does not own, which is the translator's + * signal to keep looking. Split out so the translator reads as routing rather + * than as the shape checks each arm performs. + */ + +import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts' +import { settleCodexOversizedNotification } from './codex-structured-journal-settlement' +import { + readCodexJournalRecord, + readCodexJournalString +} from './codex-structured-journal-translation-values' + +type OversizedInput = Parameters[0] + +/** A notification the transport refused to carry whole: settle whatever it + * opened rather than leaving the item mid-flight. */ +export function settleCodexOversizedNotificationFrame(input: { + sessionId: string + threadId: string + kind: string + payload: unknown + sink: OversizedInput['sink'] + streams: OversizedInput['streams'] + activeItems: OversizedInput['activeItems'] +}): CodexJournalTranslationAdmission | null { + if (input.kind !== 'frame:oversized-notification') { + return null + } + const method = readCodexJournalString(readCodexJournalRecord(input.payload), 'method') + return method + ? settleCodexOversizedNotification({ + sessionId: input.sessionId, + threadId: input.threadId, + method, + sink: input.sink, + streams: input.streams, + activeItems: input.activeItems + }) + : null +} diff --git a/src/main/codex/codex-structured-journal-translation-subagents.test.ts b/src/main/codex/codex-structured-journal-translation-subagents.test.ts new file mode 100644 index 00000000000..bf5cdffa5a9 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation-subagents.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionTurnActivity } from '../../shared/agent-session-wire' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { isSubagentGroupBlock } from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' + +type Row = { key: string; body: AgentJournalItemBody } + +function harness() { + const rows: Row[] = [] + const activities: (AgentSessionTurnActivity | null)[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: () => {}, + publish: () => {}, + setActivity: (activity) => activities.push(activity) + } + const translator = createCodexJournalTranslator({ + sink, + primaryThreadId: () => THREAD_ID, + schedule: (run: () => void) => { + run() + return () => {} + } + }) + return { translator, rows, activities } +} + +function notification(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params } +} + +function subagentItem(kind: string, agentThreadId: string, agentPath: string): unknown { + return { + turnId: TURN_ID, + item: { + type: 'subAgentActivity', + id: `item-${agentThreadId}-${kind}`, + kind, + agentThreadId, + agentPath + } + } +} + +/** Every activity item reaches the wire twice. */ +function deliverActivity( + translator: ReturnType, + params: unknown +): void { + translator.handle(notification('item/started', params)) + translator.handle(notification('item/completed', params)) +} + +function rosterAgents(rows: Row[]): { id: string; state: string; tokens?: number }[] { + const body = rows.findLast((row) => row.key.startsWith('orca:codex-subagents'))?.body + if (!body || body.kind !== 'message') { + return [] + } + return body.blocks.find(isSubagentGroupBlock)?.agents ?? [] +} + +describe('codex journal translation — subagents', () => { + it('renders a spawn group as one roster row and no opcode-shaped duplicate', () => { + const { translator, rows } = harness() + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + deliverActivity(translator, subagentItem('started', 'child-1', '/root/list_directory')) + deliverActivity(translator, subagentItem('interacted', 'child-1', '/root/list_directory')) + + expect(rosterAgents(rows)).toMatchObject([ + { id: 'child-1', label: 'list_directory', state: 'working' } + ]) + // Four wire deliveries (two items, each sent twice) collapse to ONE roster + // row, and none of the gray `codex · item:subAgentActivity` rows survive. + const providerFrameKinds = rows.flatMap((row) => + row.body.kind === 'status' && row.body.providerFrame ? [row.body.providerFrame.kind] : [] + ) + expect(providerFrameKinds).toEqual([]) + expect(rows.filter((row) => row.key.startsWith('orca:codex-subagents'))).toHaveLength(1) + }) + + // The roster claims the item, but claiming it must not take the turn tail with + // it: the activity table is reached only through the publish arm, so a bare + // return leaves the tail stuck on whatever the previous frame said. + it('still publishes the turn tail for an item the roster claims', () => { + const { translator, activities } = harness() + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + activities.length = 0 + deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) + + expect(activities.at(-1)).toEqual({ + turnId: TURN_ID, + text: 'Coordinating with another agent' + }) + }) + + it('consumes thread/tokenUsage/updated instead of swallowing it as chrome', () => { + const { translator, rows } = harness() + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) + translator.handle( + notification('thread/tokenUsage/updated', { + threadId: 'child-1', + tokenUsage: { total: { totalTokens: 40661 } } + }) + ) + + expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', tokens: 40661 }]) + }) + + // The QA scenario this row got wrong: three `spawn_agent` children were still + // running when a mid-turn correction ended their turn and opened a new one. + // They reported `completed` 57-87s later, so a turn boundary is a fact about + // the turn and never evidence that contact with a child was lost. + it('leaves children working when their turn ends and a newer turn opens', () => { + const { translator, rows } = harness() + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + deliverActivity(translator, subagentItem('started', 'child-1', '/root/read_readme')) + deliverActivity(translator, subagentItem('started', 'child-2', '/root/read_package')) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + translator.handle(notification('turn/started', { turn: { id: 'turn-2' } })) + + expect(rosterAgents(rows)).toMatchObject([ + { id: 'child-1', state: 'working' }, + { id: 'child-2', state: 'working' } + ]) + + // And the verdict a child reports after its turn ended still lands on the row. + deliverActivity(translator, subagentItem('completed', 'child-1', '/root/read_readme')) + + expect(rosterAgents(rows)).toMatchObject([ + { id: 'child-1', state: 'completed' }, + { id: 'child-2', state: 'working' } + ]) + }) + + it('sweeps every group when the provider ends', () => { + const { translator, rows } = harness() + + translator.handle(notification('turn/started', { turn: { id: TURN_ID } })) + deliverActivity(translator, subagentItem('started', 'child-1', '/root/read')) + translator.handle({ + type: 'ended', + sessionId: SESSION_ID, + reason: 'provider exited', + cause: 'unexpected-exit', + fence: 1, + acquisitionGeneration: 'gen-1' + } as CodexStructuredSessionEvent) + + expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts index c0c103bddff..c8a6fe9f158 100644 --- a/src/main/codex/codex-structured-journal-translation.ts +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -1,4 +1,10 @@ import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity' +import { + CODEX_TOKEN_USAGE_METHOD, + readCodexNotificationThreadItem +} from './codex-subagent-activity' +import { CodexSubagentRoster } from './codex-subagent-roster' +import { readCodexThreadItem } from './codex-structured-item-translation' import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames' import { CodexJournalItems } from './codex-structured-journal-items' import { CodexJournalPrompts } from './codex-structured-journal-prompts' @@ -10,16 +16,12 @@ import { } from './codex-structured-journal-contracts' import { settleCodexJournalSession, - settleCodexJournalTurn, - settleCodexOversizedNotification + settleCodexJournalTurn } from './codex-structured-journal-settlement' +import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames' import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore' import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state' import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns' -import { - readCodexJournalRecord, - readCodexJournalString -} from './codex-structured-journal-translation-values' import { readCodexTurnId } from './codex-structured-thread-facts' import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' @@ -55,6 +57,11 @@ export function createCodexJournalTranslator( const prompts = new CodexJournalPrompts(deps, (threadId, itemId) => items.detailFor(threadId, itemId) ) + const subagents = new CodexSubagentRoster({ + sink: deps.sink, + primaryThreadId: () => deps.primaryThreadId?.() ?? null, + activeTurn: (threadId) => activeTurns.current(threadId) + }) const flushStreams = (): CodexJournalTranslationAdmission => items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' } let readActivity = createCodexProviderActivityReader() @@ -118,6 +125,11 @@ export function createCodexJournalTranslator( if (!admission.accepted) { return admission } + // No event will ever settle a child once the provider is gone. + const sweep = subagents.settleSession() + if (!sweep.accepted) { + return sweep + } readActivity = createCodexProviderActivityReader() deps.sink.setActivity?.(null) items.activeItems.clear() @@ -159,7 +171,30 @@ export function createCodexJournalTranslator( if (event.method === 'turn/completed') { return completeTurn(event) } + if (event.method === CODEX_TOKEN_USAGE_METHOD) { + // Classified `status-chrome`, so the generic-frame path swallows it + // before the journal. The roster consumes it as a typed notification. + const admission = subagents.handleTokenUsage(event.params) + if (admission) { + return admission + } + } if (event.method === 'item/started' || event.method === 'item/completed') { + const subagentItem = readCodexNotificationThreadItem(event.params, readCodexThreadItem) + // Null means the roster did not claim it; fall through to normal item + // handling. Returning here unconditionally swallows every other item. + const subagentAdmission = subagentItem + ? subagents.handleItem({ + threadId: event.threadId, + turnId: readCodexTurnId(event.params) ?? activeTurns.current(event.threadId), + item: subagentItem + }) + : null + if (subagentAdmission) { + // Not a bare return: the roster claiming the item must not skip the + // turn-tail arm, which is the only publisher of its activity copy. + return publishActivity(event, subagentAdmission) + } const translated = items.handle(event) return publishActivity( event, @@ -186,30 +221,25 @@ export function createCodexJournalTranslator( items.dispose() prompts.dispose() genericFrames.dispose() + subagents.dispose() activeTurns.clear() } } + /** Settles the item a notification the transport refused to carry left + * mid-flight; null when the frame is not one. */ function settleOversizedNotification(event: { sessionId: string threadId: string kind: string payload: unknown }): CodexJournalTranslationAdmission | null { - if (event.kind !== 'frame:oversized-notification') { - return null - } - const method = readCodexJournalString(readCodexJournalRecord(event.payload), 'method') - return method - ? settleCodexOversizedNotification({ - sessionId: event.sessionId, - threadId: event.threadId, - method, - sink: deps.sink, - streams: items.streams, - activeItems: items.activeItems - }) - : null + return settleCodexOversizedNotificationFrame({ + ...event, + sink: deps.sink, + streams: items.streams, + activeItems: items.activeItems + }) } function startTurn(event: { @@ -255,6 +285,12 @@ export function createCodexJournalTranslator( if (!turnId) { return CODEX_JOURNAL_ADMITTED } + // The roster is deliberately NOT swept here. `spawn_agent` children outlive + // the turn that spawned them and go on reporting into the same group, so a + // turn boundary is no evidence contact was lost — and `turn/completed` is + // the only turn-end notification Codex sends, so an abort cannot be told + // apart from a clean finish either. Only `settleSession` may write + // `unverifiable`. const admission = settleCodexJournalTurn({ sink: deps.sink, sessionId: event.sessionId, diff --git a/src/main/codex/codex-subagent-activity.ts b/src/main/codex/codex-subagent-activity.ts new file mode 100644 index 00000000000..f12e9dfb1b3 --- /dev/null +++ b/src/main/codex/codex-subagent-activity.ts @@ -0,0 +1,140 @@ +// Reading Codex's subagent wire shapes. +// +// Established by a live probe against `codex app-server` 0.152.1, not inferred: +// * `subAgentActivity` items carry `{kind, agentThreadId, agentPath}`, and each +// one arrives TWICE — via `item/started` and again via `item/completed`. +// * `agentPath` is a tree path (`/root`, `/root/list_directory`); the trailing +// segment is a semantic task name and the only label available. There is no +// `thread/started` for a child, so nickname/role/depth do not exist. +// * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the +// probe, so nothing here reads it — state comes from `kind` alone. +// * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the +// latest frame replaces the previous one — it is never accumulated. + +import type { NativeChatSubagentState } from '../../shared/native-chat-types' +import type { CodexThreadItem } from './codex-structured-item-translation' + +export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity' +export const CODEX_TOKEN_USAGE_METHOD = 'thread/tokenUsage/updated' + +export type CodexSubagentActivity = { + kind: string + agentThreadId: string + agentPath: string | null +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentActivity | null { + if (item.type !== CODEX_SUBAGENT_ITEM_TYPE) { + return null + } + const agentThreadId = nonEmptyString(item.agentThreadId) + if (!agentThreadId) { + return null + } + return { + kind: nonEmptyString(item.kind) ?? '', + agentThreadId, + agentPath: nonEmptyString(item.agentPath) + } +} + +/** + * The state a `kind` implies for the child it names. + * + * An unrecognized kind means "this child exists and reported something we + * cannot classify" — `working`, which the session sweep will later settle to + * `unverifiable` if nothing better ever arrives. Claiming a terminal state from + * an unknown kind would assert an outcome the wire never gave us. + */ +export function codexSubagentStateForKind(kind: string): NativeChatSubagentState { + if (kind === 'completed') { + return 'completed' + } + if (kind === 'interrupted') { + return 'stopped' + } + return 'working' +} + +/** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */ +export function codexSubagentPathSegments(agentPath: string | null): string[] { + return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0) +} + +/** The one path segment that names the parent turn itself rather than a child. + * Compared after the same normalization the label uses, not against the raw + * string: `/root/` and `/root//` are the same node as `/root`, and a check that + * disagreed with `codexSubagentPathSegments` would let one path be both the + * turn and a child of it — a phantom row labelled `root` inflating the group. + * Only this segment is the root; `/morpheus` is single-segment too but IS a + * child. */ +const CODEX_ROOT_AGENT_SEGMENT = 'root' + +/** + * Whether an activity item describes the ROOT of the agent tree rather than a + * spawned child. Counting the root would make the parent turn report itself as + * its own subagent. + * + * A path-less item cannot be placed in the tree at all, so it is treated as a + * child: dropping it would lose a real spawn, while an extra row is visible and + * self-correcting. + */ +export function isCodexRootAgentActivity(activity: CodexSubagentActivity): boolean { + const segments = codexSubagentPathSegments(activity.agentPath) + return segments.length === 1 && segments[0] === CODEX_ROOT_AGENT_SEGMENT +} + +/** Row label: the agent path's trailing segment, trimmed. A segment with nothing + * visible in it survives the empty-segment filter but would draw a nameless row, + * so it reads as no label and the caller's placeholder takes over. Trimmed + * because the caller keys its collision ordinals on this string: ` read ` and + * `read` render identically and must therefore collide. */ +export function codexSubagentLabel(activity: CodexSubagentActivity): string | null { + const trailing = codexSubagentPathSegments(activity.agentPath).at(-1)?.trim() + return trailing !== undefined && trailing.length > 0 ? trailing : null +} + +export type CodexThreadTokenTotal = { threadId: string; totalTokens: number } + +/** `{threadId, tokenUsage: {total: {totalTokens}}}`. Older builds put the total + * on the envelope, so both shapes are accepted. */ +export function readCodexThreadTokenTotal(params: unknown): CodexThreadTokenTotal | null { + const root = record(params) + if (!root) { + return null + } + const threadId = nonEmptyString(root.threadId) ?? nonEmptyString(record(root.thread)?.id) + if (!threadId) { + return null + } + const usage = record(root.tokenUsage) + const total = record(usage?.total)?.totalTokens ?? usage?.totalTokens ?? root.totalTokens + return typeof total === 'number' && Number.isFinite(total) && total >= 0 + ? { threadId, totalTokens: total } + : null +} + +/** Pull the `subAgentActivity` item out of a raw notification payload. + * + * Lives beside the readers rather than in the translator: the translator's job + * is routing, and this is the shape check that decides whether a frame is one + * of ours at all. Returns null for anything that is not a thread item, which is + * the translator's signal to keep looking. */ +export function readCodexNotificationThreadItem( + params: unknown, + read: (value: unknown) => CodexThreadItem | null +): CodexThreadItem | null { + const record = + typeof params === 'object' && params !== null ? (params as Record) : {} + return read(record.item) +} diff --git a/src/main/codex/codex-subagent-roster.test.ts b/src/main/codex/codex-subagent-roster.test.ts new file mode 100644 index 00000000000..2f20c9df9bf --- /dev/null +++ b/src/main/codex/codex-subagent-roster.test.ts @@ -0,0 +1,769 @@ +import { describe, expect, it } from 'vitest' +import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary' +import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + CodexSubagentRoster, + codexSubagentGroupIdentity, + codexSubagentGroupId +} from './codex-subagent-roster' +import type { CodexThreadItem } from './codex-structured-item-translation' +import { + MAX_CODEX_SUBAGENT_GROUPS, + MAX_CODEX_SUBAGENTS_PER_GROUP, + MAX_CODEX_TOKEN_USAGE_THREADS +} from './codex-structured-journal-limits' + +const THREAD = 'thread-parent' +const TURN = 'turn-1' + +type Appended = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody } + +function createHarness(options: { threadId?: string | null } = {}): { + roster: CodexSubagentRoster + appended: Appended[] + agents: () => NativeChatSubagentEntry[] + latest: () => Appended | undefined +} { + const appended: Appended[] = [] + let clock = 1_000 + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body) => { + appended.push({ identity, body }) + return { accepted: true } + }, + tryPublish: () => ({ accepted: true }) + } + const roster = new CodexSubagentRoster({ + sink, + primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId), + activeTurn: () => TURN, + now: () => (clock += 1) + }) + const agents = (): NativeChatSubagentEntry[] => { + const body = appended.at(-1)?.body + if (!body || body.kind !== 'message') { + return [] + } + const block = body.blocks.find(isSubagentGroupBlock) + return block ? block.agents : [] + } + return { roster, appended, agents, latest: () => appended.at(-1) } +} + +function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined { + return appended.at(-1)?.identity +} + +function activity(input: { + id?: string + kind: string + agentThreadId: string + agentPath: string | null +}): CodexThreadItem { + return { + type: 'subAgentActivity', + id: input.id ?? `item-${input.agentThreadId}-${input.kind}`, + kind: input.kind, + agentThreadId: input.agentThreadId, + agentPath: input.agentPath + } +} + +function deliver( + roster: CodexSubagentRoster, + item: CodexThreadItem, + turnId: string | null = TURN +): void { + // Every activity item reaches the wire twice: item/started, then item/completed. + roster.handleItem({ threadId: THREAD, turnId, item }) + roster.handleItem({ threadId: THREAD, turnId, item }) +} + +/** + * A sink that coalesces the way the real queue does: by `coalescingKey` ALONE, + * with no op-kind check, and only draining when released. A fake that ignores + * the key cannot see an append being spliced out by its own publish. + */ +function createCoalescingHarness(): { + roster: CodexSubagentRoster + appended: Appended[] + drain: () => void +} { + const appended: Appended[] = [] + const queue: { key?: string; run: () => void }[] = [] + let clock = 1_000 + const submit = (key: string | undefined, run: () => void): void => { + const at = key === undefined ? -1 : queue.findIndex((queued) => queued.key === key) + if (at >= 0) { + queue.splice(at, 1) + } + queue.push(key === undefined ? { run } : { key, run }) + } + const sink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body, options) => { + submit(options?.coalescingKey, () => appended.push({ identity, body })) + return { accepted: true } + }, + tryPublish: (options) => { + submit(options?.coalescingKey ?? 'publish', () => {}) + return { accepted: true } + } + } + const roster = new CodexSubagentRoster({ + sink, + primaryThreadId: () => THREAD, + activeTurn: () => TURN, + now: () => (clock += 1) + }) + return { + roster, + appended, + drain: () => { + while (queue.length > 0) { + queue.shift()?.run() + } + } + } +} + +describe('CodexSubagentRoster', () => { + it('does not let its own publish evict the still-queued roster append', () => { + const { roster, appended, drain } = createCoalescingHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + drain() + + // Sharing the append's coalescing key with the publish spliced the append + // out of the queue, and `lastSerialized` then suppressed every retry. + expect(appended).toHaveLength(1) + }) + + it('counts a /morpheus agent as a child — only /root is the turn itself', () => { + const { roster, agents } = createHarness() + + deliver(roster, activity({ kind: 'started', agentThreadId: 'child-m', agentPath: '/morpheus' })) + + expect(agents()).toMatchObject([{ id: 'child-m', label: 'morpheus', state: 'working' }]) + }) + + // `codexSubagentPathSegments` already defines what a path means for the label, + // and the root check has to agree with it: a path that normalizes to the same + // node must classify the same way, or one string is both the turn itself and a + // child of it — a phantom row labelled `root` inflating the group by one. + it('reads a root path with a trailing or doubled separator as the turn itself', () => { + for (const agentPath of ['/root/', '/root//', '//root']) { + const { roster, appended } = createHarness() + + deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath })) + + expect(appended).toEqual([]) + } + }) + + it('keeps a doubled separator inside a child path off the label', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root//read/' }) + ) + + expect(agents()).toMatchObject([{ id: 'child-1', label: 'read' }]) + }) + + // An all-whitespace trailing segment survives the empty-segment filter and + // would draw a row with no visible name at all. + it('falls back to the placeholder when the trailing segment has nothing to show', () => { + const { roster, agents } = createHarness() + + deliver(roster, activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/ ' })) + + expect(agents()).toMatchObject([{ id: 'child-1', label: 'subagent' }]) + }) + + // The collision ordinal keys on the label, so two segments that render + // identically must collide rather than both draw as `read`. + it('collides labels that differ only in surrounding whitespace', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/ read ' }) + ) + + expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2']) + }) + + it('ignores the root node so a turn is not its own subagent', () => { + const { roster, appended } = createHarness() + + deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath: '/root' })) + + expect(appended).toEqual([]) + }) + + it('writes an admissible journal body carrying a plain-text fallback block', () => { + const { roster, latest } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/list_directory' }) + ) + + const body = latest()?.body + expect(body?.kind).toBe('message') + expect(isAdmissibleAgentJournalItemBody(body)).toBe(true) + expect(body?.kind === 'message' ? body.blocks.map((block) => block.type) : []).toEqual([ + 'text', + 'subagent-group' + ]) + expect( + body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : '' + ).toBe('Kicked off 1 subagent') + }) + + it('keys the durable identity by the parent turn so a revision lands on one row', () => { + const { roster, appended } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + const expected = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, TURN)) + expect(new Set(appended.map((entry) => JSON.stringify(entry.identity)))).toEqual( + new Set([JSON.stringify(expected)]) + ) + }) + + it('rule 1 — a duplicate delivery writes no second revision', () => { + const { roster, appended } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + expect(appended).toHaveLength(1) + }) + + it('rule 2 — a first event of any kind creates the entry in the state it implies', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-late', agentPath: '/root/search' }) + ) + + expect(agents()).toMatchObject([{ id: 'child-late', label: 'search', state: 'completed' }]) + }) + + it('rule 3 — a terminal state latches against a late or duplicate start', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + expect(agents()).toMatchObject([{ state: 'completed' }]) + }) + + it('rule 4 — the session sweep settles a lost child as unverifiable, not exited', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-2', agentPath: '/root/search' }) + ) + roster.settleSession() + + expect(agents()).toMatchObject([ + { id: 'child-1', state: 'unverifiable' }, + { id: 'child-2', state: 'completed' } + ]) + }) + + it('lets a swept child still report what it actually did', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + roster.settleSession() + expect(agents()[0]?.state).toBe('unverifiable') + + // Contact can return — a reconnected provider replays the child's own + // verdict. Latching the sweep would report a child that finished as one we + // never saw finish. + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + expect(agents()[0]?.state).toBe('completed') + }) + + it('refuses to put a swept child back to working', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + roster.settleSession() + // A straggler progress tick after we gave up must not re-light the row. + deliver( + roster, + activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + expect(agents()[0]?.state).toBe('unverifiable') + }) + + it('keeps a real verdict when a later frame disagrees', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'interrupted', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + expect(agents()[0]?.state).toBe('completed') + }) + + it('rule 4 — the session sweep settles every group and never un-terminals one', () => { + const { roster, agents, appended } = createHarness() + + deliver( + roster, + activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + roster.settleSession() + const afterFirstSweep = appended.length + roster.settleSession() + + expect(agents()).toMatchObject([{ state: 'unverifiable' }]) + expect(appended).toHaveLength(afterFirstSweep) + }) + + it('rule 5 — the whole roster is persisted in the carrier, not just a count', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 40661 } } }) + + expect(agents()).toMatchObject([ + { id: 'child-1', label: 'read', state: 'working', tokens: 40661 } + ]) + }) + + it('rule 6 — the group id names the parent turn, or says there was none', () => { + const { roster, appended } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/search' }), + null + ) + + expect(appended.map((entry) => entry.identity)).toEqual([ + { provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:${TURN}` }, + { provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:outside-turn` } + ]) + }) + + it('disambiguates two children that share a trailing path segment', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/read' }) + ) + + expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2']) + }) + + it('takes the latest token snapshot per child and never accumulates updates', () => { + const { roster, agents } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 100 } } }) + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 250 } } }) + + expect(agents()).toMatchObject([{ tokens: 250 }]) + }) + + it('retains a usage frame that arrives before the child is known', () => { + const { roster, agents } = createHarness() + + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } }) + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + expect(agents()).toMatchObject([{ tokens: 900 }]) + }) + + it('never attributes the parent thread its own usage', () => { + const { roster, agents, appended } = createHarness() + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + const beforeParentUsage = appended.length + roster.handleTokenUsage({ threadId: THREAD, tokenUsage: { total: { totalTokens: 26099 } } }) + + expect(appended).toHaveLength(beforeParentUsage) + expect(agents()).toHaveLength(1) + expect(agents()[0]).not.toHaveProperty('tokens') + }) + + // The row is durable and both readers clip these fields to the same cap, so + // writing more than that is bytes replayed on every reconnect and then thrown + // away. The marker is an ellipsis, not the tool-output truncation sentence: + // `id` is the roster key and the renderer's React key. + it('bounds the provider strings the roster row carries into the journal', () => { + const { roster, agents, latest } = createHarness() + const oversized = 'a'.repeat(20 * 1024) + + deliver( + roster, + activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` }) + ) + + const entry = agents()[0] + expect(entry?.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) + expect(entry?.label).toMatch(/…~0$/) + expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) + expect(entry?.id).toMatch(/…~0$/) + expect(JSON.stringify(latest()?.body)).not.toContain('output truncated') + expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true) + }) + + // The clip cuts UTF-16 code units, so a boundary landing inside a surrogate + // pair left a LONE high surrogate in a durable row — malformed, and replaced + // with U+FFFD through any non-JSON UTF-8 hop. + it('never clips a provider string mid surrogate pair', () => { + const { roster, agents } = createHarness() + const astral = '😀'.repeat(400) + + deliver( + roster, + activity({ kind: 'started', agentThreadId: astral, agentPath: `/root/${astral}` }) + ) + + const entry = agents()[0] + expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) + expect(Buffer.from(entry?.id ?? '', 'utf8').toString('utf8')).toBe(entry?.id) + expect(Buffer.from(entry?.label ?? '', 'utf8').toString('utf8')).toBe(entry?.label) + }) + + // The clip removes exactly the tail that told two children apart: `id` is the + // renderer's React key, and `claimLabel` writes its repeat ordinal at the end. + // Two clipped children collapsing to one key drew two rows under one identity. + it('keeps clipped ids and labels distinct between children', () => { + const { roster, agents } = createHarness() + const prefix = 'p'.repeat(MAX_SUBAGENT_FIELD_CHARS) + const sharedPath = `/root/${'q'.repeat(640)}` + + deliver( + roster, + activity({ kind: 'started', agentThreadId: `${prefix}AAAA`, agentPath: sharedPath }) + ) + deliver( + roster, + activity({ kind: 'started', agentThreadId: `${prefix}BBBB`, agentPath: sharedPath }) + ) + + const entries = agents() + expect(entries).toHaveLength(2) + expect(new Set(entries.map((agent) => agent.id)).size).toBe(2) + expect(new Set(entries.map((agent) => agent.label)).size).toBe(2) + for (const agent of entries) { + expect(agent.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) + expect(agent.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS) + } + }) + + it('caps the children one spawn group admits', () => { + const { roster, agents, appended } = createHarness() + for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) { + deliver( + roster, + activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }) + ) + } + const atCap = appended.length + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' }) + ) + + expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP) + expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap') + // Refusing the child must not burn a revision either. + expect(appended).toHaveLength(atCap) + }) + + // The eviction is the KNOWN LIMITATION the module documents: `groups` is never + // seeded from the journal, so the evicted group's next child rebuilds its + // durable row from that one child. Pinned so the boundary cannot move silently. + it('caps live spawn groups, and an evicted group rebuilds its row from one child', () => { + const { roster, appended, agents } = createHarness() + for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) { + deliver( + roster, + activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }), + `turn-${index}` + ) + } + const evicted = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0')) + const rowsFor = (identity: AgentJournalItemIdentity): Appended[] => + appended.filter((entry) => JSON.stringify(entry.identity) === JSON.stringify(identity)) + expect(rowsFor(evicted)).toHaveLength(1) + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }), + 'turn-0' + ) + + expect(latestIdentity(appended)).toEqual(evicted) + expect(agents().map((agent) => agent.id)).toEqual(['child-late']) + }) + + it('keeps a token count a later thread-map eviction would otherwise retract', () => { + const { roster, agents } = createHarness() + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } }) + expect(agents()).toMatchObject([{ tokens: 4242 }]) + + for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { + roster.handleTokenUsage({ + threadId: `other-${index}`, + tokenUsage: { total: { totalTokens: index } } + }) + } + deliver( + roster, + activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }]) + }) + + it('caps retained usage threads, so a frame evicted before its child is dropped', () => { + const { roster, agents } = createHarness() + roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } }) + for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) { + roster.handleTokenUsage({ + threadId: `other-${index}`, + tokenUsage: { total: { totalTokens: index } } + }) + } + + deliver( + roster, + activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + ) + + expect(agents()[0]).not.toHaveProperty('tokens') + }) + + it('declines a payload that is not a subagent item or a usage frame', () => { + const { roster } = createHarness() + + expect( + roster.handleItem({ + threadId: THREAD, + turnId: TURN, + item: { type: 'commandExecution', id: 'item-9' } + }) + ).toBeNull() + expect(roster.handleTokenUsage({ threadId: 'child-1' })).toBeNull() + }) + + // A refusal must never advance the duplicate-suppression state: an identical + // replay would short-circuit and the revision would never be retried. The + // append and the publish are the two ways to be refused, so both are covered. + it.each([{ refuse: 'append' as const }, { refuse: 'publish' as const }])( + 'retries the same revision after the $refuse is refused', + ({ refuse }) => { + let refusing = true + const appended: Appended[] = [] + const published: number[] = [] + const refusal = { accepted: false, reason: 'backpressure' } as const + const roster = new CodexSubagentRoster({ + sink: { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body) => { + if (refusing && refuse === 'append') { + return refusal + } + appended.push({ identity, body }) + return { accepted: true } + }, + tryPublish: () => { + if (refusing && refuse === 'publish') { + return refusal + } + published.push(1) + return { accepted: true } + } + }, + primaryThreadId: () => THREAD, + activeTurn: () => TURN, + now: () => 1_000 + }) + const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + + expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal) + + // The wire redelivers the very same item; nothing about the roster changed, + // so only a cleared suppression state can get the revision out. + refusing = false + expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual({ + accepted: true + }) + // The retry re-appends when the publish was the half that failed; the real + // queue coalesces those two by the group key into one journal write. What + // must not happen is the revision never being published at all. + expect(published).toHaveLength(1) + const body = appended.at(-1)?.body + expect( + body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : [] + ).toMatchObject([{ agents: [{ id: 'child-1', state: 'working' }] }]) + } + ) + + // The sweep is the last event a group ever gets. A refusal there, left + // unretried, strands the settled roster's final revision — the exact "row + // stays stale forever" this row exists to prevent. + it('republishes the settled roster when the sweep publish was refused', () => { + let refusing = false + const appended: Appended[] = [] + const published: number[] = [] + const roster = new CodexSubagentRoster({ + sink: { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: (identity, body) => { + appended.push({ identity, body }) + return { accepted: true } + }, + tryPublish: () => { + if (refusing) { + return { accepted: false, reason: 'backpressure' } + } + published.push(1) + return { accepted: true } + } + }, + primaryThreadId: () => THREAD, + activeTurn: () => TURN, + now: () => 1_000 + }) + roster.handleItem({ + threadId: THREAD, + turnId: TURN, + item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + }) + const publishedBeforeSweep = published.length + + refusing = true + expect(roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' }) + + // The retry sweep flips no state — every child already latched — so only a + // cleared suppression state can carry the unverifiable roster out. + refusing = false + expect(roster.settleSession()).toEqual({ accepted: true }) + expect(published.length).toBe(publishedBeforeSweep + 1) + const body = appended.at(-1)?.body + expect(body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []).toMatchObject([ + { agents: [{ id: 'child-1', state: 'unverifiable' }] } + ]) + }) + + it('propagates sink backpressure instead of reporting the row as written', () => { + const roster = new CodexSubagentRoster({ + sink: { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendItem: () => ({ accepted: false, reason: 'backpressure' }), + tryPublish: () => ({ accepted: true }) + }, + primaryThreadId: () => THREAD, + activeTurn: () => TURN + }) + + expect( + roster.handleItem({ + threadId: THREAD, + turnId: TURN, + item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }) + }) + ).toEqual({ accepted: false, reason: 'backpressure' }) + }) +}) diff --git a/src/main/codex/codex-subagent-roster.ts b/src/main/codex/codex-subagent-roster.ts new file mode 100644 index 00000000000..257fe705764 --- /dev/null +++ b/src/main/codex/codex-subagent-roster.ts @@ -0,0 +1,347 @@ +// The Codex subagent roster: one journal row per spawn group, revised in place. +// +// There is no snapshot to read. `agentsStates` arrived empty in the live probe +// and children get no `thread/started`, so the roster is +// accumulated purely from `subAgentActivity` items — each of which arrives TWICE +// (`item/started` and `item/completed`). Every transition here is therefore +// idempotent, and a terminal state latches: duplicate and out-of-order delivery +// must not resurrect a settled child. +// +// KNOWN LIMITATION: `groups` is process-local and is never seeded from the +// journal, while the row's identity is keyed on the group id alone. So once a +// group leaves the map its row stays, and the next activity item rebuilds that +// row from one child — rewriting N down to one. Two ways in: eviction past +// MAX_CODEX_SUBAGENT_GROUPS, which drops the oldest-inserted group in-process +// even while it is live, and skips the sweep so its children never latch +// `unverifiable`; and a restart on `threadId:outside-turn`, the one group id +// that outlives the process — `thread/resume` is verified to return the same +// thread, and a real turn id is assumed freshly minted per turn. Seeding from +// the journal is the fix. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { + canReplaceSubagentState, + isTerminalSubagentState, + MAX_SUBAGENT_FIELD_CHARS, + subagentGroupFallbackText +} from '../../shared/native-chat-subagent-summary' +import type { NativeChatSubagentEntry } from '../../shared/native-chat-types' +import type { + StructuredAgentSessionEventSink, + StructuredAgentSessionSinkAdmission +} from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + codexSubagentLabel, + codexSubagentStateForKind, + isCodexRootAgentActivity, + readCodexSubagentActivity, + readCodexThreadTokenTotal +} from './codex-subagent-activity' +import type { CodexThreadItem } from './codex-structured-item-translation' +import { + MAX_CODEX_SUBAGENT_GROUPS, + MAX_CODEX_SUBAGENTS_PER_GROUP, + MAX_CODEX_TOKEN_USAGE_THREADS +} from './codex-structured-journal-limits' + +const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true } + +/** The turn a group belongs to when Codex reports activity outside any turn. + * Mirrors the generic-frame bucket name so the two read alike in the journal. */ +const OUTSIDE_TURN = 'outside-turn' + +const UNLABELLED_AGENT = 'subagent' + +type RosterGroup = { + groupId: string + identity: AgentJournalItemIdentity + /** Insertion order is the display order; the map holds the state. */ + entries: Map + /** Times each label has been claimed, so a repeat gets an ordinal suffix. */ + labelCounts: Map + /** Last body written, so an idempotent replay writes no new revision. */ + lastSerialized: string | null +} + +/** Group identity: the parent turn that spawned the children. `agentPath` is a + * tree rooted at the parent thread, so every child of one turn shares a row + * no matter which thread's stream carried its activity item. */ +export function codexSubagentGroupId(threadId: string, turnId: string | null): string { + return `${threadId}:${turnId ?? OUTSIDE_TURN}` +} + +/** Durable journal identity for the group's row — stable across revisions and + * across a restart, so replay finds the same row instead of appending a new one. */ +export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` } +} + +export type CodexSubagentRosterDeps = { + sink: StructuredAgentSessionEventSink + /** The thread that owns the agent tree; falls back to the event's thread. */ + primaryThreadId: () => string | null + activeTurn: (threadId: string) => string | null + now?: () => number +} + +export class CodexSubagentRoster { + private readonly groups = new Map() + /** Latest reported total per thread, kept regardless of roster membership: a + * usage frame can arrive before the child's first activity item, and filtering + * at receipt would lose it permanently. Children are selected at write time; + * the map itself is LRU-capped in `handleTokenUsage`. */ + private readonly tokensByThread = new Map() + private readonly now: () => number + + constructor(private readonly deps: CodexSubagentRosterDeps) { + this.now = deps.now ?? (() => Date.now()) + } + + /** Consume a `subAgentActivity` item. Returns null when the item is not one. */ + handleItem(input: { + threadId: string + turnId: string | null + item: CodexThreadItem + }): StructuredAgentSessionSinkAdmission | null { + const activity = readCodexSubagentActivity(input.item) + if (!activity) { + return null + } + // The root node is the parent turn itself, not a child it spawned. + if (isCodexRootAgentActivity(activity)) { + return ADMITTED + } + const group = this.groupFor(input.threadId, input.turnId) + const existing = group.entries.get(activity.agentThreadId) + const state = codexSubagentStateForKind(activity.kind) + if (!existing) { + // Rule: the first event for a child may be ANY kind. An `interacted` or + // `completed` with no prior `started` creates the entry in the state its + // kind implies rather than being dropped for lacking a roster row. + if (group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) { + return ADMITTED + } + const now = this.now() + group.entries.set(activity.agentThreadId, { + id: activity.agentThreadId, + label: this.claimLabel(group, codexSubagentLabel(activity)), + state, + startedAt: now, + ...(isTerminalSubagentState(state) ? { settledAt: now } : {}) + }) + } else if (canReplaceSubagentState(existing.state, state)) { + // A child's own verdict latches. Re-applying the same non-terminal state + // is a no-op, which is what makes the duplicate `item/started` + + // `item/completed` delivery idempotent. `unverifiable` does not latch: a + // child swept when contact was lost can still report what it actually did + // if contact returns. + group.entries.set(activity.agentThreadId, { + ...existing, + state, + ...(isTerminalSubagentState(state) ? { settledAt: this.now() } : {}) + }) + } + return this.write(group) + } + + /** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */ + handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null { + const usage = readCodexThreadTokenTotal(params) + if (!usage) { + return null + } + // A running total: the newest frame REPLACES the previous one. Summing + // updates would multiply a single child's usage by its frame count. + // Re-insert so the eviction scan below sees recency: `set` on an existing + // key keeps its original position, which would age out an active thread. + this.tokensByThread.delete(usage.threadId) + this.tokensByThread.set(usage.threadId, usage.totalTokens) + while (this.tokensByThread.size > MAX_CODEX_TOKEN_USAGE_THREADS) { + const oldest = this.tokensByThread.keys().next().value + if (typeof oldest !== 'string') { + break + } + this.tokensByThread.delete(oldest) + } + for (const group of this.groups.values()) { + if (!group.entries.has(usage.threadId)) { + continue + } + const admission = this.write(group) + if (!admission.accepted) { + return admission + } + } + return ADMITTED + } + + /** + * The provider is gone, so any child still reported as working will never be + * settled by an event: it becomes `unverifiable` — contact was lost, which is + * NOT evidence the child exited. + * + * This is the ONLY sweep. A turn ending is not one: `spawn_agent` children + * routinely outlive their turn and keep reporting into the same group. + */ + settleSession(): StructuredAgentSessionSinkAdmission { + for (const group of this.groups.values()) { + const admission = this.sweep(group) + if (!admission.accepted) { + return admission + } + } + return ADMITTED + } + + dispose(): void { + this.groups.clear() + this.tokensByThread.clear() + } + + private sweep(group: RosterGroup | undefined): StructuredAgentSessionSinkAdmission { + if (!group) { + return ADMITTED + } + let changed = false + for (const [id, entry] of group.entries) { + if (isTerminalSubagentState(entry.state)) { + continue + } + group.entries.set(id, { ...entry, state: 'unverifiable', settledAt: this.now() }) + changed = true + } + // A null `lastSerialized` means the previous write was refused part-way, so + // the settled roster's last revision is queued but never published. Nothing + // is guaranteed to write this group again, so retry here even when the sweep + // itself changed nothing. + return changed || group.lastSerialized === null ? this.write(group) : ADMITTED + } + + private groupFor(threadId: string, turnId: string | null): RosterGroup { + const ownerThreadId = this.deps.primaryThreadId() ?? threadId + const ownerTurnId = + ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId) + const groupId = codexSubagentGroupId(ownerThreadId, ownerTurnId) + const existing = this.groups.get(groupId) + if (existing) { + return existing + } + const group: RosterGroup = { + groupId, + identity: codexSubagentGroupIdentity(groupId), + entries: new Map(), + labelCounts: new Map(), + lastSerialized: null + } + this.groups.set(groupId, group) + while (this.groups.size > MAX_CODEX_SUBAGENT_GROUPS) { + const oldest = this.groups.keys().next().value + if (typeof oldest !== 'string' || oldest === groupId) { + break + } + this.groups.delete(oldest) + } + return group + } + + /** Two children can share a trailing path segment; the ordinal keeps their + * rows apart without inventing a name the provider never sent. */ + private claimLabel(group: RosterGroup, label: string | null): string { + const base = label ?? UNLABELLED_AGENT + const seen = group.labelCounts.get(base) ?? 0 + group.labelCounts.set(base, seen + 1) + return seen === 0 ? base : `${base} ${seen + 1}` + } + + private write(group: RosterGroup): StructuredAgentSessionSinkAdmission { + const agents = [...group.entries].map(([id, entry]) => { + const tokens = this.tokensByThread.get(id) + if (typeof tokens !== 'number' || tokens === entry.tokens) { + return entry + } + // Persisted, not merely read: the thread map is LRU-capped, and reading it + // afresh each write would retract a count this row has already shown. + const merged = { ...entry, tokens } + group.entries.set(id, merged) + return merged + }) + const body = codexSubagentGroupBody(group.groupId, agents) + const serialized = JSON.stringify(body) + if (serialized === group.lastSerialized) { + // Nothing changed — a duplicate delivery must not burn a revision. + return ADMITTED + } + group.lastSerialized = serialized + // The append coalesces per group so a burst collapses to the latest roster. + // The publish must NOT reuse that key: the queue coalesces by key alone, + // with no op-kind check, so a publish carrying it would splice out the + // still-queued append and the row would never reach the journal. + const options = { coalescingKey: `codex-subagents:${group.groupId}` } + const admission = this.deps.sink.tryAppendItem + ? this.deps.sink.tryAppendItem(group.identity, body, options) + : (this.deps.sink.appendItem(group.identity, body, options), ADMITTED) + if (!admission.accepted) { + group.lastSerialized = null + return admission + } + const published = this.deps.sink.tryPublish + ? this.deps.sink.tryPublish() + : (this.deps.sink.publish(), ADMITTED) + if (!published.accepted) { + // Symmetric with the append refusal above: the suppression state may only + // advance once the revision is both queued AND published. Left set, an + // identical replay short-circuits and the last revision of a settled + // roster stays queued but never reaches the renderer. + group.lastSerialized = null + } + return published + } +} + +/** The roster row: the structured block plus the plain sentence an older client + * renders in its place. A message whose only block is the new variant would + * reach such a client with nothing it can draw. */ +export function codexSubagentGroupBody( + groupId: string, + agents: readonly NativeChatSubagentEntry[] +): AgentJournalItemBody { + const bounded = agents.map((agent, index) => ({ + ...agent, + id: boundSubagentField(agent.id, index), + label: boundSubagentField(agent.label, index) + })) + return { + kind: 'message', + role: 'system', + blocks: [ + { type: 'text', text: subagentGroupFallbackText(bounded) }, + { type: 'subagent-group', groupId, agents: bounded } + ] + } +} + +/** `id` and `label` are provider strings, so they take the bound both readers of + * this row already clip them to. A plain length check, not the tool-output + * bound: that one digests the whole value before it checks the length, and this + * runs twice per child on every streamed token-usage frame. + * + * A clip is not identity-preserving, so a clipped value carries the child's + * index: two ids sharing a long prefix collapse to one React key, and + * `claimLabel` writes its ordinal at the very tail the clip removes. The index + * is reserved out of the bound, not appended to it, because both readers + * re-clip to the same cap and would cut a suffix that overflowed it. */ +function boundSubagentField(value: string, index: number): string { + if (value.length <= MAX_SUBAGENT_FIELD_CHARS) { + return value + } + const suffix = `…~${index}` + const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length + // Slicing UTF-16 units can split a surrogate pair; a lone surrogate is + // malformed in a durable row and lossy through any non-JSON UTF-8 hop. + const last = value.charCodeAt(keep - 1) + const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep + return `${value.slice(0, end)}${suffix}` +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index 721e5f4ba7f..7b5b6d0dff8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -1,4 +1,8 @@ import { mkdir } from 'node:fs/promises' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' import type { AgentType } from '../../../shared/agent-status-types' import { findJournalFileFormatRemnant, @@ -6,6 +10,7 @@ import { } from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure' +import { staleSubagentRosterRevisions } from './journal-subagent-liveness' /** What any of this file's disclosures hands the store — a repair's, or the * pre-SQLite notice's. Same shape, and neither is only a repair. */ @@ -36,9 +41,9 @@ export async function openJournalStoreState(input: { adopt: (loaded: JournalLoad) => void /** Republishes an anchor row for an epoch a repair emptied. */ publishRepairEpoch: () => void - appendDisclosure: ( - identity: JournalRepairDisclosure['identity'], - body: JournalRepairDisclosure['body'], + appendItem: ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, fence: number ) => Promise agent: AgentType @@ -68,8 +73,9 @@ export async function openJournalStoreState(input: { } if (input.malformedRows() > 0 && !input.readOnly()) { const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() }) - await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence()) + await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) } + await settleStaleSubagentRosters(input, loaded) // Founding the epoch and appending the row are two transactions, and a // committed epoch sends every later open down this branch instead. Anything // that interrupts between them — a quit during startup restore, a failed @@ -92,7 +98,7 @@ export async function openJournalStoreState(input: { async function discloseFileFormatRemnant(input: { journalDir: string agent: AgentType - appendDisclosure: ( + appendItem: ( identity: JournalDisclosure['identity'], body: JournalDisclosure['body'], fence: number @@ -108,5 +114,32 @@ async function discloseFileFormatRemnant(input: { return } const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent }) - await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence()) + await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) +} + +/** + * Retires a `working` subagent roster the previous host never got to settle. + * + * Skipped on a corrupt load: that journal is still owed a rebuild from provider + * history, and content written past the repair's free sequence retires the + * demand for it. + */ +async function settleStaleSubagentRosters( + input: { + appendItem: ( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + fence: number + ) => Promise + highestFence: () => number + readOnly: () => boolean + }, + loaded: JournalLoad +): Promise { + if (input.readOnly() || loaded.corrupt) { + return + } + for (const revision of staleSubagentRosterRevisions(loaded.state.items.values())) { + await input.appendItem(revision.identity, revision.body, input.highestFence()) + } } diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index 3a5d3c7ac6e..fc69dd339d8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -39,8 +39,7 @@ export function restoreJournalStore( publishRepairEpoch: () => collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence), adopt: host.adopt, - appendDisclosure: (identity, body, fence) => - host.journal().appendItem(identity, body, { fence }), + appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }), agent: host.identity.agent, highestFence: () => host.state().highestFence, malformedRows: host.malformedRows, diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts new file mode 100644 index 00000000000..9d6887de61e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -0,0 +1,202 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { isSubagentGroupBlock } from '../../../shared/native-chat-types' +import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types' +import { + codexSubagentGroupBody, + codexSubagentGroupIdentity +} from '../../codex/codex-subagent-roster' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-store-test-open' +import { staleSubagentRosterRevisions } from './journal-subagent-liveness' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const GROUP_ID = 'thread-1:turn-1' + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +const journals = createTrackedJournalOpener() + +async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +/** The row as the producer writes it: the structured block plus its twin. */ +function rosterRow(agents: NativeChatSubagentEntry[]) { + return { + identity: codexSubagentGroupIdentity(GROUP_ID), + body: codexSubagentGroupBody(GROUP_ID, agents) + } +} + +function renderItem(agents: NativeChatSubagentEntry[]): AgentJournalRenderItem { + const row = rosterRow(agents) + return { + itemId: agentJournalItemKey(row.identity), + revision: 1, + body: row.body, + sequence: 2, + observedAt: 1 + } +} + +function rosterOf(body: AgentJournalRenderItem['body']): NativeChatSubagentEntry[] { + return body.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : [] +} + +function twinOf(body: AgentJournalRenderItem['body']): string | undefined { + return body.kind === 'message' + ? body.blocks.find((block) => block.type === 'text')?.text + : undefined +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-subagents-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('staleSubagentRosterRevisions', () => { + it('settles a child the previous host left working, and moves the twin with it', () => { + const revisions = staleSubagentRosterRevisions([ + renderItem([ + { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, + { id: 'b', label: 'read_package', state: 'completed', startedAt: 10, settledAt: 20 } + ]) + ]) + + expect(revisions).toHaveLength(1) + expect(rosterOf(revisions[0]!.body)).toMatchObject([ + { id: 'a', state: 'unverifiable' }, + { id: 'b', state: 'completed' } + ]) + // Mobile reads only this sentence, so it may not go on saying `Kicked off`. + expect(twinOf(revisions[0]!.body)).toBe('Ran 2 subagents (1 unverifiable)') + }) + + // The child stopped being observable at an unknown moment. A stamp taken now + // would report the time the app was down as how long the child ran. + it('records no terminal timestamp for a child whose run length is unknown', () => { + const revisions = staleSubagentRosterRevisions([ + renderItem([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) + ]) + + expect(rosterOf(revisions[0]!.body)[0]).not.toHaveProperty('settledAt') + }) + + it('owes nothing for a roster whose children all settled', () => { + expect( + staleSubagentRosterRevisions([ + renderItem([{ id: 'a', label: 'read', state: 'completed', settledAt: 20 }]) + ]) + ).toEqual([]) + }) + + it('leaves rows that carry no roster alone', () => { + expect( + staleSubagentRosterRevisions([ + { + itemId: 'orca:plain', + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, + sequence: 2, + observedAt: 1 + } + ]) + ).toEqual([]) + }) + + // Appending under a fresh identity would add a second row rather than revise + // the one on disk, so an unaddressable key is left exactly as it is. + it('skips a row whose key cannot be parsed back to its identity', () => { + expect( + staleSubagentRosterRevisions([ + { ...renderItem([{ id: 'a', label: 'r', state: 'working' }]), itemId: 'not-a-key' } + ]) + ).toEqual([]) + }) +}) + +describe('journal reopen after the writing host is gone', () => { + it('settles a persisted working roster to unverifiable, while the live row still reads working', async () => { + const live = await open() + const row = rosterRow([ + { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, + { id: 'b', label: 'read_package', state: 'working', startedAt: 10 } + ]) + await live.appendItem(row.identity, row.body, { fence: 0 }) + + // Still the writing host: it can see the children, so the row says so. + const beforeRestart = live.snapshot().items.at(-1)! + expect(rosterOf(beforeRestart.body)).toMatchObject([{ state: 'working' }, { state: 'working' }]) + expect(twinOf(beforeRestart.body)).toBe('Kicked off 2 subagents') + + // The host dies without ever settling them — no `ended`, so no session sweep. + await live.close() + + const reopened = await open() + const afterRestart = reopened.snapshot().items.at(-1)! + expect(afterRestart.itemId).toBe(beforeRestart.itemId) + expect(rosterOf(afterRestart.body)).toMatchObject([ + { id: 'a', state: 'unverifiable' }, + { id: 'b', state: 'unverifiable' } + ]) + expect(twinOf(afterRestart.body)).toBe('Ran 2 subagents (2 unverifiable)') + }) + + it('revises the row in place rather than appending a second one', async () => { + const live = await open() + const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) + await live.appendItem(row.identity, row.body, { fence: 0 }) + const before = live.snapshot().items.length + await live.close() + + const reopened = await open() + expect(reopened.snapshot().items).toHaveLength(before) + expect(reopened.snapshot().items.at(-1)?.revision).toBe(2) + }) + + it('writes nothing on a second reopen once every child is settled', async () => { + const live = await open() + const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) + await live.appendItem(row.identity, row.body, { fence: 0 }) + await live.close() + + const once = await open() + const revision = once.snapshot().items.at(-1)?.revision + await once.close() + + const twice = await open() + expect(twice.snapshot().items.at(-1)?.revision).toBe(revision) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts new file mode 100644 index 00000000000..9b2724e9d1d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.ts @@ -0,0 +1,101 @@ +// A roster row left claiming live children by a host that is gone. +// +// The writing host revises its `subagent-group` rows in place while it can see +// the children, and sweeps whatever is still `working` when the provider goes +// away. A host that DIED — crash, quit, force-restart — does neither: its last +// revision goes on saying `working`, and nothing replays those children, so no +// later event can ever settle them. Opening the journal is the one moment a new +// host can state the truth about the old one: contact was lost. That is +// `unverifiable`, never a synthesized exit — see +// `docs/reference/ssh-execution-boundary.md`. +// +// Reconciles JOURNAL ROWS, not roster state: nothing here seeds the producer's +// in-process group map, so the roster's known limitation is untouched. + +import { + agentJournalItemKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { + isSubagentGroupFallbackText, + normalizeSubagentState, + subagentGroupFallbackText +} from '../../../shared/native-chat-subagent-summary' +import { + isSubagentGroupBlock, + type NativeChatBlock, + type NativeChatSubagentGroupBlock +} from '../../../shared/native-chat-types' + +export type JournalSubagentLivenessRevision = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody +} + +/** The revisions a reopened journal owes: one per row still claiming a live + * child. Empty — the common case — when nothing was left mid-flight. */ +export function staleSubagentRosterRevisions( + items: Iterable +): JournalSubagentLivenessRevision[] { + const revisions: JournalSubagentLivenessRevision[] = [] + for (const item of items) { + const body = item.body + if (body.kind !== 'message' || !body.blocks.some(hasWorkingChild)) { + continue + } + // A key that will not parse cannot be re-addressed, and appending under a + // fresh identity would duplicate the row rather than revise it. + const identity = parseAgentJournalItemKey(item.itemId) + if (!identity || agentJournalItemKey(identity) !== item.itemId) { + continue + } + revisions.push({ identity, body: { ...body, blocks: settleBlocks(body.blocks) } }) + } + return revisions +} + +function hasWorkingChild(block: NativeChatBlock): boolean { + return ( + isSubagentGroupBlock(block) && + block.agents.some((agent) => normalizeSubagentState(agent.state) === 'working') + ) +} + +/** No `settledAt`: the child stopped being observable at an unknown moment, and + * stamping the reopen would report the time the app was down as how long it + * ran. Readers already draw an unverifiable child with no stamp as having no + * known run length. */ +function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { + const settled = blocks.map((block) => + hasWorkingChild(block) ? settleGroup(block as NativeChatSubagentGroupBlock) : block + ) + const rosters = settled.filter(isSubagentGroupBlock) + const only = rosters.length === 1 ? rosters[0] : undefined + if (!only) { + return settled + } + // The plain-text twin is all a client without the block type ever shows, so it + // has to move with the block or the two would disagree about the same row. + const twin = subagentGroupFallbackText(only.agents) + return settled.map((block) => + block.type === 'text' && isSubagentGroupFallbackText(block.text) + ? { ...block, text: twin } + : block + ) +} + +function settleGroup(block: NativeChatSubagentGroupBlock): NativeChatSubagentGroupBlock { + return { + ...block, + agents: block.agents.map((agent) => + normalizeSubagentState(agent.state) === 'working' + ? { ...agent, state: 'unverifiable' as const } + : agent + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts index 79d9e4205cf..40504873282 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-activity.test.ts @@ -28,6 +28,16 @@ describe('provider frame activity', () => { expect(codexProviderFrameActivity('item/reasoning/summaryPartAdded', {})).toBeNull() }) + it('names a fan-out from either Codex item type that reports one', () => { + for (const type of ['collabAgentToolCall', 'subAgentActivity']) { + expect( + codexProviderFrameActivity('item/started', { + item: { type, kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' } + }) + ).toBe('Coordinating with another agent') + } + }) + it('uses Claude descriptions and safe semantic status without exposing tool labels', () => { expect( claudeProviderFrameActivity('message:system:task_started', { diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts index 9860aaa81d8..d4726a9b602 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -6,6 +6,7 @@ import { isDeltaShapedProviderFrameKind, PROVIDER_FRAME_CLASSIFICATIONS } from './provider-frame-disposition' +import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame' describe('provider frame classification catalog', () => { it('classifies every pinned Codex app-server notification method', () => { @@ -124,7 +125,7 @@ describe('provider frame classification catalog', () => { ) }) - it('keeps subagent items visible — the only evidence a spawned agent is working', () => { + it('suppresses subAgentActivity once the roster renders it, but never collabAgentToolCall', () => { expect( classifyProviderFrame('codex', 'item:subAgentActivity', { id: 'a-1', @@ -132,7 +133,9 @@ describe('provider frame classification catalog', () => { agentThreadId: 'thread-child', agentPath: '/root/list_directory' }) - ).toBe('timeline-substantive') + // The spawn-group roster row renders this now, so a raw gray row beside it + // would duplicate it. Suppressing it was gated on that renderer existing. + ).toBe('status-chrome') expect( classifyProviderFrame('codex', 'item:collabAgentToolCall', { id: 'c-1', @@ -161,3 +164,45 @@ describe('provider frame classification catalog', () => { } }) }) + +describe('codex subagent item disposition', () => { + it('keeps subagent lifecycle out of the transcript now that it renders as a roster row', () => { + expect( + classifyProviderFrame('codex', 'item:subAgentActivity', { + type: 'subAgentActivity', + kind: 'started', + agentThreadId: 'child-1', + agentPath: '/root/read' + }) + ).toBe('status-chrome') + }) + + it('leaves collab tool calls substantive — they may be the only subagent signal', () => { + // A session that reports no `subAgentActivity` gets no roster row, so + // suppressing this too would render its fan-out blank. + expect( + classifyProviderFrame('codex', 'item:collabAgentToolCall', { + type: 'collabAgentToolCall', + agentsStates: {} + }) + ).not.toBe('status-chrome') + }) + + it('journals no fallback row for subagent activity', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'item:subAgentActivity', { + kind: 'completed', + agentThreadId: 'child-1' + }) + ).toBeNull() + }) + + it('still surfaces a subagent frame that reports a failure', () => { + expect( + classifyProviderFrame('codex', 'item:collabAgentToolCall', { + type: 'collabAgentToolCall', + status: 'failed' + }) + ).toBe('error-surface') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts index f05f4cd4c6c..35223a1971f 100644 --- a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -1,4 +1,5 @@ import type { CodexAppServerNotificationMethod } from '../../codex/codex-app-server-notification-schema' +import { CODEX_SUBAGENT_ITEM_TYPE } from '../../codex/codex-subagent-activity' import type { ClaudeStreamJsonFrameKind } from './claude-stream-json-frame-schema' export type ProviderFrameClassification = @@ -198,10 +199,21 @@ const CODEX_ITEM_CLASSIFICATIONS: Record = // The `thread/compacted` notification is already chrome; its item form is the // same event and must not read as a mysterious opcode row. contextCompaction: 'status-chrome', + // Subagent lifecycle renders as the spawn-group roster row, so its raw items + // must not print a gray `codex · item:` row beside it. The live + // notification path intercepts them before this catalog is reached; + // `restoreThread` replays them straight through `items.handle`, which is where + // the classification earns its keep. + // + // `collabAgentToolCall` is deliberately NOT suppressed with it. Nothing + // guarantees a session reports subagent work as `subAgentActivity` at all; one + // that only ever emits the collab tool call gets no roster row, and suppressing + // that too would leave its fan-out showing nothing. + [CODEX_SUBAGENT_ITEM_TYPE]: 'status-chrome', // `{id, durationMs}` and nothing else — Codex's own transcript renders it as // nothing at all. Every other item type this build does not model carries text - // a user would want (review output, an image path, hook prompt text, subagent - // progress), so those keep their visible fallback row. + // a user would want (review output, an image path, hook prompt text), so those + // keep their visible fallback row. sleep: 'status-chrome' } diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts index 7899a63fb73..f47a46605e1 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.test.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { MAX_CODEX_SUBAGENTS_PER_GROUP } from '../../codex/codex-structured-journal-limits' import { boundWorkerTranscriptMessages, redactWorkerTerminalLines @@ -57,6 +58,80 @@ describe('worker transcript wire bounds', () => { ) }) + // The bound matches the producer's per-group cap, so nothing this build writes + // is clipped here. It stays because the journal schema declares no maximum and + // a remote host may run a build with a larger one — the transport's own + // invariant that no single block is huge. + it('caps and redacts a spawn group the way every other collection is capped', () => { + const result = boundWorkerTranscriptMessages([ + { + id: 'message-roster', + role: 'system', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'thread-1:turn-1', + agents: Array.from({ length: 80 }, (_unused, index) => ({ + id: `child-${index}`, + label: index === 0 ? `dcap_${'A'.repeat(24)}` : 'read', + state: 'working' as const + })) + } + ] + } + ]) + + const block = result.messages[0]?.blocks[0] + expect(block?.type).toBe('subagent-group') + expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength( + MAX_CODEX_SUBAGENTS_PER_GROUP + ) + expect(JSON.stringify(result.messages)).not.toContain('dcap_') + expect(result.limited).toBe(true) + expect(result.warnings).toEqual( + expect.arrayContaining([ + 'Some subagents were omitted from oversized spawn groups.', + 'Dispatch capability tokens were redacted from transcript output.' + ]) + ) + }) + + it('bounds a spawn-group state a newer build wrote as an oversized open string', () => { + const result = boundWorkerTranscriptMessages([ + { + id: 'message-roster-state', + role: 'system', + timestamp: null, + source: 'transcript', + blocks: [ + { + type: 'subagent-group', + groupId: 'g'.repeat(900), + agents: [ + { + id: 'i'.repeat(900), + label: 'l'.repeat(900), + state: 's'.repeat(900) as 'working' + } + ] + } + ] + } + ]) + + const block = result.messages[0]?.blocks[0] + const agent = block?.type === 'subagent-group' ? block.agents[0] : undefined + expect(block?.type === 'subagent-group' ? block.groupId.length : 0).toBe(512) + expect(agent?.id.length).toBe(512) + expect(agent?.label.length).toBe(512) + // A clipped state names no state any build knows, which is what + // `unverifiable` records — a 512-character fragment is not a state at all. + expect(agent?.state).toBe('unverifiable') + expect(result.limited).toBe(true) + }) + it('keeps complete bounded messages unlimited', () => { const result = boundWorkerTranscriptMessages([ { diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts index bcfc7cb0b75..f9d83e20c62 100644 --- a/src/main/runtime/orchestration/worker-transcript-payload.ts +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -1,5 +1,10 @@ import { createHash } from 'node:crypto' -import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatSubagentState +} from '../../../shared/native-chat-types' export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40 export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50 @@ -7,6 +12,14 @@ const MAX_WORKER_TRANSCRIPT_BLOCKS = 6 const MAX_WORKER_TRANSCRIPT_BLOCK_CHARS = 1_200 const MAX_WORKER_TRANSCRIPT_INPUT_ITEMS = 20 const MAX_WORKER_TRANSCRIPT_INPUT_NODES = 100 +// Matches the producer's per-group cap, so no group this build writes is clipped +// here. The bound stays because the journal schema declares no maximum and a +// remote host may run a build with a larger one. +const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 64 +// Message ids, turn ids, tool-call names and image urls, not only roster fields. +// Equal to `MAX_SUBAGENT_FIELD_CHARS` today, kept a separate literal so a +// roster-motivated change to that cap cannot silently move this one. +const MAX_WORKER_TRANSCRIPT_METADATA_CHARS = 512 const MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES = 512 * 1024 const TRUNCATION_MARKER = '\n… (truncated)' const DISPATCH_CAPABILITY_PATTERN = /\bdcap_[A-Za-z0-9_-]{20,}\b/g @@ -128,6 +141,24 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native input: boundToolInput(block.input, budget, 0, state) } } + if (block.type === 'subagent-group') { + const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS) + if (agents.length < block.agents.length) { + markClipped(state, 'Some subagents were omitted from oversized spawn groups.') + } + // Labels, ids and states come from provider-supplied strings, so they get the + // same redaction and clipping every other piece of transcript metadata gets. + return { + ...block, + groupId: clipMetadata(block.groupId, state), + agents: agents.map((agent) => ({ + ...agent, + id: clipMetadata(agent.id, state), + label: clipMetadata(agent.label, state), + state: clipSubagentState(agent.state, state) + })) + } + } if (block.path || (block.url && isLocalFileLocator(block.url))) { markClipped(state, 'Local image paths were omitted from transcript output.') return { @@ -165,11 +196,22 @@ function isLocalFileLocator(value: string): boolean { function clipMetadata(value: string, state: TranscriptBoundState): string { const redacted = redactSensitiveText(value, state.warnings) - if (redacted.length <= 512) { + if (redacted.length <= MAX_WORKER_TRANSCRIPT_METADATA_CHARS) { return redacted } markClipped(state, 'Oversized transcript metadata was clipped.') - return redacted.slice(0, 512) + return redacted.slice(0, MAX_WORKER_TRANSCRIPT_METADATA_CHARS) +} + +/** `state` is an open string on the wire, so it takes the same bound. A value + * that had to be redacted or clipped names no state any build knows, which is + * exactly what `unverifiable` records. */ +function clipSubagentState( + value: NativeChatSubagentState, + state: TranscriptBoundState +): NativeChatSubagentState { + const clipped = clipMetadata(value, state) + return clipped === value ? value : normalizeSubagentState(clipped) } function clipText(value: string, state: TranscriptBoundState): string { diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts new file mode 100644 index 00000000000..fc19273d5ca --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-block-sanitize.ts @@ -0,0 +1,132 @@ +import { + MAX_SUBAGENT_FIELD_CHARS, + normalizeSubagentState +} from '../../../../shared/native-chat-subagent-summary' +import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types' +import type { RpcContext } from '../core' +import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' + +// Why: the mobile-only payload diet. Inline image bytes are kept off every RPC +// transport; everything below that only applies to `mobile` clients, whose +// renderer previews block bodies rather than showing them whole. + +// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. +// The mobile view only previews tool block bodies, so truncate them on the wire +// to keep the payload small; the marker tells the user content was clipped. +const MOBILE_BLOCK_CHAR_CAP = 4000 +// Why: text blocks are the message body itself, rendered in full by the chat +// view — a preview-sized cap cut long assistant replies mid-sentence with no way +// to read on (STA-3230). Keep only a generous safety ceiling: a transcript +// record can legally reach 2MB, and shipping that much markdown in one block +// would freeze the phone. +const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 +const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 +const MOBILE_TOOL_INPUT_NODE_CAP = 100 +// Why: a spawn group's roster is metadata, not a body — provider-supplied agent +// paths and an open-string lifecycle whose schema declares no maximum, so a +// journal from a newer build can carry more children and longer strings than +// this build ever writes. +const MOBILE_SUBAGENT_CAP = 64 +const TRUNCATION_MARKER = '\n… (truncated)' + +function clip(text: string, cap: number): string { + return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text +} + +export function sanitizeNativeChatRpcBlock( + block: NativeChatBlock, + clientKind: RpcContext['clientKind'] +): NativeChatBlock { + if (block.type === 'image-ref') { + return sanitizeNativeChatRpcImageBlock(block) + } + if (clientKind !== 'mobile') { + return block + } + if (block.type === 'text') { + return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP + ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-result') { + return block.output.length > MOBILE_BLOCK_CHAR_CAP + ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } + : block + } + if (block.type === 'tool-call') { + const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } + return { ...block, input: sanitizeToolInput(block.input, budget, 0) } + } + if (block.type === 'subagent-group') { + return { + ...block, + groupId: clip(block.groupId, MAX_SUBAGENT_FIELD_CHARS), + agents: block.agents.slice(0, MOBILE_SUBAGENT_CAP).map((agent) => ({ + ...agent, + id: clip(agent.id, MAX_SUBAGENT_FIELD_CHARS), + label: clip(agent.label, MAX_SUBAGENT_FIELD_CHARS), + state: clipSubagentState(agent.state) + })) + } + } + return block +} + +/** A state too long to be one this build knows names no state at all, which is + * what `unverifiable` records — clipping it would ship a truncated word. */ +function clipSubagentState(value: NativeChatSubagentState): NativeChatSubagentState { + return value.length > MAX_SUBAGENT_FIELD_CHARS ? normalizeSubagentState(value) : value +} + +function sanitizeToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + return '… (truncated)' + } + if (typeof value === 'string') { + const length = Math.min(value.length, budget.remaining) + budget.remaining -= length + return length < value.length ? `${value.slice(0, length)}… (truncated)` : value + } + if (!value || typeof value !== 'object' || depth >= 5) { + return value && typeof value === 'object' ? '… (truncated)' : value + } + if (Array.isArray(value)) { + const result = value + .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) + .map((item) => sanitizeToolInput(item, budget, depth + 1)) + if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { + result.push('… (truncated)') + } + return result + } + const result: Record = {} + let count = 0 + for (const key in value) { + if (!Object.hasOwn(value, key)) { + continue + } + if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { + result['…'] = 'truncated' + break + } + let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) + // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse + // to the same bounded key; suffix collisions so neither field is silently lost. + if (Object.hasOwn(result, boundedKey)) { + boundedKey = `${boundedKey}~${count}` + } + budget.remaining -= boundedKey.length + result[boundedKey] = sanitizeToolInput( + (value as Record)[key], + budget, + depth + 1 + ) + count++ + } + return result +} diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index 65bb525e798..1417e716770 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -266,6 +266,39 @@ describe('nativeChat.readSession clientKind truncation gating', () => { expect(JSON.stringify(input)).toContain('truncated') }) + // The roster block reached mobile through a bare fall-through, uncapped, on the + // one path that exists to keep the payload off the phone. + it('bounds a spawn-group roster before sending it to mobile', async () => { + cachedResult.value = { + messages: [ + { + ...makeMessage('ignored'), + blocks: [ + { + type: 'subagent-group', + groupId: 'thread-1:turn-1', + agents: Array.from({ length: 80 }, (_unused, index) => ({ + id: `child-${index}`, + label: index === 0 ? OVERSIZED : 'read', + state: index === 0 ? (OVERSIZED as 'working') : ('working' as const) + })) + } + ] + } + ] + } + + const result = await readSessionHandler()({ agent: 'codex', sessionId: 's' }, ctxWith('mobile')) + const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0] + if (block.type !== 'subagent-group') { + throw new Error('expected a subagent-group block') + } + + expect(block.agents).toHaveLength(64) + expect(block.agents[0].label.length).toBeLessThan(OVERSIZED.length) + expect(block.agents[0].state).toBe('unverifiable') + }) + it('preserves AskUserQuestion option objects at the supported nesting depth', async () => { cachedResult.value = { messages: [ diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 8fc86bf695a..e1a92dd52db 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -1,9 +1,5 @@ import { z } from 'zod' -import type { - NativeChatBlock, - NativeChatMessage, - AgentType -} from '../../../../shared/native-chat-types' +import type { NativeChatMessage, AgentType } from '../../../../shared/native-chat-types' import { readNativeChatTranscriptTail, subscribeNativeChatTranscript, @@ -11,7 +7,7 @@ import { type SubscribeNativeChatTranscriptArgs } from '../../../native-chat/transcript-watch' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' -import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block' +import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The // desktop reaches the readers via Electron IPC; mobile/web clients reach the @@ -68,109 +64,15 @@ const NativeChatUnsubscribe = z.object({ // older history as the user scrolls back. const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40 const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000 -// Why: a single tool result (a big file read, a long diff) can be hundreds of KB. -// The mobile view only previews tool block bodies, so truncate them on the wire -// to keep the payload small; the marker tells the user content was clipped. -const MOBILE_BLOCK_CHAR_CAP = 4000 -// Why: text blocks are the message body itself, rendered in full by the chat -// view — a preview-sized cap cut long assistant replies mid-sentence with no way -// to read on (STA-3230). Keep only a generous safety ceiling: a transcript -// record can legally reach 2MB, and shipping that much markdown in one block -// would freeze the phone. -const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000 -const MOBILE_TOOL_INPUT_ITEMS_CAP = 20 -const MOBILE_TOOL_INPUT_NODE_CAP = 100 -const TRUNCATION_MARKER = '\n… (truncated)' - -function clip(text: string, cap: number): string { - return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text -} - -function sanitizeBlock( - block: NativeChatBlock, - clientKind: RpcContext['clientKind'] -): NativeChatBlock { - if (block.type === 'image-ref') { - return sanitizeNativeChatRpcImageBlock(block) - } - if (clientKind !== 'mobile') { - return block - } - if (block.type === 'text') { - return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP - ? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-result') { - return block.output.length > MOBILE_BLOCK_CHAR_CAP - ? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) } - : block - } - if (block.type === 'tool-call') { - const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP } - return { ...block, input: sanitizeToolInput(block.input, budget, 0) } - } - return block -} - -function sanitizeToolInput( - value: unknown, - budget: { remaining: number; nodes: number }, - depth: number -): unknown { - budget.nodes-- - if (budget.nodes < 0 || budget.remaining <= 0) { - return '… (truncated)' - } - if (typeof value === 'string') { - const length = Math.min(value.length, budget.remaining) - budget.remaining -= length - return length < value.length ? `${value.slice(0, length)}… (truncated)` : value - } - if (!value || typeof value !== 'object' || depth >= 5) { - return value && typeof value === 'object' ? '… (truncated)' : value - } - if (Array.isArray(value)) { - const result = value - .slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP) - .map((item) => sanitizeToolInput(item, budget, depth + 1)) - if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) { - result.push('… (truncated)') - } - return result - } - const result: Record = {} - let count = 0 - for (const key in value) { - if (!Object.hasOwn(value, key)) { - continue - } - if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) { - result['…'] = 'truncated' - break - } - let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128)) - // Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse - // to the same bounded key; suffix collisions so neither field is silently lost. - if (Object.hasOwn(result, boundedKey)) { - boundedKey = `${boundedKey}~${count}` - } - budget.remaining -= boundedKey.length - result[boundedKey] = sanitizeToolInput( - (value as Record)[key], - budget, - depth + 1 - ) - count++ - } - return result -} function sanitizeMessage( message: NativeChatMessage, clientKind: RpcContext['clientKind'] ): NativeChatMessage { - return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) } + return { + ...message, + blocks: message.blocks.map((block) => sanitizeNativeChatRpcBlock(block, clientKind)) + } } function sanitizeAppendForClient( diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index 5b71136b86f..cb9ad6932f0 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -4,6 +4,11 @@ import '@testing-library/jest-dom/vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { subagentGroupFallbackText } from '../../../../shared/native-chat-subagent-summary' +import type { + NativeChatMessage, + NativeChatSubagentEntry +} from '../../../../shared/native-chat-types' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { NativeChatMessageList } from './NativeChatMessageList' @@ -560,3 +565,293 @@ describe('NativeChatMessageList assistant messages', () => { ) }) }) + +// List-level, because every defect this feature has shipped so far lived in the +// assembly between rows — the roster is its own `role: 'system'` journal row, and +// what reaches the DOM depends on `foldToolMessages`, the turn-key mapping and the +// disclosure state the list owns. Rendering `NativeChatToolRun` in isolation +// supplies those by hand and agrees with whatever the caller was asked to assume. +describe('NativeChatMessageList spawn-group roster', () => { + const ROSTER: NativeChatSubagentEntry[] = [ + { id: 'a', label: 'read', state: 'completed' }, + { id: 'b', label: 'search', state: 'failed' } + ] + + /** The exact two-block row `codexSubagentGroupBody` writes: the structured + * block plus the plain-text twin a client without the block type reads. */ + function rosterMessage(agents: NativeChatSubagentEntry[], at: number): NativeChatMessage { + return { + id: 'roster-1', + role: 'system', + blocks: [ + { type: 'text', text: subagentGroupFallbackText(agents) }, + { type: 'subagent-group', groupId: 'thread-1:turn-1', agents } + ], + timestamp: at, + source: 'transcript' + } + } + + // Explicit ascending timestamps: the list re-sorts by (timestamp, id), so rows + // sharing a millisecond tie-break alphabetically and the user turn can land + // last — which would strand the roster outside its own turn. + function rosterSession( + agents: NativeChatSubagentEntry[], + startedAt: number + ): NativeChatLiveSession { + return { + ...session, + status: 'ready', + messages: [ + { + id: 'user-fanout', + role: 'user', + blocks: [{ type: 'text', text: 'Fan this out' }], + timestamp: startedAt, + source: 'transcript' + }, + { + id: 'assistant-fanout', + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'pwd' }, state: 'completed' }, + { type: 'tool-result', output: '/repo' } + ], + timestamp: startedAt + 1, + source: 'transcript' + }, + rosterMessage(agents, startedAt + 2) + ] + } + } + + // A settled turn with its activity collapsed is the resting state of the whole + // transcript, so this is the roster's normal appearance, not an edge case. The + // completed-turn disclosure guard used to swallow it here — the compact row the + // feature exists to leave behind vanished the moment its turn ended. + it('leaves the roster row behind on a settled turn whose activity is collapsed', () => { + const startedAt = Date.now() - 3000 + render( + + ) + + expect(screen.getByRole('button', { name: 'Toggle turn details' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toHaveTextContent('1 failed') + // The twin is the roster written out for clients that cannot draw the block. + // This one draws it, so printing the sentence too would say it all twice. + expect(screen.queryByText('Ran 2 subagents (1 failed)')).toBeNull() + }) + + // The block is provider-agnostic — the Claude lane feeds it too — so a lane + // that folds a roster into a message carrying real prose is a live shape. The + // filter used to drop EVERY text block once a roster was present, so that + // prose vanished on desktop while mobile, which reads the raw blocks, kept it. + it('keeps prose beside a roster block and drops only the twin', () => { + const startedAt = Date.now() - 3000 + const twin = subagentGroupFallbackText(ROSTER) + render( + + ) + + expect(screen.getByText('Handing the audit to two children.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Ran 2 subagents/ })).toBeInTheDocument() + expect(screen.queryByText(twin)).toBeNull() + }) + + // The reordering that kept the roster visible must not have let TOOL activity + // out from behind the same disclosure: a failed child command reading as live + // on a finished turn is what put that guard there. + it('keeps tool activity behind the disclosure the roster now bypasses', () => { + const startedAt = Date.now() - 3000 + render( + + ) + + expect(screen.queryByRole('button', { name: /1× shell/ })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Toggle turn details' })) + expect(screen.getByRole('button', { name: /1× shell/ })).toBeInTheDocument() + // Expanding must reveal the tools beside the roster, never a second copy of it. + expect(screen.getAllByRole('button', { name: /Ran 2 subagents/ })).toHaveLength(1) + }) + + it('reads as a live spawn while the turn is still working', () => { + render( + + ) + + expect(screen.getByRole('button', { name: /Kicked off 2 subagents/ })).toHaveTextContent( + '2 working' + ) + }) + + // The QA defect, at the seam that produced it. A mid-turn correction opens a + // NEW turn, so `isCurrentTurn` goes false for the fan-out's row and the list + // passes `activeTurnIsWorking={false}` down to the roster. The row used to + // relabel every live child `unverifiable` and flip its headline to "Ran" — + // claiming both that contact was lost and that the fan-out had finished, while + // the three real children were still running and completed 57-87s later. + it('keeps live children working after a newer turn supersedes their own', () => { + const startedAt = Date.now() - 3000 + const live = rosterSession( + [ + { id: 'a', label: 'read_readme', state: 'working', startedAt }, + { id: 'b', label: 'read_package', state: 'working', startedAt } + ], + startedAt + ) + render( + + ) + + const roster = screen.getByRole('button', { name: /Kicked off 2 subagents/ }) + expect(roster).toHaveTextContent('2 working') + expect(roster).not.toHaveTextContent('unverifiable') + expect(screen.queryByRole('button', { name: /Ran 2 subagents/ })).toBeNull() + }) +}) + +// The block schema admits `agents: []`, so a childless spawn group is a shape the +// wire allows even though no producer writes one. It draws nothing, so the row +// must not be mounted on its account: "counts as renderable" and "actually draws" +// have to answer the same. A row that passes the first and fails the second is an +// invisible div that still consumes one `gap-5` slot of the transcript. +describe('NativeChatMessageList childless spawn group', () => { + const NO_AGENTS: NativeChatSubagentEntry[] = [] + + function rosterSession(blocks: NativeChatMessage['blocks'], at: number): NativeChatLiveSession { + return { + ...session, + status: 'ready', + messages: [ + { + id: 'user-fanout', + role: 'user', + blocks: [{ type: 'text', text: 'Fan this out' }], + timestamp: at, + source: 'transcript' + }, + { id: 'roster-1', role: 'system', blocks, timestamp: at + 1, source: 'transcript' } + ] + } + } + + /** Every slot the transcript column lays out — one per row that mounted. */ + function emptySlots(container: HTMLElement): Element[] { + const column = container.querySelector('.max-w-4xl') + expect(column).not.toBeNull() + return Array.from(column!.children).filter((slot) => slot.textContent === '') + } + + it('mounts no row for a bare spawn group with no children', () => { + const startedAt = Date.now() - 3000 + const { container } = render( + + ) + + expect(screen.getByText('Fan this out')).toBeInTheDocument() + expect(emptySlots(container)).toEqual([]) + }) + + it('falls back to the plain-text twin when the block it stands in for cannot draw', () => { + const startedAt = Date.now() - 3000 + const { container } = render( + + ) + + // The twin is dropped only because the block draws the roster instead. This + // one cannot, so suppressing it too would leave the row with nothing at all. + expect(screen.getByText(subagentGroupFallbackText(NO_AGENTS))).toBeInTheDocument() + expect(emptySlots(container)).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 07ea51b5a62..64f489a1b48 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -4,7 +4,11 @@ import CommentMarkdown, { } from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' -import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + isSubagentGroupFallbackText, + subagentGroupBlocks +} from '../../../../shared/native-chat-subagent-summary' +import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' import { splitNativeChatBlocks } from './native-chat-tool-fold' import { NativeChatToolRun } from './NativeChatToolRun' import { nativeChatProseToMarkdown } from './native-chat-prose' @@ -47,12 +51,28 @@ export const MessageRow = memo(function MessageRow({ const rowRef = useRef(null) // One pass per block set: a streaming turn re-renders this row on every frame, and these // derivations used to re-run each time even though `message.blocks` had not changed. - const { hasImages, markdown, prose, tools } = useMemo(() => { + const { hasImages, markdown, prose, subagentGroups, tools } = useMemo(() => { const split = splitNativeChatBlocks(message.blocks) + const groups = subagentGroupBlocks(split.prose) + // A spawn-group row carries a plain-text twin so a client without the block + // type still reads the roster. This one draws the block, so the twin is + // dropped rather than printed beside it — only the twin, never the prose + // beside it: the block is provider-agnostic, so a lane that folds a roster + // into a message with real text must not lose that text here. + const prose = + groups.length === 0 + ? split.prose + : split.prose.filter( + (block) => + !isSubagentGroupBlock(block) && + !(block.type === 'text' && isSubagentGroupFallbackText(block.text)) + ) return { - ...split, - markdown: nativeChatProseToMarkdown(split.prose), - hasImages: split.prose.some((block) => block.type === 'image-ref') + tools: split.tools, + prose, + subagentGroups: groups, + markdown: nativeChatProseToMarkdown(prose), + hasImages: prose.some((block) => block.type === 'image-ref') } }, [message.blocks]) const isUser = message.role === 'user' @@ -69,7 +89,7 @@ export const MessageRow = memo(function MessageRow({ // Skip rows with nothing renderable so the transcript shows no empty/ghost // bubble. // After all hooks, so hook order stays unconditional. - if (markdown.length === 0 && !hasImages && tools.length === 0) { + if (markdown.length === 0 && !hasImages && tools.length === 0 && subagentGroups.length === 0) { return null } @@ -151,9 +171,10 @@ export const MessageRow = memo(function MessageRow({ linkifyFilePaths={onLinkClick !== undefined} /> ) : null} - {tools.length > 0 ? ( + {tools.length > 0 || subagentGroups.length > 0 ? ( { + it('reads as a live spawn while children work', () => { + render( + + ) + + expect(screen.getByText('Kicked off 2 subagents')).toBeInTheDocument() + expect(screen.getByRole('button')).toHaveTextContent('1 working') + expect(screen.getByRole('button')).toHaveTextContent('40.7k tokens') + }) + + it('switches to Ran once every child completed', () => { + render( + + ) + + expect(screen.getByText('Ran 2 subagents')).toBeInTheDocument() + expect(screen.getByRole('button')).toHaveTextContent('completed') + }) + + it('shows the worst settled verdict, not the count of finished children', () => { + render( + + ) + + expect(screen.getByRole('button')).toHaveTextContent('2 failed') + }) + + it('surfaces a failed child while its siblings still work', () => { + const { container } = render( + + ) + + const row = screen.getByRole('button') + expect(row).toHaveTextContent('3 working') + expect(row).toHaveTextContent('+1 failed') + // The dot carries the failure; the pulse still says the group is in flight. + expect(container.querySelector('.bg-destructive.animate-pulse')).not.toBeNull() + }) + + it('leaves the dot neutral when nothing has gone wrong', () => { + const { container } = render( + + ) + + expect(screen.getByRole('button')).not.toHaveTextContent('failed') + expect(container.querySelector('.bg-destructive')).toBeNull() + }) + + // The QA defect: a mid-turn correction opened a new turn while three real + // children were still running, and the row relabelled every one of them + // `unverifiable` and flipped its headline to `Ran`. The children completed + // 57-87s later. A turn boundary says nothing about a child. + it('keeps a working child working once its turn is no longer the current one', () => { + render() + + const row = screen.getByRole('button') + expect(row).toHaveTextContent('working') + expect(row).not.toHaveTextContent('unverifiable') + expect(screen.getByText('Kicked off 1 subagent')).toBeInTheDocument() + }) + + it('reports the verdict a child lands after its turn ended', () => { + render( + + ) + + expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() + expect(screen.getByRole('button')).toHaveTextContent('completed') + }) + + // Only the writing host may claim loss of contact, and it writes that verdict + // into the row itself. The renderer draws it, and never infers it. + it('draws the unverifiable verdict the host recorded', () => { + render( + + ) + + expect(screen.getByRole('button')).toHaveTextContent('unverifiable') + }) + + it('leads with the bot glyph, decorative beside the word that names the group', () => { + const { container } = render( + + ) + + const glyph = container.querySelector('.lucide-bot') + expect(glyph).not.toBeNull() + expect(glyph).toHaveAttribute('aria-hidden', 'true') + // Never icon-only: the word is what carries the accessible name. + expect(screen.getByRole('button')).toHaveAccessibleName(/Kicked off 1 subagent/) + }) + + it('keeps the same glyph in every state, so a settling row never changes identity', () => { + const states: NativeChatSubagentState[] = [ + 'working', + 'idle', + 'completed', + 'failed', + 'stopped', + 'unverifiable' + ] + + for (const state of states) { + const { container } = render( + + ) + + expect(container.querySelectorAll('.lucide-bot')).toHaveLength(1) + expect(container.querySelector('.lucide-check')).toBeNull() + expect(container.querySelector('.lucide-users')).toBeNull() + cleanup() + } + }) + + // The only aria-hidden span carrying text is the elapsed-clock wrapper: the + // glyph's Bot is an and the status dots render empty. + function hiddenTextSpans(container: HTMLElement): Element[] { + return [...container.querySelectorAll('span[aria-hidden="true"]')].filter( + (element) => (element.textContent ?? '').trim().length > 0 + ) + } + + it('keeps the ticking clock out of the live region until it stops moving', () => { + const { container } = render( + + ) + + const row = screen.getByRole('button') + expect(row).toHaveAttribute('aria-live', 'polite') + // A clock that reticks every second would announce a new duration every + // second and bury the state changes the live region exists to report. + expect(hiddenTextSpans(container)).toHaveLength(1) + }) + + it('reads the elapsed time out once it has stopped moving', () => { + const { container } = render( + + ) + + // Settled: the duration is fixed, so hiding it would cost a reader real + // information for no announcement churn. + expect(hiddenTextSpans(container)).toHaveLength(0) + expect(screen.getByRole('button')).toHaveTextContent('4s') + }) + + it('shows no duration for a child whose run length was never recorded', () => { + render( + + ) + + const row = screen.getByRole('button') + expect(row).toHaveTextContent('unverifiable') + // `unverifiable` with no terminal timestamp has no known run length, so the + // clock would measure to `now` and report the time since we lost sight of + // the child as how long it ran — on a row that is not even counting. + expect(row.textContent).not.toContain('·') + }) + + // A partial sweep leaves one child settled and one whose fate is unknown. The + // group's clock would then report the settled sibling's duration as the + // group's run length while the other child is still unaccounted for. + it('shows no duration while one child settled and another is unaccounted for', () => { + render( + + ) + + const row = screen.getByRole('button') + expect(row).toHaveTextContent('unverifiable') + expect(row.textContent).not.toContain('·') + }) +}) + +describe('NativeChatToolRun with a spawn group', () => { + it('renders a roster with no tool calls without inventing a tool count', () => { + render( + + ) + + expect(screen.getByText('Kicked off 1 subagent')).toBeInTheDocument() + expect(screen.queryByText('1 tool call')).toBeNull() + }) + + // Every settled turn sits here by default: the list passes + // `expandOverride={expandedTurnIds.has(turnKey)}` — false until the reader + // opens that turn — and `activeTurnIsWorking={false}`. The completed-turn + // guard above bailed before the roster branch, so the one row this feature + // exists to draw vanished the moment its turn finished, and the message row + // that kept itself alive for it rendered an empty ghost bubble. + it('keeps the roster visible on a completed turn whose activity is collapsed', () => { + render( + + ) + + expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() + }) + + // The roster-only branch returns a `mt-3` wrapper whenever it has rows, so a + // group that draws nothing must not count as one — that wrapper would be the + // empty bubble with a margin that the message row refuses to emit. + it('draws nothing at all for a spawn group that carries no children', () => { + const { container } = render( + + ) + + expect(container).toBeEmptyDOMElement() + }) + + // The roster-only escape above is keyed on `blocks.length === 0`, so a group + // sharing its message with tool calls falls through to the settled-turn guard + // — which returned bare null and took the roster with it. + it('keeps a roster that shares its message with tool calls on a collapsed turn', () => { + render( + + ) + + expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() + expect(screen.queryByText('shell ls')).toBeNull() + }) + + it('renders the roster alongside the tool activity of its turn', () => { + render( + + ) + + expect(screen.getByText('Ran 1 subagent')).toBeInTheDocument() + expect(screen.getByText('shell ls')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx new file mode 100644 index 00000000000..af3bf468011 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx @@ -0,0 +1,277 @@ +import { useMemo, useState } from 'react' +import { Bot, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { useNow } from '@/hooks/use-now' +import { + normalizeSubagentState, + summarizeSubagentGroup +} from '../../../../shared/native-chat-subagent-summary' +import type { + NativeChatSubagentGroupBlock, + NativeChatSubagentState +} from '../../../../shared/native-chat-types' +import { formatNativeChatDuration } from './NativeChatWorkingStatus' + +/** Compact token counts: the row shows scale, not an exact ledger. */ +function formatSubagentTokens(tokens: number): string { + if (tokens < 1_000) { + return String(Math.round(tokens)) + } + const scaled = tokens < 1_000_000 ? tokens / 1_000 : tokens / 1_000_000 + const suffix = tokens < 1_000_000 ? 'k' : 'M' + return `${scaled.toFixed(1).replace(/\.0$/, '')}${suffix}` +} + +/** The group's one-line verdict. A single-child group reads as a bare word; any + * larger group always carries the count, because "working" alone would not say + * how many of the children it covers. `completed` never takes one: every child + * finishing is the whole group finishing. */ +function subagentStateLabel( + state: NativeChatSubagentState, + count: number, + groupTotal: number +): string { + if (state === 'completed') { + return translate('components.native-chat.subagents.state.completed', 'completed') + } + if (groupTotal <= 1) { + switch (state) { + case 'working': + return translate('components.native-chat.subagents.state.working', 'working') + case 'idle': + return translate('components.native-chat.subagents.state.idle', 'idle') + case 'failed': + return translate('components.native-chat.subagents.state.failed', 'failed') + case 'stopped': + return translate('components.native-chat.subagents.state.stopped', 'stopped') + case 'unverifiable': + return translate('components.native-chat.subagents.state.unverifiable', 'unverifiable') + } + } + switch (state) { + case 'working': + return translate( + 'components.native-chat.subagents.state.workingCount', + '{{value0}} working', + { + value0: count + } + ) + case 'idle': + return translate('components.native-chat.subagents.state.idleCount', '{{value0}} idle', { + value0: count + }) + case 'failed': + return translate('components.native-chat.subagents.state.failedCount', '{{value0}} failed', { + value0: count + }) + case 'stopped': + return translate( + 'components.native-chat.subagents.state.stoppedCount', + '{{value0}} stopped', + { + value0: count + } + ) + case 'unverifiable': + return translate( + 'components.native-chat.subagents.state.unverifiableCount', + '{{value0}} unverifiable', + { value0: count } + ) + } +} + +const STATE_DOT_CLASS: Record = { + working: 'bg-foreground/70', + idle: 'bg-muted-foreground/40', + completed: 'bg-muted-foreground/60', + failed: 'bg-destructive', + stopped: 'bg-muted-foreground', + unverifiable: 'bg-muted-foreground' +} + +/** + * The group's identity glyph, fixed across every state — a settling row must not + * appear to change identity. State is carried by {@link StatusDot} and the tone + * of the words beside it. + * + * SWAP POINT: once the shared category-icon component lands (PR #18760), this + * whole component becomes that component asked for the `bot` category, which is + * the same glyph the individual `subAgentActivity` rows use. + */ +function SubagentGlyph(): React.JSX.Element { + return ( + + + ) +} + +/** `pulsing` is separate from `state` so a group that is still working can show + * a failed sibling's colour without losing its in-flight cue. */ +function StatusDot({ + state, + pulsing = false +}: { + state: NativeChatSubagentState + pulsing?: boolean +}): React.JSX.Element { + return ( +