diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index 0ddb55bcde7..28cda2e4374 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -98,5 +98,13 @@ "dynamic": false, "count": 1, "reason": "aria-label shipped untranslated on main (#8142, agent status in terminal tabs); mirrors main's coverage posture — localize in a dedicated i18n pass, not this rebase." + }, + { + "filePath": "src/renderer/src/components/data-recovery/DataRecoveryPinExitCustomAgentExample.tsx", + "kind": "jsx-attribute:label", + "text": "Codex", + "dynamic": false, + "count": 1, + "reason": "'Codex' is the agent product name in a static aria-hidden picker mock; brand names are never localized." } ] diff --git a/config/scripts/release-cut-token-permissions.test.mjs b/config/scripts/release-cut-token-permissions.test.mjs index f2f544a8f27..fa4cd2570e8 100644 --- a/config/scripts/release-cut-token-permissions.test.mjs +++ b/config/scripts/release-cut-token-permissions.test.mjs @@ -5,13 +5,10 @@ import { parse } from 'yaml' const RELEASE_WORKFLOW = '.github/workflows/release-cut.yml' const EXPECTED_MATRIX = { - '.github/workflows/docs.yml#check': { contents: 'read' }, - '.github/workflows/docs.yml#production': { contents: 'read' }, - '.github/workflows/docs.yml#release_gate': { contents: 'read' }, '.github/workflows/e2e.yml#build': { contents: 'read' }, '.github/workflows/e2e.yml#changed-e2e': { contents: 'read' }, '.github/workflows/e2e.yml#e2e': { contents: 'read' }, - '.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' }, + '.github/workflows/e2e.yml#ssh-custom-agent': { contents: 'read' }, '.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' }, '.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' }, '.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' }, @@ -19,7 +16,6 @@ const EXPECTED_MATRIX = { [`${RELEASE_WORKFLOW}#build-mac`]: { actions: 'write', contents: 'read' }, [`${RELEASE_WORKFLOW}#create-release`]: { contents: 'write' }, [`${RELEASE_WORKFLOW}#cut`]: { contents: 'write' }, - [`${RELEASE_WORKFLOW}#docs-production-dispatch`]: { actions: 'write' }, [`${RELEASE_WORKFLOW}#homebrew-bump`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#homebrew-bump -> .github/workflows/homebrew-bump.yml#bump-cask`]: { contents: 'read' @@ -31,7 +27,6 @@ const EXPECTED_MATRIX = { }, [`${RELEASE_WORKFLOW}#post-release-e2e`]: { actions: 'write' }, [`${RELEASE_WORKFLOW}#publish-release`]: { contents: 'write' }, - [`${RELEASE_WORKFLOW}#release-preflight`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-linux-floor-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#skill-sharing-release-gate`]: { contents: 'read' }, [`${RELEASE_WORKFLOW}#terminal-rendering-golden`]: { contents: 'read' }, diff --git a/src/main/agent-launch/agent-catalog-projections.test.ts b/src/main/agent-launch/agent-catalog-projections.test.ts index c7f14080eae..c919021a117 100644 --- a/src/main/agent-launch/agent-catalog-projections.test.ts +++ b/src/main/agent-launch/agent-catalog-projections.test.ts @@ -71,18 +71,18 @@ describe('remote snapshot projection', () => { expect(second).toMatchObject({ status: 'ready', envState: 'available', - availabilityCheck: 'host-preflight' + availabilityCheck: 'launch-reported' }) }) - it('uses host-preflight for a configured executable regardless of env', () => { + it('uses launch-reported for a configured executable regardless of env', () => { const snapshot = buildAgentCatalogSnapshot( settingsWith({ customTuiAgents: [liveAgent({ commandOverride: '/opt/codex' })] }) ) if ('code' in snapshot) { throw new Error('unexpected projection error') } - expect(snapshot.customAgents[0]).toMatchObject({ availabilityCheck: 'host-preflight' }) + expect(snapshot.customAgents[0]).toMatchObject({ availabilityCheck: 'launch-reported' }) }) it('projects valid-id repair rows without raw fields and omits malformed/duplicate rows', () => { diff --git a/src/main/agent-launch/agent-catalog-projections.ts b/src/main/agent-launch/agent-catalog-projections.ts index a64e3771225..2723e9a5c57 100644 --- a/src/main/agent-launch/agent-catalog-projections.ts +++ b/src/main/agent-launch/agent-catalog-projections.ts @@ -60,10 +60,12 @@ function syncedRow(definition: CustomTuiAgent): SyncedCustomTuiAgent { envState, // Conservative: a configured executable or host-applicable env means stock // baseline detection cannot vouch for this row, and naming the actual - // reason (e.g. PATH) would leak which env key exists. + // reason (e.g. PATH) would leak which env key exists. No executable + // existence check runs anywhere on the launch boundary — 'launch-reported' + // deliberately claims only that the launch itself is the availability check. availabilityCheck: definition.commandOverride || envState === 'available' - ? 'host-preflight' + ? 'launch-reported' : 'baseline-detection' } } diff --git a/src/main/agent-launch/agent-launch-operation-store.test.ts b/src/main/agent-launch/agent-launch-operation-store.test.ts index 2482f7909eb..b725f6d555c 100644 --- a/src/main/agent-launch/agent-launch-operation-store.test.ts +++ b/src/main/agent-launch/agent-launch-operation-store.test.ts @@ -149,6 +149,15 @@ describe('in-flight pending snapshots', () => { expect(store.getPending('token-1')).toBe(a) expect(store.getPending('token-2')).toBe(b) }) + + it('releaseSpawnInFlight drops only the in-flight guard, never the pending', () => { + const store = new AgentLaunchOperationStore() + store.beginPending(pending({ launchToken: 'token-x' })) + expect(store.isSpawnInFlight('token-x')).toBe(true) + store.releaseSpawnInFlight('token-x') + expect(store.isSpawnInFlight('token-x')).toBe(false) + expect(store.getPending('token-x')).not.toBeNull() + }) }) describe('settled ledger', () => { diff --git a/src/main/agent-launch/agent-launch-operation-store.ts b/src/main/agent-launch/agent-launch-operation-store.ts index e6af8a5e0d5..70c69708b1e 100644 --- a/src/main/agent-launch/agent-launch-operation-store.ts +++ b/src/main/agent-launch/agent-launch-operation-store.ts @@ -196,6 +196,14 @@ export class AgentLaunchOperationStore { return this.inFlightSpawnTokens.has(launchToken) } + /** Release only the in-flight spawn guard, KEEPING the pending snapshot: the + * contact-lost arm of the spawn transaction ends this process's spawn attempt + * without settling the operation, so reconciliation must become able to + * resolve the retained pending on real host evidence. */ + releaseSpawnInFlight(launchToken: string): void { + this.inFlightSpawnTokens.delete(launchToken) + } + getPending(launchToken: string): PendingAgentLaunchSnapshot | null { return this.pendingByToken.get(launchToken) ?? null } diff --git a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts index 940811dd9cc..b3f34d5e2c8 100644 --- a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts +++ b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts @@ -22,6 +22,12 @@ // user's Retry then spawns a duplicate beside it. Such hosts fall back to the // pre-launchToken identification, and hold the launch pending when that is // inconclusive. +// +// KNOWN GAP (pre-existing, out of custom-agent scope): token authority is read +// per-host here, but the SSH path withholds the token per-launch on a mutable +// probe that also collapses timeouts into `false` — so a recovered probe can read +// a live launch's missing echo as absence. Needs the delivery fact pinned at +// dispatch; always sending the token does not fix it (old relays drop it). import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' import { diff --git a/src/main/agent-launch/agent-launch-spawn-contact-loss.test.ts b/src/main/agent-launch/agent-launch-spawn-contact-loss.test.ts new file mode 100644 index 00000000000..39aa266dd78 --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn-contact-loss.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { isSpawnContactLossError } from './agent-launch-spawn-contact-loss' + +describe('isSpawnContactLossError', () => { + it('recognizes the SSH transport rejections that can interrupt a dispatched spawn', () => { + expect( + isSpawnContactLossError( + Object.assign(new Error('SSH connection lost, reconnecting...'), { + code: 'CONNECTION_LOST' + }) + ) + ).toBe(true) + expect( + isSpawnContactLossError( + Object.assign(new Error('Multiplexer disposed'), { code: 'DISPOSED' }) + ) + ).toBe(true) + expect( + isSpawnContactLossError( + Object.assign(new Error('Request "pty.spawn" timed out after 30000ms'), { + code: 'SSH_MUX_REQUEST_TIMEOUT' + }) + ) + ).toBe(true) + }) + + it('recognizes the established post-dispatch ambiguity marker', () => { + expect( + isSpawnContactLossError( + Object.assign(new Error('execution_owner_unavailable'), { + agentSessionOperationOutcome: 'unknown' as const + }) + ) + ).toBe(true) + }) + + it('treats host-attested spawn failures as attested, not contact loss', () => { + expect(isSpawnContactLossError(new Error('pty boom'))).toBe(false) + expect(isSpawnContactLossError(new Error('client_disconnected'))).toBe(false) + expect(isSpawnContactLossError(new Error('execution_owner_unavailable'))).toBe(false) + expect( + isSpawnContactLossError(Object.assign(new Error('other'), { code: 'SOMETHING_ELSE' })) + ).toBe(false) + expect(isSpawnContactLossError(null)).toBe(false) + expect(isSpawnContactLossError('CONNECTION_LOST')).toBe(false) + }) +}) diff --git a/src/main/agent-launch/agent-launch-spawn-contact-loss.ts b/src/main/agent-launch/agent-launch-spawn-contact-loss.ts new file mode 100644 index 00000000000..541738754c3 --- /dev/null +++ b/src/main/agent-launch/agent-launch-spawn-contact-loss.ts @@ -0,0 +1,34 @@ +// Classifies a launch-spawn rejection: did the execution host AFFIRMATIVELY +// report the spawn failed, or did contact with the host break while the request +// was (possibly) in flight? Per docs/reference/ssh-execution-boundary.md, loss +// of contact is never evidence of process death — the relay may have spawned +// the agent before the link dropped — so a contact-loss rejection must settle +// `launch_state_unknown` (pending retained, plain Retry blocked) rather than a +// retryable `spawn_failed` that would let Retry cold-start a duplicate beside a +// live remote agent. + +import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer' + +// Codes minted by createSshDisposalError (ssh-channel-multiplexer): every relay +// request pending when the link drops or the mux is disposed rejects with one +// of these. Neither observes the remote process — the spawn may have landed. +const SSH_DISPOSAL_ERROR_CODES = new Set(['CONNECTION_LOST', 'DISPOSED']) + +/** True when a spawn rejection cannot prove the execution host never spawned + * the agent. Recognizes the established post-dispatch ambiguity marker + * (`agentSessionOperationOutcome: 'unknown'`, stamped wherever the owning code + * already decided the outcome is unprovable) plus the SSH transport rejections + * that can interrupt a dispatched `pty.spawn` (disposal and request timeout). */ +export function isSpawnContactLossError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false + } + const marked = error as { agentSessionOperationOutcome?: unknown; code?: unknown } + if (marked.agentSessionOperationOutcome === 'unknown') { + return true + } + return ( + (typeof marked.code === 'string' && SSH_DISPOSAL_ERROR_CODES.has(marked.code)) || + isSshMuxRequestTimeoutError(error) + ) +} diff --git a/src/main/agent-launch/agent-launch-worktree-transaction.test.ts b/src/main/agent-launch/agent-launch-worktree-transaction.test.ts index 7621393ebb1..85fc3ba3389 100644 --- a/src/main/agent-launch/agent-launch-worktree-transaction.test.ts +++ b/src/main/agent-launch/agent-launch-worktree-transaction.test.ts @@ -18,6 +18,9 @@ import type { } from '../../shared/agent-launch-contract' import type { ExecuteAgentLaunchResult } from './agent-launch-boundary' import type { AdmissionPrincipal } from './agent-launch-admission-store' +import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation' +import { buildReconcileAgentLaunchDeps } from './agent-launch-reconcile-runtime-deps' +import { reconcileOnePendingAgentLaunch } from './agent-launch-worktree-reconcile-writer' const SNAPSHOT: AgentLaunchSnapshot = { version: 1, @@ -222,6 +225,108 @@ describe('runWorktreeAgentLaunchTransaction', () => { expect(persistFailure).toHaveBeenCalledTimes(1) }) + it('maps a mid-spawn contact loss to launch_state_unknown WITHOUT settling', async () => { + // Branch-review HIGH 1: a transport drop mid-spawn is not evidence the host + // never spawned the agent (ssh-execution-boundary). Settling spawn_failed + // would clear the pending and let Retry cold-start a duplicate. + const log: CallLog = [] + const spawn = vi.fn(async () => { + log.push('spawn') + throw Object.assign(new Error('SSH connection lost, reconnecting...'), { + code: 'CONNECTION_LOST' + }) + }) + const { deps, operationStore, settle, persistFailure } = makeDeps({ log, spawn }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + expect(outcome.status).toBe('failed') + if (outcome.status === 'failed') { + expect(outcome.failure).toMatchObject({ code: 'launch_state_unknown', intent: 'interactive' }) + } + // The admission reservation is NOT settled failed and the operation is NOT + // in the settled ledger — nothing has actually settled. + expect(settle).not.toHaveBeenCalled() + expect(operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1')).toBeNull() + // The private pending survives for host-evidence reconciliation, but the + // in-flight guard is released so a reconcile pass may process the token. + expect(operationStore.getPending('tok-1')).not.toBeNull() + expect(operationStore.isSpawnInFlight('tok-1')).toBe(false) + // The durable card is written once; its code blocks the server-side retry + // gate, so no plain Retry can double-launch. + expect(persistFailure).toHaveBeenCalledTimes(1) + expect(retryRecoveryGateForFailureCode('launch_state_unknown')).toEqual({ + kind: 'launch_state_unknown' + }) + }) + + it('treats the post-dispatch ambiguity marker as contact loss', async () => { + const spawn = vi.fn(async () => { + throw Object.assign(new Error('execution_owner_unavailable'), { + agentSessionOperationOutcome: 'unknown' as const + }) + }) + const { deps, operationStore, settle } = makeDeps({ spawn }) + const outcome = await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + expect(outcome.status).toBe('failed') + if (outcome.status === 'failed') { + expect(outcome.failure.code).toBe('launch_state_unknown') + } + expect(settle).not.toHaveBeenCalled() + expect(operationStore.getPending('tok-1')).not.toBeNull() + }) + + it('reconciles the retained pending as launched when the token later proves live', async () => { + // End-to-end no-double-launch proof: contact loss keeps the pending; a + // provider-reconnect re-list that finds the token live ADOPTS the surviving + // terminal (settles launched) instead of ever spawning a second agent. + const spawn = vi.fn(async () => { + throw Object.assign(new Error('lost'), { code: 'CONNECTION_LOST' }) + }) + const { deps, operationStore } = makeDeps({ spawn }) + await runWorktreeAgentLaunchTransaction( + deps, + params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT })) + ) + const retained = operationStore.getPending('tok-1') + expect(retained).not.toBeNull() + + const arm = { + settleLaunched: vi.fn(), + settleFailed: vi.fn(), + markUnknown: vi.fn() + } + const settleBoundary = vi.fn((_token: string, _settlement: 'registered' | 'failed') => {}) + const reconcileDeps = buildReconcileAgentLaunchDeps({ + operationStore, + liveTerminalByToken: () => ({ ptyId: 'ssh-term-1', worktreeId: 'wt-1' }), + isHostAuthoritative: () => true, + isHostTokenAuthoritative: () => true, + expectedWorktreeId: (pending) => pending.scope, + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + }, + settleBoundary, + mintFailureId: () => 'fail-r1', + now: () => 2000 + }) + const outcome = reconcileOnePendingAgentLaunch(reconcileDeps, retained!) + expect(outcome).toEqual({ kind: 'launched' }) + expect(settleBoundary).toHaveBeenCalledWith('tok-1', 'registered') + expect(arm.settleLaunched).toHaveBeenCalledTimes(1) + expect(operationStore.getPending('tok-1')).toBeNull() + expect(operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({ + status: 'launched' + }) + }) + it('marks the token spawn-in-flight for the whole beginPending→settle window', async () => { // Regression (L4-M2): a reconcile pass firing while the spawn is running // must be able to skip this token — the PTY registers it only after spawn diff --git a/src/main/agent-launch/agent-launch-worktree-transaction.ts b/src/main/agent-launch/agent-launch-worktree-transaction.ts index bb1c76491a7..2fa989029e2 100644 --- a/src/main/agent-launch/agent-launch-worktree-transaction.ts +++ b/src/main/agent-launch/agent-launch-worktree-transaction.ts @@ -6,9 +6,12 @@ // synchronous write BEFORE the writer, so a crash mid-spawn still self- // identifies the terminal by token; // 3. spawn exactly ONE PTY from the resolved plan (token travels inside it); -// 4. settle — registered clears pending + records `launched`; any post-create -// failure keeps the workspace, writes a durable `agentLaunchFailure`, and -// records `failed`. No path spawns a substitute blank terminal (I9). +// 4. settle — registered clears pending + records `launched`; a host-attested +// post-create failure keeps the workspace, writes a durable +// `agentLaunchFailure`, and records `failed`. Loss of contact mid-spawn is +// NOT attested (the host may have spawned the agent): it keeps the pending +// and reservation, and writes launch_state_unknown instead of settling. +// No path spawns a substitute blank terminal (I9). // A request error performs no owner-state write. Electron-free and injectable. import type { AgentStartupPlan } from '../../shared/tui-agent-startup' @@ -19,6 +22,7 @@ import type { AgentLaunchRequestError, PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' +import { isSpawnContactLossError } from './agent-launch-spawn-contact-loss' import type { TuiAgent } from '../../shared/types' import type { AgentLaunchBoundary, ExecuteAgentLaunchResult } from './agent-launch-boundary' import type { AdmissionPrincipal } from './agent-launch-admission-store' @@ -189,7 +193,35 @@ export async function runWorktreeAgentLaunchTransaction( try { const spawned = await deps.spawn(plan, receipt) terminalId = spawned.terminalId - } catch { + } catch (error) { + if (isSpawnContactLossError(error)) { + // Contact with the execution host broke while the spawn was (possibly) in + // flight — the host may well have spawned the agent (ssh-execution-boundary: + // a transport failure can only ever produce `unverifiable`). Settling + // `failed` here would clear the pending and hand the user a plain Retry + // that cold-starts a duplicate beside a live remote agent. Instead: keep + // the private pending + admission reservation (coexistence rule, exactly + // like a reconciled launch_state_unknown), release only the in-flight + // guard so provider-reconnect reconciliation may settle this token on real + // host evidence, and persist the non-retryable launch_state_unknown card + // (the server-side retry gate blocks on this code; the client card offers + // reconnect/forget, never plain Retry). No settled ledger entry: the + // operation has NOT settled. + deps.operationStore.releaseSpawnInFlight(receipt.launchToken) + const failure: PersistedAgentLaunchFailure = { + code: 'launch_state_unknown', + requestedAgent: receipt.requestedAgent, + baseAgent: receipt.baseAgent, + version: 1, + failureId: deps.mintFailureId(), + intent: params.intent, + occurredAt: nowFn() + } + // persistFailure also clears the public pending metadata; the durable card + // replaces it client-side while the private snapshot retains attribution. + deps.persistFailure(failure) + return { status: 'failed', failure } + } deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed') // Settled ledger entry (inside persistedFailure) BEFORE clearPending: a // crash between the two durable writes must never lose both the pending diff --git a/src/main/agent-launch/resolve-agent-launch.ts b/src/main/agent-launch/resolve-agent-launch.ts index eb296609ea3..1465b91b6ae 100644 --- a/src/main/agent-launch/resolve-agent-launch.ts +++ b/src/main/agent-launch/resolve-agent-launch.ts @@ -318,7 +318,9 @@ export function resolveAgentLaunch( // Stock-name detection gates only stock catalog argv with no accepted user PATH // override; configured/custom prefixes and custom PATH env cannot be evaluated - // by name detection and proceed to preflight/spawn. + // by name detection and proceed straight to spawn — no executable-existence + // preflight exists, so for those rows the launch itself is the availability + // check ('launch-reported' in the catalog projection). if ( command.prefixSource === 'catalog' && request.detectedStockBaseAgents !== null && diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index 012686b9c5e..621fc4e0780 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -12,8 +12,10 @@ import type { PtyRendererDeliveryStateReport } from '../../shared/pty-renderer-delivery-health' import type { AgentKind, LaunchSource, RequestKind } from '../../shared/telemetry-events' -import type { AgentLaunchNoticeCode } from '../../shared/agent-launch-contract' -import type { PersistedLaunchNoticeState } from '../../shared/agent-launch-contract' +import type { + AgentLaunchNoticeCode, + PersistedLaunchNoticeState +} from '../../shared/agent-launch-contract' import type { AgentLaunchInput, AgentLaunchSpawnOutcome diff --git a/src/preload/index.ts b/src/preload/index.ts index b7b402ef7ad..ba334e126cf 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -438,7 +438,10 @@ import type { AgentLaunchVaultResumeDetailsResult, AgentLaunchVaultResumeEntry } from '../shared/agent-launch-spawn-request' -import type { AgentLaunchNoticeCode } from '../shared/agent-launch-contract' +import type { + AgentLaunchNoticeCode, + PersistedLaunchNoticeState +} from '../shared/agent-launch-contract' import type { ForgetUnknownAgentLaunchResult, WorktreeRetryAgentLaunchResult @@ -463,7 +466,6 @@ import type { MemorySnapshot } from '../shared/process-stats-types' import type { NestedRepoScanResult } from '../shared/project-group-types' import type { BaseRefDefaultResult, BaseRefSearchResult } from '../shared/repo-types' import type { TuiAgent } from '../shared/tui-agent' -import type { PersistedLaunchNoticeState } from '../shared/agent-launch-contract' import type { FloatingTerminalCwdRequest } from '../shared/ui-chrome-types' import type { UpdateStatus } from '../shared/update-status-types' import type { diff --git a/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts b/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts index 1f510e39266..6caabb6c88f 100644 --- a/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts +++ b/src/renderer/src/components/agent/custom-agent-catalog-entries.test.ts @@ -94,7 +94,7 @@ describe('mergeCustomAgentCatalogEntries', () => { expect(custom).toMatchObject({ cmd: '/opt/bin/agent', baseAgent: 'codex' }) }) - it('appends a host-preflighted custom whose base row is absent from the built-in list', () => { + it('appends a launch-reported custom whose base row is absent from the built-in list', () => { const merged = mergeCustomAgentCatalogEntries( [builtIn('claude', 'Claude', 'claude')], snapshot([ diff --git a/src/renderer/src/components/agent/custom-agent-catalog-entries.ts b/src/renderer/src/components/agent/custom-agent-catalog-entries.ts index b8ba0d25064..7cb9e439588 100644 --- a/src/renderer/src/components/agent/custom-agent-catalog-entries.ts +++ b/src/renderer/src/components/agent/custom-agent-catalog-entries.ts @@ -8,7 +8,7 @@ import type { TuiAgent } from '../../../../shared/types' * base harness like the settings catalog and the tab quick-launch surfaces. * * Oracle 35 gating: a baseline-stock custom is offered only when its base is - * detected; a configured-executable/custom-PATH custom is host-preflighted at + * detected; a configured-executable/custom-PATH custom is launch-reported at * launch and never client-gated. A null `detectedAgentIds` means detection is * unknown, so nothing is detection-gated (mirrors the built-in list). Adapted * rows carry the base harness id so AgentCombobox renders the base icon, and @@ -56,7 +56,7 @@ export function mergeCustomAgentCatalogEntries( placed.add(entry.id) } } - // A host-preflighted custom whose base row was filtered out (base disabled or + // A launch-reported custom whose base row was filtered out (base disabled or // undetected) is still launch-eligible, so append it rather than drop it. for (const [base, group] of customsByBase) { if (!placed.has(base)) { diff --git a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts index 95537e863df..b734522a952 100644 --- a/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts +++ b/src/renderer/src/components/right-sidebar/source-control-agent-action-dialog-support.ts @@ -39,7 +39,7 @@ export function isSourceControlAgentUnavailable( } /** Gate on the resolved BASE (custom ids never index detection by their own id) and - * skip detection for host-preflight rows, which baseline stock detection can't evaluate. */ + * skip detection for launch-reported rows, which baseline stock detection can't evaluate. */ export function isSourceControlAgentDetectedAndEnabled( agent: TuiAgent | null, detectedAgents: TuiAgent[], @@ -50,7 +50,7 @@ export function isSourceControlAgentDetectedAndEnabled( return false } return ( - availability.availabilityClass === 'host-preflight' || + availability.availabilityClass === 'launch-reported' || detectedAgents.includes(availability.baseAgent) ) } diff --git a/src/renderer/src/components/settings/agent-catalog-rows.ts b/src/renderer/src/components/settings/agent-catalog-rows.ts index 1882bf8a9f6..6295c2fa6ca 100644 --- a/src/renderer/src/components/settings/agent-catalog-rows.ts +++ b/src/renderer/src/components/settings/agent-catalog-rows.ts @@ -24,7 +24,7 @@ import { } from '../../../../shared/agent-search-query' /** Effective status shown as a single quiet badge (plan §971). Desktop-local - * never surfaces `host-preflight`; that reason is withheld to paired clients. */ + * never surfaces `launch-reported`; that reason is withheld to paired clients. */ export type AgentCatalogRowStatus = | 'enabled' | 'disabled' diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts index 5afd1719a2f..461e73ed6fe 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.test.ts @@ -119,7 +119,7 @@ describe('custom agents in the tab quick-launch list', () => { it('gates a baseline-stock custom on base detection but never gates a configured executable', () => { // Base not detected: the stock-PATH custom drops, the overridden one stays - // (its availability is host-preflighted at launch, oracle 35). + // (its availability is launch-reported, oracle 35). const stock = orderTabLaunchAgents(null, ['claude'], [customEntry]) expect(stock).not.toContain(CUSTOM_ID) const overridden = orderTabLaunchAgents( diff --git a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts index e78b02852d3..9f92c5066eb 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-launch-options.ts @@ -19,7 +19,7 @@ export type TabCustomAgentLaunchEntry = { commandOverride?: string /** Baseline-stock customs launch via the base's PATH binary, so they are * only offered when that base is detected; configured-executable and - * custom-PATH agents are host-preflighted at launch and never gated on + * custom-PATH agents are launch-reported (only the launch itself checks them) and never gated on * client detection (oracle 35). */ requiresDetectedBase: boolean } @@ -88,7 +88,7 @@ export function orderTabLaunchAgents( ordered.push(entry.id) } // Customs group under their base harness (the settings-catalog ordering); - // a host-preflighted custom is offered even when its base binary is not + // a launch-reported custom is offered even when its base binary is not // detected, since the base row's absence says nothing about its override. for (const custom of launchableCustoms) { if (custom.baseAgent === entry.id) { diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts index 986121934ed..3e20d98271d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { CustomTuiAgent } from '../../../../shared/types' import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' +import type { AppState } from '../../store/types' +import { useAppStore } from '../../store' import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing' import { pasteTerminalText } from './terminal-bracketed-paste' import { @@ -158,6 +161,75 @@ describe('resolveProtectedMultilinePasteOptionsForPane', () => { }) }) +describe('custom-agent-owned panes', () => { + // Regression: agent-status rows and pty launch metadata keep the custom id + // unresolved, but TUI_AGENT_CONFIG is keyed by built-in ids — indexing raw + // read `windowsInputRecordPasteNewline` off undefined BEFORE the win32 guard, + // so every multiline paste threw on macOS/Linux too. + const CUSTOM_CODEX_ID = 'custom-agent:codex:3f9f1c22-2a1b-4c33-9a44-55d6e7f8a900' as const + const codexDerived: CustomTuiAgent = { + id: CUSTOM_CODEX_ID, + baseAgent: 'codex', + label: 'Codex (review)', + args: '', + env: {}, + syncEnv: false + } + let originalSettings: AppState['settings'] + + beforeEach(() => { + originalSettings = useAppStore.getState().settings + useAppStore.setState({ + settings: { + customTuiAgents: [codexDerived], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + }) + + afterEach(() => { + useAppStore.setState({ settings: originalSettings }) + }) + + const customStatusRow = { + agentStatusByPaneKey: { [AGENT_PANE_KEY]: agentEntry({ agentType: CUSTOM_CODEX_ID }) } + } + + it('does not throw on a non-Windows host and brackets like its base', () => { + expect(() => decide(customStatusRow)).not.toThrow() + expect(decide(customStatusRow)).toEqual({ forceBracketedPasteForMultiline: true }) + }) + + it('brackets a process-confirmed custom foreground agent with no status row', () => { + const args = { + agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: { + [AGENT_PANE_KEY]: foregroundEntry({ agent: CUSTOM_CODEX_ID }) + } + } + expect(() => decide(args)).not.toThrow() + expect(decide(args)).toEqual({ forceBracketedPasteForMultiline: true }) + }) + + it('inherits the base agent Windows input-record newline', () => { + expect(decide({ hostPlatform: 'win32', ...customStatusRow })).toEqual({ + windowsInputRecordNewline: 'alt-enter' + }) + }) + + it('degrades to plain bracketing when the catalog lost the custom id', () => { + useAppStore.setState({ + settings: { + customTuiAgents: [], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + expect(decide({ hostPlatform: 'win32', ...customStatusRow })).toEqual({ + forceBracketedPasteForMultiline: true + }) + }) +}) + describe('leading-newline paste into a remote agent pane', () => { // Regression: a mac client on a remote Windows host pasted a block starting with "\n". // ConPTY never forwarded DECSET 2004, so xterm rewrote the newline to CR and codex diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts index 6090ba6159a..7565245d4e6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-paste-bracketing.ts @@ -1,6 +1,11 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { + isBuiltInTuiAgent, + isTuiAgent, + TUI_AGENT_CONFIG +} from '../../../../shared/tui-agent-config' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolvePaneOwnerBaseAgent } from '../../lib/agent-base-identity' import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' import type { TerminalPasteTextOptions } from './terminal-paste-model' @@ -81,8 +86,13 @@ export function resolveProtectedMultilinePasteOptionsForAgentEvidence({ // measured live. An idle agent also sits at `done` past the 30-minute freshness // TTL, so neither state nor TTL can gate this either. A false negative sends the // user's parked draft; a false positive only changes encoding within this paste. - const windowsInputRecordPasteNewline = agent - ? TUI_AGENT_CONFIG[agent].windowsInputRecordPasteNewline + // Why base-resolved and guarded: TUI_AGENT_CONFIG is keyed by built-in ids only, + // and `agent` can be a custom id (agent-status rows and pty launch metadata keep + // it unresolved). Indexing raw threw on every multiline paste into a custom pane; + // a catalog-orphaned id keeps `agent` truthy and falls to plain bracketing below. + const baseAgent = resolvePaneOwnerBaseAgent(agent ?? undefined) + const windowsInputRecordPasteNewline = isBuiltInTuiAgent(baseAgent) + ? TUI_AGENT_CONFIG[baseAgent].windowsInputRecordPasteNewline : undefined if (hostPlatform === 'win32' && windowsInputRecordPasteNewline) { return { diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts index 27e0cce41bc..dd4aa014dd3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { CustomTuiAgent } from '../../../../shared/types' +import type { AppState } from '../../store/types' +import { useAppStore } from '../../store' import { hasCtrlEnterCsiUAuthorityForPane } from './terminal-ctrl-enter' const PANE_KEY = 'tab:pane' @@ -64,3 +67,74 @@ describe('hasCtrlEnterCsiUAuthorityForPane', () => { } }) }) + +describe('custom-agent foregrounds', () => { + // Regression: TUI_AGENT_CONFIG is keyed by built-in ids only — indexing with a + // custom foreground id read `ctrlEnterEncoding` off undefined and threw. + const CUSTOM_DROID_ID = 'custom-agent:droid:0f9f1c22-2a1b-4c33-9a44-55d6e7f8a901' as const + const CUSTOM_PI_ID = 'custom-agent:pi:0f9f1c22-2a1b-4c33-9a44-55d6e7f8a902' as const + + function derivedAgent( + id: CustomTuiAgent['id'], + baseAgent: CustomTuiAgent['baseAgent'] + ): CustomTuiAgent { + return { id, baseAgent, label: `${baseAgent} (derived)`, args: '', env: {}, syncEnv: false } + } + + let originalSettings: AppState['settings'] + + beforeEach(() => { + originalSettings = useAppStore.getState().settings + useAppStore.setState({ + settings: { + customTuiAgents: [derivedAgent(CUSTOM_DROID_ID, 'droid'), derivedAgent(CUSTOM_PI_ID, 'pi')], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + }) + + afterEach(() => { + useAppStore.setState({ settings: originalSettings }) + }) + + function trustedState( + agent: CustomTuiAgent['id'] + ): Parameters[0] { + return { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent, routingTrusted: true, shellForeground: false } + } + } + } + + it('a trusted custom agent inherits its base CSI-u capability without throwing', () => { + expect(() => + hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY) + ).not.toThrow() + expect(hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY)).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_PI_ID), PANE_KEY)).toBe(false) + }) + + it('matches a custom foreground against its base committed title', () => { + const state = { + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: CUSTOM_DROID_ID, shellForeground: false } + } + } + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, '⠋ Droid')).toBe(true) + expect(hasCtrlEnterCsiUAuthorityForPane(state, PANE_KEY, 'C:\\work\\grok-project')).toBe(false) + }) + + it('denies CSI-u for a custom id the catalog cannot resolve', () => { + useAppStore.setState({ + settings: { + customTuiAgents: [], + deletedCustomTuiAgents: [] + } as unknown as AppState['settings'] + }) + expect(() => + hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY) + ).not.toThrow() + expect(hasCtrlEnterCsiUAuthorityForPane(trustedState(CUSTOM_DROID_ID), PANE_KEY)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts index 457a1f51beb..11ffa63b1ca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ctrl-enter.ts @@ -1,5 +1,6 @@ -import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' +import { isBuiltInTuiAgent, TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config' import { resolveCommittedTitleAgentType } from '../../lib/pane-agent-evidence' +import { resolvePaneOwnerBaseAgent } from '../../lib/agent-base-identity' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' type CtrlEnterPaneState = { @@ -7,7 +8,10 @@ type CtrlEnterPaneState = { } function agentAcceptsCtrlEnterCsiU(agent: PaneForegroundAgentEntry['agent']): boolean { - return agent !== null && TUI_AGENT_CONFIG[agent].ctrlEnterEncoding === 'csi-u' + // Why base-resolved and guarded: TUI_AGENT_CONFIG is keyed by built-in ids only; + // a custom agent inherits its base's encoding, an unresolvable id gets plain CR. + const baseAgent = resolvePaneOwnerBaseAgent(agent ?? undefined) + return isBuiltInTuiAgent(baseAgent) && TUI_AGENT_CONFIG[baseAgent].ctrlEnterEncoding === 'csi-u' } /** Resolves pane-scoped authority for query-only CSI-u consumers such as Droid and Grok. */ @@ -24,7 +28,9 @@ export function hasCtrlEnterCsiUAuthorityForPane( return agentAcceptsCtrlEnterCsiU(foreground.agent) } const titleAgent = terminalTitle ? resolveCommittedTitleAgentType(terminalTitle) : null - if (foreground?.agent != null && foreground.agent !== titleAgent) { + // Why base-resolved: the veto compares harness identity, and a custom id compares + // raw-false against its own base's committed title, silently dropping CSI-u. + if (foreground?.agent != null && resolvePaneOwnerBaseAgent(foreground.agent) !== titleAgent) { return false } return agentAcceptsCtrlEnterCsiU(titleAgent) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index 29ba3bd06a8..3d6a66ce857 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -4,7 +4,6 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' -import { beforeEach } from 'vitest' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' const useLocalAgentCatalogMock = vi.fn((_options?: { enabled?: boolean }) => ({ diff --git a/src/renderer/src/lib/agent-catalog-custom-display.test.tsx b/src/renderer/src/lib/agent-catalog-custom-display.test.tsx index 860b0cb498d..e6344dd18d1 100644 --- a/src/renderer/src/lib/agent-catalog-custom-display.test.tsx +++ b/src/renderer/src/lib/agent-catalog-custom-display.test.tsx @@ -1,8 +1,7 @@ // @vitest-environment happy-dom -import { render, screen } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { cleanup } from '@testing-library/react' import type { CustomTuiAgentId } from '../../../shared/types' import { AgentIcon, getAgentLabel } from './agent-catalog' import { registerAgentCatalogSettingsSource } from './agent-catalog-settings-source' diff --git a/src/renderer/src/lib/detected-agent-availability.ts b/src/renderer/src/lib/detected-agent-availability.ts index ff3cd44e42c..eddce6a1b4d 100644 --- a/src/renderer/src/lib/detected-agent-availability.ts +++ b/src/renderer/src/lib/detected-agent-availability.ts @@ -7,7 +7,7 @@ import type { TuiAgent } from '../../../shared/types' * * Host detection probes built-in harness binaries only, so a custom id is never * a member of that list: a baseline-stock custom stands on its base harness's - * detection, and one carrying its own executable is host-preflighted at launch + * detection, and one carrying its own executable is launch-reported (only the launch itself checks it) * and never client-gated (oracle 35). Without this, every custom assignment * reads as "not available on this host" and its surface refuses to launch. */ export function isDetectedAgentAvailable( diff --git a/src/renderer/src/lib/source-control-agent-action-plan.test.ts b/src/renderer/src/lib/source-control-agent-action-plan.test.ts index fec39083bad..a2e32599396 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.test.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.test.ts @@ -37,22 +37,22 @@ describe('resolveSourceControlAgentAvailability', () => { }) }) - it('marks a built-in with a configured executable override host-preflight', () => { + it('marks a built-in with a configured executable override launch-reported', () => { expect( resolveSourceControlAgentAvailability( 'claude', settings({ agentCmdOverrides: { claude: '/opt/claude' } }) ) - ).toEqual({ baseAgent: 'claude', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'claude', availabilityClass: 'launch-reported' }) }) - it('marks a built-in with agent env host-preflight', () => { + it('marks a built-in with agent env launch-reported', () => { expect( resolveSourceControlAgentAvailability( 'claude', settings({ agentDefaultEnv: { claude: { API_KEY: 'x' } } }) ) - ).toEqual({ baseAgent: 'claude', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'claude', availabilityClass: 'launch-reported' }) }) it('resolves a plain custom agent to its base, baseline-detection', () => { @@ -64,22 +64,22 @@ describe('resolveSourceControlAgentAvailability', () => { ).toEqual({ baseAgent: 'codex', availabilityClass: 'baseline-detection' }) }) - it('marks a custom agent with a command override host-preflight', () => { + it('marks a custom agent with a command override launch-reported', () => { expect( resolveSourceControlAgentAvailability( CUSTOM_ID, settings({ customTuiAgents: [customAgent({ commandOverride: 'my-codex' })] }) ) - ).toEqual({ baseAgent: 'codex', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'codex', availabilityClass: 'launch-reported' }) }) - it('marks a custom agent with env host-preflight', () => { + it('marks a custom agent with env launch-reported', () => { expect( resolveSourceControlAgentAvailability( CUSTOM_ID, settings({ customTuiAgents: [customAgent({ env: { TOKEN: 'y' } })] }) ) - ).toEqual({ baseAgent: 'codex', availabilityClass: 'host-preflight' }) + ).toEqual({ baseAgent: 'codex', availabilityClass: 'launch-reported' }) }) it('returns a null base for an unknown custom id', () => { @@ -131,11 +131,11 @@ describe('planSourceControlAgentActionLaunch', () => { ).toEqual({ ok: false, error: 'The selected agent was not detected on this workspace host.' }) }) - it('never blocks a host-preflight agent whose base is not detected', () => { + it('never blocks a launch-reported agent whose base is not detected', () => { const result = planSourceControlAgentActionLaunch({ agent: CUSTOM_ID, baseAgent: 'codex', - availabilityClass: 'host-preflight', + availabilityClass: 'launch-reported', commandInput: 'Fix checks', promptDelivery: 'submit-after-ready', detectedAgents: [] diff --git a/src/renderer/src/lib/source-control-agent-action-plan.ts b/src/renderer/src/lib/source-control-agent-action-plan.ts index 04b6f86600a..78712c35fa4 100644 --- a/src/renderer/src/lib/source-control-agent-action-plan.ts +++ b/src/renderer/src/lib/source-control-agent-action-plan.ts @@ -6,9 +6,10 @@ import { translate } from '@/i18n/i18n' // Preview classes mirror the host `availabilityCheck` projection (plan §830/§972): // a stock-prefix launch is gated on baseline detection, while a configured -// executable or agent env is host-preflight and must never be blocked here — -// baseline stock detection cannot evaluate it, and the real launch reports the outcome. -export type SourceControlAgentAvailabilityClass = 'baseline-detection' | 'host-preflight' +// executable or agent env is launch-reported and must never be blocked here — +// baseline stock detection cannot evaluate it, no preflight runs anywhere, and +// only the real launch on the execution host reports the outcome. +export type SourceControlAgentAvailabilityClass = 'baseline-detection' | 'launch-reported' export type SourceControlAgentAvailability = { /** Proven built-in base of the selected id; null for an unknown/unresolvable id. */ @@ -25,7 +26,7 @@ type SourceControlAgentAvailabilitySettings = Partial< /** Resolve the selected agent's proven base and availability class from the local * catalog view. Custom ids resolve their base through the catalog (never id syntax), - * and a configured executable or agent env marks the row host-preflight so a + * and a configured executable or agent env marks the row launch-reported so a * base-not-detected host cannot falsely block it. Zero host projection field added. */ export function resolveSourceControlAgentAvailability( agent: TuiAgent | null | undefined, @@ -46,7 +47,7 @@ export function resolveSourceControlAgentAvailability( ) return { baseAgent, - availabilityClass: usesHostPreflight ? 'host-preflight' : 'baseline-detection' + availabilityClass: usesHostPreflight ? 'launch-reported' : 'baseline-detection' } } const override = settings?.agentCmdOverrides?.[baseAgent] @@ -54,7 +55,7 @@ export function resolveSourceControlAgentAvailability( const usesHostPreflight = Boolean(override || (env && Object.keys(env).length > 0)) return { baseAgent, - availabilityClass: usesHostPreflight ? 'host-preflight' : 'baseline-detection' + availabilityClass: usesHostPreflight ? 'launch-reported' : 'baseline-detection' } } diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index b1480259599..3da324359a5 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -62,7 +62,9 @@ function productionSources(): { path: string; source: string }[] { walk(path) continue } - if (!/\.(ts|tsx)$/.test(entry) || /\.test\.|\.d\.ts$/.test(entry)) { + // `-test-fixtures.ts` carries no `.test.` segment, so name it explicitly: mock + // return values there are scaffolding, not production settle sites. + if (!/\.(ts|tsx)$/.test(entry) || /\.test\.|\.d\.ts$|-test-fixtures?\.tsx?$/.test(entry)) { continue } sources.push({ diff --git a/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts index d4dfbb52b94..0b1ccbb3f42 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-actions.test.ts @@ -25,7 +25,9 @@ const mocks = vi.hoisted(() => ({ moveUnifiedTabToGroup: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), - applyFreshWebSessionTabsSnapshot: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(), + settleWebSessionTabsMirror: vi.fn(), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -44,9 +46,13 @@ vi.mock('../store', () => ({ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, - applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => - mocks.setState(buildPatch), + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return mocks.settleWebSessionTabsMirror + }, resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) @@ -73,7 +79,8 @@ describe('moveWebRuntimeSessionTab', () => { }, setActiveWorktree: mocks.setActiveWorktree }) - mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: true, settlesHostMirror: true }) + mocks.applyWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) }) afterEach(() => { @@ -120,7 +127,8 @@ describe('moveWebRuntimeSessionTab', () => { timeoutMs: 15_000 }) expect(runtimeCall).toHaveBeenCalledTimes(1) - expect(mocks.applyFreshWebSessionTabsSnapshot).not.toHaveBeenCalled() + expect(mocks.decideWebSessionTabsSnapshot).not.toHaveBeenCalled() + expect(mocks.applyWebSessionTabsSnapshot).not.toHaveBeenCalled() }) it('maps mirrored local browser unified ids back to host session tab ids', async () => { @@ -279,7 +287,8 @@ describe('web runtime session tab actions', () => { mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => updater({ state: 'before', activeWorktreeId: WORKTREE_ID }) ) - mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: true, settlesHostMirror: true }) + mocks.applyWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( (_state, args: { tabId: string }) => args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null @@ -363,7 +372,15 @@ describe('web runtime session tab actions', () => { }, timeoutMs: 15_000 }) - expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalled() + // Why: the close's eager list must flow through decide → apply so host + // activation state mirrors locally only once this frame's decision allows it. + expect(mocks.decideWebSessionTabsSnapshot).toHaveBeenCalledWith(makeSnapshot(), ENVIRONMENT_ID) + expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( + { state: 'before', activeWorktreeId: WORKTREE_ID }, + makeSnapshot(), + ENVIRONMENT_ID + ) + expect(mocks.settleWebSessionTabsMirror).toHaveBeenCalled() }) it('supersedes browser focus intent when a terminal is activated next', async () => { @@ -558,9 +575,41 @@ describe('web runtime session tab actions', () => { ENVIRONMENT_ID, WORKTREE_ID ) + // Why: freshness tracking must be released before the authoritative re-list + // is applied, or the republished snapshot would be rejected as stale. expect(mocks.acceptReplayedWebSessionTabsSnapshot.mock.invocationCallOrder[0]).toBeLessThan( - mocks.applyFreshWebSessionTabsSnapshot.mock.invocationCallOrder[0]! + mocks.applyWebSessionTabsSnapshot.mock.invocationCallOrder[0]! ) + expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( + { state: 'before', activeWorktreeId: WORKTREE_ID }, + authoritative, + ENVIRONMENT_ID + ) + }) + + it('discards a snapshot the frame decision rejects without patching local tabs', async () => { + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: false, settlesHostMirror: true }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ id: 'close', ok: true, result: {} }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe(true) + + expect(mocks.decideWebSessionTabsSnapshot).toHaveBeenCalledWith(makeSnapshot(), ENVIRONMENT_ID) + // Why: an outranked answer never rewrites local tabs, but the mirror still + // settles because an equal-or-newer accepted view backs the rejection. + expect(mocks.applyWebSessionTabsSnapshot).not.toHaveBeenCalled() + expect(mocks.settleWebSessionTabsMirror).toHaveBeenCalled() }) it('keeps the close intent when a refused lifecycle close was not republished', async () => { diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts index fc4e893789b..5aaefe55cdb 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-agent-launch.test.ts @@ -26,7 +26,8 @@ const mocks = vi.hoisted(() => ({ moveUnifiedTabToGroup: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), - applyFreshWebSessionTabsSnapshot: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -45,9 +46,13 @@ vi.mock('../store', () => ({ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, - applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => - mocks.setState(buildPatch), + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return () => {} + }, resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts index 108bb8b6cd1..d93aac9e0a0 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-create.test.ts @@ -24,7 +24,9 @@ const mocks = vi.hoisted(() => ({ moveUnifiedTabToGroup: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), focusBrowserTabInWorktree: vi.fn(), - applyFreshWebSessionTabsSnapshot: vi.fn(), + applyWebSessionTabsSnapshot: vi.fn(), + decideWebSessionTabsSnapshot: vi.fn(), + settleWebSessionTabsMirror: vi.fn(), acceptReplayedWebSessionTabsSnapshot: vi.fn(), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), @@ -43,9 +45,13 @@ vi.mock('../store', () => ({ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, - applyFreshWebSessionTabsSnapshot: mocks.applyFreshWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => - mocks.setState(buildPatch), + applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, + decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { + mocks.setState(buildPatch) + // The production caller invokes the returned settle receipt. + return mocks.settleWebSessionTabsMirror + }, resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) @@ -307,11 +313,15 @@ describe('createWebRuntimeSessionTerminal', () => { }, timeoutMs: 15_000 }) - expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledWith( + // Why: activation mirroring is host-authoritative — the post-create list must + // pass through the frame's own decision before it may patch the local store. + expect(mocks.decideWebSessionTabsSnapshot).toHaveBeenCalledWith(snapshot, ENVIRONMENT_ID) + expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith( { state: 'before', activeWorktreeId: WORKTREE_ID }, snapshot, ENVIRONMENT_ID ) + expect(mocks.settleWebSessionTabsMirror).toHaveBeenCalled() }) it('keeps exact legacy ordering when structured creation cannot express afterTabId', async () => { diff --git a/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts b/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts index 34784f9daf5..3fe1802678c 100644 --- a/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts +++ b/src/renderer/src/runtime/web-runtime-session-test-fixtures.ts @@ -13,7 +13,8 @@ export type WebRuntimeSessionTestMocks = { moveUnifiedTabToGroup: Mock setRemoteBrowserPageHandle: Mock focusBrowserTabInWorktree: Mock - applyFreshWebSessionTabsSnapshot: Mock + applyWebSessionTabsSnapshot: Mock + decideWebSessionTabsSnapshot: Mock acceptReplayedWebSessionTabsSnapshot: Mock resolveHostSessionTabIdForWebSessionTab: Mock trackTerminalPaneSplit: Mock @@ -83,7 +84,8 @@ export function primeTerminalSessionState(mocks: WebRuntimeSessionTestMocks): vo activeWorktreeId: WORKTREE_ID }) }) - mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.decideWebSessionTabsSnapshot.mockReturnValue({ apply: true, settlesHostMirror: true }) + mocks.applyWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null) mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true) } diff --git a/src/renderer/src/web/web-agent-catalog-sync.test.ts b/src/renderer/src/web/web-agent-catalog-sync.test.ts index aeb0af980a0..629c6aa3900 100644 --- a/src/renderer/src/web/web-agent-catalog-sync.test.ts +++ b/src/renderer/src/web/web-agent-catalog-sync.test.ts @@ -20,7 +20,7 @@ function hostSnapshot(overrides: Partial = {}): AgentCatal syncEnv: true, status: 'ready', envState: 'available', - availabilityCheck: 'host-preflight' + availabilityCheck: 'launch-reported' } ], deletedCustomAgents: [], diff --git a/src/renderer/src/web/web-agent-catalog-sync.ts b/src/renderer/src/web/web-agent-catalog-sync.ts index 2d96fcb7022..8fdd71e6cc4 100644 --- a/src/renderer/src/web/web-agent-catalog-sync.ts +++ b/src/renderer/src/web/web-agent-catalog-sync.ts @@ -46,11 +46,12 @@ function readyRow(agent: Extract): Lo }, // Env never crosses the wire, so its size is unknowable here; no UI reads it. envSummary: { entryCount: 0, bytes: 0 }, - // `host-preflight` means the host, not stock base detection, vouches for the - // row — keep it out of `baseline-stock` so clients don't gate it on detection. + // `launch-reported` means stock base detection cannot vouch for the row and + // only the launch on the execution host establishes availability — keep it + // out of `baseline-stock` so clients don't gate it on detection. availabilityReason: agent.commandOverride ? 'configured-executable' - : agent.availabilityCheck === 'host-preflight' + : agent.availabilityCheck === 'launch-reported' ? 'custom-path' : 'baseline-stock' } diff --git a/src/shared/agent-catalog-snapshot.ts b/src/shared/agent-catalog-snapshot.ts index e87418766f2..8a0b449ec16 100644 --- a/src/shared/agent-catalog-snapshot.ts +++ b/src/shared/agent-catalog-snapshot.ts @@ -42,7 +42,13 @@ export type SyncedCustomTuiAgent = // Describes host launch capability; keys and values are never projected. envState: 'none' | 'available' | 'withheld' // Conservative remote UX hint; never identifies PATH or another env key. - availabilityCheck: 'baseline-detection' | 'host-preflight' + // 'launch-reported' means baseline stock detection CANNOT vouch for this + // row (configured executable or host-applicable env) and NO preflight runs + // anywhere: availability is only ever established by an actual launch on + // the execution host, which reports its own failure. Clients must not gate + // such rows on baseline detection — and must not treat the value as proof + // the executable exists. + availabilityCheck: 'baseline-detection' | 'launch-reported' }) | { id: CustomTuiAgentId