fix(agent-status): resolve a structured row's surface by session, not by its key

The pane key's tab-id half is now derived from the session id, so it stops
naming the surface whenever `terminal-surfaces.ts` reuses a tab: a conversation
that replaces another keeps the superseded tab, whose local id still spells the
old session while `entityId` becomes the new one. Two readers were using the key
as surface routing and broke on that.

`WorktreeCardAgents`'s row click matched the tab by exact id, found nothing, and
returned silently — the row stayed and did nothing. It now falls back to the
session the key encodes, via a new inverse of `structuredAgentSessionTabId`.

The live-entry worktree index maps tab id to worktree, and a `done` row is
bucketed only through it (a live row still has `entry.worktreeId`). A replaced
conversation's settled row therefore left the sidebar entirely. The index now
registers each agent-session tab under its derived session tab id too.

`stateStartedAt` needed no shared module. The renderer store's own default
already holds the state clock across republished evidence, and carries the
Command Code new-turn clause besides; the divergence was that the bridge passed
a `timing.stateStartedAt` that overrode it. The bridge now passes none, so the
store applies its own rule, and `resolveAgentStatusStateStartedAt` is gone — it
would have had one caller, and `server-reaping.ts` keeps its own inline copy
regardless.
This commit is contained in:
Merge Sim
2026-09-11 16:37:06 -07:00
parent d95f3f1ee6
commit cf2ff6659a
14 changed files with 321 additions and 120 deletions
+50 -8
View File
@@ -285,8 +285,18 @@ keying on it, and both are fixed:
filter would have produced two rows for one chat;
- the key held a second `:`, which `parsePaneKey` rejects. An unparseable key is
dropped by `buildWorktreeAgentRows` (both the `entriesByTabId` bucket and the
worktree-attributed fallback), so a `:history-N` session had **no** sidebar or
dashboard row at all. It gets one now.
worktree-attributed fallback), so a surface re-hosted at `:history-N` published
a key no reader could bucket. It now publishes the session's key like any other
surface.
That is narrower than a session gaining a row it did not have. The suffix is
only assigned when the base id is already occupied, and the occupant is
normally the same session's other surface — which was already publishing the
base key, and whose row the suffixed surface now shares rather than adds to.
The bridge test for a disambiguated mirrored surface pins the derivation, not
a reachable end state: its fixture has a suffixed surface with nothing at the
base id, which `buildMirroredAgentTabs` does not produce. Read it as a guard
on the key, not as evidence of a row the user gains.
Nothing was stranded under an old key: main never wrote a suffixed key, and the
renderer's `agentStatusByPaneKey` is in-memory, so a `:history-N` row only ever
@@ -297,14 +307,46 @@ session (the collision that produces the suffix), and they now share one key, so
the status bridge's unmount cleanup no longer clears the row while another
surface still mirrors that session.
### `stateStartedAt`: one rule, the host's
### The key names the session, so readers resolve the surface by session
`src/shared/agent-status-state-start.ts` holds it, and both writers call it:
a row's `stateStartedAt` is the start of the state the row is in, so republished
A structured pane key's tab-id half is `structuredAgentSessionTabId(sessionId)`,
not the id of the surface hosting the chat. Two readers were using it as surface
routing, and `terminal-surfaces.ts` breaks that agreement in both of its
id-collision paths — a conversation that _replaces_ another reuses the superseded
tab, so the local id keeps spelling the old session while `entityId` becomes the
new one:
- `WorktreeCardAgents`'s row click resolved the tab by exact id, so a replaced
conversation's row did nothing at all. It now falls back to
`activateStructuredAgentSessionForRow`, which reads the session id back out of
the tab id (`structuredAgentSessionIdFromTabId`) and resolves the surface by
`entityId`.
- the live-entry worktree index in `worktree-agent-row-selectors.ts` maps tab id
to worktree, and a `done` row is bucketed only through that index (a live row
still has `entry.worktreeId` to fall back on). A replaced conversation's
settled row therefore vanished from the sidebar. The index now registers each
agent-session tab under its derived session tab id as well as its local id.
`structured-agent-session-projection.ts` owns both directions of the derivation,
so no reader re-spells the prefix.
### `stateStartedAt`: the renderer stops overriding the store's rule
A row's `stateStartedAt` is the start of the state the row is in, so republished
evidence never moves it and only a state change (or Command Code's same-state
new turn) resets it. `attachStatusTiming` had this rule already; the renderer's
`projectStatus` had an extra `desired.state !== 'done'` clause that restamped a
settled row on every republish, and that clause is gone.
new turn) resets it. `attachStatusTiming` had this rule already, and so did the
renderer store's own default in `agent-status-live-entry-builder.ts` — including
the Command Code clause, which it computes internally.
The divergence was that the bridge's `projectStatus` _passed_ a
`timing.stateStartedAt`, which is exactly the override that displaces that
default, and the value it passed carried an extra `desired.state !== 'done'`
clause that restamped a settled row on every republish. The bridge now passes no
`stateStartedAt` at all, so the store applies its own rule. There is no shared
helper: one would have had a single caller, and it could not express the store's
Command Code clause from the bridge's call site. `server-reaping.ts` still holds
its own inline copy of the same shape, so "one rule for every writer" would not
have been true either.
`done` is not an exception, because `agentEntryCompletionAt` reads a settled
row's `stateStartedAt` as its completion time. A moving one re-dates a turn that
@@ -111,8 +111,8 @@ describe('AgentHookServer ingestStructuredStatus', () => {
})
// The renderer's bridge used to restamp a settled row on every republish while this writer held
// it. Both now share `resolveAgentStatusStateStartedAt`; `agentEntryCompletionAt` reads this
// field as the completion time, so a moving one would re-date a finished turn.
// it. This writer's rule is now the only one: `agentEntryCompletionAt` reads this field as the
// completion time, so a moving one would re-date a finished turn.
it('keeps a completed turn dated when a settled session is republished', () => {
const server = new AgentHookServer()
server.ingestStructuredStatus(summary({ status: 'idle' }))
@@ -3,7 +3,6 @@ import { createHash } from 'node:crypto'
import { getCohortAtEmit } from '../../telemetry/cohort-classifier'
import { track } from '../../telemetry/client'
import { isCommandCodeNewTurnWhileWorking } from '../../../shared/command-code-turn-boundary'
import { resolveAgentStatusStateStartedAt } from '../../../shared/agent-status-state-start'
import { isNewTurnEvent } from '../../../shared/agent-hook-listener/provider-event-routing'
import type { AgentHookEventPayload } from '../../../shared/agent-hook-listener/listener-event'
import type {
@@ -40,14 +39,10 @@ export abstract class AgentHookServerStatusApplication extends AgentHookServerSt
previousPromptInteractionKey: previous.promptInteractionKey,
incomingPromptInteractionKey: payload.promptInteractionKey
})
const stateStartedAt = resolveAgentStatusStateStartedAt({
previous: previous
? { state: previous.payload.state, stateStartedAt: previous.stateStartedAt }
: undefined,
nextState: payload.payload.state,
observedAt: observedAt ?? now,
newTurn: commandCodeNewTurn
})
const stateStartedAt =
previous && previous.payload.state === payload.payload.state && !commandCodeNewTurn
? previous.stateStartedAt
: (observedAt ?? now)
// Why: `stateStartedAt` tracks the current state, while `receivedAt` tracks every arrival.
return {
...payload,
@@ -11,7 +11,6 @@ import {
type AgentSubagentSnapshot,
type AgentSubagentState
} from '../../../../shared/agent-status-types'
import { resolveAgentStatusStateStartedAt } from '../../../../shared/agent-status-state-start'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionStatusState
@@ -191,12 +190,8 @@ function projectStatus(
updatedAt: summary.updatedAt,
// This ordered host feed can correct a legacy publication clock after upgrade.
allowOlderTimestamp: true,
// The host's rule, shared so the two writers cannot date the same turn differently.
stateStartedAt: resolveAgentStatusStateStartedAt({
previous: current,
nextState: desired.state,
observedAt: summary.updatedAt
}),
// No `stateStartedAt`: passing one overrode the store's own rule, which is the host's. A
// settled row keeps its completion time, which `agentEntryCompletionAt` reads.
evidenceObservedAt: summary.updatedAt
},
{ tabId: tab.id, worktreeId: tab.worktreeId },
@@ -103,7 +103,7 @@ const staleAgentRowMocks = vi.hoisted(() => ({
}))
const structuredActivationMocks = vi.hoisted(() => ({
activateStructuredAgentSessionTab: vi.fn()
activateStructuredAgentSessionForRow: vi.fn()
}))
vi.mock('@/store', () => ({
@@ -128,7 +128,8 @@ vi.mock('../terminal-pane/stale-agent-row', () => ({
}))
vi.mock('@/lib/structured-agent-session-tab-activation', () => ({
activateStructuredAgentSessionTab: structuredActivationMocks.activateStructuredAgentSessionTab
activateStructuredAgentSessionForRow:
structuredActivationMocks.activateStructuredAgentSessionForRow
}))
vi.mock('./useWorktreeAgentRows', () => ({
@@ -169,7 +170,7 @@ describe('WorktreeCardAgents activation', () => {
mockActiveTabId = null
mockActiveTabType = 'editor'
capturedRowActivations = []
structuredActivationMocks.activateStructuredAgentSessionTab.mockImplementation(
structuredActivationMocks.activateStructuredAgentSessionForRow.mockImplementation(
({ tabId }: { tabId: string }) => mockStructuredTabIds.has(tabId)
)
})
@@ -195,7 +196,7 @@ describe('WorktreeCardAgents activation', () => {
capturedRowActivations[0].onActivate(tabId, paneKey)
expect(activationMocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1')
expect(structuredActivationMocks.activateStructuredAgentSessionTab).toHaveBeenCalledWith({
expect(structuredActivationMocks.activateStructuredAgentSessionForRow).toHaveBeenCalledWith({
worktreeId: 'wt-1',
tabId
})
@@ -26,7 +26,7 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant
import { revealElementInScrollContainer } from './worktree-sidebar-reveal'
import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state'
import { translate } from '@/i18n/i18n'
import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation'
import { activateStructuredAgentSessionForRow } from '@/lib/structured-agent-session-tab-activation'
import { selectAcknowledgedAgentTimes } from './worktree-card-agent-ack-inputs'
export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT =
@@ -178,7 +178,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
flashFocusedPane: true,
scrollToBottomIfOutputSinceLastView: true
})
} else if (!activateStructuredAgentSessionTab({ worktreeId, tabId })) {
} else if (!activateStructuredAgentSessionForRow({ worktreeId, tabId })) {
const liveEntry = useAppStore.getState().agentStatusByPaneKey[paneKey]
if (liveEntry?.worktreeId === worktreeId) {
// Why: orchestration worker status can be worktree-attributed before the renderer knows its tab; keep the live row instead of dismissing as stale.
@@ -0,0 +1,188 @@
// @vitest-environment happy-dom
import { renderToStaticMarkup } from 'react-dom/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { Tab } from '../../../../shared/tab-types'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../../../shared/structured-agent-session-projection'
// A conversation that replaced another: `terminal-surfaces.ts` reuses the superseded tab, so the
// local surface id still spells the OLD session while `entityId` is the NEW one. PR 2a derives the
// status pane key from the session id alone, so the key no longer names the surface that hosts it.
const OLD_SESSION = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
const NEW_SESSION = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e'
const LOCAL_TAB_ID = structuredAgentSessionTabId(OLD_SESSION)
const PANE_KEY = structuredAgentSessionPaneKey(NEW_SESSION)
const NOW = 1_757_030_400_000
const replacedTab: Tab = {
id: LOCAL_TAB_ID,
entityId: NEW_SESSION,
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'agent-session',
agentSessionAgent: 'codex',
label: 'Codex Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: NOW,
isPinned: false
}
function entry(state: AgentStatusEntry['state']): AgentStatusEntry {
return {
paneKey: PANE_KEY,
state,
agentType: 'codex',
prompt: 'Keep going',
updatedAt: NOW,
stateStartedAt: NOW,
evidenceObservedAt: NOW,
stateHistory: [],
worktreeId: 'wt-1',
tabId: LOCAL_TAB_ID
} as unknown as AgentStatusEntry
}
const activationMocks = vi.hoisted(() => ({
activateAndRevealWorktree: vi.fn(),
activateTabAndFocusPane: vi.fn(),
activateTab: vi.fn(),
focusGroup: vi.fn(),
setActiveTabType: vi.fn(),
callRuntimeRpc: vi.fn(async () => ({ ok: true })),
dismissStaleAgentRowByKey: vi.fn()
}))
let agentStatusByPaneKey: Record<string, AgentStatusEntry> = {}
let capturedRowActivations: {
paneKey: string
onActivate: (tabId: string, paneKey: string) => void
}[] = []
function buildMockStoreState(): Record<string, unknown> {
return {
agentActivityDisplayMode: 'full',
acknowledgedAgentsByPaneKey: {},
cacheTimerByKey: {},
dropAgentStatus: vi.fn(),
dismissRetainedAgent: vi.fn(),
acknowledgeAgents: vi.fn(),
agentSendPopoverTargetMode: null,
agentStatusByPaneKey,
agentStatusEpoch: 1,
activeTabId: null,
activeTabType: 'editor',
activateTab: activationMocks.activateTab,
focusGroup: activationMocks.focusGroup,
setActiveTab: vi.fn(),
setActiveTabType: activationMocks.setActiveTabType,
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {},
runtimeAgentOrchestrationByPaneKey: {},
tabsByWorktree: {},
unifiedTabsByWorktree: { 'wt-1': [replacedTab] },
terminalLayoutsByTabId: {},
ptyIdsByTabId: {},
runtimePaneTitlesByTabId: {},
sendPromptToSidebarAgentTarget: vi.fn(),
settings: { promptCacheTimerEnabled: true, promptCacheTtlMs: 60_000 }
}
}
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) => selector(buildMockStoreState()),
{ getState: () => buildMockStoreState() }
)
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: activationMocks.activateAndRevealWorktree
}))
vi.mock('@/lib/activate-tab-and-focus-pane', () => ({
activateTabAndFocusPane: activationMocks.activateTabAndFocusPane
}))
vi.mock('../terminal-pane/stale-agent-row', () => ({
dismissStaleAgentRowByKey: activationMocks.dismissStaleAgentRowByKey
}))
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: () => 'env-1'
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({
callRuntimeRpc: activationMocks.callRuntimeRpc,
getActiveRuntimeTarget: () => ({ kind: 'environment', environmentId: 'env-1' })
}))
vi.mock('@/runtime/runtime-worktree-selector', () => ({
toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}`
}))
vi.mock('@/hooks/use-now', () => ({ useNow: vi.fn(() => NOW) }))
vi.mock('@/components/dashboard/DashboardAgentRow', () => ({
default: ({
agent,
onActivate
}: {
agent: DashboardAgentRowData
onActivate: (tabId: string, paneKey: string) => void
}) => {
capturedRowActivations.push({ paneKey: agent.paneKey, onActivate })
return <div data-testid="agent-row" data-pane-key={agent.paneKey} />
}
}))
vi.mock('./focused-agent-row-highlight', () => ({
useFocusedAgentPaneKey: vi.fn(() => null)
}))
async function renderRows(): Promise<void> {
const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents')
renderToStaticMarkup(<WorktreeCardAgents worktreeId="wt-1" />)
}
describe('a replaced conversation keeps a working sidebar row', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Date, 'now').mockReturnValue(NOW)
capturedRowActivations = []
agentStatusByPaneKey = {}
})
it('activates the surface that hosts the session the pane key names', async () => {
agentStatusByPaneKey = { [PANE_KEY]: entry('working') }
await renderRows()
expect(capturedRowActivations).toHaveLength(1)
const row = capturedRowActivations[0]
// The row builder synthesizes the missing tab from the pane key, so the id it hands back is the
// derived one — never the local surface id.
row.onActivate(structuredAgentSessionTabId(NEW_SESSION), row.paneKey)
expect(activationMocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1')
expect(activationMocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1')
expect(activationMocks.activateTab).toHaveBeenCalledWith(LOCAL_TAB_ID, { worktreeId: 'wt-1' })
expect(activationMocks.setActiveTabType).toHaveBeenCalledWith('agent-session', 'wt-1')
expect(activationMocks.dismissStaleAgentRowByKey).not.toHaveBeenCalled()
})
it('keeps the settled row attributed to the worktree that owns the surface', async () => {
// A `done` row has no `entry.worktreeId` fallback in the live index: it is bucketed only if the
// pane key's tab id resolves to a tab, which for a replaced conversation it no longer does.
agentStatusByPaneKey = { [PANE_KEY]: entry('done') }
await renderRows()
expect(capturedRowActivations.map((row) => row.paneKey)).toEqual([PANE_KEY])
})
})
@@ -6,6 +6,7 @@ import type {
MigrationUnsupportedPtyEntry
} from '../../../../shared/agent-status-types'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection'
import {
type LiveEntriesByWorktreeCache,
liveEntryWorktreeId,
@@ -114,6 +115,9 @@ function getLiveTabIdToWorktreeId(
for (const tab of tabs) {
if (tab.contentType === 'agent-session') {
tabIdToWorktreeId.set(tab.id, worktreeId)
// Why: a structured row's pane key names the session, not the surface, so a tab reused for
// a replacing conversation (or re-hosted at `${baseId}:history-N`) is reachable only here.
tabIdToWorktreeId.set(structuredAgentSessionTabId(tab.entityId), worktreeId)
}
}
}
@@ -32,8 +32,10 @@ vi.mock('@/runtime/runtime-worktree-selector', () => ({
import {
activateStructuredAgentSessionById,
activateStructuredAgentSessionForRow,
activateStructuredAgentSessionTab
} from './structured-agent-session-tab-activation'
import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection'
describe('activateStructuredAgentSessionTab', () => {
beforeEach(() => {
@@ -75,6 +77,25 @@ describe('activateStructuredAgentSessionTab', () => {
)
})
it('activates a status row whose derived tab id no longer names its surface', () => {
// A replaced conversation reuses the superseded tab, so the local id still spells the old
// session while the row's key is derived from the new one.
expect(
activateStructuredAgentSessionForRow({
worktreeId: 'wt-1',
tabId: structuredAgentSessionTabId('session-1')
})
).toBe(true)
expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' })
})
it('leaves a row whose tab id encodes no session to the caller', () => {
expect(activateStructuredAgentSessionForRow({ worktreeId: 'wt-1', tabId: 'worker-tab' })).toBe(
false
)
expect(mocks.activateTab).not.toHaveBeenCalled()
})
it('routes a provider-owned vault row through its structured session id', () => {
expect(activateStructuredAgentSessionById({ worktreeId: 'wt-1', sessionId: 'session-1' })).toBe(
true
@@ -1,3 +1,4 @@
import { structuredAgentSessionIdFromTabId } from '../../../shared/structured-agent-session-projection'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
import { useAppStore } from '@/store'
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
@@ -41,3 +42,22 @@ export function activateStructuredAgentSessionById(args: {
? activateStructuredAgentSessionTab({ worktreeId: args.worktreeId, tabId: tab.id })
: false
}
/**
* Activate the session a status row points at. A structured row's tab id comes out of its pane key,
* which PR 2a derives from the session id alone, so it stops matching the local surface whenever
* `terminal-surfaces.ts` reuses a tab for a replacing conversation or re-hosts a mirror at
* `${baseId}:history-N`. The session id the key encodes is the identity that survives both.
*/
export function activateStructuredAgentSessionForRow(args: {
worktreeId: string
tabId: string
}): boolean {
if (activateStructuredAgentSessionTab(args)) {
return true
}
const sessionId = structuredAgentSessionIdFromTabId(args.tabId)
return sessionId
? activateStructuredAgentSessionById({ worktreeId: args.worktreeId, sessionId })
: false
}
@@ -1,61 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveAgentStatusStateStartedAt } from './agent-status-state-start'
describe('resolveAgentStatusStateStartedAt', () => {
it('stamps the observation when there is no row yet', () => {
expect(
resolveAgentStatusStateStartedAt({
previous: undefined,
nextState: 'working',
observedAt: 100
})
).toBe(100)
})
it('restarts the clock when the state changes', () => {
expect(
resolveAgentStatusStateStartedAt({
previous: { state: 'working', stateStartedAt: 100 },
nextState: 'done',
observedAt: 200
})
).toBe(200)
})
it.each(['working', 'blocked', 'done'] as const)(
'holds the clock while the state stays %s',
(state) => {
expect(
resolveAgentStatusStateStartedAt({
previous: { state, stateStartedAt: 100 },
nextState: state,
observedAt: 200
})
).toBe(100)
}
)
// `done` is the case the two structured writers disagreed on before PR 2a. It is not an
// exception: `agentEntryCompletionAt` reads a settled row's `stateStartedAt` as the completion
// time, so restamping it on a republish would re-date a turn that already finished.
it('does not re-date a completed turn when a settled row is republished', () => {
expect(
resolveAgentStatusStateStartedAt({
previous: { state: 'done', stateStartedAt: 100 },
nextState: 'done',
observedAt: 200
})
).toBe(100)
})
it('restarts a same-state clock for a new turn', () => {
expect(
resolveAgentStatusStateStartedAt({
previous: { state: 'working', stateStartedAt: 100 },
nextState: 'working',
observedAt: 200,
newTurn: true
})
).toBe(200)
})
})
-27
View File
@@ -1,27 +0,0 @@
import type { AgentStatusState } from './agent-status-types'
export type AgentStatusStateStart = {
state: AgentStatusState
stateStartedAt: number
}
/**
* When a row's `stateStartedAt` moves. One rule for every writer: the clock is the start of the
* state the row is in, so it survives republished evidence and only resets when the state itself
* changes (or a same-state write is a new turn, which only Command Code can be).
*
* `done` is not an exception. `agentEntryCompletionAt` reads a settled row's `stateStartedAt` as
* its completion time, so a writer that restamped it on every republish would keep re-dating a
* finished turn.
*/
export function resolveAgentStatusStateStartedAt(args: {
previous: AgentStatusStateStart | undefined
nextState: AgentStatusState
observedAt: number
/** A same-state write that is nevertheless a new turn; Command Code has no prompt hook. */
newTurn?: boolean
}): number {
return args.previous && args.previous.state === args.nextState && args.newTurn !== true
? args.previous.stateStartedAt
: args.observedAt
}
@@ -9,6 +9,7 @@ import {
projectStructuredItemToNativeChat,
projectStructuredAgentSessionStatus,
projectStructuredAgentSessionStatusSummary,
structuredAgentSessionIdFromTabId,
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from './structured-agent-session-projection'
@@ -383,6 +384,19 @@ describe('structured agent session status projection', () => {
expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-session-1' })
})
it('reads the session back out of a derived tab id, and refuses anything else', () => {
// The key names the session, not the surface, so a reader that needs the surface has to invert
// this rather than compare tab ids.
expect(structuredAgentSessionIdFromTabId(structuredAgentSessionTabId('session-1'))).toBe(
'session-1'
)
expect(
structuredAgentSessionIdFromTabId(parsePaneKey(structuredAgentSessionPaneKey('s2'))!.tabId)
).toBe('s2')
expect(structuredAgentSessionIdFromTabId('terminal-tab-1')).toBeNull()
expect(structuredAgentSessionIdFromTabId(structuredAgentSessionTabId(''))).toBeNull()
})
it('admits no surface identity into a structured row key', () => {
// Two writers derive this key. A tab-id parameter is how they came apart: a mirrored session
// that collides with an occupied id is re-hosted at `${baseId}:history-N`, and the host never
@@ -207,6 +207,15 @@ export function structuredAgentSessionTabId(sessionId: string): string {
return `structured-agent-session-${sessionId}`
}
const STRUCTURED_AGENT_SESSION_TAB_ID_PREFIX = structuredAgentSessionTabId('')
/** The session id a derived tab id (or the tab-id half of a status pane key) encodes, if any. */
export function structuredAgentSessionIdFromTabId(tabId: string): string | null {
return tabId.startsWith(STRUCTURED_AGENT_SESSION_TAB_ID_PREFIX)
? tabId.slice(STRUCTURED_AGENT_SESSION_TAB_ID_PREFIX.length) || null
: null
}
export function projectStructuredAgentSessionStatus(
items: readonly AgentJournalRenderItem[],
submissions: readonly AgentJournalSubmission[] = [],