fix(sidebar): keep agent rows ordered by their session start once state history wraps (#14654)

* fix(sidebar): keep agent rows ordered by their session start once history wraps

Sidebar agent rows are ordered by the session's first reported state, read as
`stateHistory[0].startedAt`. `stateHistory` is a rolling window capped at
AGENT_STATE_HISTORY_MAX that trims oldest-first, so past ~20 transitions that
row is a recent turn: the row's identity clock silently becomes an activity
clock and busy agents re-sort themselves on every turn.

Latch the session origin in `firstStateStartedAt` when the entry is built and
read it through `agentEntrySessionStartedAt`, keeping the history lookup as a
fallback for entries that predate the field.

* test(sidebar): cover row order through activity and retention

* fix(sidebar): preserve session row order through activity and retention

* test: complete paired terminal status fixtures

* fix: carry accepted session origins across paired rows

* test: cover paired origin work and mobile action parity

* test(mobile): expect sessionLive in the phone chat identity after main's start-a-chat change
This commit is contained in:
Brennan Benson
2026-10-29 14:27:16 -07:00
committed by GitHub
parent 6f99d480b3
commit 004907eb11
14 changed files with 753 additions and 10 deletions
@@ -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]
@@ -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])
})
})
@@ -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): {
@@ -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(
@@ -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
}
}
@@ -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)
}
)
@@ -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 ||
@@ -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,
@@ -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' &&
@@ -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)
}
}
@@ -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<AgentStatusTiming, 'updatedAt'> = {}
) =>
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)
})
+77
View File
@@ -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<AgentStatusEntry, 'firstStateStartedAt' | 'stateHistory' | 'stateStartedAt'>
): 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)
)
}
+2
View File
@@ -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). */
+24
View File
@@ -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]) })
})
})