mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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[] = [],
|
||||
|
||||
Reference in New Issue
Block a user