diff --git a/mobile/src/session/mobile-session-tab-status-clock.test.ts b/mobile/src/session/mobile-session-tab-status-clock.test.ts index bc542a2a8e1..667ff47b347 100644 --- a/mobile/src/session/mobile-session-tab-status-clock.test.ts +++ b/mobile/src/session/mobile-session-tab-status-clock.test.ts @@ -1,4 +1,19 @@ import { describe, expect, it } from 'vitest' +import { deepStrictEqual } from 'node:assert/strict' +import { + createSessionTabsTabDeltaDecoder, + createSessionTabsTabDeltaEncoder +} from '../../../src/shared/session-tabs-tab-delta' +import { SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY } from '../../../src/shared/session-tabs-runtime-capabilities' +import { shouldClearNativeChatWorkingSuppression } from '../../../src/shared/native-chat/native-chat-working-suppression' +import { isRpcResponse } from '../transport/rpc-response-shape' +import { resolveMobileTerminalTabAgentId } from './mobile-terminal-tab-agent' +import { + canShowMobileNativeChat, + resolveMobileNativeChat, + withRememberedNativeChatIdentity +} from './native-chat/mobile-native-chat-eligibility' +import { shouldDismissKeyboardAfterTerminalSend } from './agent-send-keyboard-dismissal' import { agentDotState } from '../worktree/agent-row-display' import type { MobileSessionTab } from './mobile-session-route-types' import { @@ -48,6 +63,87 @@ function dot(tab: MobileSessionTab | undefined, now: number) { } describe('session tab status on the phone clock', () => { + it('preserves phone display and action inputs with optional origins through the wire codec', () => { + const base = terminal(PHONE_NOW - MINUTE) + if (base.type !== 'terminal' || !base.agentStatus) { + throw new Error('Expected terminal status') + } + const status = { + ...base.agentStatus, + agentType: 'claude', + providerSession: { key: 'session_id' as const, id: 'session-1' } + } + const results = [[], [SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY]].flatMap((capabilities) => { + const encoder = createSessionTabsTabDeltaEncoder(capabilities) + const decoder = createSessionTabsTabDeltaDecoder() + return [undefined, PHONE_NOW - 10 * MINUTE].map((origin, version) => { + const tab = { + ...base, + agentStatus: { + ...status, + ...(origin === undefined ? {} : { firstStateStartedAt: origin }) + } + } + const frame = { + type: 'snapshot', + worktree: 'wt-1', + snapshotVersion: version + 1, + tabs: [tab, { ...base, id: 'quiet' }], + activeTabId: tab.id, + activeTabType: 'terminal' as const + } + const sent = version === 0 ? encoder.full(frame) : encoder.update(frame) + expect(Object.hasOwn(sent, 'tabsDelta')).toBe(capabilities.length > 0 && version > 0) + const response: unknown = JSON.parse( + JSON.stringify({ id: 'stream-1', ok: true, result: sent }) + ) + if (!isRpcResponse(response) || !response.ok) { + throw new Error('Expected accepted RPC envelope') + } + const decoded = decoder.decode(response.result) + deepStrictEqual(decoded, { ok: true as const, result: frame }) + const received = withRememberedNativeChatIdentity( + withSessionTabStatusReceipts(decoded.result.tabs, [], PHONE_NOW), + [] + )[0] + if (received.type !== 'terminal' || !received.agentStatus) { + throw new Error('Expected received terminal status') + } + return { + dot: dot(received, PHONE_NOW), + agent: resolveMobileTerminalTabAgentId(received), + chat: resolveMobileNativeChat(received), + sendAccepted: shouldDismissKeyboardAfterTerminalSend(received, true), + chatAllowed: canShowMobileNativeChat(received), + stopEpoch: received.agentStatus.stateStartedAt, + clearsStop: shouldClearNativeChatWorkingSuppression({ + working: received.agentStatus.state === 'working', + interrupted: true, + workingEpoch: received.agentStatus.stateStartedAt, + previousWorkingEpoch: status.stateStartedAt + }) + } + }) + }) + expect(results).toEqual( + Array.from({ length: 4 }, () => ({ + dot: 'working', + agent: 'claude', + chat: { + agent: 'claude', + sessionId: 'session-1', + sessionLive: true, + transcriptPath: null, + agentConfirmed: true + }, + sendAccepted: true, + chatAllowed: true, + stopEpoch: status.stateStartedAt, + clearsStop: false + })) + ) + }) + it.each([ ['an hour behind', -60 * MINUTE], ['an hour ahead', 60 * MINUTE] diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts index ff2512ea595..16ebeeb1706 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.test.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.test.ts @@ -1,14 +1,27 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildWorktreeAgentRows } from '../sidebar/worktree-agent-rows' import { useAppStore } from '@/store' import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-pane-authority' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api' +import { + ENV, + LEAF_ID, + WT, + makeSnapshot, + resetWebSessionTabsSyncTestState +} from '@/runtime/web-session-tabs-sync-test-harness' import type { Repo } from '../../../../shared/repo-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' -import { collectRetainedAgentsOnDisappear, useRetainedAgentsSync } from './useRetainedAgents' +import { + buildRetainedAgentsSyncSnapshot, + collectRetainedAgentsOnDisappear, + useRetainedAgentsSync +} from './useRetainedAgents' const initialAppState = useAppStore.getInitialState() @@ -98,6 +111,97 @@ function makeAgentRow(args: { } describe('collectRetainedAgentsOnDisappear', () => { + it('carries paired retained order through same-session return and resets known replacement', () => { + resetWebSessionTabsSyncTestState() + useAppStore.setState({ + repos: [makeRepo()], + worktreesByRepo: { 'repo-1': [makeWorktree({ id: WT })] } + }) + const entry: AgentStatusEntry = { + ...makeAgentRow({ paneKey: makePaneKey('host-tab-1', LEAF_ID), state: 'done' }).entry, + worktreeId: WT, + providerSession: { key: 'session_id', id: 'A' } + } + const quiet: AgentStatusEntry = { + ...entry, + paneKey: makePaneKey('host-tab-2', LEAF_ID), + state: 'working', + updatedAt: 2000, + stateStartedAt: 2000, + providerSession: { key: 'session_id', id: 'quiet' } + } + const update = (agentStatus: AgentStatusEntry | undefined, version: number) => { + const tabs = [agentStatus, quiet].map((status, index) => ({ + type: 'terminal' as const, + id: `host-tab-${index + 1}::${LEAF_ID}`, + title: 'Claude', + parentTabId: `host-tab-${index + 1}`, + leafId: LEAF_ID, + isActive: index === 0, + status: 'ready' as const, + terminal: `terminal-${index + 1}`, + agentStatus: status + })) + useAppStore.setState( + applyWebSessionTabsSnapshot( + useAppStore.getState(), + makeSnapshot(tabs, { snapshotVersion: version }), + ENV, + 1_000_000 + version + ) + ) + } + const snapshot = () => + buildRetainedAgentsSyncSnapshot({ + repos: [makeRepo()], + worktreesByRepo: { 'repo-1': [makeWorktree({ id: WT })] }, + folderWorkspaces: [], + tabsByWorktree: useAppStore.getState().tabsByWorktree, + agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey, + now: 1_000_000 + }) + const rows = () => + buildWorktreeAgentRows({ + tabs: useAppStore.getState().tabsByWorktree[WT] ?? [], + entries: Object.values(useAppStore.getState().agentStatusByPaneKey), + retained: Object.values(useAppStore.getState().retainedAgentsByPaneKey), + now: 1_000_000 + }) + update({ ...entry, updatedAt: 1000, stateStartedAt: 1000, state: 'working' }, 1) + update({ ...entry, updatedAt: 5000, stateStartedAt: 5000 }, 2) + const previous = snapshot() + update(undefined, 3) + const current = snapshot() + const result = collectRetainedAgentsOnDisappear({ + previousAgents: previous.currentAgents, + currentAgents: current.currentAgents, + retainedAgentsByPaneKey: useAppStore.getState().retainedAgentsByPaneKey, + retentionSuppressedPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {}, + recentlyRetiredAgentStatusPaneKeys: {}, + tabIndex: current.tabIndex + }) + expect(result.toRetain).toHaveLength(1) + useAppStore.getState().retainAgents(result.toRetain) + expect(rows().map((row) => row.startedAt)).toEqual([1000, 2000]) + update({ ...entry, updatedAt: 6000, stateStartedAt: 6000, state: 'working' }, 4) + expect(rows().map((row) => row.entry.providerSession?.id)).toEqual(['A', 'quiet']) + expect(rows().map((row) => row.startedAt)).toEqual([1000, 2000]) + update(undefined, 5) + update( + { + ...entry, + updatedAt: 7000, + stateStartedAt: 6000, + state: 'working', + providerSession: { key: 'session_id', id: 'B' } + }, + 6 + ) + expect(rows().map((row) => row.entry.providerSession?.id)).toEqual(['quiet', 'B']) + expect(rows().map((row) => row.startedAt)).toEqual([2000, 7000]) + }) + it('retains a clean done row when a different tab closed', () => { const previousAgents = new Map([ ['tab-1:1', { row: makeAgentRow({ paneKey: 'tab-1:1', state: 'done' }), worktreeId: 'wt-1' }] @@ -436,4 +540,55 @@ describe('useRetainedAgentsSync', () => { expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBeUndefined() hook.unmount() }) + + it('keeps a wrapped-history completion in position through disappearance retention', () => { + const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + const store = useAppStore.getState() + store.setAgentStatus( + paneKey, + { state: 'working', prompt: 'task', agentType: 'pi' }, + undefined, + { + updatedAt: 1000 + } + ) + for (let i = 0; i < 23; i++) { + store.setAgentStatus( + paneKey, + { state: i % 2 ? 'working' : 'done', prompt: 'task', agentType: 'pi' }, + undefined, + { updatedAt: 3000 + i * 1000 } + ) + } + const entry = useAppStore.getState().agentStatusByPaneKey[paneKey] + expect(entry.stateHistory[0].startedAt).toBeGreaterThan(2000) + const previous = buildRetainedAgentsSyncSnapshot({ + repos: [makeRepo()], + worktreesByRepo: { 'repo-1': [makeWorktree()] }, + folderWorkspaces: [], + tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1' })] }, + agentStatusByPaneKey: { [paneKey]: entry }, + now: 30000 + }) + const result = collectRetainedAgentsOnDisappear({ + previousAgents: previous.currentAgents, + currentAgents: new Map(), + retainedAgentsByPaneKey: {}, + retentionSuppressedPaneKeys: {}, + recentlyClosedAgentStatusTabIds: {}, + recentlyRetiredAgentStatusPaneKeys: {} + }) + expect(result.toRetain[0]?.startedAt).toBe(1000) + const quiet = makeAgentRow({ + paneKey: makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222'), + state: 'working' + }).entry + const rows = buildWorktreeAgentRows({ + tabs: [makeTab({ id: 'tab-2' })], + entries: [{ ...quiet, stateStartedAt: 2000 }], + retained: result.toRetain, + now: 30000 + }) + expect(rows.map((row) => row.paneKey)).toEqual([paneKey, quiet.paneKey]) + }) }) diff --git a/src/renderer/src/components/dashboard/useRetainedAgents.ts b/src/renderer/src/components/dashboard/useRetainedAgents.ts index 412ba7cabfe..bc9579f7694 100644 --- a/src/renderer/src/components/dashboard/useRetainedAgents.ts +++ b/src/renderer/src/components/dashboard/useRetainedAgents.ts @@ -1,3 +1,4 @@ +import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time' import { useEffect, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' @@ -94,7 +95,7 @@ function buildLiveTabIndex(args: { } function agentStartedAt(entry: AgentStatusEntry): number { - return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + return agentEntrySessionStartedAt(entry) } export function buildRetainedAgentsSyncSnapshot(args: RetainedAgentsSyncSnapshotInputs): { diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts index 72237e08cba..ec4640c3cca 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts @@ -1,10 +1,11 @@ +import { agentEntrySessionStartedAt } from '../../../../shared/agent-session-start-time' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { parseAgentStatusPaneIdentity } from '../../../../shared/agent-status-worktree-attribution' import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): number { - return entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + return agentEntrySessionStartedAt(entry) } export function tabFromWorktreeAttributedStatusEntry( diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts index 1ab75b7bd54..d5e20233b3e 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-surface-projection.test.ts @@ -390,6 +390,7 @@ describe('buildMobileSessionTabSnapshots', () => { prompt: 'fix parity', updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000, + firstStateStartedAt: 1_699_999_990_000, agentType: 'codex', paneKey, terminalTitle: 'codex [working]', @@ -406,6 +407,7 @@ describe('buildMobileSessionTabSnapshots', () => { state: 'working', prompt: 'fix parity', agentType: 'codex', + firstStateStartedAt: 1_699_999_990_000, paneKey } } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts index 5dee72ee834..68e7774c3f2 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-agent-status.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildWorktreeAgentRows } from '../components/sidebar/worktree-agent-rows' import { makePaneKey } from '../../../shared/stable-pane-id' +import * as sessionOrigin from '../../../shared/agent-session-start-time' +import { buildMirroredAgentStatusPatch } from './web-session-tabs-sync/agent-status-patch' import { applyWebSessionTabsSnapshot } from './web-session-tabs-sync/snapshot-api' import type { WebSessionTabsSyncState } from './web-session-tabs-sync/state' import { @@ -22,6 +25,61 @@ vi.mock('../store', () => ({ describe('applyWebSessionTabsSnapshot', () => { beforeEach(resetWebSessionTabsSyncTestState) + it('resolves origins linearly without publishing equal paired snapshots', () => { + const resolve = vi.spyOn(sessionOrigin, 'resolveAgentSessionStartedAt') + try { + const counts = [10, 100].map((population) => { + let state = makeState() + const surfaces = Array.from({ length: population }, (_, index) => ({ + type: 'terminal' as const, + id: `host-${index}::${LEAF_ID}`, + title: 'Claude', + parentTabId: `host-${index}`, + leafId: LEAF_ID, + isActive: index === 0, + status: 'ready' as const, + terminal: `terminal-${index}`, + agentStatus: { + state: 'working' as const, + prompt: 'task', + agentType: 'claude', + paneKey: makePaneKey(`host-${index}`, LEAF_ID), + worktreeId: WT, + updatedAt: NOW, + stateStartedAt: NOW - 10_000, + stateHistory: [], + providerSession: { key: 'session_id' as const, id: `session-${index}` } + } + })) + const patch = () => + buildMirroredAgentStatusPatch(state, [], surfaces, [], ENV, WT, new Set(), NOW) + resolve.mockClear() + state = { ...state, ...patch() } + const initial = resolve.mock.calls.length + expect(Object.keys(state.agentStatusByPaneKey)).toHaveLength(population) + resolve.mockClear() + expect(patch()).toBeNull() + const repeated = resolve.mock.calls.length + const epoch = state.sortEpoch + surfaces[0].agentStatus = { + ...surfaces[0].agentStatus, + updatedAt: NOW + 1, + providerSession: { key: 'session_id', id: 'replacement' } + } + resolve.mockClear() + state = { ...state, ...patch() } + expect(state.sortEpoch).toBe(epoch + 1) + expect(state.agentStatusEpoch).toBe(2) + expect(Object.values(state.agentStatusByPaneKey)[0].firstStateStartedAt).toBe(NOW + 1) + return [initial, repeated, resolve.mock.calls.length] + }) + expect(counts[0]).toEqual([10, 10, 10]) + expect(counts[1]).toEqual(counts[0].map((count) => count * 10)) + } finally { + resolve.mockRestore() + } + }) + it('remaps host agent status onto mirrored terminal pane keys', () => { const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID) const patch = applyWebSessionTabsSnapshot( @@ -537,3 +595,71 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.sortEpoch).toBe((initialState.sortEpoch ?? 0) + 1) }) }) + +it.each([false, true])( + 'keeps paired rows stable with rolling history=%s across host enrichment and replacement', + (history) => { + resetWebSessionTabsSyncTestState() + let state = makeState() + const order = () => + buildWorktreeAgentRows({ + tabs: state.tabsByWorktree[WT] ?? [], + entries: Object.values(state.agentStatusByPaneKey), + retained: [], + now: NOW + }).map((row) => row.paneKey) + const update = (at: number, tick: number, session = 'A', origin?: number, stale = false) => { + const tabs = [1, 2].map((index) => ({ + type: 'terminal' as const, + id: `host-tab-${index}::${LEAF_ID}`, + title: 'Claude', + parentTabId: `host-tab-${index}`, + leafId: LEAF_ID, + isActive: index === 1, + status: 'ready' as const, + terminal: `terminal-${index}`, + agentStatus: { + state: 'working' as const, + prompt: 'task', + updatedAt: NOW + tick - (stale ? 100 : 0), + stateStartedAt: NOW - (index === 1 ? 10000 - at : 8000), + agentType: 'claude', + paneKey: makePaneKey(`host-tab-${index}`, LEAF_ID), + providerSession: { key: 'session_id' as const, id: index === 1 ? session : 'quiet' }, + ...(origin !== undefined ? { firstStateStartedAt: origin } : {}), + stateHistory: history + ? [ + { + state: 'done' as const, + prompt: '', + startedAt: NOW - (index === 1 ? 10000 - at : 8000) + } + ] + : [] + } + })) + state = { + ...state, + ...applyWebSessionTabsSnapshot( + state, + makeSnapshot(tabs, { snapshotVersion: tick + 1 }), + ENV, + NOW + tick + 100_000 + ) + } + } + update(0, 0) + const before = order() + update(5000, 1, 'A', NOW - 5000) + expect(order()).toEqual(before) + const epoch = state.sortEpoch + update(6000, 2, 'B', NOW - 5000) + expect(order()).toEqual(before.toReversed()) + expect(state.agentStatusByPaneKey[before[0]!].firstStateStartedAt).toBe(NOW - 5000) + expect(state.sortEpoch).toBeGreaterThan(epoch ?? 0) + update(7000, 3, 'rejected-old-session', undefined, true) + expect(state.agentStatusByPaneKey[before[0]!].firstStateStartedAt).toBe(NOW - 5000) + update(6000, 4, 'legacy-next-session') + expect(state.agentStatusByPaneKey[before[0]!].firstStateStartedAt).toBe(NOW + 4) + } +) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts index c53de6c4ee2..6a674095148 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-patch.ts @@ -1,3 +1,4 @@ +import { resolveAgentSessionStartedAt } from '../../../../shared/agent-session-start-time' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { withMirroredEvidenceReceipt } from '../../../../shared/agent-status-freshness' import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' @@ -122,10 +123,17 @@ export function buildMirroredAgentStatusPatch( : entry.lastAssistantMessageIsToolOutput } : entry - nextByPaneKey.set( - entry.paneKey, + const accepted = nextEntry === entry ? entry : withHostProgramStatus(nextEntry, entry.programStatus) - ) + const retained = existing ? undefined : state.retainedAgentsByPaneKey?.[entry.paneKey] + nextByPaneKey.set(entry.paneKey, { + ...accepted, + firstStateStartedAt: resolveAgentSessionStartedAt({ + entry: accepted, + previous: existing ?? retained?.entry, + previousStartedAt: retained?.startedAt + }) + }) } let nextAgentStatusByPaneKey = state.agentStatusByPaneKey @@ -169,7 +177,10 @@ export function buildMirroredAgentStatusPatch( for (const [paneKey, entry] of nextByPaneKey) { const existing = nextAgentStatusByPaneKey[paneKey] - if (agentStatusEntryEqual(existing, entry)) { + if ( + agentStatusEntryEqual(existing, entry) && + existing?.firstStateStartedAt === entry.firstStateStartedAt + ) { continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { @@ -196,6 +207,7 @@ export function buildMirroredAgentStatusPatch( !!existing && agentMainAgentVerdict(existing) !== agentMainAgentVerdict(entry) const entrySortRelevantChange = !existing || + existing.firstStateStartedAt !== entry.firstStateStartedAt || existing.state !== entry.state || !isAgentStatusFresh(existing, now) || entryFreshnessChanged || diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts index a14709b6ba5..0bd145fb3b8 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts @@ -1,4 +1,5 @@ import { canonicalOrchestrationForRow } from '@/runtime/visible-agent-orchestration' +import { resolveAgentSessionStartedAt } from '../../../../../shared/agent-session-start-time' import { sameAgentStatusTerminalOwner } from '../../../../../shared/agent-status-terminal-owner' import type { AppState } from '../../types' import { resolveAgentStatusLiveEntryMainAgent } from './agent-status-live-entry-main-agent' @@ -273,6 +274,13 @@ export function buildAgentStatusLiveEntry( ? existing.sessionBoundary : undefined) } + const retained = existing ? undefined : state.retainedAgentsByPaneKey[paneKey] + entry.firstStateStartedAt = resolveAgentSessionStartedAt({ + entry, + previous: existing ?? retained?.entry, + previousStartedAt: retained?.startedAt, + identity: { ...identity, ...routing, providerSession: metadata?.providerSession } + }) const facts = deriveAgentStatusLiveFacts({ state, paneKey, diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-facts.ts index 422f11ed0fd..a65d6056392 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-facts.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-facts.ts @@ -62,7 +62,9 @@ export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): Agen const attributionChanged = existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId const sortRelevantChange = - isSmartSortRelevantAgentChange(existing, entry, updatedAt) || commandCodeNewTurn + isSmartSortRelevantAgentChange(existing, entry, updatedAt) || + commandCodeNewTurn || + existing?.firstStateStartedAt !== entry.firstStateStartedAt const doneRetentionFieldsChanged = existing?.state === 'done' && entry.state === 'done' && diff --git a/src/renderer/src/store/slices/agent-status/agent-status-pane-key-tab-binding.ts b/src/renderer/src/store/slices/agent-status/agent-status-pane-key-tab-binding.ts index b48bede1acf..d052219c115 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-pane-key-tab-binding.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-pane-key-tab-binding.ts @@ -1,3 +1,4 @@ +import { agentEntrySessionStartedAt } from '../../../../../shared/agent-session-start-time' import type { AppState } from '../../types' import type { AgentStatusEntry, @@ -148,7 +149,7 @@ export function getRetainedFallbackTab(entry: AgentStatusEntry, worktreeId: stri customTitle: null, color: null, sortOrder: 0, - createdAt: entry.stateStartedAt + createdAt: agentEntrySessionStartedAt(entry) } } @@ -165,7 +166,7 @@ export function retainedAgentEntryFromLive( worktreeId, tab, agentType, - startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt + startedAt: agentEntrySessionStartedAt(entry) } } diff --git a/src/renderer/src/store/slices/agent-status/agent-status-row-order-origin.test.ts b/src/renderer/src/store/slices/agent-status/agent-status-row-order-origin.test.ts new file mode 100644 index 00000000000..cb169dbf290 --- /dev/null +++ b/src/renderer/src/store/slices/agent-status/agent-status-row-order-origin.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resetAgentPaneAuthorityAliasesForTests } from '../agent-pane-authority' +import { wslHookRelayConnectionId } from '../../../../../shared/wsl-hook-relay-contract' +import { createTestStore } from '../store-test-helpers' +import { buildWorktreeAgentRows } from '@/components/sidebar/worktree-agent-rows' +import { AGENT_STATE_HISTORY_MAX } from '../../../../../shared/agent-status-types' +import { makePaneKey, parsePaneKey } from '../../../../../shared/stable-pane-id' +import type { AgentStatusTiming } from './agent-status-contract' +import { retainedAgentEntryFromLive } from './agent-status-pane-key-tab-binding' +import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api' +import { ENV, makeSnapshot, makeState } from '@/runtime/web-session-tabs-sync-test-harness' + +const pane = makePaneKey('tab-order', '11111111-1111-4111-8111-111111111111') +const quiet = makePaneKey('tab-quiet', '22222222-2222-4222-8222-222222222222') +afterEach(() => { + vi.useRealTimers() + resetAgentPaneAuthorityAliasesForTests() +}) + +function fixture(agentType = 'pi') { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + const store = createTestStore() + const write = ( + at: number, + state: 'working' | 'done', + session?: string, + timing: Omit = {} + ) => + store + .getState() + .setAgentStatus( + pane, + { state, prompt: 'task', agentType }, + undefined, + { ...timing, updatedAt: at }, + { worktreeId: 'wt-order' }, + session ? { providerSession: { key: 'session_id', id: session } } : undefined + ) + const rows = () => + buildWorktreeAgentRows({ + tabs: [], + entries: Object.values(store.getState().agentStatusByPaneKey), + retained: [], + now: 1_000_000 + }) + return { store, write, rows } +} + +describe('observed agent session row origin', () => { + it('carries a source-dated replacement from local status through paired row projection', () => { + const { store, write, rows } = fixture('claude') + let client = makeState() + const publish = (version: number) => { + const tabs = Object.values(store.getState().agentStatusByPaneKey).map((entry) => { + const parsed = parsePaneKey(entry.paneKey) + if (!parsed) { + throw new Error('Expected a terminal pane key') + } + return { + type: 'terminal' as const, + id: `${parsed.tabId}::${parsed.leafId}`, + title: 'Claude', + parentTabId: parsed.tabId, + leafId: parsed.leafId, + isActive: entry.paneKey === pane, + status: 'ready' as const, + terminal: `terminal-${parsed.tabId}`, + agentStatus: entry + } + }) + client = { + ...client, + ...applyWebSessionTabsSnapshot( + client, + makeSnapshot(tabs, { worktree: 'wt-order', snapshotVersion: version }), + ENV, + 1_000_000 + version + ) + } + return buildWorktreeAgentRows({ + tabs: client.tabsByWorktree['wt-order'] ?? [], + entries: Object.values(client.agentStatusByPaneKey), + retained: [], + now: 1_000_000 + version + }) + } + write(1000, 'working', 'A') + store + .getState() + .setAgentStatus( + quiet, + { state: 'working', prompt: '', agentType: 'claude' }, + undefined, + { updatedAt: 2000 }, + { worktreeId: 'wt-order' }, + { providerSession: { key: 'session_id', id: 'quiet' } } + ) + expect(publish(1).map((row) => row.entry.providerSession?.id)).toEqual(['A', 'quiet']) + write(7000, 'working', 'B', { stateStartedAt: 1000, evidenceObservedAt: 6000 }) + expect(store.getState().agentStatusByPaneKey[pane]).toMatchObject({ + stateStartedAt: 1000, + firstStateStartedAt: 6000, + updatedAt: 7000, + evidenceObservedAt: 6000 + }) + expect(rows().map((row) => row.entry.providerSession?.id)).toEqual(['quiet', 'B']) + const mirrored = publish(2) + expect(mirrored.map((row) => row.entry.providerSession?.id)).toEqual(['quiet', 'B']) + expect(mirrored[1].entry).toMatchObject({ stateStartedAt: 1000, firstStateStartedAt: 6000 }) + }) + + it.each(['pi', 'claude', 'codex'])( + 'keeps %s rows ordered after the recent-history cap', + (agent) => { + const { store, write, rows } = fixture(agent) + write(1000, 'working') + store + .getState() + .setAgentStatus( + quiet, + { state: 'working', prompt: '', agentType: agent }, + undefined, + { updatedAt: 2000 }, + { worktreeId: 'wt-order' } + ) + const before = rows().map((row) => row.paneKey) + for (let i = 0; i < AGENT_STATE_HISTORY_MAX + 2; i++) { + write(3000 + i * 1000, i % 2 ? 'working' : 'done') + } + expect(store.getState().agentStatusByPaneKey[pane].stateHistory[0].startedAt).toBeGreaterThan( + 2000 + ) + expect(rows().map((row) => row.paneKey)).toEqual(before) + const entry = store.getState().agentStatusByPaneKey[pane] + expect(retainedAgentEntryFromLive(store.getState(), 'wt-order', entry, agent).startedAt).toBe( + 1000 + ) + } + ) + + it('distinguishes known replacement from enrichment, omission and turn completion', () => { + const { store, write, rows } = fixture('claude') + write(1000, 'working') + write(2000, 'working', 'A') + write(3000, 'done', 'A') + write(4000, 'working', 'A') + expect(rows()[0].startedAt).toBe(1000) + write(5000, 'done', 'A') + write(6000, 'working', 'B') + expect(rows()[0].startedAt).toBe(6000) + const epoch = store.getState().agentStatusEpoch + write(7000, 'working', 'C') + expect(rows()[0].startedAt).toBe(7000) + expect(store.getState().agentStatusEpoch).toBeGreaterThan(epoch) + write(8000, 'working') + write(7500, 'done', 'D') + expect(rows()[0].startedAt).toBe(7000) + store.getState().dropAgentStatus(pane) + write(9000, 'working', 'E') + expect(rows()[0].startedAt).toBe(9000) + }) + + it('reuses a retained displayed origin only for an uncontradicted session', () => { + const { store, write, rows } = fixture('claude') + write(1000, 'done', 'A') + const retained = retainedAgentEntryFromLive( + store.getState(), + 'wt-order', + store.getState().agentStatusByPaneKey[pane], + 'claude' + ) + store.setState({ agentStatusByPaneKey: {}, retainedAgentsByPaneKey: { [pane]: retained } }) + write(3000, 'working', 'A') + expect(rows()[0].startedAt).toBe(1000) + store.setState({ agentStatusByPaneKey: {}, retainedAgentsByPaneKey: { [pane]: retained } }) + write(5000, 'working', 'B') + expect(rows()[0].startedAt).toBe(5000) + }) +}) + +it('preserves origin through routing enrichment, WSL equivalence and pane transfer', () => { + const { store, write, rows } = fixture('claude') + write(1000, 'working', 'A') + const workspace = String.raw`repo::\\wsl.localhost\Ubuntu\home\user\repo` + store + .getState() + .setAgentStatus( + pane, + { state: 'working', prompt: 'task', agentType: 'claude' }, + undefined, + { updatedAt: 2000 }, + { worktreeId: workspace, connectionId: null } + ) + expect(rows()[0].startedAt).toBe(2000) + store + .getState() + .setAgentStatus( + pane, + { state: 'working', prompt: 'task', agentType: 'claude' }, + undefined, + { updatedAt: 3000 }, + { connectionId: wslHookRelayConnectionId('Ubuntu'), worktreeId: workspace } + ) + expect(rows()[0].startedAt).toBe(2000) + const transferred = makePaneKey('tab-transfer', '33333333-3333-4333-8333-333333333333') + store.getState().transferAgentPaneAuthority({ fromPaneKey: pane, toPaneKey: transferred }) + expect(rows()[0].startedAt).toBe(2000) + expect(rows()[0].paneKey).toBe(transferred) + store + .getState() + .setAgentStatus( + transferred, + { state: 'working', prompt: 'task', agentType: 'claude' }, + undefined, + { updatedAt: 4000 }, + { connectionId: 'different-host', worktreeId: workspace } + ) + expect(rows()[0].startedAt).toBe(4000) +}) + +it('does not treat a newly available Pi transcript path as a new session', () => { + const { store, write, rows } = fixture('pi') + write(1000, 'working', 'A') + store + .getState() + .setAgentStatus( + pane, + { state: 'working', prompt: 'task', agentType: 'pi' }, + undefined, + { updatedAt: 2000 }, + undefined, + { providerSession: { key: 'session_id', id: 'A', transcriptPath: '/qa/pi/session.jsonl' } } + ) + expect(rows()[0].startedAt).toBe(1000) +}) diff --git a/src/shared/agent-session-start-time.ts b/src/shared/agent-session-start-time.ts new file mode 100644 index 00000000000..1ced1e0b579 --- /dev/null +++ b/src/shared/agent-session-start-time.ts @@ -0,0 +1,77 @@ +import type { AgentStatusEntry } from './agent-status-types' +import { agentProviderSessionsEqual } from './agent-session-resume' +import { sameAgentStatusTerminalOwner } from './agent-status-terminal-owner' + +type SessionIdentity = Pick< + AgentStatusEntry, + 'agentType' | 'providerSession' | 'connectionId' | 'worktreeId' +> + +function finite(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +export function agentEntrySessionStartedAt( + entry: Pick +): number { + return ( + finite(entry.firstStateStartedAt) ?? + finite(entry.stateHistory[0]?.startedAt) ?? + entry.stateStartedAt + ) +} + +/** Missing identity enriches a row; only accepted contradictory identity starts another session. */ +export function resolveAgentSessionStartedAt(args: { + entry: AgentStatusEntry + previous?: AgentStatusEntry + previousStartedAt?: number + identity?: SessionIdentity +}): number { + const { entry, previous, previousStartedAt, identity = entry } = args + if (!previous) { + return agentEntrySessionStartedAt(entry) + } + const agentChanged = + previous.agentType && + previous.agentType !== 'unknown' && + identity.agentType && + identity.agentType !== 'unknown' && + previous.agentType !== identity.agentType + const sessionChanged = + previous.providerSession && + identity.providerSession && + !agentProviderSessionsEqual( + identity.agentType, + { + ...previous.providerSession, + transcriptPath: + previous.providerSession.transcriptPath ?? identity.providerSession.transcriptPath + }, + { + ...identity.providerSession, + transcriptPath: + identity.providerSession.transcriptPath ?? previous.providerSession.transcriptPath + } + ) + const worktreeChanged = + previous.worktreeId && + identity.worktreeId && + !sameAgentStatusTerminalOwner( + { worktreeId: previous.worktreeId, connectionId: null }, + { worktreeId: identity.worktreeId, connectionId: null } + ) + const connectionChanged = + previous.connectionId !== undefined && + identity.connectionId !== undefined && + !sameAgentStatusTerminalOwner(previous, identity) + if (agentChanged || sessionChanged || worktreeChanged || connectionChanged) { + // State clocks can span sessions; only the accepted source dates a replacement. + return finite(entry.firstStateStartedAt) ?? finite(entry.evidenceObservedAt) ?? entry.updatedAt + } + return ( + finite(previous.firstStateStartedAt) ?? + finite(previousStartedAt) ?? + agentEntrySessionStartedAt(previous) + ) +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 109fac80b9d..5a99bd39c9d 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -113,6 +113,8 @@ export type AgentStatusEntry = { terminalTitle?: string /** Rolling log of previous states, capped at AGENT_STATE_HISTORY_MAX. */ stateHistory: AgentStateHistoryEntry[] + /** Observed session origin for row ordering; recent history can evict its first state. */ + firstStateStartedAt?: number /** Name of the tool the agent is currently using (e.g. "Edit", "Bash"). */ toolName?: string /** Short preview of the tool input (e.g. file path, command). */ diff --git a/src/shared/session-tabs-tab-delta.test.ts b/src/shared/session-tabs-tab-delta.test.ts index bb0e20faa9a..e1d882ba4b0 100644 --- a/src/shared/session-tabs-tab-delta.test.ts +++ b/src/shared/session-tabs-tab-delta.test.ts @@ -163,3 +163,27 @@ describe('session tabs tab delta', () => { ).toEqual({ ok: false }) }) }) + +it('accepts the optional row origin with legacy and delta session frames', () => { + const decoder = createSessionTabsTabDeltaDecoder() + const encoder = createSessionTabsTabDeltaEncoder(CAPABLE) + const tab = { + ...terminalTab(0), + agentStatus: { + state: 'working', + prompt: 'task', + updatedAt: 5000, + paneKey: 'tab-0:leaf-0', + stateStartedAt: 5000, + firstStateStartedAt: 1000, + stateHistory: [] + } + } + const legacy = wire({ type: 'snapshot', ...frame(1, [tab]) }) + expect(decoder.decode(legacy)).toEqual({ ok: true, result: legacy }) + const sent = wire({ type: 'snapshot', ...encoder.full(frame(2, [tab])) }) + expect(decoder.decode(sent)).toEqual({ + ok: true, + result: wire({ type: 'snapshot', ...frame(2, [tab]) }) + }) +})