fix(remote-runtime): stop the host tab mirror from fighting renderer-owned agent status

A remote-paired terminal tab flickered several times per second between the
agent's generated title with a running status and a plain "Terminal" tab
reading "Done - Claude", because two writers owned the same state.

The paired client derives agent status from the terminal byte stream, but every
host session.tabs snapshot rebuilt the mirrored tab without the client's
generated title and re-decided status by comparing `updatedAt` stamps taken on
two different machines. The host also read a neutral live title ("Terminal") as
proof the agent had finished, synthesised a `done` entry, and dated it with the
pane's last-output time — so the host frame advanced with every output byte and
always looked newer. Neither writer could win, so the UI alternated forever.

The fix removes the second writer instead of arbitrating two clocks:

- new renderer-owned-agent-status-registry: a pane claims authority where
  shouldOwnAgentStatusInRenderer is decided and proves it on the first
  byte-derived write; registration returns an identity-checked release so a
  superseded mount's late dispose cannot strip a live pane's claim
- mirror keeps a fresh client-owned entry (overwrite and delete paths) while
  still adopting host identity fields; host `blocked`/interactivePrompt states
  pierce the fence because they only reach the host over its HTTP agent hook
- mirrored tabs carry the client generatedTitle, and unified tabEqual compares
  generatedLabel so the two label surfaces cannot diverge
- host projection: only shell/management title evidence may downgrade a
  published status to `done`, and the identity-only fallback is dated by its
  title evidence instead of pty.lastOutputAt

Purely local and plain SSH panes have a single writer and are unaffected.
This commit is contained in:
Jinwoo Hong
2026-08-05 01:33:43 -04:00
parent eebaf47df0
commit 214a4f22fd
7 changed files with 932 additions and 10 deletions
@@ -0,0 +1,211 @@
// issue1-ui-flash (host half): `session.tabs` must not manufacture completion or
// freshness. A neutral live title ('Terminal', an editor, a cwd) is not proof the
// agent finished, and the identity-only fallback must date its evidence instead of
// the byte stream — otherwise the host frame is perpetually newer than a paired
// client's own live status and the pane flaps between the two writers.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { makePaneKey } from '../../shared/stable-pane-id'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const TAB_ID = 'remote-tab'
const WORKTREE_ID = 'wt-1'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
const PTY_ID = 'pty-remote'
const T0 = 1_700_000_000_000
const PROVIDER_SESSION = {
key: 'session_id' as const,
id: 'ac1f6b90-2f77-4f0e-9c5e-1d2f6a4b8c31',
transcriptPath: '/transcripts/ac1f6b90.jsonl'
}
async function createRuntime(rows: AgentStatusIpcPayload[] = []): Promise<OrcaRuntimeService> {
const runtime = new OrcaRuntimeService(null, undefined, {
getAgentStatusSnapshot: () => rows
})
const internals = runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<unknown>
}
vi.spyOn(internals, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({
id: WORKTREE_ID,
path: '/repo/app',
connectionId: null,
repo: null,
folderWorkspace: null
})
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: PTY_ID }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await runtime.createTerminal(`id:${WORKTREE_ID}`, {
tabId: TAB_ID,
leafId: LEAF_ID,
launchAgent: 'claude',
title: 'Terminal'
})
return runtime
}
/** Publish a renderer graph: one terminal pane with a live title and the
* renderer's own byte-derived agent status (what a paired host mirrors). */
function publishRendererWorkingPane(runtime: OrcaRuntimeService, paneTitle: string): void {
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
title: 'Terminal',
activeLeafId: LEAF_ID,
layout: null
}
],
leaves: [
{
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneRuntimeId: 1,
ptyId: PTY_ID,
paneTitle
}
],
mobileSessionTabs: [
{
worktree: WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: `${TAB_ID}::${LEAF_ID}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `${TAB_ID}::${LEAF_ID}`,
parentTabId: TAB_ID,
leafId: LEAF_ID,
title: paneTitle,
launchAgent: 'claude',
agentStatus: {
state: 'working',
prompt: 'Configurar y entender los comprobantes',
updatedAt: T0,
stateStartedAt: T0,
agentType: 'claude',
paneKey: PANE_KEY,
stateHistory: []
},
isActive: true
}
]
}
]
})
}
async function projectAgentStatus(
runtime: OrcaRuntimeService
): Promise<Record<string, unknown> | undefined> {
const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
const tab = result.tabs[0]
return tab?.type === 'terminal'
? (tab.agentStatus as unknown as Record<string, unknown> | undefined)
: undefined
}
describe('mobile session tabs: live-title evidence vs published agent status', () => {
it('keeps renderer-published working under a neutral live title', async () => {
const runtime = await createRuntime()
publishRendererWorkingPane(runtime, 'Terminal')
expect(await projectAgentStatus(runtime)).toEqual(
expect.objectContaining({
state: 'working',
prompt: 'Configurar y entender los comprobantes'
})
)
})
// #1437 preserved: a genuine shell prompt is proof the agent released the pane.
it.each(['zsh', 'bash', 'pwsh'])(
'clears a stale working to identity-only done under the shell title %s',
async (shell) => {
const runtime = await createRuntime()
publishRendererWorkingPane(runtime, shell)
const agentStatus = await projectAgentStatus(runtime)
expect(agentStatus).toEqual(expect.objectContaining({ state: 'done', prompt: '' }))
expect(agentStatus).toHaveProperty('agentType', 'claude')
}
)
it('clears a stale working under a Claude management title', async () => {
const runtime = await createRuntime()
publishRendererWorkingPane(runtime, 'claude agents')
expect(await projectAgentStatus(runtime)).toEqual(
expect.objectContaining({ state: 'done', prompt: '' })
)
})
})
describe('mobile session tabs: identity-only fallback freshness', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(T0)
})
afterEach(() => {
vi.useRealTimers()
})
it('dates the identity-only done by its title evidence, not by output bytes', async () => {
const resumeRow: AgentStatusIpcPayload = {
paneKey: PANE_KEY,
state: 'done',
prompt: '',
agentType: 'claude',
connectionId: null,
receivedAt: T0,
stateStartedAt: T0,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
providerSession: PROVIDER_SESSION,
providerSessionOnly: true
}
const runtime = await createRuntime([resumeRow])
// Title evidence lands once...
runtime.onPtyData(PTY_ID, '\x1b]0;Terminal\x07', T0)
const titleObservedAt = Date.now()
// ...then the pane keeps streaming output with no new status evidence.
vi.setSystemTime(T0 + 60_000)
runtime.onPtyData(PTY_ID, 'building...\r\n', T0 + 60_000)
const agentStatus = await projectAgentStatus(runtime)
expect(agentStatus).toEqual(
expect.objectContaining({
state: 'done',
updatedAt: titleObservedAt,
stateStartedAt: titleObservedAt
})
)
// Republishing after still more output must not refresh the frame.
vi.setSystemTime(T0 + 120_000)
runtime.onPtyData(PTY_ID, 'still building...\r\n', T0 + 120_000)
expect(await projectAgentStatus(runtime)).toEqual(
expect.objectContaining({ updatedAt: titleObservedAt })
)
})
})
+14 -9
View File
@@ -29955,7 +29955,6 @@ export class OrcaRuntimeService {
ownerAgent
)
const liveTitleEvidence = leafTitle ?? ptyTitle
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
// Why: renderer status can precede hook session identity, leaving native chat with no transcript address.
const rendererStatusAgent =
resolveCompatibleAgentTypeForOwner(tab.agentStatus?.agentType, ownerAgent) ??
@@ -29987,11 +29986,14 @@ export class OrcaRuntimeService {
const hasLiveAgentSignal =
normalizedTabAgentStatus?.interactivePrompt != null ||
normalizedTabAgentStatus?.toolName != null
// Why: only shell/management evidence proves the agent released the pane
// (same predicate as the terminal-status API). A merely neutral live title
// — 'Terminal', an editor, a cwd — proves nothing, and treating it as
// completion published a synthetic `done` that fought the client's own
// live status on every republication.
const keepFullAgentStatus =
normalizedTabAgentStatus &&
(liveTitleEvidence === null ||
liveTitleEvidenceClassification === 'agent' ||
hasLiveAgentSignal)
(!terminalTitleBlocksExplicitAgentStatus(liveTitleEvidence) || hasLiveAgentSignal)
const agentStatus = keepFullAgentStatus
? { agentStatus: normalizedTabAgentStatus }
: // Why: idle live title → drop stale "working" (no spinner) but keep agent identity so native chat can still address the transcript.
@@ -30186,9 +30188,12 @@ export class OrcaRuntimeService {
}
}
// Last resort: the pane's hook evidence is identity only (resume rows, stale
// rows, or a row the freshness gate rejected). `done` with a now-stamp is the
// honest projection — and it is what retires the card once the agent exits.
const now = pty?.lastOutputAt ?? Date.now()
// rows, or a row the freshness gate rejected). `done` is the honest
// projection — and it is what retires the card once the agent exits.
// Why not lastOutputAt: this state is title-derived, so it must be dated by
// its evidence. Stamping it with the byte stream made the frame advance on
// every output byte, so a paired client's live status could never outrank it.
const evidenceAt = pty?.lastOscTitleEpochMs ?? hookRow.providerSessionReceivedAt ?? Date.now()
const agentType = ownerAgent ?? undefined
return {
agentStatus: {
@@ -30199,8 +30204,8 @@ export class OrcaRuntimeService {
? 'blocked'
: 'done',
prompt: '',
updatedAt: now,
stateStartedAt: now,
updatedAt: evidenceAt,
stateStartedAt: evidenceAt,
paneKey,
...(terminalHandle ? { terminalHandle } : {}),
...(agentType ? { agentType } : {}),
@@ -320,6 +320,10 @@ import {
registerTerminalSideEffectFactConsumer
} from './terminal-side-effect-facts-handler'
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
import {
markRendererOwnedAgentStatusWrite,
registerRendererOwnedAgentStatusPane
} from './renderer-owned-agent-status-registry'
import type { DirectSshPaneRetryAttempt } from '@/store/slices/direct-ssh-terminal-recovery'
import { directSshAuthoritiesEqual } from '@/store/slices/direct-ssh-terminal-authority-ledger'
@@ -3594,6 +3598,14 @@ export function connectPanePty(
})
: undefined
const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null
// Why: the host also mirrors agent status for this pane through session.tabs.
// Claiming here (decided once at transport creation, like the side-effect
// authority below) lets the mirror keep this renderer's byte-derived status
// instead of overwriting/deleting it on every republication.
const releaseRendererOwnedAgentStatusPane =
runtimeEnvironmentId !== null
? registerRendererOwnedAgentStatusPane(cacheKey, runtimeEnvironmentId)
: null
handleRendererOwnedAgentStatus = (payload): void => {
if (
shouldSuppressCodexAutoApprovalStatus(payload, {
@@ -3620,6 +3632,9 @@ export function connectPanePty(
agentType ?? authoritativePaneAgent
)
: resolvedStatusTitle
// Why: proves the claim — only a pane that really produced byte-derived
// status may fence the host mirror out of its store key.
markRendererOwnedAgentStatusWrite(cacheKey)
if (launchToken) {
currentState.setAgentStatus(cacheKey, statusPayload, statusTitle, undefined, routing, {
launchToken
@@ -9090,6 +9105,9 @@ export function connectPanePty(
reconcileIfSessionMissing,
dispose() {
disposed = true
// Why: a detached client stops observing the pane's bytes, so it must cede
// agent-status authority back to the host on the next mirrored snapshot.
releaseRendererOwnedAgentStatusPane?.()
directSshPaneRetrySettlementCancelled = true
for (const timer of directSshPaneRetrySettlementTimers) {
clearTimeout(timer)
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
_getRendererOwnedAgentStatusPaneCountForTest,
isClientAuthoritativeAgentStatusPane,
markRendererOwnedAgentStatusWrite,
registerRendererOwnedAgentStatusPane,
resetRendererOwnedAgentStatusPanesForTests
} from './renderer-owned-agent-status-registry'
const PANE = 'tab-1:11111111-1111-4111-8111-111111111111'
const OTHER_PANE = 'tab-2:22222222-2222-4222-8222-222222222222'
const ENV = 'web-env-1'
describe('renderer-owned agent status registry', () => {
beforeEach(() => {
resetRendererOwnedAgentStatusPanesForTests()
})
it('is not authoritative until the renderer actually writes status', () => {
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(false)
registerRendererOwnedAgentStatusPane(PANE, ENV)
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(false)
markRendererOwnedAgentStatusWrite(PANE)
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(true)
})
it('ignores writes for panes that never registered', () => {
markRendererOwnedAgentStatusWrite(OTHER_PANE)
expect(isClientAuthoritativeAgentStatusPane(OTHER_PANE)).toBe(false)
expect(_getRendererOwnedAgentStatusPaneCountForTest()).toBe(0)
})
it('cedes authority on teardown and leaks no entry', () => {
const release = registerRendererOwnedAgentStatusPane(PANE, ENV)
markRendererOwnedAgentStatusWrite(PANE)
release()
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(false)
expect(_getRendererOwnedAgentStatusPaneCountForTest()).toBe(0)
// A post-teardown write must not resurrect the claim.
markRendererOwnedAgentStatusWrite(PANE)
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(false)
})
it('keeps the earned claim across a remount in the same environment', () => {
registerRendererOwnedAgentStatusPane(PANE, ENV)
markRendererOwnedAgentStatusWrite(PANE)
registerRendererOwnedAgentStatusPane(PANE, ENV)
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(true)
expect(_getRendererOwnedAgentStatusPaneCountForTest()).toBe(1)
})
it('drops the claim when the pane re-registers under another environment', () => {
registerRendererOwnedAgentStatusPane(PANE, ENV)
markRendererOwnedAgentStatusWrite(PANE)
registerRendererOwnedAgentStatusPane(PANE, 'web-env-2')
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(false)
})
it('scopes authority per pane', () => {
const releasePane = registerRendererOwnedAgentStatusPane(PANE, ENV)
const releaseOther = registerRendererOwnedAgentStatusPane(OTHER_PANE, ENV)
markRendererOwnedAgentStatusWrite(PANE)
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(true)
expect(isClientAuthoritativeAgentStatusPane(OTHER_PANE)).toBe(false)
releasePane()
releaseOther()
expect(_getRendererOwnedAgentStatusPaneCountForTest()).toBe(0)
})
// A replacement mount registers before the superseded pane's dispose runs
// (use-terminal-pane-lifecycle cleanup), and both share `${tabId}:${leafId}`.
it('keeps the successor claim when a superseded pane releases late', () => {
const staleRelease = registerRendererOwnedAgentStatusPane(PANE, ENV)
markRendererOwnedAgentStatusWrite(PANE)
registerRendererOwnedAgentStatusPane(PANE, ENV)
staleRelease()
expect(isClientAuthoritativeAgentStatusPane(PANE)).toBe(true)
expect(_getRendererOwnedAgentStatusPaneCountForTest()).toBe(1)
})
})
@@ -0,0 +1,62 @@
/**
* Panes whose agent status this renderer owns from the PTY byte stream.
*
* Remote-runtime panes have two writers for one store key: the client's own
* OSC pipeline (pty-connection `shouldOwnAgentStatusInRenderer`) and the
* mirrored host `session.tabs` snapshot. Without a fence they overwrite each
* other on every publication and the tab flaps between "working + generated
* title" and "done + Terminal". Registration marks the claim (decided once at
* transport creation); the first byte-derived write proves it, so a mirrored
* pane that never produced status keeps ceding to the host.
*/
type RendererOwnedAgentStatusPane = {
environmentId: string
hasClientWrite: boolean
}
const panesByPaneKey = new Map<string, RendererOwnedAgentStatusPane>()
/** Claims the pane and returns its release; call the release on teardown. */
export function registerRendererOwnedAgentStatusPane(
paneKey: string,
environmentId: string
): () => void {
const existing = panesByPaneKey.get(paneKey)
// Why: a remount re-registers the same pane; keep the earned claim unless the
// pane moved to another runtime environment (then its bytes are a new stream).
const entry: RendererOwnedAgentStatusPane = {
environmentId,
hasClientWrite: existing?.environmentId === environmentId && existing.hasClientWrite
}
panesByPaneKey.set(paneKey, entry)
// Why identity-checked: a replacement mount registers before the superseded
// pane's dispose runs, and paneKey is `${tabId}:${leafId}` — shared across that
// handoff. An unconditional delete would strip the live pane's claim for good,
// since markRendererOwnedAgentStatusWrite never recreates a missing entry.
return () => {
if (panesByPaneKey.get(paneKey) === entry) {
panesByPaneKey.delete(paneKey)
}
}
}
export function markRendererOwnedAgentStatusWrite(paneKey: string): void {
const existing = panesByPaneKey.get(paneKey)
if (!existing || existing.hasClientWrite) {
return
}
existing.hasClientWrite = true
}
/** True once this renderer both claimed the pane and actually wrote its status. */
export function isClientAuthoritativeAgentStatusPane(paneKey: string): boolean {
return panesByPaneKey.get(paneKey)?.hasClientWrite === true
}
export function _getRendererOwnedAgentStatusPaneCountForTest(): number {
return panesByPaneKey.size
}
export function resetRendererOwnedAgentStatusPanesForTests(): void {
panesByPaneKey.clear()
}
@@ -0,0 +1,502 @@
/**
* issue1-ui-flash repro: on a paired client viewing a remote-runtime worktree,
* TWO independent writers own the same store keys for one mirrored pane:
*
* A) the client's own byte-derived pipeline (renderer owns agent status for
* remote panes: pty-connection.ts `shouldOwnAgentStatusInRenderer =
* runtimeEnvironmentId !== null`): setAgentStatus('working', prompt) ->
* setGeneratedTabTitleFromAgentPrompt writes agentStatusByPaneKey +
* tab.generatedTitle (+ unified generatedLabel), all client-clock stamped;
* B) the host `session.tabs` snapshot mirror (applyWebSessionTabsSnapshot):
* every accepted snapshot REBUILDS the mirrored TerminalTab (dropping the
* client-set generatedTitle — buildMirroredTerminalTabs carries only
* default/custom title, color, pin, viewMode) and last-write-wins-merges
* agentStatus by cross-machine wall-clock updatedAt (or DELETES the client
* entry outright when the host surface carries none).
*
* Neither writer is authoritative, so while both keep publishing (the host
* republishes on any host store change; client OSC/hook heartbeats keep
* firing) the client UI alternates between frame A ("Configurar y entender
* ..." + working) and frame B ("Terminal" + "Done - Claude") indefinitely —
* the flash in the user screenshots. The tab bar renders terminal titles from
* tabsByWorktree via resolveTerminalTabTitle (TabBar.tsx:1037), so
* tab.generatedTitle is the VISIBLE label source that flaps.
*
* Suite layout:
* - "single-authority invariant" tests assert convergence. This is the fix
* gate (RED before the client-authority fence existed).
* - "converged sequence pinned" tests were the inverted defect pins: same
* harness, same controlled clocks, now asserting the exact post-fix store
* sequence (one transition, then stability) instead of the alternation.
* - "authority handback" tests cover the other direction: a pane the client
* does NOT own (never wrote / torn down / gone stale) still cedes to the
* host, and host-only state classes still pierce the fence.
*
* The fix is a client-authority fence keyed by pane: pty-connection registers
* the pane when `shouldOwnAgentStatusInRenderer` is decided and marks a write
* on each byte-derived status, so `replayClientOscWorking` below does both —
* that is what the renderer really does for a remote pane.
*
* Clocks are injected everywhere (vi.setSystemTime + explicit snapshot
* timestamps); timing is never the oracle — transition counts/ordering are.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../shared/agent-status-types'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
import { deriveGeneratedTabTitle } from '../../../shared/agent-tab-title'
import { getDefaultSettings } from '../../../shared/constants'
import type { AppState } from '../store/types'
import { createTestStore, makeWorktree, seedStore } from '../store/slices/store-test-helpers'
import {
markRendererOwnedAgentStatusWrite,
registerRendererOwnedAgentStatusPane,
resetRendererOwnedAgentStatusPanesForTests
} from '../components/terminal-pane/renderer-owned-agent-status-registry'
import {
applyFreshWebSessionTabsSnapshot,
resetWebSessionTabsSnapshotFreshnessForTests
} from './web-session-tabs-sync'
// Why: web-session-tabs-sync imports the app-level store singleton; this
// harness drives a createTestStore instance instead, like the sibling suite.
vi.mock('../store', () => ({
useAppStore: {
setState: vi.fn(),
getState: vi.fn(() => ({})),
subscribe: vi.fn(() => () => {})
}
}))
const WT = 'repo1::/path/wt1'
const ENV = 'web-env-1'
const HOST_TAB_ID = 'host-tab-1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const HOST_SURFACE_ID = `${HOST_TAB_ID}::${LEAF_ID}`
const MIRROR_TAB_ID = toWebTerminalSurfaceTabId(HOST_TAB_ID)
const MIRROR_PANE_KEY = makePaneKey(MIRROR_TAB_ID, LEAF_ID)
const HOST_PANE_KEY = makePaneKey(HOST_TAB_ID, LEAF_ID)
const T0 = 1_700_000_000_000
const CYCLES = 3
// The prompt Claude is running on the host (user screenshot: "Configurar y entender ...").
const CLIENT_PROMPT = 'Configurar y entender los comprobantes electronicos del sistema'
const EXPECTED_GENERATED_TITLE = deriveGeneratedTabTitle(CLIENT_PROMPT)!
// Unrelated local pane that must survive every mirror cycle untouched.
const LOCAL_WT = 'repo1::/path/local-wt'
const LOCAL_PANE_KEY = makePaneKey('local-tab-1', LEAF_ID)
type TestStore = ReturnType<typeof createTestStore>
function makeHostSnapshot(args: {
snapshotVersion: number
hostNow: number
includeAgentStatus: boolean
agentStatusOverrides?: Partial<AgentStatusEntry>
}): RuntimeMobileSessionTabsResult {
return {
worktree: WT,
publicationEpoch: 'host-epoch-1',
snapshotVersion: args.snapshotVersion,
activeGroupId: 'host-group-1',
activeTabId: HOST_SURFACE_ID,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: HOST_SURFACE_ID,
// Host has no generated title for this pane, so its resolved surface
// title is the plain default (frame B in the screenshots).
title: 'Terminal',
parentTabId: HOST_TAB_ID,
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-1',
...(args.includeAgentStatus
? {
// Host hook projection: the turn looks finished from the host's
// point of view ("Done - Claude" in the sidebar, frame B).
agentStatus: {
state: 'done' as const,
prompt: '',
updatedAt: args.hostNow,
stateStartedAt: args.hostNow - 1_000,
agentType: 'claude' as const,
paneKey: HOST_PANE_KEY,
tabId: HOST_TAB_ID,
worktreeId: WT,
stateHistory: [],
...args.agentStatusOverrides
}
}
: {})
}
]
}
}
/** Mirrors applyWebSessionTabsStorePatch: build the patch from live state, then setState it. */
function applyHostSnapshot(
store: TestStore,
snapshot: RuntimeMobileSessionTabsResult,
now: number
): void {
const state = store.getState()
const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, now)
// The freshness gate must accept every republished frame (monotonic version).
expect(patch).not.toBe(state)
store.setState(patch as Partial<AppState>)
}
/**
* Byte-identical replay of what the client renderer does for a remote pane on
* a Claude status OSC (pty-connection.ts handleRendererOwnedAgentStatus with
* shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null): timing is
* undefined, so updatedAt is the CLIENT's Date.now().
*/
/** Returns the pane's release, as pty-connection holds it for dispose(). */
function replayClientOscWorking(store: TestStore, clientNow: number): () => void {
vi.setSystemTime(clientNow)
// Exactly what pty-connection does for a remote pane: claim the pane once at
// transport creation, then prove the claim on every byte-derived status.
const release = registerRendererOwnedAgentStatusPane(MIRROR_PANE_KEY, ENV)
markRendererOwnedAgentStatusWrite(MIRROR_PANE_KEY)
store
.getState()
.setAgentStatus(
MIRROR_PANE_KEY,
{ state: 'working', prompt: CLIENT_PROMPT, agentType: 'claude' },
'claude',
undefined,
{ tabId: MIRROR_TAB_ID, worktreeId: WT }
)
return release
}
type Frame = {
source: string
statusState: string | null
tabGeneratedTitle: string | null
visibleTabBarLabel: string | null
unifiedGeneratedLabel: string | null
unifiedLabel: string | null
agentStatusEpoch: number
}
function captureFrame(store: TestStore, source: string): Frame {
const s = store.getState()
const tab = (s.tabsByWorktree[WT] ?? []).find((t) => t.id === MIRROR_TAB_ID)
const unified = (s.unifiedTabsByWorktree[WT] ?? []).find((t) => t.id === MIRROR_TAB_ID)
return {
source,
statusState: s.agentStatusByPaneKey[MIRROR_PANE_KEY]?.state ?? null,
tabGeneratedTitle: tab?.generatedTitle ?? null,
// What the tab bar shows: resolveTerminalTabTitle precedence for this
// fixture reduces to generatedTitle || title.
visibleTabBarLabel: tab?.generatedTitle ?? tab?.title ?? null,
unifiedGeneratedLabel: unified?.generatedLabel ?? null,
unifiedLabel: unified?.label ?? null,
agentStatusEpoch: s.agentStatusEpoch
}
}
function countTransitions(values: (string | null)[]): number {
let transitions = 0
for (let i = 1; i < values.length; i += 1) {
if (values[i] !== values[i - 1]) {
transitions += 1
}
}
return transitions
}
function seedPairedClientStore(): TestStore {
const store = createTestStore()
seedStore(store, {
settings: {
...getDefaultSettings('/tmp'),
tabAutoGenerateTitle: true
},
worktreesByRepo: {
repo1: [
makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' }),
makeWorktree({ id: LOCAL_WT, repoId: 'repo1', path: '/path/local-wt' })
]
},
activeWorktreeId: WT
} as Partial<AppState>)
return store
}
/**
* Drives the dual-publication interleaving with fully controlled clocks:
* host and client each keep emitting with normally advancing timestamps
* (zero cross-machine skew required). Returns one frame per publication.
*/
function runDualPublicationCycles(store: TestStore, includeAgentStatus: boolean): Frame[] {
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 1, hostNow: T0 - 1_000, includeAgentStatus }),
T0
)
const frames: Frame[] = [captureFrame(store, 'host-snapshot-1')]
for (let cycle = 1; cycle <= CYCLES; cycle += 1) {
// Client OSC heartbeat: Claude is mid-turn per the client's byte stream.
replayClientOscWorking(store, T0 + cycle * 1_000 - 500)
frames.push(captureFrame(store, `client-osc-${cycle}`))
// Host republishes (any host store change republishes; the hook row
// re-delivers `done` with a fresh host wall-clock updatedAt).
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 1 + cycle,
hostNow: T0 + cycle * 1_000,
includeAgentStatus
}),
T0 + cycle * 1_000
)
frames.push(captureFrame(store, `host-snapshot-${1 + cycle}`))
}
return frames
}
describe('remote-paired pane: host snapshot mirror vs client byte-derived status/title (issue1-ui-flash)', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(T0)
resetWebSessionTabsSnapshotFreshnessForTests()
resetRendererOwnedAgentStatusPanesForTests()
})
afterEach(() => {
vi.useRealTimers()
resetRendererOwnedAgentStatusPanesForTests()
})
describe('single-authority invariant (fix gate — RED on current main)', () => {
it('agent status and visible tab label converge to one authoritative value under dual publication', () => {
const store = seedPairedClientStore()
const frames = runDualPublicationCycles(store, true)
const evidence = `observed publication frames:\n${JSON.stringify(frames, null, 2)}`
// Disagreement begins at the first client byte-derived write (frame 1).
// A single-authority design allows at most one more transition after
// that (whichever side is authoritative wins and sticks). Current main
// flips on EVERY publication: 2 * CYCLES status transitions and
// 2 * CYCLES visible-label transitions.
const statusTransitionsAfterDisagreement = countTransitions(
frames.slice(1).map((f) => f.statusState)
)
const visibleLabelTransitionsAfterDisagreement = countTransitions(
frames.slice(1).map((f) => f.visibleTabBarLabel)
)
expect(statusTransitionsAfterDisagreement, evidence).toBeLessThanOrEqual(1)
expect(visibleLabelTransitionsAfterDisagreement, evidence).toBeLessThanOrEqual(1)
})
it('a host surface without agentStatus does not delete the client-owned fresher live entry', () => {
const store = seedPairedClientStore()
// Initial mirror: host publishes the pane with NO hook status at all.
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 1, hostNow: T0, includeAgentStatus: false }),
T0
)
// Client byte pipeline: agent is working; client clock strictly AHEAD of
// anything the host ever published, so no freshness rule can justify a wipe.
replayClientOscWorking(store, T0 + 1_000)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working')
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 2, hostNow: T0, includeAgentStatus: false }),
T0 + 1_500
)
const entryAfterMirror = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]
// RED on main: buildMirroredAgentStatusPatch deletes the live entry
// because the host surface carries no agentStatus for the pane key.
expect(entryAfterMirror?.state, 'host mirror deleted the fresher client live entry').toBe(
'working'
)
})
})
describe('converged sequence pinned (inverted defect pins — deterministic post-fix)', () => {
it('settles on the client-owned status and generated label and stops flipping on republication', () => {
const store = seedPairedClientStore()
expect(EXPECTED_GENERATED_TITLE).toBeTruthy()
// Unrelated local pane written by the local pipeline; must never be touched.
store
.getState()
.setAgentStatus(
LOCAL_PANE_KEY,
{ state: 'working', prompt: 'unrelated local work', agentType: 'codex' },
undefined,
{ updatedAt: T0 - 10_000 },
{ tabId: 'local-tab-1', worktreeId: LOCAL_WT }
)
const localEntryBefore = store.getState().agentStatusByPaneKey[LOCAL_PANE_KEY]
expect(localEntryBefore?.state).toBe('working')
const frames = runDualPublicationCycles(store, true)
// Exact post-fix sequence: the host frame owns the pane until the client's
// byte pipeline claims it, and every later republication is a no-op —
// including the unified generatedLabel, which now tracks the terminal tab
// instead of diverging behind the equality bail.
const expectedFrames: Partial<Frame>[] = [
{
source: 'host-snapshot-1',
statusState: 'done',
tabGeneratedTitle: null,
visibleTabBarLabel: 'Terminal',
unifiedGeneratedLabel: null,
unifiedLabel: 'Terminal'
}
]
for (let cycle = 1; cycle <= CYCLES; cycle += 1) {
for (const source of [`client-osc-${cycle}`, `host-snapshot-${1 + cycle}`]) {
expectedFrames.push({
source,
statusState: 'working',
tabGeneratedTitle: EXPECTED_GENERATED_TITLE,
visibleTabBarLabel: EXPECTED_GENERATED_TITLE,
unifiedGeneratedLabel: EXPECTED_GENERATED_TITLE,
unifiedLabel: 'Terminal'
})
}
}
expect(frames.map(({ agentStatusEpoch: _epoch, ...frame }) => frame)).toEqual(expectedFrames)
expect(countTransitions(frames.map((f) => f.statusState))).toBe(1)
expect(countTransitions(frames.map((f) => f.visibleTabBarLabel))).toBe(1)
// No re-render/re-sort churn: a host republication that changes nothing
// must not bump the global agent-status epoch.
for (const [index, frame] of frames.entries()) {
if (frame.source.startsWith('host-snapshot-') && index > 0) {
expect(frame.agentStatusEpoch, frame.source).toBe(frames[index - 1]!.agentStatusEpoch)
}
}
// Negative safety: the unrelated local pane survives all mirror cycles untouched.
expect(store.getState().agentStatusByPaneKey[LOCAL_PANE_KEY]).toEqual(localEntryBefore)
})
it('keeps the client live status and generated title when the host surface has no agentStatus', () => {
const store = seedPairedClientStore()
const frames = runDualPublicationCycles(store, false)
// The hook-only host publishes nothing for an OSC-driven pane; that is not
// proof the agent stopped, so the client entry survives every cycle.
const statusSequence = frames.map((f) => f.statusState)
expect(statusSequence).toEqual([
null,
'working',
'working',
'working',
'working',
'working',
'working'
])
expect(countTransitions(statusSequence)).toBe(1)
expect(frames.map((f) => f.tabGeneratedTitle)).toEqual([
null,
...Array.from({ length: 2 * CYCLES }, () => EXPECTED_GENERATED_TITLE)
])
})
})
// The fence must be narrow: it only holds while THIS renderer is proving it
// owns the pane's bytes, and it never hides host-only state classes.
describe('authority handback', () => {
it('does not fence a pane that claimed authority but never wrote byte-derived status', () => {
const store = seedPairedClientStore()
registerRendererOwnedAgentStatusPane(MIRROR_PANE_KEY, ENV)
store
.getState()
.setAgentStatus(
MIRROR_PANE_KEY,
{ state: 'working', prompt: CLIENT_PROMPT, agentType: 'claude' },
'claude',
{ updatedAt: T0 },
{ tabId: MIRROR_TAB_ID, worktreeId: WT }
)
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 1, hostNow: T0 + 1_000, includeAgentStatus: true }),
T0 + 1_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('done')
})
it('cedes back to the host once the pane is torn down', () => {
const store = seedPairedClientStore()
const release = replayClientOscWorking(store, T0 + 500)
// pty-connection.dispose(): a detached client stops observing the bytes.
release()
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 2, hostNow: T0 + 1_000, includeAgentStatus: true }),
T0 + 1_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('done')
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 3, hostNow: T0 + 2_000, includeAgentStatus: false }),
T0 + 2_000
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toBeUndefined()
})
it('lets the stale boundary release a client "working" the agent never closed', () => {
const store = seedPairedClientStore()
replayClientOscWorking(store, T0)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working')
// The agent died OSC-silent: no further client write, no host status.
const afterStale = T0 + AGENT_STATUS_STALE_AFTER_MS + 1
applyHostSnapshot(
store,
makeHostSnapshot({ snapshotVersion: 2, hostNow: afterStale, includeAgentStatus: false }),
afterStale
)
expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]).toBeUndefined()
})
it('lets a host permission block pierce the fence (hook-HTTP-only on the host)', () => {
const store = seedPairedClientStore()
replayClientOscWorking(store, T0 + 500)
const blockedAt = T0 + 1_000
applyHostSnapshot(
store,
makeHostSnapshot({
snapshotVersion: 2,
hostNow: blockedAt,
includeAgentStatus: true,
agentStatusOverrides: {
state: 'blocked',
prompt: 'Allow Bash(rm -rf)?',
interactivePrompt: 'Allow Bash(rm -rf)?'
}
}),
blockedAt
)
const entry = store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]
expect(entry?.state).toBe('blocked')
expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?')
})
})
})
@@ -33,6 +33,7 @@ import { getRemoteRuntimePtyEnvironmentId, toRemoteRuntimePtyId } from './runtim
import { sanitizeTerminalLayoutPaneTitlesForLabels } from '@/lib/terminal-pane-title-sanitization'
import { terminalLayoutEqual } from '@/lib/terminal-layout-equality'
import { normalizeTerminalLayoutPtyOwnership } from '@/components/terminal-pane/terminal-layout-pty-ownership'
import { isClientAuthoritativeAgentStatusPane } from '@/components/terminal-pane/renderer-owned-agent-status-registry'
import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { getReachableRuntimeSessionMirrorTargets } from '@/lib/runtime-session-mirror-targets'
import {
@@ -649,6 +650,9 @@ function buildMirroredTerminalTabs(
worktreeId: snapshot.worktree,
title,
defaultTitle: existing?.defaultTitle ?? title,
// Why: the host transport carries no generated title, so rebuilding the tab
// without this dropped the client's agent-prompt label on every snapshot.
...(existing?.generatedTitle ? { generatedTitle: existing.generatedTitle } : {}),
...(quickCommandLabel ? { quickCommandLabel } : {}),
...(startupCwd ? { startupCwd } : {}),
customTitle: existing?.customTitle ?? null,
@@ -703,6 +707,28 @@ function isMirroredAgentPaneKeyForTabs(paneKey: string, tabIds: ReadonlySet<stri
return parsed !== null && tabIds.has(parsed.tabId)
}
/** Host states the client's byte pipeline cannot observe: permission blocks and
* interactive question cards reach the host over its HTTP agent hook, never
* through PTY bytes, so they must pierce the client-authority fence. */
function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean {
return entry.state === 'blocked' || entry.interactivePrompt != null
}
/** True while this renderer's own byte-derived status owns the pane: it claimed
* the pane at transport creation, wrote status from bytes, and that write has
* not gone stale (an OSC-silent dead agent hands the pane back to the host). */
function isFencedClientAgentStatus(
paneKey: string,
existing: AgentStatusEntry | undefined,
now: number
): existing is AgentStatusEntry {
return (
existing !== undefined &&
isClientAuthoritativeAgentStatusPane(paneKey) &&
isAgentStatusFresh(existing, now)
)
}
/** Generates a state patch for mirrored agent statuses, merging host entries with client overrides. */
function buildMirroredAgentStatusPatch(
state: WebSessionTabsSyncState,
@@ -753,8 +779,15 @@ function buildMirroredAgentStatusPatch(
entry.state === 'done' &&
existing.state !== 'done' &&
existing.stateStartedAt > entry.stateStartedAt
// Why: cross-machine wall clocks are not comparable, so the host frame could
// outrank live client status forever; a proven client writer keeps its own
// state (still adopting the host's identity fields below) unless the host
// carries a state class the client's bytes can never see.
const clientOwnsEntry =
isFencedClientAgentStatus(entry.paneKey, existing, now) &&
!hostAgentStatusPiercesClientAuthority(entry)
const nextEntry =
existing && existing.updatedAt > entry.updatedAt
existing && (clientOwnsEntry || existing.updatedAt > entry.updatedAt)
? {
...normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType),
paneKey: entry.paneKey,
@@ -780,6 +813,12 @@ function buildMirroredAgentStatusPatch(
if (nextByPaneKey.has(paneKey)) {
continue
}
// Why: the host surface carrying no status is not proof the agent stopped —
// hook-only hosts publish nothing for OSC-driven panes. Keep a live entry
// this renderer owns; it decays through the normal freshness boundary.
if (isFencedClientAgentStatus(paneKey, state.agentStatusByPaneKey[paneKey], now)) {
continue
}
if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) {
nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey }
}
@@ -1624,6 +1663,9 @@ function tabEqual(a: Tab, b: Tab): boolean {
a.worktreeId === b.worktreeId &&
a.contentType === b.contentType &&
a.label === b.label &&
// Why: the generated label is the visible tab title; ignoring it let the
// equality bail keep a unified tab that disagreed with its terminal tab.
a.generatedLabel === b.generatedLabel &&
a.customLabel === b.customLabel &&
a.color === b.color &&
a.sortOrder === b.sortOrder &&