refactor(agent-status): make the renderer a subscriber for structured rows

Main's two half-migration filters are gone, so a locally hosted structured
session's row now reaches both renderer windows over `agentStatus:set` and comes
back on the `agentStatus:getSnapshot` replay pull. Removing the send guard is
also what first gives the dashboard popout structured sessions at all.

Removing them was not enough on its own. A structured pane key resolves to
nothing in the renderer's terminal-tab routing index — an `agent-session` tab
lives in `unifiedTabsByWorktree` — so the applicator held every published row as
`pending` forever, with every test still green. The new routing module resolves a
structured row against that tab, and requires one: deliberately narrower than
`worktree ps`, because it is what the sidebar already showed, and because a
terminal tab in chat view mode is backed by a structured session too and already
has its own row.

`structuredHost: 'owned'` maps back to `structuredHostOwned` so the freshness
bypass still fires; `terminalResumeEligible: false` is kept so no sleeping record
offers to relaunch a chat as a TUI; `allowOlderTimestamp` is dropped because main
stamps a forward-only `receivedAt`. The row's name is read from the session tab
rather than put on the wire.

The bridge stops projecting for a local target only. A remote host writes into
ITS store and nothing mirrors structured rows down, so it stays that session's
writer. Its unmount teardown stays for both lanes: the host's close drops its row
through `dropStatusEntry`, which emits no renderer clear.

Command Code output seeds, parked-pane seeds and the pty-exit removal are NOT
deleted, against the plan. Main emits Command Code working/done as side-effect
FACTS the renderer turns into the write, gated on renderer-only pane ownership
state; nothing puts them in the hook store. The done-settle window stays with
them. The doc records both, with the mechanism.
This commit is contained in:
Merge Sim
2026-09-11 20:36:29 -07:00
parent a88d042a53
commit b009f95d4e
13 changed files with 824 additions and 199 deletions
+162 -40
View File
@@ -12,7 +12,7 @@ this order, each independently shippable:
3. shared: one worktree-status rollup and one freshness rule for every reader.
The PR that carries this document is PR 1a. Sections below are grouped under
the step that delivers them; PR 1a, PR 1b and PR 2a have landed.
the step that delivers them; PR 1a, PR 1b, PR 2a and PR 2b have landed.
## The problem this solves
@@ -92,14 +92,14 @@ The structured feed keeps its job of projecting a session's journal into a
summary and streaming it to subscribers. On every publish it additionally
ingests the summary into the hook server as a status row:
| Row field | From |
| ----------------- | ------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(sessionId)`, the key the renderer also derives (PR 2a made it take the session id alone); its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |
| Row field | From |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `paneKey` | `structuredAgentSessionPaneKey(sessionId)`, the key the renderer also derives (PR 2a made it take the session id alone); its leaf is UUID-shaped so pane-key validation accepts it |
| `tabId` | `structuredAgentSessionTabId(sessionId)` |
| `worktreeId` | `summary.workspaceId` (a folder workspace id is a valid value) |
| `state` | `structuredAgentSessionStatusState(summary.status)`, the mapping #19217 shared |
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
| prompt, tool, last message, model, provider session | the summary's fields |
Sessions with no persisted turn (`status === null`) produce no row, matching
what the chat shows. When the host revokes live ownership the row is re-set
@@ -179,10 +179,10 @@ path, and the hand-rolled check in `runtime-worktree-agent-rows.ts` goes.
an old client ignores them. `worktree ps` rows keep their shape and vocabulary,
so the mobile app sees no change.
Until PR 2 the main process does not forward structured rows to the renderer
over `agentStatus:set` or `agentStatus:getSnapshot`. The renderer's feed
bridge still writes those rows itself, and forwarding them too would give one
pane key two writers. Removing that filter is the first step of PR 2.
PR 1a did not forward structured rows to the renderer over `agentStatus:set` or
`agentStatus:getSnapshot`: the renderer's feed bridge still wrote those rows
itself, and forwarding them too would have given one pane key two writers.
PR 2b removed both filters.
## PR 1b: the runtime's retained row store is deleted
@@ -426,50 +426,172 @@ moving now sorts by when it finished rather than by its latest journal write.
## PR 2b: the renderer subscribes
With structured rows arriving over `agentStatus:set`, the renderer's
`StructuredAgentSessionStatusBridge` no longer needs to write status; its
unmount cleanup becomes a tab-close signal to the host. The IPC applicator is
the single writer for observed status. The 2026-09-09 audit sorted the other
writers:
Landed. Both filters are gone, the IPC applicator applies the host's structured
rows, and `StructuredAgentSessionStatusBridge` no longer projects status for a
locally hosted session.
### The two filters this step removed
- `main/startup/main-window-agent-status.ts` — the `if (structuredHost) return`
guard above the `agentStatus:set` sends. It sat above BOTH the main window and
`getDashboardPopoutWindow()`, so removing it is also what first gives the
dashboard popout structured sessions.
- `main/ipc/agent-hooks.ts` — the `.filter((entry) => entry.structuredHost === undefined)`
on `agentStatus:getSnapshot`, which is the pull a renderer does after
hydration. Without it a native chat had no row until its next journal edge, and
a settled one never came back at all.
Two things the window listener now skips for a row carrying `structuredHost`,
neither of which the plan anticipated:
- `maybeAutoRenameBranchOnFirstWork`. First-work branch rename is a PTY-agent
feature whose structured-session gap is tracked on its own; publishing the row
must not switch it on half-covered as a side effect.
- `driveSyntheticTitleFromHook`. It injects a title into a pane's title slot, and
a structured session has no pane.
### Removing the filters was not enough on its own
The plan treated this step as a switch flip. It is not: a structured pane key
resolves to no entry in the renderer's terminal-tab routing index, because an
`agent-session` tab lives in `unifiedTabsByWorktree`, not `tabsByWorktree`. The
applicator's `exists` gate therefore held every published structured row as
`pending` forever — main would have published rows the renderer dropped on the
floor, with every test still green.
`ipc-events/structured-agent-session-row-routing.ts` is what closes that. It
resolves a structured row against the `agent-session` tab, matching either the
tab's own id or the id derived from its `entityId`, so a `:history-N` mirrored
surface still matches.
That tab is REQUIRED — deliberately narrower than the admission rule
`worktree ps` uses, which lists a session the host holds whether or not a surface
is open. Two reasons:
- it is what the renderer already showed. The bridge this replaces only wrote a
row for a mounted `agent-session` tab, so requiring one keeps sidebar
visibility unchanged rather than adding rows as a side effect of the flip;
- a terminal tab in chat view mode is backed by a structured session too
(`TerminalPaneNativeChatPortal`), and its own pane already has a row.
Admitting the host's row for that session as well would put two rows in the
sidebar for one tab. `worktree ps` has carried that pair since PR 1a; the
sidebar must not inherit it here.
It also resolves the row's title from that tab. The wire deliberately carries no
title — naming a structured session is a separate lane — but the sidebar
synthesizes a tab for a paneless row (`worktree-agent-row-fallback-tab.ts`) and
names it `'Agent'` when the entry has none. The plan's reading that
`worktree-agent-row-type.ts` would fall back to `tab?.title` was about
AGENT-TYPE resolution, not the row's name, and it does not apply here: a
structured session has no `TerminalTab` at all. Reading the label in the renderer
keeps today's behavior without teaching main a name or putting one on the wire.
### The four fields the bridge wrote that main's ingest does not
- `terminalTitle` — resolved renderer-side from the session tab, as above.
- `structuredHostOwned` — the wire spells it `structuredHost: 'owned' | 'held'`,
and the applicator maps it back. It is the freshness bypass in
`shared/agent-status-freshness.ts`; without the mapping every native chat that
works for over half an hour decays to idle in the sidebar.
- `terminalResumeEligible: false` — kept, derived from `structuredHost` being
present at all. `agent-status-sleeping-records.ts` reads it: a structured row
carries a `providerSession`, so without this Orca mints a sleeping record and
offers to relaunch a native chat as a TUI.
- `allowOlderTimestamp` — dropped. The bridge needed it because it stamped the
journal clock as `updatedAt`, which a legacy publication can move backwards.
Main stamps `receivedAt = Math.max(Date.now(), watermark + 1)`
(`server-status-update.ts`), so the applicator's `data.receivedAt <
existingStatus.updatedAt` ordering check can never see a host row go backwards.
The journal clock still reaches the row, as `evidenceObservedAt`.
### The unmount cleanup stays, and the plan's reason it could go is wrong
The plan said the bridge's unmount cleanup becomes a tab-close signal to the
host. That signal already exists — `tab-group/workspace-tab-close-commands.ts`
calls `agentSession.close`, which reaches `forgetStructuredAgentSession` — but
sending it is not enough. The host drops its row through `dropStatusEntry`, which
notifies `subscribeStatusDrop` subscribers inside main and emits NO renderer
clear (only `clearPaneState` does, and the renderer's clear handler skips a row
already reading `done`, which is how a settled session ends). Deleting the
unmount write would therefore have left a ghost row in the sidebar for every
closed chat until the next reload.
So the removal stays, for the local lane as much as the remote one. It is the
"unmount" row of the disposition table, not a status write. A projection unmounts
when its tab leaves the tab map — a close or a host retraction — not on a
worktree switch: the bridge maps over every structured tab in every worktree.
### The bridge is retired for local sessions only
One store per execution host. A session on a REMOTE runtime is projected into
that host's store, and nothing mirrors structured rows down — the web-session
mirror carries terminal surfaces and their `agentStatus`, and an `agent-session`
surface in that snapshot has no status field. Deleting the bridge outright would
have left every remote native chat with no sidebar row.
So the bridge's STATUS WRITES are fenced on `target.kind === 'local'`: for a
local session it opens no feed and projects nothing, and for a remote one it
stays that session's writer, exactly as the disposition table already says for
the remote-runtime OSC parse. That is rule 3 of
[`remote-wire-compatibility.md`](./remote-wire-compatibility.md) — the fence
comes off when a remote store's rows are mirrored down, which is separate work.
### The disposition table, corrected
| Writer | Disposition |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
| structured bridge status writes | delete; main now publishes the row |
| structured bridge status writes, local sessions | deleted; main publishes the row |
| structured bridge status writes, remote sessions | kept; nothing mirrors a remote host's structured rows down |
| structured bridge unmount removal | kept for both lanes; `dropStatusEntry` emits no renderer clear |
| Command Code output seeds, parked-pane seeds | NOT deleted — see below |
| pty-exit removal | NOT deleted — see below |
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
| web-session mirror receipt clock | keep; the decay rule needs both clocks from one machine |
The Command Code done-settle window is renderer policy with no main
equivalent. PR 2b either moves it into main's detector or leaves it, and says
which.
The audit's "main already emits the same facts" was true of the word FACTS and
false of the store:
### The two filters this step removes
- **Command Code output seeds and parked-pane seeds.** Main's detector
(`orca-runtime-create-terminal-side-effect-command-code-detector.ts`) calls
`recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working' | 'command-code-done' })`.
That fact travels to the RENDERER, where
`terminal-side-effect-facts-handler.ts` turns it into the store write. Nothing
in main puts it in the hook server, and the write is gated on
`canCommandCodeOutputOwnPane`, which reads `paneForegroundAgentByPaneKey`,
`retainedAgentsByPaneKey` and `agentLaunchConfigByPaneKey` — renderer state
main does not have. Command Code has hooks for PreToolUse/PostToolUse/Stop but
no prompt-submit hook, which is why the scrape exists at all. Deleting these
seeds without first building a main-side ingest would leave Command Code with
no working row. That ingest is a producer-routing change, PR-1 shaped, not part
of "the renderer subscribes".
- **pty-exit removal** (`pty-connection/pty-exit-hibernate.ts`). Every
attributable exit reaches `clearProviderPtyState` -> `clearPaneState`, which
does emit a renderer clear. But `server-cleanup.ts` documents the case that
resolution misses: a restored or reattached PTY may never rebuild the
spawn-time `ptyPaneKey` mapping, and those panes "keep a `working` row and its
latches for good". The renderer's own removal is the last thing retiring them.
Both were added by PR 1a and are the only thing keeping main out of the
renderer's lane. Each is pinned by a test, so removing them should turn those
tests red first, deliberately:
### The Command Code done-settle window stays in the renderer
- `main/startup/main-window-agent-status.ts` — the `if (structuredHost) return`
guard above the `agentStatus:set` sends. It sits above BOTH the main window
and `getDashboardPopoutWindow()`, so removing it is also what first gives the
dashboard popout structured sessions.
- `main/ipc/agent-hooks.ts` — the `.filter((entry) => entry.structuredHost === undefined)`
on `agentStatus:getSnapshot`.
It is left where it is. Moving it into main's detector only makes sense together
with a main-side Command Code status ingest — the window exists to decide when a
row completes, and main writes no such row today. Moving the timer without the
row would put the deadline on one side of the process boundary and the write on
the other. It is the same deferral as the item above, and belongs with it.
### What "one writer" actually means after this
Not zero renderer writers. The IPC applicator becomes the single writer for
OBSERVED status; the table above keeps four categories on purpose. Of those,
only the launch placeholder seeds are a deferral rather than a principle —
main holds the launch config and could seed them, and that is the next thing
to remove after this step, not part of it.
Not zero renderer writers. The IPC applicator is the single writer for OBSERVED
status on a locally hosted structured session; the table above keeps the rest on
purpose. Of those, only the launch placeholder seeds are a deferral rather than a
principle — main holds the launch config and could seed them.
### Ordering
Depends on PR 2a. Removing these filters before the two derivations converge
renders one session twice and shifts the chat's elapsed clock.
Depended on PR 2a: with the two derivations disagreeing, removing these filters
rendered one session twice and shifted the chat's elapsed clock.
## PR 3: one rollup, one clock
@@ -54,8 +54,10 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
}
/** The host no longer holds the session; its last projection is history the journal keeps.
* `dropStatusEntry`, not `clearPaneState`: the renderer's own bridge still owns this pane key,
* so a pane-status-clear would make main a second writer for it. */
* `dropStatusEntry`, not `clearPaneState`: the pane caches and authority fences a pane-status
* clear tears down belong to a PTY, and a structured session never had any. The renderer's copy
* is taken out by the surface teardown in `StructuredAgentSessionStatusBridge`, since this drop
* emits no renderer clear. */
dropStructuredStatus(sessionId: string): void {
this.dropStatusEntry(structuredAgentSessionPaneKey(sessionId), {
preserveResumeIdentity: false
+10 -5
View File
@@ -152,9 +152,10 @@ describe('agentStatus:getSnapshot IPC', () => {
expect(handler!({})).toEqual(snapshot)
})
// The half-migration seam: until PR 2 retires the renderer's own feed bridge, main must not
// publish structured rows to the renderer at all — one pane key, one writer.
it('omits structured rows the renderer feed bridge still owns', async () => {
// PR 2b: the renderer subscribes instead of deriving, so a replay pull after hydration has to
// carry structured rows too — otherwise a native chat has no sidebar row until its next journal
// edge, and a settled one never comes back at all.
it('includes structured rows so a hydration replay does not lose native chats', async () => {
getStatusSnapshot.mockReturnValue([
{
paneKey: PANE_KEY,
@@ -179,8 +180,12 @@ describe('agentStatus:getSnapshot IPC', () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const rows = handleHandlers.get('agentStatus:getSnapshot')!({}) as { paneKey: string }[]
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY])
const rows = handleHandlers.get('agentStatus:getSnapshot')!({}) as {
paneKey: string
structuredHost?: string
}[]
expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY, CHILD_PANE_KEY])
expect(rows[1]?.structuredHost).toBe('owned')
})
it('enriches the hook cache snapshot with runtime lineage metadata', async () => {
+3 -7
View File
@@ -49,13 +49,9 @@ export function registerAgentHookHandlers(
// Why: the renderer pulls this after workspace hydration, so startup cannot
// lose replayed statuses while its local store is still empty. Match the
// live push enrichment in main/index.ts so parent/child rows survive replay.
return (
agentHookServer
.getStatusSnapshot()
// Same rule as the live push: the renderer's feed bridge owns structured rows for now.
.filter((entry) => entry.structuredHost === undefined)
.map((entry) => enrichAgentStatusIpcPayload(entry, runtime))
)
return agentHookServer
.getStatusSnapshot()
.map((entry) => enrichAgentStatusIpcPayload(entry, runtime))
})
ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => {
if (typeof request !== 'object' || request === null) {
+7 -7
View File
@@ -49,11 +49,6 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
if (state.mainWindow?.isDestroyed()) {
return
}
// Why: the renderer still derives structured rows from its own feed subscription; forwarding
// these too would give one pane key two writers until that bridge is retired.
if (structuredHost) {
return
}
if (providerSessionOnly) {
// Why: session_start just refreshes durable resume identity while Pi is idle; forward it without titles, telemetry, or status UI.
state.mainWindow?.webContents.send('agentStatus:set', {
@@ -72,7 +67,9 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
})
return
}
if (!restoredUnconfirmed) {
if (!restoredUnconfirmed && !structuredHost) {
// Why not structured rows: first-work branch rename is a PTY-agent feature whose
// structured-session gap is tracked on its own; publishing the row must not enable it.
options.maybeAutoRenameBranchOnFirstWork({ paneKey, tabId, worktreeId, payload, isReplay })
}
const runtime = state.runtime
@@ -102,7 +99,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
...(observation ? { observation } : {}),
...(orchestration ? { orchestration } : {})
...(orchestration ? { orchestration } : {}),
...(structuredHost ? { structuredHost } : {})
}
state.mainWindow?.webContents.send('agentStatus:set', statusEvent)
if (!suppressSyntheticCodexAutoApprovalTitle || isAskUserQuestionTool(payload.toolName)) {
@@ -113,6 +111,8 @@ export function installMainWindowAgentStatusListeners(options: MainWindowAgentSt
const profile = getSyntheticAgentTitleProfile(payload.agentType)
if (
profile &&
// A structured session has no PTY and no terminal title slot to inject one into.
!structuredHost &&
shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) &&
!suppressSyntheticCodexAutoApprovalTitle
) {
@@ -1,90 +0,0 @@
// The renderer half of the half-migration seam.
//
// Until PR 2 retires `StructuredAgentSessionStatusBridge`, the renderer writes structured rows
// itself. Main forwarding them too would give one pane key two writers, so the window listener
// drops them — a filter nothing else asserts, which makes deleting it green everywhere.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server'
const hooks = vi.hoisted(() => ({
listener: null as ((payload: EnrichedAgentHookEventPayload) => void) | null
}))
vi.mock('electron', () => ({
app: { getPath: () => '', on: vi.fn(), isReady: () => true }
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: {
setListener: (listener: ((payload: EnrichedAgentHookEventPayload) => void) | null) => {
hooks.listener = listener
},
setPaneStatusClearListener: vi.fn()
}
}))
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
setMigrationUnsupportedPtyListener: vi.fn()
}))
vi.mock('../window/dashboard-popout-window', () => ({
getDashboardPopoutWindow: () => null
}))
vi.mock('./synthetic-title-runtime', () => ({
driveSyntheticTitleFromHook: vi.fn(),
shouldSuppressCodexAutoApprovalSyntheticTitleFromHook: () => false,
stopAllSyntheticTitleSpinners: vi.fn()
}))
import { installMainWindowAgentStatusListeners } from './main-window-agent-status'
import { mainProcessState } from './main-process-state'
const sent: { channel: string; event: { paneKey: string } }[] = []
function statusPayload(
over: Partial<EnrichedAgentHookEventPayload>
): EnrichedAgentHookEventPayload {
return {
paneKey: 'pane-1',
tabId: 'tab-1',
worktreeId: 'repo::/wt',
connectionId: null,
receivedAt: 1,
stateStartedAt: 1,
payload: { state: 'working', prompt: 'ship it', agentType: 'codex' },
...over
} as EnrichedAgentHookEventPayload
}
beforeEach(() => {
sent.length = 0
hooks.listener = null
mainProcessState.runtime = null
mainProcessState.mainWindow = {
isDestroyed: () => false,
webContents: {
send: (channel: string, event: { paneKey: string }) => sent.push({ channel, event })
}
} as unknown as typeof mainProcessState.mainWindow
installMainWindowAgentStatusListeners({
window: mainProcessState.mainWindow!,
maybeAutoRenameBranchOnFirstWork: vi.fn(),
onRecordAgentState: vi.fn()
})
})
describe('the main-window agent-status listener', () => {
it('forwards a hook row but never a structured one', () => {
expect(hooks.listener).not.toBeNull()
hooks.listener!(statusPayload({ paneKey: 'hook-pane' }))
hooks.listener!(
statusPayload({
paneKey: 'structured-agent-session-s1:leaf',
structuredHost: 'owned'
})
)
expect(sent.map((entry) => `${entry.channel}:${entry.event.paneKey}`)).toEqual([
'agentStatus:set:hook-pane'
])
})
})
@@ -0,0 +1,125 @@
// PR 2b: main is the writer for a locally hosted structured session, so the window listener
// forwards its rows like any other. It sends to BOTH renderer windows, which is what first gives
// the dashboard popout structured sessions at all — the popout has never had them.
//
// The two things it must NOT do for a paneless row: drive a synthetic terminal title into a pane
// that does not exist, and trip first-work branch rename, whose structured-session gap is tracked
// on its own.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server'
const hooks = vi.hoisted(() => ({
listener: null as ((payload: EnrichedAgentHookEventPayload) => void) | null,
popout: null as { webContents: { send: (channel: string, event: unknown) => void } } | null,
driveSyntheticTitleFromHook: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => '', on: vi.fn(), isReady: () => true }
}))
vi.mock('../agent-hooks/server', () => ({
agentHookServer: {
setListener: (listener: ((payload: EnrichedAgentHookEventPayload) => void) | null) => {
hooks.listener = listener
},
setPaneStatusClearListener: vi.fn()
}
}))
vi.mock('../agent-hooks/migration-unsupported-pty-state', () => ({
setMigrationUnsupportedPtyListener: vi.fn()
}))
vi.mock('../window/dashboard-popout-window', () => ({
getDashboardPopoutWindow: () => hooks.popout
}))
vi.mock('./synthetic-title-runtime', () => ({
driveSyntheticTitleFromHook: hooks.driveSyntheticTitleFromHook,
shouldSuppressCodexAutoApprovalSyntheticTitleFromHook: () => false,
stopAllSyntheticTitleSpinners: vi.fn()
}))
import { installMainWindowAgentStatusListeners } from './main-window-agent-status'
import { mainProcessState } from './main-process-state'
const sent: { channel: string; event: { paneKey: string } }[] = []
const popoutSent: { channel: string; event: { paneKey: string } }[] = []
const autoRenamed: { paneKey: string }[] = []
const STRUCTURED_PANE_KEY = 'structured-agent-session-s1:leaf'
function statusPayload(
over: Partial<EnrichedAgentHookEventPayload>
): EnrichedAgentHookEventPayload {
return {
paneKey: 'pane-1',
tabId: 'tab-1',
worktreeId: 'repo::/wt',
connectionId: null,
receivedAt: 1,
stateStartedAt: 1,
payload: { state: 'working', prompt: 'ship it', agentType: 'codex' },
...over
} as EnrichedAgentHookEventPayload
}
beforeEach(() => {
sent.length = 0
popoutSent.length = 0
autoRenamed.length = 0
hooks.driveSyntheticTitleFromHook.mockClear()
hooks.listener = null
hooks.popout = {
webContents: {
send: (channel: string, event: unknown) =>
popoutSent.push({ channel, event: event as { paneKey: string } })
}
}
mainProcessState.runtime = null
mainProcessState.mainWindow = {
isDestroyed: () => false,
webContents: {
send: (channel: string, event: { paneKey: string }) => sent.push({ channel, event })
}
} as unknown as typeof mainProcessState.mainWindow
installMainWindowAgentStatusListeners({
window: mainProcessState.mainWindow!,
maybeAutoRenameBranchOnFirstWork: (event) => autoRenamed.push({ paneKey: event.paneKey }),
onRecordAgentState: vi.fn()
})
})
describe('the main-window agent-status listener', () => {
it('forwards a structured row to both renderer windows, carrying its host ownership', () => {
expect(hooks.listener).not.toBeNull()
hooks.listener!(statusPayload({ paneKey: 'hook-pane' }))
hooks.listener!(statusPayload({ paneKey: STRUCTURED_PANE_KEY, structuredHost: 'owned' }))
expect(sent.map((entry) => `${entry.channel}:${entry.event.paneKey}`)).toEqual([
'agentStatus:set:hook-pane',
`agentStatus:set:${STRUCTURED_PANE_KEY}`
])
// The popout has never had structured sessions; removing the send guard is what gives it them.
expect(popoutSent.map((entry) => `${entry.channel}:${entry.event.paneKey}`)).toEqual([
'agentStatus:set:hook-pane',
`agentStatus:set:${STRUCTURED_PANE_KEY}`
])
expect(sent[1]?.event).toMatchObject({ structuredHost: 'owned' })
})
it('drives no synthetic terminal title and no first-work rename for a paneless row', () => {
// `blocked` is a state the codex profile does synthesize a title for, so the PTY row proves
// the arm is live and the structured row proves it is skipped.
const blocked = {
payload: { state: 'blocked', prompt: 'ship it', agentType: 'codex' }
} as Partial<EnrichedAgentHookEventPayload>
hooks.listener!(statusPayload({ paneKey: 'hook-pane', ...blocked }))
hooks.listener!(
statusPayload({ paneKey: STRUCTURED_PANE_KEY, structuredHost: 'held', ...blocked })
)
expect(autoRenamed).toEqual([{ paneKey: 'hook-pane' }])
expect(hooks.driveSyntheticTitleFromHook.mock.calls.map((call) => call[0])).toEqual([
'hook-pane'
])
})
})
@@ -124,7 +124,10 @@ describe('StructuredAgentSessionStatusBridge', () => {
mocks.supportsCapability.mockResolvedValue(true)
mocks.store?.setState({
agentStatusByPaneKey: {},
testRuntimeOwner: null,
// PR 2b: the LOCAL host publishes its own sessions over `agentStatus:set`, so the bridge
// no longer writes for them. What it still owns is a session on a remote runtime, whose
// store nothing mirrors down — that is the lane these cases drive.
testRuntimeOwner: 'env-1',
unifiedTabsByWorktree: { 'wt-1': [structuredTab] }
})
})
@@ -165,7 +168,7 @@ describe('StructuredAgentSessionStatusBridge', () => {
it('projects the host status feed without opening a transcript reader', async () => {
render(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
expect(feed().target).toEqual({ kind: 'local' })
expect(feed().target).toEqual({ kind: 'environment', environmentId: 'env-1' })
expect(mocks.subscribeTranscript).not.toHaveBeenCalled()
act(() => feed().emit({ type: 'snapshot', sessions: [summary()] }))
@@ -606,6 +609,39 @@ describe('StructuredAgentSessionStatusBridge', () => {
expect(feed().target).toEqual({ kind: 'environment', environmentId: 'env-1' })
})
// PR 2b: main publishes the row for a locally hosted session and the IPC applicator applies it.
// A second writer here would race the applicator on one pane key.
it('writes nothing and opens no feed for a locally hosted session', async () => {
mocks.store?.setState({ testRuntimeOwner: null })
render(<StructuredAgentSessionStatusBridge />)
await act(() => Promise.resolve())
expect(mocks.subscribeStatus).not.toHaveBeenCalled()
expect(mocks.setAgentStatus).not.toHaveBeenCalled()
expect(statuses()).toEqual([])
})
// The host drops its own row on `agentSession.close`, but that goes through `dropStatusEntry`,
// which emits no renderer clear — so the last surface to leave is still what takes this copy
// out of the store, for a locally hosted session as much as a remote one.
it('still clears a locally hosted row when its last surface leaves', async () => {
mocks.store?.setState({
testRuntimeOwner: null,
agentStatusByPaneKey: {
[structuredAgentSessionPaneKey('session-1')]: {
paneKey: structuredAgentSessionPaneKey('session-1')
} as AgentStatusEntry
}
})
render(<StructuredAgentSessionStatusBridge />)
await act(() => Promise.resolve())
act(() => mocks.store?.setState({ unifiedTabsByWorktree: { 'wt-1': [] } }))
expect(mocks.removeAgentStatus).toHaveBeenCalledWith(structuredAgentSessionPaneKey('session-1'))
expect(statuses()).toEqual([])
})
it('does not project an unknown provider as Codex', async () => {
mocks.store?.setState({
unifiedTabsByWorktree: {
@@ -54,21 +54,26 @@ export function getStructuredAgentSessionTabs(
return tabs
}
const NO_SUBSCRIPTION = (): (() => void) => () => {}
/** The host's projected status for one session, live while the caller is mounted. */
function useStructuredAgentSessionStatusSummary(
sessionId: string,
target: RuntimeClientTarget
target: RuntimeClientTarget,
enabled: boolean
): { summary: AgentSessionStatusSummary | null; observation: 'live' | 'unverifiable' } {
const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target])
useEffect(() => feed.activate(), [feed])
useEffect(() => (enabled ? feed.activate() : undefined), [feed, enabled])
const summary = useSyncExternalStore(
feed.subscribe,
() => feed.getSnapshot().get(sessionId) ?? null,
enabled ? feed.subscribe : NO_SUBSCRIPTION,
() => (enabled ? (feed.getSnapshot().get(sessionId) ?? null) : null),
() => null
)
// Gated with the summary: a disabled bridge must open no feed at all, and an
// unsubscribed session is exactly what `unverifiable` means.
const observation = useSyncExternalStore(
feed.subscribe,
() => feed.getSessionObservation(sessionId),
enabled ? feed.subscribe : NO_SUBSCRIPTION,
() => (enabled ? feed.getSessionObservation(sessionId) : ('unverifiable' as const)),
() => 'unverifiable' as const
)
return { summary, observation }
@@ -211,14 +216,31 @@ function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab })
() => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }),
[environmentId]
)
const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target)
// The local host publishes its own sessions into the agent-status store, and the IPC applicator
// is the single writer for them. A remote host writes into ITS store, which nothing mirrors down
// (the web-session mirror carries terminal surfaces only), so this surface stays that session's
// writer until a mirror exists — rule 3 of remote-wire-compatibility.md.
const hostPublishesRow = target.kind === 'local'
const { summary, observation } = useStructuredAgentSessionStatusSummary(
tab.entityId,
target,
!hostPublishesRow
)
useEffect(() => {
if (hostPublishesRow) {
return
}
projectStatus(tab, summary, observation)
}, [summary, observation, tab])
}, [hostPublishesRow, summary, observation, tab])
// Kept for BOTH lanes — this is the "unmount" row of the writer disposition, not a status write.
// A projection unmounts when its tab leaves the tab map, which is a close or a host retraction;
// the host drops its own row on `agentSession.close` (`forgetStructuredAgentSession`), but that
// drop goes through `dropStatusEntry`, which emits no renderer clear, so nothing else would take
// this copy out of the store.
useEffect(
() => () => {
const state = useAppStore.getState()
// Two surfaces can mirror one session (the `:history-N` id collision path), and they now share
// Two surfaces can mirror one session (the `:history-N` id collision path), and they share
// one pane key. Only the last surface to leave clears the row.
const stillMirrored = getStructuredAgentSessionTabs(state.unifiedTabsByWorktree).some(
(candidate) => candidate.entityId === tab.entityId && candidate.id !== tab.id
@@ -0,0 +1,71 @@
import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature'
import type { AgentStatusUpdate } from '@/store/slices/agent-status'
import { useAppStore } from '../../store'
import {
applyResolvedAgentTerminalTitleToTab,
shouldApplyResolvedAgentTerminalTitleToTab
} from './agent-status-routing'
import type { AgentStatusApplyOptions, AgentStatusApplyResult } from './agent-status-bridge-types'
/** The title slots one accepted status may overwrite, resolved before the commit picks a lane. */
export type AgentStatusTitleWrite = {
paneKey: string
ownerTabId: string | undefined
/** The TAB record's title, which is the slot this path writes; see agent-status-routing.ts. */
tabTitle: string | undefined
terminalTitle: string | undefined
title: string | undefined
identityTitle: string | undefined
titleUsesTabTitle: boolean
}
/**
* Commits one accepted status update, into the open batch or straight to the store.
*
* The two lanes have to stay paired: a batch stages the tab title (and the projected title a later
* event in the same batch reads) instead of writing it, and defers the completion notification to
* its post-commit phase, because a rolled-back transaction must not have notified.
*/
export function commitAgentStatusUpdate(args: {
store: ReturnType<typeof useAppStore.getState>
update: AgentStatusUpdate
batch: AgentStatusApplyOptions['batch']
notify: () => void
titleWrite: AgentStatusTitleWrite
}): AgentStatusApplyResult {
const { store, update, batch, notify, titleWrite } = args
const { paneKey, ownerTabId, tabTitle, terminalTitle, title, identityTitle } = titleWrite
if (!batch) {
store.setAgentStatus(
update.paneKey,
update.payload,
update.terminalTitle,
update.timing,
update.routing,
update.metadata
)
applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, tabTitle, terminalTitle)
notify()
return 'applied'
}
if (!batch.transaction.apply(update)) {
return 'dropped'
}
batch.notificationEffects.push(notify)
if (
!terminalTitle ||
!ownerTabId ||
!shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, tabTitle, terminalTitle)
) {
return 'applied'
}
batch.tabTitlesByTabId.set(ownerTabId, terminalTitle)
if (titleWrite.titleUsesTabTitle) {
const titleChanges = !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle)
batch.projectedTitlesByTabId.set(ownerTabId, {
title: titleChanges ? terminalTitle : title,
identityTitle: titleChanges ? terminalTitle : identityTitle
})
}
return 'applied'
}
@@ -4,7 +4,6 @@ import {
resolveAgentStatusIdentity,
shouldSuppressInheritedTerminalStatus
} from '../../../../shared/agent-status-identity'
import { isDecorativeAgentTitleFrameChange } from '../../../../shared/agent-decorative-title-signature'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression'
import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title'
@@ -14,12 +13,11 @@ import type { AgentStatusBatchUpdate, AgentStatusUpdate } from '@/store/slices/a
import { observeAgentHookCompletionForNotification } from '../agent-hook-completion-notifications'
import { useAppStore } from '../../store'
import {
applyResolvedAgentTerminalTitleToTab,
hasRuntimeBackedWorktreeAttribution,
isAgentStatusForRecentlyClosedTab,
resolveHookPayloadAgentType,
shouldApplyResolvedAgentTerminalTitleToTab
resolveHookPayloadAgentType
} from './agent-status-routing'
import { commitAgentStatusUpdate } from './agent-status-apply-commit'
import {
createAgentStatusPaneRoutingIndex,
resolvePaneKeyFromRoutingIndex,
@@ -31,6 +29,10 @@ import type {
PendingAgentStatusEvent
} from './agent-status-bridge-types'
import { normalizeAgentStatusEvent } from './normalize-agent-status-event'
import {
resolveStructuredAgentSessionRowRouting,
structuredAgentSessionRowMetadata
} from './structured-agent-session-row-routing'
export function createAgentStatusEventApplicator(args: {
pendingAgentStatusEvents: PendingAgentStatusEvent[]
@@ -81,6 +83,22 @@ export function createAgentStatusEventApplicator(args: {
identityTitle = projectedTitles.identityTitle
}
tabTitle = options?.batch?.tabTitlesByTabId.get(ownerTabId ?? '') ?? tabTitle
const structuredRouting = data.structuredHost
? resolveStructuredAgentSessionRowRouting(store, ownerTabId)
: undefined
if (structuredRouting) {
// The host owns this row outright: no pane and no connection to arbitrate. `tabTitle` is
// set to the same value so the tab-title write-back below is a no-op — a structured
// session has no terminal tab record to write into.
exists = true
owningWorktreeId = structuredRouting.worktreeId
repoConnectionId = null
repoConnectionResolved = true
title = structuredRouting.title
identityTitle = structuredRouting.title
titleUsesTabTitle = false
tabTitle = structuredRouting.title
}
if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) {
const fallbackOwnership = resolveWorktreeConnectionFromRoutingIndex(
routingIndex,
@@ -220,6 +238,7 @@ export function createAgentStatusEventApplicator(args: {
) {
return 'dropped'
}
const structuredMetadata = structuredAgentSessionRowMetadata(data)
const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
const statusWorktreeId = data.worktreeId ?? owningWorktreeId
const update: AgentStatusUpdate = {
@@ -240,8 +259,9 @@ export function createAgentStatusEventApplicator(args: {
...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {})
},
metadata:
data.providerSession || data.launchToken
data.providerSession || data.launchToken || structuredMetadata
? {
...structuredMetadata,
...(data.providerSession ? { providerSession: data.providerSession } : {}),
...(data.launchToken ? { launchToken: data.launchToken } : {})
}
@@ -261,39 +281,21 @@ export function createAgentStatusEventApplicator(args: {
})
}
}
if (options?.batch) {
if (!options.batch.transaction.apply(update)) {
return 'dropped'
return commitAgentStatusUpdate({
store,
update,
batch: options?.batch,
notify: applyPostCommitNotification,
titleWrite: {
paneKey,
ownerTabId,
tabTitle,
terminalTitle,
title,
identityTitle,
titleUsesTabTitle
}
options.batch.notificationEffects.push(applyPostCommitNotification)
if (
terminalTitle &&
shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, tabTitle, terminalTitle)
) {
if (ownerTabId) {
options.batch.tabTitlesByTabId.set(ownerTabId, terminalTitle)
if (titleUsesTabTitle) {
const titleChanges = !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle)
options.batch.projectedTitlesByTabId.set(ownerTabId, {
title: titleChanges ? terminalTitle : title,
identityTitle: titleChanges ? terminalTitle : identityTitle
})
}
}
}
} else {
store.setAgentStatus(
update.paneKey,
update.payload,
update.terminalTitle,
update.timing,
update.routing,
update.metadata
)
applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, tabTitle, terminalTitle)
applyPostCommitNotification()
}
return 'applied'
})
}
return applyAgentStatus
@@ -0,0 +1,63 @@
import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection'
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import type { AgentStatusMetadata } from '@/store/slices/agent-status'
import type { AppState } from '../../store/types'
/**
* Routing for a status row the host published for a structured (native chat) session.
*
* A structured session has no PTY and no terminal tab, so the terminal-tab routing index the PTY
* path uses resolves nothing for its pane key. Its surface is an `agent-session` tab in
* `unifiedTabsByWorktree`, and that tab is REQUIRED here — deliberately narrower than the
* admission rule `worktree ps` uses, which lists a session the host holds whether or not any
* surface is open:
*
* - it is what the renderer already showed. The bridge this replaces only ever wrote a row for a
* mounted `agent-session` tab, so requiring one keeps sidebar visibility unchanged;
* - a terminal tab in chat view mode is backed by a structured session too
* (`TerminalPaneNativeChatPortal`), and it already has its own pane's row. Admitting the host's
* row for that session as well would put two rows in the sidebar for one tab.
*/
export function resolveStructuredAgentSessionRowRouting(
state: Pick<AppState, 'unifiedTabsByWorktree'>,
ownerTabId: string | undefined
): { worktreeId: string; title: string | undefined } | undefined {
if (!ownerTabId) {
return undefined
}
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree ?? {})) {
for (const tab of tabs) {
if (
tab.contentType === 'agent-session' &&
// A mirrored session whose derived id was occupied is re-hosted at `${baseId}:history-N`,
// so match the id derived from the session as well as the surface's own.
(tab.id === ownerTabId || structuredAgentSessionTabId(tab.entityId) === ownerTabId)
) {
// The chat's own name, read from this renderer's tab state — the row carries no title,
// and the sidebar's synthesized tab for a paneless row would otherwise read 'Agent'.
return {
worktreeId,
title: tab.customLabel?.trim() || tab.label?.trim() || undefined
}
}
}
}
return undefined
}
/** Row facets that follow from the host owning this session, not from anything a pane reported. */
export function structuredAgentSessionRowMetadata(
data: Pick<AgentStatusIpcPayload, 'structuredHost'>
): AgentStatusMetadata | undefined {
if (!data.structuredHost) {
return undefined
}
return {
// No terminal to resume into: the session restores itself from its journal, and a resume
// record minted for it would offer to relaunch a native chat as a TUI.
terminalResumeEligible: false,
// The freshness bypass in agent-status-freshness.ts. Without it an owned session that works
// for over half an hour decays to stale while the host is still running it.
...(data.structuredHost === 'owned' ? { structuredHostOwned: true as const } : {})
}
}
@@ -0,0 +1,271 @@
// PR 2b: main publishes a structured (native chat) session's row over `agentStatus:set`, and the
// renderer applies it instead of deriving its own. A structured pane key resolves to no terminal
// tab, so before this the applicator held every such row as `pending` forever — removing main's
// two filters on its own would have published rows the renderer then dropped on the floor.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildWorktreeAgentRows } from '@/components/sidebar/worktree-agent-rows'
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { createTestStore } from '@/store/slices/store-test-helpers'
import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../../../shared/agent-status-types'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../../../shared/structured-agent-session-projection'
import type { Tab } from '../../../../shared/tab-types'
import type { AgentStatusSetData } from '../ipc-events-agent-status-store-test-fixtures'
import { buildWindowApi } from '../ipc-events-agent-status-window-test-fixtures'
vi.mock('../agent-hook-completion-notifications', () => ({
observeAgentHookCompletionForNotification: vi.fn(),
syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn()
}))
const SESSION_ID = 'session-2b'
const WORKTREE_ID = 'repo-1::/wt-1'
const STRUCTURED_PANE_KEY = structuredAgentSessionPaneKey(SESSION_ID)
const PTY_PANE_KEY = makePaneKey('tab-pty', '11111111-1111-4111-8111-111111111111')
const structuredTab: Tab = {
id: structuredAgentSessionTabId(SESSION_ID),
worktreeId: WORKTREE_ID,
groupId: 'group-1',
contentType: 'agent-session',
entityId: SESSION_ID,
label: 'Refactor the store',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0,
isPinned: false,
agentSessionAgent: 'codex'
}
/** Exactly the shape `main/startup/main-window-agent-status.ts` sends for a structured row. */
function hostRow(over: Partial<AgentStatusIpcPayload> = {}): AgentStatusSetData {
const now = Date.now()
return {
paneKey: STRUCTURED_PANE_KEY,
tabId: structuredAgentSessionTabId(SESSION_ID),
worktreeId: WORKTREE_ID,
connectionId: null,
state: 'working',
agentType: 'codex',
prompt: 'hello',
receivedAt: now,
stateStartedAt: now,
evidenceObservedAt: now,
structuredHost: 'owned',
...over
} as AgentStatusSetData
}
function ptyRow(over: Partial<AgentStatusIpcPayload> = {}): AgentStatusSetData {
const now = Date.now()
return {
paneKey: PTY_PANE_KEY,
tabId: 'tab-pty',
worktreeId: WORKTREE_ID,
connectionId: null,
state: 'working',
agentType: 'claude',
prompt: 'pty work',
receivedAt: now,
stateStartedAt: now,
...over
} as AgentStatusSetData
}
async function withBridge(
run: (args: {
store: ReturnType<typeof createTestStore>
send: (row: AgentStatusSetData) => void
}) => Promise<void>
): Promise<void> {
vi.resetModules()
const store = createTestStore()
store.setState({
workspaceSessionReady: true,
activeWorktreeId: null,
unifiedTabsByWorktree: { [WORKTREE_ID]: [structuredTab] },
tabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'tab-pty',
ptyId: 'pty-1',
worktreeId: WORKTREE_ID,
title: 'Claude',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 0
}
]
},
terminalLayoutsByTabId: {
'tab-pty': {
root: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' },
activeLeafId: '11111111-1111-4111-8111-111111111111',
expandedLeafId: null,
titlesByLeafId: {}
}
}
} as never)
let onSet: (payload: AgentStatusSetData) => void = () => {
throw new Error('listener missing')
}
vi.doMock('../../store', () => ({ useAppStore: store }))
vi.stubGlobal(
'window',
buildWindowApi({
getSnapshot: async () => [],
onSet: (callback) => {
onSet = callback
return () => {}
}
})
)
const { registerAgentStatusIpcBridge } = await import('./agent-status-ipc-bridge')
const unsubs: (() => void)[] = []
const bridge = registerAgentStatusIpcBridge(unsubs)
try {
await run({ store, send: (row) => onSet(row) })
} finally {
bridge.disposeAsyncState()
bridge.unsubscribeStore()
unsubs.forEach((unsubscribe) => unsubscribe())
}
}
afterEach(() => {
vi.doUnmock('../../store')
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('a host-published structured session row', () => {
it('lands as exactly one row, on the key the host and the renderer both derive', async () => {
await withBridge(async ({ store, send }) => {
send(hostRow())
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]).toBeDefined()
})
const rows = store.getState().agentStatusByPaneKey
expect(Object.keys(rows)).toEqual([STRUCTURED_PANE_KEY])
expect(rows[STRUCTURED_PANE_KEY]).toMatchObject({
state: 'working',
prompt: 'hello',
agentType: 'codex',
worktreeId: WORKTREE_ID,
// No terminal to resume into; without this Orca offers to relaunch the chat as a TUI.
terminalResumeEligible: false,
structuredHostOwned: true
})
})
})
it('settles to done on the same key instead of sticking on working', async () => {
await withBridge(async ({ store, send }) => {
send(hostRow())
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]?.state).toBe('working')
})
send(hostRow({ state: 'done', receivedAt: Date.now() + 10 }))
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]?.state).toBe('done')
})
expect(Object.keys(store.getState().agentStatusByPaneKey)).toEqual([STRUCTURED_PANE_KEY])
})
})
// The bypass in shared/agent-status-freshness.ts fires on `structuredHostOwned`, which the wire
// spells `structuredHost: 'owned'`. Lose the mapping and every native chat that works for over
// half an hour silently decays to idle in the sidebar.
it('stays fresh past the 30-minute window while the host owns it', async () => {
await withBridge(async ({ store, send }) => {
const observedAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1
send(hostRow({ evidenceObservedAt: observedAt }))
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]).toBeDefined()
})
const entry = store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]
expect(entry.structuredHostOwned).toBe(true)
expect(isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS)).toBe(true)
})
})
it('drops the ownership flag once the host revokes live execution', async () => {
await withBridge(async ({ store, send }) => {
send(hostRow())
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]).toBeDefined()
})
send(hostRow({ structuredHost: 'held', receivedAt: Date.now() + 10 }))
await vi.waitFor(() => {
expect(
store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]?.structuredHostOwned
).toBeUndefined()
})
})
})
// The wire carries no title (deliberately: naming a structured session is its own lane), so the
// row's name has to come from this renderer's own tab state. The sidebar synthesizes a tab for a
// paneless row and would otherwise name it 'Agent'.
it('names its sidebar row from the session tab, not the wire', async () => {
await withBridge(async ({ store, send }) => {
send(hostRow())
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]).toBeDefined()
})
const entry = store.getState().agentStatusByPaneKey[STRUCTURED_PANE_KEY]
const rows = buildWorktreeAgentRows({
tabs: [],
entries: [entry as AgentStatusEntry],
retained: [],
now: Date.now()
})
expect(rows).toHaveLength(1)
expect(rows[0].tab.title).toBe('Refactor the store')
expect(rows[0].paneKey).toBe(STRUCTURED_PANE_KEY)
})
})
// A terminal tab in chat view mode is backed by a structured session too, and its own pane
// already has a row. Admitting the host's row for that session as well puts two rows in the
// sidebar for one tab — so the renderer requires an `agent-session` surface, which is narrower
// than the admission rule `worktree ps` uses and is exactly what the sidebar showed before.
it('does not admit a session with no agent-session surface in this renderer', async () => {
await withBridge(async ({ store, send }) => {
store.setState({ unifiedTabsByWorktree: { [WORKTREE_ID]: [] } } as never)
send(hostRow())
send(ptyRow())
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[PTY_PANE_KEY]).toBeDefined()
})
expect(Object.keys(store.getState().agentStatusByPaneKey)).toEqual([PTY_PANE_KEY])
})
})
it('leaves a PTY row untouched', async () => {
await withBridge(async ({ store, send }) => {
send(ptyRow())
await vi.waitFor(() => {
expect(store.getState().agentStatusByPaneKey[PTY_PANE_KEY]).toBeDefined()
})
const entry = store.getState().agentStatusByPaneKey[PTY_PANE_KEY]
expect(entry).toMatchObject({ state: 'working', agentType: 'claude' })
expect(entry.structuredHostOwned).toBeUndefined()
expect(entry.terminalResumeEligible).toBeUndefined()
})
})
})