mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 19:17:28 +00:00
feat(native-chat): the chat strip reads the host's child records with its parent's verdict
The strip's roster now renders from the child views its channel carries, and passes the verdict the session's own status row gives its children, built the way the sidebar builds it (the row's freshness and the status feed's observation). So one child reads the same in the strip and the sidebar, live, after the transport drops, and once the row goes stale. A roster of finished children stays shown until the next turn but no longer animates the monitoring indicator or blocks conversation commands. Tests: an end-to-end run on a host with no renderer (a real hook server as the status sink) shows the summary and the strip channel carrying the same records at every step, the parent row folded from them, retention, and an older client's task list holding live work only; a wired renderer test shows both surfaces agree when live, lost and stale.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AGENT_LAUNCH_RUNTIME_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY,
|
||||
@@ -51,11 +52,12 @@ const REMOTE_ONLY_BY_DECISION: readonly RuntimeCapability[] = [
|
||||
]
|
||||
|
||||
/** Gates the renderer must pass against its own main process. The Electron remote list omits all
|
||||
* five; mobile advertises the structured ones, so this is an Electron-remote gap rather than a
|
||||
* six; mobile advertises the structured ones, so this is an Electron-remote gap rather than a
|
||||
* statement that no remote client wants them. Why it is one is not recorded here. */
|
||||
const LOCAL_ONLY_BY_DECISION: readonly RuntimeCapability[] = [
|
||||
AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY,
|
||||
AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY,
|
||||
AGENT_SESSION_TURN_ITEM_CAPABILITY,
|
||||
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
|
||||
CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionProviderChildIdentity } from './structured-agent-session-host-types'
|
||||
|
||||
export function indexedStatusFeedSession(session: {
|
||||
export function indexedStatusFeedSession<P extends AgentSessionHandleProvider = 'codex'>(session: {
|
||||
journal: AgentSessionJournal
|
||||
child?: (StructuredAgentSessionProviderChildIdentity & { phase: 'starting' | 'ready' }) | null
|
||||
provider?: AgentSessionHandleProvider
|
||||
provider?: P
|
||||
}) {
|
||||
return {
|
||||
journal: session.journal,
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
// The switch, end to end on a host with no renderer: a Codex session's frames reach the host's
|
||||
// canonical store, and the status summary every session list reads and the chat strip's channel
|
||||
// both carry the same child records from it, while the parent row is folded from them too.
|
||||
//
|
||||
// Deliberately NOT a comparison against the provider tracker's task list: that tracker and the
|
||||
// record producer read the same child executions, so agreeing with it could not catch a defect in
|
||||
// what those executions observe. Every expectation below is written out literally.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentSessionBackgroundTaskState,
|
||||
AgentSessionStatusSummary
|
||||
} from '../../shared/agent-session-wire'
|
||||
import type { AgentChildWorkView } from '../../shared/agent-status-child-work-view'
|
||||
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
|
||||
import type {
|
||||
CodexAppServerConnection,
|
||||
CodexAppServerConnectionHandlers,
|
||||
openCodexAppServerConnection
|
||||
} from '../codex/codex-app-server-connection'
|
||||
import { AgentHookServer, _internals } from '../agent-hooks/server'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
hostTestAttachParams
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
||||
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
|
||||
import {
|
||||
ensureStructuredAgentSessionHost,
|
||||
stopStructuredAgentSessionRuntime
|
||||
} from './structured-agent-session-runtime'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
|
||||
|
||||
const THREAD = 'thread-switch'
|
||||
const REVIEWER = 'thread-reviewer'
|
||||
const ROUTES: Record<string, unknown> = {
|
||||
'thread/start': { thread: { id: THREAD } },
|
||||
'model/list': {
|
||||
data: [
|
||||
{
|
||||
model: 'gpt-test',
|
||||
displayName: 'GPT Test',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
isDefault: true
|
||||
}
|
||||
],
|
||||
nextCursor: null
|
||||
}
|
||||
}
|
||||
|
||||
type Row = Pick<AgentChildWorkView, 'kind' | 'description' | 'state' | 'membership'> & {
|
||||
outcome?: AgentChildWorkView['outcome']
|
||||
tool?: string
|
||||
owner?: string
|
||||
}
|
||||
|
||||
/** What a surface would show of each child, independent of which channel carried it. */
|
||||
function rows(views: readonly AgentChildWorkView[] | undefined): Row[] {
|
||||
const byId = new Map((views ?? []).map((view) => [view.id, view]))
|
||||
return (views ?? []).map((view) => ({
|
||||
kind: view.kind,
|
||||
...(view.description ? { description: view.description } : {}),
|
||||
state: view.state,
|
||||
membership: view.membership,
|
||||
...(view.outcome ? { outcome: view.outcome } : {}),
|
||||
...(view.operation ? { tool: `${view.operation.toolName}: ${view.operation.input}` } : {}),
|
||||
...(view.parentChildWorkId
|
||||
? { owner: byId.get(view.parentChildWorkId)?.description ?? '?' }
|
||||
: {})
|
||||
}))
|
||||
}
|
||||
|
||||
describe('the chat strip and the session list read the same host child records', () => {
|
||||
let root: string
|
||||
let server: AgentHookServer
|
||||
|
||||
beforeEach(async () => {
|
||||
_internals.resetCachesForTests()
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-child-records-switch-'))
|
||||
server = new AgentHookServer()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stopStructuredAgentSessionRuntime()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('carries one record per child to both, and folds the parent row from them', async () => {
|
||||
const connections: CodexAppServerConnectionHandlers[] = []
|
||||
const openConnection: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
|
||||
connections.push(handlers)
|
||||
const connection: CodexAppServerConnection = {
|
||||
pid: 4321,
|
||||
closed: false,
|
||||
request: async (method) => (method in ROUTES ? ROUTES[method] : {}),
|
||||
notify: () => {},
|
||||
respond: () => {},
|
||||
respondWithError: () => {},
|
||||
close: async () => true
|
||||
}
|
||||
return connection
|
||||
}
|
||||
// Production's sink wiring, onto a real hook server: no renderer is involved anywhere.
|
||||
const statusSink: StructuredAgentSessionStatusSink = {
|
||||
publish: (summary, subject) => server.ingestStructuredStatus(summary, subject),
|
||||
forget: (subject) => server.dropStructuredStatus(subject),
|
||||
publishChildWork: (subject, evidence, provider) =>
|
||||
server.ingestStructuredChildWork(subject, evidence, provider),
|
||||
readChildWork: (subject) => server.getStructuredChildWorkViews(subject)
|
||||
}
|
||||
const host = await ensureStructuredAgentSessionHost({
|
||||
stateDirectory: root,
|
||||
hostId: 'local',
|
||||
claimKeyId: 'key-1',
|
||||
resolveWorkspacePath: async () => root,
|
||||
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
|
||||
resolveCodexCommand: () => 'codex',
|
||||
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
|
||||
openCodexConnection: openConnection,
|
||||
readProcessStartTime: async () => 1_700_000_000_000,
|
||||
statusSink
|
||||
})
|
||||
const attachParams = hostTestAttachParams(null, { providerHandle: undefined })
|
||||
attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}`
|
||||
expect(await host.attach({ callerKey: 'switch-test' }, attachParams)).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
await host.hold(SESSION, 'desktop-chat:1')
|
||||
const summaries: AgentSessionStatusSummary[] = []
|
||||
host.subscribeStatus({
|
||||
id: 'session-list',
|
||||
emit: (event) => {
|
||||
if (event.type === 'status') {
|
||||
summaries.push(event.session)
|
||||
} else if (event.type === 'snapshot') {
|
||||
summaries.push(...event.sessions.filter((session) => session.sessionId === SESSION))
|
||||
}
|
||||
}
|
||||
})
|
||||
const strip: (AgentSessionBackgroundTaskState | null)[] = []
|
||||
host.subscribe({
|
||||
id: 'strip',
|
||||
sessionId: SESSION,
|
||||
emit: (event) => {
|
||||
if ('backgroundTasks' in event && event.backgroundTasks !== undefined) {
|
||||
strip.push(event.backgroundTasks)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Journal writes land asynchronously; let each frame's rows publish before the next.
|
||||
const notify = async (method: string, params: Record<string, unknown>) => {
|
||||
connections[0]?.onNotification?.(method, params)
|
||||
for (let tick = 0; tick < 5; tick += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
}
|
||||
const item = (
|
||||
method: 'item/started' | 'item/completed',
|
||||
threadId: string,
|
||||
turnId: string,
|
||||
value: Record<string, unknown>
|
||||
) => notify(method, { threadId, turnId, item: value })
|
||||
const parentRow = () => server.getStatusSnapshot().find((row) => row.structuredHost)
|
||||
const summaryRows = () => rows(summaries.at(-1)?.children)
|
||||
const stripRows = () => rows(strip.at(-1)?.children ?? undefined)
|
||||
const both = (expected: Row[]) => {
|
||||
expect(summaryRows()).toEqual(expected)
|
||||
expect(stripRows()).toEqual(expected)
|
||||
}
|
||||
|
||||
await notify('turn/started', { threadId: THREAD, turn: { id: 'p1', status: 'inProgress' } })
|
||||
await item('item/completed', THREAD, 'p1', {
|
||||
type: 'agentMessage',
|
||||
id: 'lead-1',
|
||||
text: 'Spawning a reviewer'
|
||||
})
|
||||
await notify('turn/started', { threadId: REVIEWER, turn: { id: 'r1', status: 'inProgress' } })
|
||||
await item('item/started', THREAD, 'p1', {
|
||||
type: 'subAgentActivity',
|
||||
id: 'spawn-1',
|
||||
kind: 'started',
|
||||
agentThreadId: REVIEWER,
|
||||
agentPath: '/root/review'
|
||||
})
|
||||
both([{ kind: 'agent', description: 'review', state: 'working', membership: 'live' }])
|
||||
|
||||
await item('item/started', REVIEWER, 'r1', {
|
||||
type: 'commandExecution',
|
||||
id: 'cmd-1',
|
||||
command: 'npm test',
|
||||
source: 'agent',
|
||||
status: 'inProgress'
|
||||
})
|
||||
await item('item/started', REVIEWER, 'r1', {
|
||||
type: 'commandExecution',
|
||||
id: 'exec-1',
|
||||
command: 'npm run dev',
|
||||
source: 'unifiedExecStartup',
|
||||
status: 'inProgress'
|
||||
})
|
||||
both([
|
||||
{
|
||||
kind: 'agent',
|
||||
description: 'review',
|
||||
state: 'working',
|
||||
membership: 'live',
|
||||
tool: 'Bash: npm test'
|
||||
},
|
||||
{
|
||||
kind: 'command',
|
||||
description: 'npm run dev',
|
||||
state: 'working',
|
||||
membership: 'live',
|
||||
owner: 'review'
|
||||
}
|
||||
])
|
||||
|
||||
// The parent's turn ends first: its own work is done, a subagent's is not.
|
||||
await notify('turn/completed', { threadId: THREAD, turn: { id: 'p1', status: 'completed' } })
|
||||
expect(parentRow()).toMatchObject({ state: 'working' })
|
||||
expect(parentRow()).not.toHaveProperty('workingMode')
|
||||
|
||||
// The child finishes; the shell it launched still runs.
|
||||
await notify('turn/completed', { threadId: REVIEWER, turn: { id: 'r1', status: 'completed' } })
|
||||
both([
|
||||
{
|
||||
kind: 'agent',
|
||||
description: 'review',
|
||||
state: 'done',
|
||||
membership: 'settled',
|
||||
outcome: 'succeeded'
|
||||
},
|
||||
{
|
||||
kind: 'command',
|
||||
description: 'npm run dev',
|
||||
state: 'working',
|
||||
membership: 'live',
|
||||
owner: 'review'
|
||||
}
|
||||
])
|
||||
expect(parentRow()).toMatchObject({ state: 'working', workingMode: 'monitoring' })
|
||||
// An older client folds the summary's task list: only live work is on it.
|
||||
expect(summaries.at(-1)?.backgroundTasks?.map((task) => task.kind)).toEqual(['command'])
|
||||
|
||||
await item('item/completed', REVIEWER, 'r1', {
|
||||
type: 'commandExecution',
|
||||
id: 'exec-1',
|
||||
command: 'npm run dev',
|
||||
source: 'unifiedExecStartup',
|
||||
status: 'completed',
|
||||
exitCode: 0
|
||||
})
|
||||
expect(parentRow()).toMatchObject({ state: 'done' })
|
||||
// Finished children stay listed, with how they ended, until the session's next turn.
|
||||
both([
|
||||
{
|
||||
kind: 'agent',
|
||||
description: 'review',
|
||||
state: 'done',
|
||||
membership: 'settled',
|
||||
outcome: 'succeeded'
|
||||
},
|
||||
{
|
||||
kind: 'command',
|
||||
description: 'npm run dev',
|
||||
state: 'done',
|
||||
membership: 'settled',
|
||||
outcome: 'succeeded',
|
||||
owner: 'review'
|
||||
}
|
||||
])
|
||||
|
||||
await notify('turn/started', { threadId: THREAD, turn: { id: 'p2', status: 'inProgress' } })
|
||||
expect(summaries.at(-1)).not.toHaveProperty('children')
|
||||
expect(strip.at(-1)).toBeNull()
|
||||
|
||||
// The provider exits: the records go with the session, on both.
|
||||
connections[0]?.onExit?.(new Error('scripted provider exit'))
|
||||
expect(server.getStructuredChildWorkViews(parentSubject(summaries))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function parentSubject(summaries: AgentSessionStatusSummary[]) {
|
||||
const summary = summaries.at(-1)
|
||||
if (!summary) {
|
||||
throw new Error('no summary')
|
||||
}
|
||||
return makeStructuredAgentStatusSubject(
|
||||
{
|
||||
executionHostId: 'local',
|
||||
wslDistro: null,
|
||||
workspaceId: summary.workspaceId,
|
||||
workspaceKind: 'git-worktree'
|
||||
},
|
||||
summary.sessionId
|
||||
)
|
||||
}
|
||||
@@ -302,6 +302,7 @@ export function NativeChatStructuredSession(
|
||||
agentLabel={agentLabel}
|
||||
startupPhase={hostExecution.phase}
|
||||
startupChildKey={hostExecution.childKey}
|
||||
paneKey={paneKey}
|
||||
// Said once: on the pane when the failure took it, else here beside the transcript. A
|
||||
// failure that names nothing is only the pane reconnecting.
|
||||
error={
|
||||
|
||||
@@ -35,6 +35,7 @@ function statusElement(
|
||||
agentLabel="Claude"
|
||||
startupPhase={startupPhase}
|
||||
startupChildKey={startupChildKey}
|
||||
paneKey="pane-1"
|
||||
error={null}
|
||||
composerError={null}
|
||||
isVisible
|
||||
|
||||
@@ -3,6 +3,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { useDelayedStatus } from '@/hooks/use-delayed-status'
|
||||
import { NativeChatBackgroundTasksStatus } from './NativeChatBackgroundTasksStatus'
|
||||
import type { StructuredSessionBackgroundTasksView } from './structured-session-background-tasks-view'
|
||||
import { useStructuredSessionChildRowContext } from './use-structured-session-child-row-context'
|
||||
|
||||
type StoppingBackgroundTasks = {
|
||||
sessionId: string
|
||||
@@ -22,6 +23,8 @@ export function NativeChatStructuredSessionStatus(props: {
|
||||
/** The host's word on the provider child; `starting` is published but not yet answering. */
|
||||
startupPhase: 'starting' | 'ready' | null
|
||||
startupChildKey: string | number | null
|
||||
/** The session's own status row, whose verdict the strip's children read. */
|
||||
paneKey: string
|
||||
error: string | null
|
||||
/** A read the pane is reconnecting on its own: said plainly, not as an error. */
|
||||
reconnecting?: boolean
|
||||
@@ -38,6 +41,7 @@ export function NativeChatStructuredSessionStatus(props: {
|
||||
props.startupPhase === 'starting' ? 'starting' : null,
|
||||
SLOW_STARTUP_NOTICE_DELAY_MS
|
||||
)
|
||||
const childRowContext = useStructuredSessionChildRowContext(props.paneKey)
|
||||
|
||||
const onStop = (taskId?: string) => {
|
||||
const sessionId = props.sessionId
|
||||
@@ -95,6 +99,9 @@ export function NativeChatStructuredSessionStatus(props: {
|
||||
isVisible={props.isVisible}
|
||||
tasks={props.backgroundTasks.tasks}
|
||||
settledTasks={props.backgroundTasks.settledTasks}
|
||||
{...(props.backgroundTasks.children
|
||||
? { childViews: props.backgroundTasks.children, childRowContext }
|
||||
: {})}
|
||||
indicatorActive={props.backgroundTasks.isMonitoring}
|
||||
supportsTaskStop={props.backgroundTasks.supportsStop}
|
||||
supportsStopAll={props.backgroundTasks.supportsStopAll}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// The switch, wired end to end in the renderer: a host that publishes its child records reaches the
|
||||
// sidebar through the status bridge and the chat strip through the session channel, and both read
|
||||
// one child the same way — including once the parent's transport is lost or its row goes stale.
|
||||
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
AgentSessionBackgroundTaskState,
|
||||
AgentSessionStatusEvent,
|
||||
AgentSessionStatusSummary
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
store: null as null | {
|
||||
getState: () => AppState
|
||||
setState: (state: Partial<AppState> & { testRuntimeOwner?: string | null }) => void
|
||||
},
|
||||
subscribeStatus: vi.fn(),
|
||||
unsubscribe: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { createTestStore } = await import('@/store/slices/store-test-helpers')
|
||||
const useAppStore = createTestStore()
|
||||
mocks.store = useAppStore
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof RuntimeRpcClientModule>()),
|
||||
runtimeEnvironmentSupportsCapability: vi.fn(async () => true)
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
subscribeStructuredAgentSession: vi.fn(),
|
||||
subscribeStructuredAgentSessionStatus: mocks.subscribeStatus
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/CacheTimer', () => ({
|
||||
default: () => null,
|
||||
usePromptCacheCountdownForPane: () => null
|
||||
}))
|
||||
|
||||
import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge'
|
||||
import { NativeChatStructuredSessionStatus } from './NativeChatStructuredSessionStatus'
|
||||
import { structuredSessionBackgroundTasksView } from './structured-session-background-tasks-view'
|
||||
import { CompactAgentRow } from '@/components/sidebar/worktree-card-compact-agent-row'
|
||||
import { buildSubagentChildRows } from '@/components/sidebar/worktree-subagent-child-rows'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed'
|
||||
|
||||
const MINUTE = 60_000
|
||||
const tab = {
|
||||
id: 'structured-tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
contentType: 'agent-session',
|
||||
entityId: 'session-1',
|
||||
label: 'Codex Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
isPinned: false,
|
||||
agentSessionAgent: 'codex'
|
||||
} satisfies Tab
|
||||
const PANE_KEY = structuredAgentSessionPaneKey(tab.id, tab.entityId)
|
||||
|
||||
function view(id: string, overrides: Partial<AgentChildWorkView> = {}): AgentChildWorkView {
|
||||
const now = Date.now()
|
||||
return {
|
||||
id,
|
||||
providerId: `thread-${id}`,
|
||||
kind: 'agent',
|
||||
description: `Audit ${id}`,
|
||||
state: 'working',
|
||||
membership: 'live',
|
||||
firstObservedAt: now - 5 * MINUTE,
|
||||
observedAt: now - 2 * MINUTE,
|
||||
stoppable: false,
|
||||
invocation: { invocationId: `turn-${id}`, generation: 1 },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function summary(over: Partial<AgentSessionStatusSummary> = {}): AgentSessionStatusSummary {
|
||||
return {
|
||||
sessionId: tab.entityId,
|
||||
workspaceId: tab.worktreeId,
|
||||
agent: 'codex',
|
||||
status: 'idle',
|
||||
hostExecutionOwned: true,
|
||||
latestPrompt: 'fan out',
|
||||
updatedAt: Date.now(),
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
function feed(): (event: AgentSessionStatusEvent) => void {
|
||||
const call = mocks.subscribeStatus.mock.calls[0]
|
||||
if (!call) {
|
||||
throw new Error('status feed not subscribed')
|
||||
}
|
||||
return call[1] as (event: AgentSessionStatusEvent) => void
|
||||
}
|
||||
|
||||
function row(): AgentStatusEntry {
|
||||
const entry = mocks.store?.getState().agentStatusByPaneKey[PANE_KEY]
|
||||
if (!entry) {
|
||||
throw new Error('no structured row')
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
type RenderedRow = { dot: string; lead: string; trail: string }
|
||||
|
||||
function readRow(root: Element, separator: string): RenderedRow {
|
||||
const [lead, trail] = [...(root.querySelector('span.truncate')?.children ?? [])].map(
|
||||
(span) => span.textContent ?? ''
|
||||
)
|
||||
return {
|
||||
dot: root.querySelector('[aria-label]')?.getAttribute('aria-label') ?? '',
|
||||
lead: lead ?? '',
|
||||
trail: trail?.startsWith(separator) ? trail.slice(separator.length) : (trail ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
/** The sidebar's rows for the session, built exactly as the worktree card builds them. */
|
||||
function sidebarRows(): RenderedRow[] {
|
||||
const entry = row()
|
||||
const terminalTab: TerminalTab = {
|
||||
id: tab.id,
|
||||
ptyId: null,
|
||||
worktreeId: tab.worktreeId,
|
||||
title: tab.label,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
const parentIsFresh = isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS)
|
||||
return buildSubagentChildRows({ parentEntry: entry, tab: terminalTab, parentIsFresh }).map(
|
||||
(agent) => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<CompactAgentRow agent={agent} now={Date.now()} onActivate={() => {}} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
return readRow(container, ' - ')
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** The strip's rows, from the real session-status component over the channel's roster. */
|
||||
function stripRows(container: HTMLElement): RenderedRow[] {
|
||||
return [...container.querySelectorAll('[data-native-chat-background-tasks] li')]
|
||||
.filter((item) => item.querySelector(':scope > span.truncate'))
|
||||
.map((item) => readRow(item, ' · '))
|
||||
}
|
||||
|
||||
function Surfaces(props: { roster: AgentSessionBackgroundTaskState }): React.JSX.Element {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<StructuredAgentSessionStatusBridge />
|
||||
<NativeChatStructuredSessionStatus
|
||||
sessionId={tab.entityId}
|
||||
agentLabel="Claude"
|
||||
startupPhase="ready"
|
||||
paneKey={PANE_KEY}
|
||||
error={null}
|
||||
composerError={null}
|
||||
isVisible
|
||||
backgroundTasks={structuredSessionBackgroundTasksView(props.roster, null)}
|
||||
stopBackgroundTask={async () => undefined}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('the switch: both surfaces read the host child records', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe })
|
||||
mocks.store?.setState({
|
||||
agentStatusByPaneKey: {},
|
||||
testRuntimeOwner: null,
|
||||
unifiedTabsByWorktree: { 'wt-1': [tab] }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
})
|
||||
|
||||
async function mountWith(children: AgentChildWorkView[]) {
|
||||
// The summary and the channel carry the same views; the summary omits usage.
|
||||
const roster: AgentSessionBackgroundTaskState = {
|
||||
state: 'monitoring',
|
||||
supportsStopAll: false,
|
||||
children
|
||||
}
|
||||
const rendered = render(<Surfaces roster={roster} />)
|
||||
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
|
||||
act(() => feed()({ type: 'snapshot', sessions: [summary({ children })] }))
|
||||
const header = rendered.container.querySelector('[aria-expanded]')
|
||||
if (header) {
|
||||
fireEvent.click(header)
|
||||
}
|
||||
return rendered
|
||||
}
|
||||
|
||||
it("copies the host's views onto the row and derives the legacy roster from them", async () => {
|
||||
const shell = view('shell', {
|
||||
kind: 'command',
|
||||
providerId: 'codex-command:thread:thread-a:cmd-1',
|
||||
description: 'npm run dev',
|
||||
state: 'monitoring',
|
||||
parentChildWorkId: 'a'
|
||||
})
|
||||
const finished = view('a', {
|
||||
state: 'done',
|
||||
membership: 'settled',
|
||||
outcome: 'succeeded',
|
||||
settledAt: Date.now() - MINUTE,
|
||||
lastMessage: 'Parser is clean'
|
||||
})
|
||||
const running = view('b')
|
||||
await mountWith([finished, shell, running])
|
||||
expect(row().children).toEqual([finished, shell, running])
|
||||
// The legacy roster lists live agents only, under the id a Codex subagent's row always had.
|
||||
expect(row().subagents).toEqual([
|
||||
expect.objectContaining({ id: 'codex-agent:thread-b', state: 'working' })
|
||||
])
|
||||
// An idle lead held open by a live subagent reads working, from the records.
|
||||
expect(row()).toMatchObject({ state: 'working' })
|
||||
})
|
||||
|
||||
it('shows one child the same way on both surfaces, live, lost, and stale', async () => {
|
||||
const children = [
|
||||
view('a', {
|
||||
operation: { toolName: 'Bash', input: 'npm test', basis: 'open', observedAt: Date.now() }
|
||||
})
|
||||
]
|
||||
const { container } = await mountWith(children)
|
||||
const live = { dot: 'Working', lead: 'Audit a', trail: 'Bash: npm test' }
|
||||
expect(sidebarRows()).toEqual([live])
|
||||
expect(stripRows(container)).toEqual([live])
|
||||
|
||||
// The status stream drops: the host is not heard from, so neither surface vouches for it.
|
||||
act(() => feed()({ type: 'end' }))
|
||||
const lost = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' }
|
||||
await waitFor(() => expect(sidebarRows()).toEqual([lost]))
|
||||
expect(stripRows(container)).toEqual([lost])
|
||||
})
|
||||
|
||||
it('reads a stale parent row the same way on both surfaces', async () => {
|
||||
const { container } = await mountWith([view('a')])
|
||||
// The host no longer owns the session, and its last word is older than the freshness window.
|
||||
act(() =>
|
||||
feed()({
|
||||
type: 'status',
|
||||
session: summary({
|
||||
hostExecutionOwned: undefined,
|
||||
status: 'working',
|
||||
updatedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - MINUTE,
|
||||
children: [view('a')]
|
||||
})
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(isExplicitAgentStatusFresh(row(), Date.now(), AGENT_STATUS_STALE_AFTER_MS)).toBe(false)
|
||||
)
|
||||
const stale = { dot: 'No recent update', lead: 'Audit a', trail: 'No update in 2m' }
|
||||
expect(sidebarRows()).toEqual([stale])
|
||||
expect(stripRows(container)).toEqual([stale])
|
||||
})
|
||||
})
|
||||
+14
-3
@@ -5,21 +5,28 @@
|
||||
// foreground fan-out is running work while the turn is still open. It stays
|
||||
// mounted through a running turn — turn state is not a filter on the rows,
|
||||
// because the producers publish only tasks they still have live evidence for.
|
||||
// Only an idle session lets it animate or speak for itself.
|
||||
// A host that publishes its child records also keeps a finished child listed,
|
||||
// with how it ended, until the session's next turn. Only an idle session with
|
||||
// live work lets the strip animate or speak for itself.
|
||||
|
||||
import type {
|
||||
AgentSessionBackgroundTask,
|
||||
AgentSessionBackgroundTaskState
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { agentChildWorkLiveness } from '../../../../shared/agent-status-child-work-liveness'
|
||||
import type { AgentChildWorkView } from '../../../../shared/agent-status-child-work-view'
|
||||
import { decodeAgentChildWorkViews } from '../../../../shared/agent-status-child-work-view-wire'
|
||||
|
||||
export type StructuredSessionBackgroundTasksView = {
|
||||
/** The strip renders whenever the host reports monitoring — mid-turn included. */
|
||||
/** The strip renders whenever the host reports rows — mid-turn included. */
|
||||
show: boolean
|
||||
/** Idle-only: gates the animated monitoring indicator and conversation
|
||||
* commands, never the strip itself. A running turn owns the voice. */
|
||||
isMonitoring: boolean
|
||||
tasks: AgentSessionBackgroundTask[]
|
||||
settledTasks: AgentSessionBackgroundTask[]
|
||||
/** The host's child records, when it publishes them; the rows then read these. */
|
||||
children?: AgentChildWorkView[]
|
||||
supportsStop: boolean
|
||||
supportsStopAll: boolean
|
||||
}
|
||||
@@ -29,11 +36,15 @@ export function structuredSessionBackgroundTasksView(
|
||||
turnId: string | null
|
||||
): StructuredSessionBackgroundTasksView {
|
||||
const monitoring = backgroundTasks?.state === 'monitoring'
|
||||
const children = monitoring ? decodeAgentChildWorkViews(backgroundTasks.children) : undefined
|
||||
// A roster of finished children is shown, but nothing in it is running.
|
||||
const liveWork = children ? agentChildWorkLiveness(children) !== null : monitoring
|
||||
return {
|
||||
show: monitoring,
|
||||
isMonitoring: turnId === null && monitoring,
|
||||
isMonitoring: turnId === null && liveWork,
|
||||
tasks: backgroundTasks?.tasks ?? [],
|
||||
settledTasks: backgroundTasks?.settledTasks ?? [],
|
||||
...(children ? { children } : {}),
|
||||
supportsStop: backgroundTasks?.supportsTaskStop === true,
|
||||
// Absent means the host predates the field and does accept an untargeted
|
||||
// stop; only a host that says `false` has none to offer.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
agentChildRowContextForParent,
|
||||
type AgentChildRowContext
|
||||
} from '../../../../shared/agent-child-row-model'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import { useNow } from '@/hooks/use-now'
|
||||
import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
/** The verdict a session's status row gives its children, exactly as the sidebar derives it. */
|
||||
export function structuredSessionChildRowContext(
|
||||
parent: AgentStatusEntry,
|
||||
now: number
|
||||
): AgentChildRowContext {
|
||||
return agentChildRowContextForParent(
|
||||
parent,
|
||||
isExplicitAgentStatusFresh(parent, now, AGENT_STATUS_STALE_AFTER_MS)
|
||||
)
|
||||
}
|
||||
|
||||
/** The strip's children read their parent's verdict from the same status row the sidebar reads,
|
||||
* so one child never says "No update" in one place and "Working" in the other. */
|
||||
export function useStructuredSessionChildRowContext(
|
||||
paneKey: string
|
||||
): AgentChildRowContext | undefined {
|
||||
const parent = useAppStore((state) => state.agentStatusByPaneKey[paneKey])
|
||||
// The freshness window is thirty minutes; a coarse shared tick is well inside its precision.
|
||||
const now = useNow(30_000, parent !== undefined)
|
||||
return useMemo(
|
||||
() => (parent ? structuredSessionChildRowContext(parent, now) : undefined),
|
||||
[parent, now]
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user