mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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]) })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user