Handle spawn contact loss as unknown state, not retryable failure

- When contact with the execution host breaks during spawn, loss of contact is
  not evidence the spawn failed; the host may have spawned the agent. Retain
  the pending operation and admission reservation for reconciliation to settle on
  real host evidence instead of settling the operation failed, which would let
  plain Retry cold-start a duplicate beside a live remote agent.

- Rename availabilityCheck from 'host-preflight' to 'launch-reported': no
  executable-existence preflight runs anywhere; availability is established only
  by the launch itself on the execution host. Clients must not gate such rows on
  baseline detection.

- Fix custom agent ID resolution in terminal pane operations (paste bracketing,
  ctrl-enter input encoding): these can be owned by custom IDs, but TUI_AGENT_CONFIG
  is keyed by built-in IDs. Resolve to base agent before indexing, falling back
  to baseline behavior when the catalog is orphaned.
This commit is contained in:
Jinjing
2026-09-01 03:54:16 -07:00
parent 83e1a5d213
commit b617aedf51
37 changed files with 571 additions and 83 deletions
@@ -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."
}
]
@@ -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' },
@@ -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', () => {
@@ -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'
}
}
@@ -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', () => {
@@ -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
}
@@ -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 {
@@ -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)
})
})
@@ -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)
)
}
@@ -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
@@ -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
@@ -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 &&
+4 -2
View File
@@ -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
+4 -2
View File
@@ -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 {
@@ -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([
@@ -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)) {
@@ -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)
)
}
@@ -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'
@@ -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(
@@ -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) {
@@ -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
@@ -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 {
@@ -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<typeof hasCtrlEnterCsiUAuthorityForPane>[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)
})
})
@@ -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)
@@ -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 }) => ({
@@ -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'
@@ -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(
@@ -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: []
@@ -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'
}
}
@@ -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({
@@ -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 () => {
@@ -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
}))
@@ -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 () => {
@@ -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)
}
@@ -20,7 +20,7 @@ function hostSnapshot(overrides: Partial<AgentCatalogSnapshot> = {}): AgentCatal
syncEnv: true,
status: 'ready',
envState: 'available',
availabilityCheck: 'host-preflight'
availabilityCheck: 'launch-reported'
}
],
deletedCustomAgents: [],
@@ -46,11 +46,12 @@ function readyRow(agent: Extract<SyncedCustomTuiAgent, { status: 'ready' }>): 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'
}
+7 -1
View File
@@ -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