diff --git a/src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts b/src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts new file mode 100644 index 00000000000..8a4fae0dfac --- /dev/null +++ b/src/main/runtime/orca-runtime-mobile-agent-status-title-truthfulness.test.ts @@ -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 { + const runtime = new OrcaRuntimeService(null, undefined, { + getAgentStatusSnapshot: () => rows + }) + const internals = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise + } + 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 | undefined> { + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + return tab?.type === 'terminal' + ? (tab.agentStatus as unknown as Record | 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 }) + ) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0c339475b68..aa9ee8d557b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 } : {}), diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index f66ff91b7a5..90fe243210d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts b/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts new file mode 100644 index 00000000000..ff9f6c220da --- /dev/null +++ b/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.ts b/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.ts new file mode 100644 index 00000000000..392c1fc180e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/renderer-owned-agent-status-registry.ts @@ -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() + +/** 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() +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts new file mode 100644 index 00000000000..574b4d5cc5b --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -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 + +function makeHostSnapshot(args: { + snapshotVersion: number + hostNow: number + includeAgentStatus: boolean + agentStatusOverrides?: Partial +}): 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) +} + +/** + * 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) + 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[] = [ + { + 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)?') + }) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index f3447e62643..9e878ada53c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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 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 &&