Files
orca/tests/e2e/codex-child-approval-activity-row.unit.test.ts
T

314 lines
12 KiB
TypeScript

// @vitest-environment happy-dom
// Round-trip subagent approval through the real translator/journal/status/activity pipeline; answering must preserve done time and read state.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createElement } from 'react'
import { act, cleanup, render, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../src/shared/agent-session/agent-session-wire'
import {
AGENT_JOURNAL_THREAD_SCOPE,
type AgentJournalItemBody
} from '../../src/shared/agent-session/journal/agent-session-journal-types'
import { parseAgentJournalItemKey } from '../../src/shared/agent-session/journal/agent-session-journal-item-key'
import type { Tab } from '../../src/shared/tab-types'
import type { AppState } from '../../src/renderer/src/store/types'
import { createCodexJournalTranslator } from '../../src/main/codex/structured/journal/codex-structured-journal-translation'
import '../../src/main/codex/structured/prompts/codex-structured-prompt-replies'
import { CODEX_COMMAND_APPROVAL_METHOD } from '../../src/main/codex/structured/prompts/codex-prompt-registry'
import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/store-lifecycle/journal-host-database-test-support'
import { createDeferredStructuredAgentSessionEventSink } from '../../src/main/native-chat/agent-session-wire/journal-ingress/structured-agent-session-event-sink'
import { StructuredAgentSessionStatusFeed } from '../../src/main/native-chat/agent-session-wire/observations/structured-agent-session-status-feed'
import { indexedStatusFeedSession } from '../../src/main/native-chat/agent-session-wire/observations/testing/structured-agent-session-status-feed-test-session'
import {
makeRepo,
makeWorktree
} from '../../src/renderer/src/components/activity/ActivityPrototypePage-test-fixtures'
import {
activityThreadRowCopy,
activityThreadStatusId
} from '../../src/renderer/src/components/activity/activity-thread-presentation'
import { countActivityUnread } from '../../src/renderer/src/components/activity/useActivityUnreadCount'
import { useAgentPaneThreads } from '../../src/renderer/src/components/activity/use-agent-pane-threads'
type TestStore = {
getState: () => AppState
setState: (state: Partial<AppState> & { testRuntimeOwner?: string | null }) => void
}
const mocks = vi.hoisted(() => {
const hoisted: { store: TestStore | null; subscribeStatus: Mock; unsubscribe: Mock } = {
store: null,
subscribeStatus: vi.fn(),
unsubscribe: vi.fn()
}
return hoisted
})
vi.mock('@/store', async () => {
const { createTestStore } = await import('@/store/testing/app-store-test-fixture')
const useAppStore = createTestStore()
mocks.store = useAppStore
return { useAppStore }
})
vi.mock(
'../../src/renderer/src/runtime/native-chat/requests/structured-agent-session-call',
() => ({
callStructuredAgentSession: vi.fn()
})
)
vi.mock(
'../../src/renderer/src/runtime/native-chat/requests/structured-agent-session-subscriptions',
() => ({
subscribeStructuredAgentSession: vi.fn(),
subscribeStructuredAgentSessionStatus: mocks.subscribeStatus
})
)
import { StructuredAgentSessionStatusBridge } from '../../src/renderer/src/components/native-chat/session/status/StructuredAgentSessionStatusBridge'
import { resetStructuredAgentSessionStatusFeedsForTests } from '../../src/renderer/src/runtime/native-chat/status/structured-agent-session-status-feed'
import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/host/structured-agent-session-logger'
import { testEventSinkLogging } from '../../src/main/native-chat/agent-session-wire/host/testing/structured-agent-session-logger-test-support'
import { codexProviderHandle } from '../../src/shared/agent-session/lifecycle/agent-session-provider-handle-encoding'
const SESSION = 'codex-child-approval'
const CODEX_THREAD = 'thread-parent'
const CODEX_CHILD = 'thread-child'
const structuredTab = {
id: 'structured-tab-1',
worktreeId: 'wt-1',
groupId: 'group-1',
contentType: 'agent-session',
entityId: SESSION,
label: 'Codex Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 0,
isPinned: false,
agentSessionAgent: 'codex'
} satisfies Tab
let root: string
const journals = createTrackedJournalOpener()
function store(): TestStore {
if (!mocks.store) {
throw new Error('store missing')
}
return mocks.store
}
beforeEach(async () => {
vi.clearAllMocks()
resetStructuredAgentSessionStatusFeedsForTests()
mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe })
root = await mkdtemp(join(tmpdir(), 'orca-codex-child-approval-'))
const worktree = makeWorktree()
store().setState({
agentStatusByPaneKey: {},
acknowledgedAgentsByPaneKey: {},
activityClearedAtByPaneKey: {},
retainedAgentsByPaneKey: {},
refreshGitHubForWorktreeIfStale: vi.fn(),
testRuntimeOwner: null,
repos: [makeRepo()],
worktreesByRepo: { [worktree.repoId]: [worktree] },
unifiedTabsByWorktree: { 'wt-1': [structuredTab] }
})
})
afterEach(async () => {
cleanup()
vi.restoreAllMocks()
resetStructuredAgentSessionStatusFeedsForTests()
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
/** The host half: journal, feed and the real Codex translator over one advancing clock. */
async function openHost() {
let clock = 10_000
const tick = (): number => (clock += 1_000)
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'wt-1',
hostId: 'local',
agent: 'codex',
providerHandle: codexProviderHandle(CODEX_THREAD)
},
now: tick,
stateDirectory: join(root, SESSION)
})
const feed = new StructuredAgentSessionStatusFeed({
logger: createStructuredAgentSessionLogger(),
sessions: new Map([
[
SESSION,
indexedStatusFeedSession({
journal,
child: { phase: 'ready', generation: null, fence: 1 }
})
]
]),
getConversation: () => null,
now: () => 1
})
const events: AgentSessionStatusEvent[] = []
feed.subscribe({ id: 'renderer', emit: (event) => events.push(event) })
const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging())
const publish = (): void => feed.publish(SESSION, journal)
deferred.bind({ journal, fence: 1, publish })
const prompts: string[] = []
const translator = createCodexJournalTranslator({
sink: deferred.sink,
sessionId: SESSION,
primaryThreadId: () => CODEX_THREAD,
now: tick,
bindPromptItemId: (journalItemId) => prompts.push(journalItemId),
schedule: (run) => {
run()
return () => {}
}
})
const on = (threadId: string, method: string, params: Record<string, unknown> = {}) =>
translator.handle({
type: 'notification',
sessionId: SESSION,
threadId,
method,
params: { threadId, ...params },
observedAt: tick()
})
const drain = async (): Promise<void> => {
expect(await deferred.drained()).toEqual({ ok: true })
}
/** What the host's answer path commits before it tells Codex. */
const answer = async (itemId: string): Promise<void> => {
const identity = parseAgentJournalItemKey(itemId)
const asked = journal.snapshot().items.find((item) => item.itemId === itemId)?.body
if (!identity || asked?.kind !== 'approval') {
throw new Error(`approval ${itemId} missing`)
}
const resolved: AgentJournalItemBody = {
...asked,
resolution: {
state: 'resolved',
selectedOptionId: 'accept',
resolvedBy: 'user',
resolvedAt: tick()
}
}
await journal.appendItem(identity, resolved, {
fence: 1,
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
publish()
translator.resolvePrompt(itemId)
}
return { journal, translator, on, drain, answer, prompts, events, tick, close: deferred.close }
}
describe("a Codex subagent's answered approval on the settled parent's Activity row", () => {
it('reads done, lists the ask before the done, and leaves the answer read', async () => {
const host = await openHost()
render(createElement(StructuredAgentSessionStatusBridge))
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
const toRenderer: (event: AgentSessionStatusEvent) => void =
mocks.subscribeStatus.mock.calls[0]?.[1]
let forwarded = 0
const deliver = (): AgentSessionStatusSummary => {
act(() => {
for (const event of host.events.slice(forwarded)) {
toRenderer(event)
}
})
forwarded = host.events.length
const latest = host.events.findLast((event) => event.type === 'status')
if (latest?.type !== 'status') {
throw new Error('status publication missing')
}
return latest.session
}
const paneKey = (): string => Object.keys(store().getState().agentStatusByPaneKey)[0] ?? ''
await host.journal.appendItem(
{ provider: 'orca', clientMessageId: 'prompt-1' },
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fan out' }] },
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
host.on(CODEX_THREAD, 'turn/started', { turn: { id: 'parent-turn' } })
const spawn = {
type: 'subAgentActivity',
id: 'spawn-child',
kind: 'started',
agentThreadId: CODEX_CHILD,
agentPath: '/root/review'
}
host.on(CODEX_THREAD, 'item/started', { turnId: 'parent-turn', item: spawn })
host.on(CODEX_THREAD, 'item/completed', { turnId: 'parent-turn', item: spawn })
host.on(CODEX_CHILD, 'turn/started', { turn: { id: 'child-turn' } })
host.on(CODEX_THREAD, 'turn/completed', { turn: { id: 'parent-turn', status: 'completed' } })
await host.drain()
const settled = deliver()
expect(settled).toMatchObject({ status: 'idle', statusStartedAt: expect.any(Number) })
store().setState({ acknowledgedAgentsByPaneKey: { [paneKey()]: host.tick() } })
host.translator.handle({
type: 'prompt',
sessionId: SESSION,
threadId: CODEX_CHILD,
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { command: 'pnpm test', availableDecisions: ['accept', 'decline'] },
codexItemId: 'child-exec',
promptKey: 'child-approval'
})
await host.drain()
const asked = deliver()
expect(asked).toMatchObject({ status: 'attention' })
expect(asked.statusStartedAt).toBeGreaterThan(settled.statusStartedAt ?? Infinity)
// The user reads the ask, then answers it.
store().setState({ acknowledgedAgentsByPaneKey: { [paneKey()]: host.tick() } })
const [approval] = host.prompts
await host.answer(approval ?? '')
host.on(CODEX_CHILD, 'turn/completed', { turn: { id: 'child-turn', status: 'completed' } })
await host.drain()
const answered = deliver()
// The host rule under test elsewhere: the answer never re-dates the session's done.
expect(answered).toMatchObject({ status: 'idle', statusStartedAt: settled.statusStartedAt })
expect(answered.updatedAt).toBeGreaterThan(asked.updatedAt)
const activity = renderHook(() =>
useAgentPaneThreads({
query: '',
readFilter: 'all',
groupBy: 'none',
selectedPaneKey: null,
showChildAgents: true
})
)
const [row, ...others] = activity.result.current.allThreads
expect(others).toHaveLength(0)
if (!row) {
throw new Error('activity thread missing')
}
expect(activityThreadStatusId(row)).toBe('done')
expect(activityThreadRowCopy(row).needsAttention).toBe(false)
expect(row.events.map((event) => event.state)).toEqual(['done', 'blocked', 'done'])
expect(row.events.map((event) => event.unread)).toEqual([false, false, false])
expect(countActivityUnread(store().getState())).toBe(0)
host.translator.dispose()
host.close()
})
})