mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
[CI] Refresh unit timing weights and reduce repeated test setup (#27315)
This commit is contained in:
+12127
-9740
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ const EXTENSIONS = [
|
||||
'/index.js'
|
||||
]
|
||||
const INDIRECT_INPUT =
|
||||
/\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/
|
||||
/\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker|createRequire|getBuiltinModule)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/
|
||||
|
||||
function localPath(file, specifier) {
|
||||
if (specifier.startsWith('.')) {
|
||||
@@ -45,6 +45,9 @@ export function buildUnitDependencyGraph(sources, workspace = { resolve: () => n
|
||||
opaque.add(file)
|
||||
}
|
||||
for (const imported of ts.preProcessFile(source, true, true).importedFiles) {
|
||||
if (imported.fileName === 'module' || imported.fileName === 'node:module') {
|
||||
opaque.add(file)
|
||||
}
|
||||
const workspaceSource = workspace.resolve(imported.fileName)
|
||||
const path = workspaceSource?.file ?? localPath(file, imported.fileName)
|
||||
if (path === null) {
|
||||
|
||||
@@ -86,3 +86,58 @@ it.each(['@orca/unknown', '@orca/process-host/private'])(
|
||||
).toThrow('No public orca-source export')
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an assigned native loader',
|
||||
'src/main/windows-native-registry.ts',
|
||||
"import { createRequire } from 'node:module'; const requireFromMain = createRequire(__filename); export const loadWindowsNativeRegistry = () => requireFromMain('@orca/windows-registry')",
|
||||
'src/main/windows-registry-addon.test.ts',
|
||||
"import { loadWindowsNativeRegistry } from './windows-native-registry'"
|
||||
],
|
||||
[
|
||||
'a direct loader with a runtime serializer path',
|
||||
'src/main/daemon/serialize-grid-roundtrip.ts',
|
||||
"import { createRequire } from 'node:module'; export const loadOldSerializer = (path) => createRequire(import.meta.url)(path)",
|
||||
'src/main/daemon/serialize-grid.differential.fuzz.test.ts',
|
||||
"import { loadOldSerializer } from './serialize-grid-roundtrip'"
|
||||
],
|
||||
[
|
||||
'a module namespace whose computed loader escapes through an object',
|
||||
'src/main/stored-native-loader.ts',
|
||||
"import * as hostModule from 'node:module'; const native = hostModule; const load = native['create' + 'Require'](__filename); const api = { load }; export const open = (path) => api.load(path)",
|
||||
'src/main/stored-native-loader.test.ts',
|
||||
"import { open } from './stored-native-loader'"
|
||||
],
|
||||
[
|
||||
'an optional builtin loader',
|
||||
'src/main/sqlite/sync-database.ts',
|
||||
"export const isSqliteAvailable = () => process.getBuiltinModule?.('node:sqlite')",
|
||||
'src/main/sqlite/sync-database-portability.test.ts',
|
||||
"import { isSqliteAvailable } from './sync-database'"
|
||||
],
|
||||
[
|
||||
'an assigned builtin loader',
|
||||
'src/main/sqlite/assigned-reader.ts',
|
||||
"const getBuiltin = process.getBuiltinModule; export const sqlite = () => getBuiltin('node:sqlite')",
|
||||
'src/main/sqlite/assigned-reader.test.ts',
|
||||
"import { sqlite } from './assigned-reader'"
|
||||
]
|
||||
])(
|
||||
'keeps real test consumers of %s when another covered source changes',
|
||||
(_label, owner, source, consumer, testSource) => {
|
||||
const graph = collectUnitDependencyGraph(
|
||||
fixture({
|
||||
[owner]: source,
|
||||
[consumer]: testSource,
|
||||
'src/selected-input.ts': 'export const value = 1',
|
||||
'src/selected-input.test.ts': "import { value } from './selected-input'"
|
||||
})
|
||||
)
|
||||
const tests = [...graph.files].filter((file) => file.endsWith('.test.ts')).sort()
|
||||
expect(selectUnitFiles(tests, ['src/selected-input.ts'], graph)).toMatchObject({
|
||||
full: false,
|
||||
files: [consumer, 'src/selected-input.test.ts'].sort()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { formatOrcaSessionAddress } from '../../../shared/orca-session-address'
|
||||
import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture'
|
||||
import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes'
|
||||
import { ORCHESTRATION_TARGET_PARAM } from '../orchestration/orchestration-party'
|
||||
import {
|
||||
mintStructuredWorkerHandle,
|
||||
mintStructuredWorkerPaneKey,
|
||||
@@ -100,20 +99,6 @@ function assignTerminal(runId: string): void {
|
||||
}
|
||||
|
||||
describe('every target param resolves both spellings of a party to one canonical address', () => {
|
||||
it('covers exactly the target params the contract lists', () => {
|
||||
expect(Object.keys(ORCHESTRATION_TARGET_PARAM).sort()).toEqual(
|
||||
[
|
||||
'orchestration.send',
|
||||
'orchestration.ask',
|
||||
'orchestration.dispatch',
|
||||
'orchestration.inbox',
|
||||
'orchestration.sessionAddress',
|
||||
'orchestration.partyLocation',
|
||||
'orchestration.workerStart'
|
||||
].sort()
|
||||
)
|
||||
})
|
||||
|
||||
it.each(WORKER_SPELLINGS)('send: a worker at %s gets its Dispatch mailbox', async (_l, to) => {
|
||||
const dispatchId = assignWorker(await chatRun())
|
||||
const { message } = await as(undefined, 'orchestration.send', {
|
||||
|
||||
@@ -77,34 +77,6 @@ describe('resolveRelayPushTarget', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not combine remote.pushDefault with a base-branch merge target', async () => {
|
||||
const git = gitForConfig({
|
||||
branch: 'feature/fix',
|
||||
pushRemote: new Error('missing pushRemote'),
|
||||
pushDefault: 'fork',
|
||||
branchRemote: 'origin',
|
||||
merge: 'refs/heads/main',
|
||||
base: 'refs/remotes/origin/main'
|
||||
})
|
||||
|
||||
await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a fork head target when the contributor branch matches the base branch name', async () => {
|
||||
const git = gitForConfig({
|
||||
branch: 'review/pr-1',
|
||||
pushRemote: 'fork',
|
||||
branchRemote: 'fork',
|
||||
merge: 'refs/heads/main',
|
||||
base: 'refs/remotes/origin/main'
|
||||
})
|
||||
|
||||
await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({
|
||||
remote: 'fork',
|
||||
refspec: 'HEAD:main'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses remote.pushDefault when branch pushRemote is missing', async () => {
|
||||
const git = gitForConfig({
|
||||
pushRemote: new Error('missing pushRemote'),
|
||||
@@ -118,42 +90,6 @@ describe('resolveRelayPushTarget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a URL-valued branch remote to a matching named remote', async () => {
|
||||
const forkUrl = 'https://github.com/contributor/orca.git'
|
||||
const git = gitForConfig({
|
||||
pushRemote: new Error('missing pushRemote'),
|
||||
pushDefault: new Error('missing pushDefault'),
|
||||
branchRemote: forkUrl,
|
||||
remotes: ['origin', 'pr-contributor-orca'],
|
||||
remoteUrls: {
|
||||
origin: 'https://github.com/stablyai/orca.git',
|
||||
'pr-contributor-orca': forkUrl
|
||||
}
|
||||
})
|
||||
|
||||
await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({
|
||||
remote: 'pr-contributor-orca',
|
||||
refspec: 'HEAD:feature/fix'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a URL-valued pushRemote when no named remote matches it', async () => {
|
||||
const forkUrl = 'git@github.com:contributor/orca.git'
|
||||
const git = gitForConfig({
|
||||
pushRemote: forkUrl,
|
||||
branchRemote: forkUrl,
|
||||
remotes: ['origin'],
|
||||
remoteUrls: {
|
||||
origin: 'git@github.com:stablyai/orca.git'
|
||||
}
|
||||
})
|
||||
|
||||
await expect(resolveRelayPushTarget(git, '/repo', undefined)).resolves.toEqual({
|
||||
remote: forkUrl,
|
||||
refspec: 'HEAD:feature/fix'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an explicit push target without reading branch config', async () => {
|
||||
const git = vi.fn(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
|
||||
@@ -53,11 +53,6 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
expect(clearInput).not.toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('still defaults to a single Ctrl+U when no draft is parked', () => {
|
||||
sendNativeChatMessage(PTY, 'plain')
|
||||
expect(writes()[0]).toBe(NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT)
|
||||
})
|
||||
|
||||
it('holds the body until the clear is confirmed, then submits after the gap', () => {
|
||||
const clearInput = buildAgentTuiClearInputForText(DRAFT)
|
||||
sendNativeChatMessage(PTY, 'edited', {
|
||||
@@ -117,18 +112,6 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('re-clears before the body, never after it', () => {
|
||||
sendNativeChatMessage(PTY, 'edited', {
|
||||
clearInput: buildAgentTuiClearInputForText(DRAFT),
|
||||
confirmCleared: () => false
|
||||
})
|
||||
vi.advanceTimersByTime(NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS)
|
||||
const order = writes()
|
||||
expect(order.indexOf(AGENT_TUI_CLEAR_INPUT_MAX)).toBeLessThan(
|
||||
order.indexOf(buildNativeChatPasteBytes('edited'))
|
||||
)
|
||||
})
|
||||
|
||||
it('charges the confirm gap to the handle so the send card outlives the Enter', () => {
|
||||
const withConfirm = sendNativeChatMessage(PTY, 'a', {
|
||||
clearInput: '\x15',
|
||||
|
||||
@@ -90,8 +90,6 @@ describe('standalone runtime artifacts', () => {
|
||||
it('keeps target-specific files out of the shared template set', () => {
|
||||
const common = orcadTemplateCommonFilenames()
|
||||
for (const target of SERVER_TARGETS) {
|
||||
const specific = orcadArtifactFilenames(target).filter((file) => !common.includes(file))
|
||||
expect(specific.every((file) => !common.includes(file))).toBe(true)
|
||||
expect(common.every((file) => orcadArtifactFilenames(target).includes(file))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Register preload/store seams before importing their consumers.
|
||||
import {
|
||||
fixture,
|
||||
transport,
|
||||
WORKSPACE,
|
||||
SUBJECT,
|
||||
SCOPE,
|
||||
NOTIFICATION_SETTINGS,
|
||||
addPrompt,
|
||||
AttentionPolicy,
|
||||
dismissIds
|
||||
} from './structured-attention-read-retirement.test-fixture'
|
||||
import { Fragment, createElement } from 'react'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
agentSessionAttentionSubjectPrefix,
|
||||
agentSessionPromptAttentionKey
|
||||
} from '../../src/shared/agent-session-attention'
|
||||
import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture'
|
||||
import {
|
||||
projectStructuredAgentSessionStatusState,
|
||||
structuredAgentSessionPaneKey
|
||||
} from '../../src/shared/structured-agent-session-projection'
|
||||
import { StructuredAgentSessionTurnCompletionFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed'
|
||||
import { createStructuredAttentionMobileDelivery } from '../../src/main/runtime/structured-agent-session-mobile-attention'
|
||||
import { makeUnifiedTab } from '@/store/slices/store-test-helpers'
|
||||
import { useAppStore } from '@/store'
|
||||
import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge'
|
||||
import { useStructuredAgentSessionRead } from '@/components/native-chat/use-structured-agent-session-read'
|
||||
|
||||
const TARGET = { kind: 'local' } as const
|
||||
|
||||
function Transcript({ visible }: { visible: boolean }): null {
|
||||
useStructuredAgentSessionRead({
|
||||
sessionId: SESSION,
|
||||
target: TARGET,
|
||||
isVisible: visible,
|
||||
isViewed: visible
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function bridge(visible?: boolean) {
|
||||
return createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
...(visible === undefined ? [] : [createElement(Transcript, { visible })])
|
||||
)
|
||||
}
|
||||
|
||||
function settleTurn(): void {
|
||||
fixture.items = fixture.items.map((item): AgentJournalRenderItem =>
|
||||
item.body.kind === 'turn'
|
||||
? {
|
||||
...item,
|
||||
revision: item.revision + 1,
|
||||
body: { ...item.body, state: 'completed', outcome: 'success' }
|
||||
}
|
||||
: item
|
||||
)
|
||||
fixture.sequence += 1
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await act(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
const promptKey = (id: string) => agentSessionPromptAttentionKey(SCOPE, SESSION, id)
|
||||
|
||||
it('Mark read on a chat never opened withdraws the prompt alert it surfaced', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
render(bridge())
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit'))
|
||||
await flush()
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
expect(dismissIds()).toEqual([promptKey('A')])
|
||||
})
|
||||
|
||||
it('Mark read on a chat never opened withdraws its completion alert, as before prompt alerts', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
render(bridge())
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => settleTurn())
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit'))
|
||||
await flush()
|
||||
expect(dismissIds()).toEqual([`${agentSessionAttentionSubjectPrefix(SCOPE, SESSION)}turn:turn-1`])
|
||||
})
|
||||
|
||||
it('Mark read on a chat hidden since an earlier view covers the newer prompt it surfaced', async () => {
|
||||
const screen = render(bridge(true))
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' }))
|
||||
screen.rerender(bridge(false))
|
||||
await flush()
|
||||
transport.dispatch.mockClear()
|
||||
act(() => addPrompt('B'))
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit'))
|
||||
await flush()
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
expect(dismissIds()).toEqual([promptKey('A'), promptKey('B')])
|
||||
})
|
||||
|
||||
it('Mark read never covers a prompt raised after the click', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
render(bridge())
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => {
|
||||
useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')
|
||||
addPrompt('B')
|
||||
})
|
||||
await flush()
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2))
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion')
|
||||
expect(dismissIds()).toEqual([promptKey('A')])
|
||||
})
|
||||
|
||||
it('Mark all read covers every chat it marks, viewed earlier or never opened', async () => {
|
||||
const OTHER = 'session-beta'
|
||||
const otherSubject = structuredAgentSessionPaneKey('chat-2', OTHER)
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: {
|
||||
[WORKSPACE]: [
|
||||
...(useAppStore.getState().unifiedTabsByWorktree[WORKSPACE] ?? []),
|
||||
makeUnifiedTab({
|
||||
id: 'chat-2',
|
||||
worktreeId: WORKSPACE,
|
||||
groupId: 'group',
|
||||
contentType: 'agent-session',
|
||||
entityId: OTHER,
|
||||
agentSessionAgent: 'claude'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
// The second chat's host side: its own journal, the same phone delivery and renderer stream.
|
||||
let otherItems = fixture.items.map((item) => ({ ...item }))
|
||||
let otherSequence = fixture.sequence
|
||||
const otherFeed = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions: new Map([
|
||||
[
|
||||
OTHER,
|
||||
{
|
||||
journal: { cursor: () => ({ epoch: 'journal-b', sequence: otherSequence }) },
|
||||
params: { location: SCOPE }
|
||||
}
|
||||
]
|
||||
]),
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(otherItems),
|
||||
now: () => 42
|
||||
})
|
||||
const delivery = createStructuredAttentionMobileDelivery({
|
||||
readNotificationSettings: () => ({ ...NOTIFICATION_SETTINGS, suppressWhenFocused: false }),
|
||||
readWorkspaceLabels: () => ({}),
|
||||
dispatch: (event) => fixture.controller.dispatch(event),
|
||||
reconcile: (state) => fixture.controller.reconcileStructuredPromptAttention(state),
|
||||
now: () => 42
|
||||
})
|
||||
otherFeed.subscribe({
|
||||
id: 'other',
|
||||
includePrompts: true,
|
||||
emit: (event) => {
|
||||
if (event.type !== 'end') {
|
||||
delivery.deliver(event, undefined)
|
||||
fixture.completion?.({
|
||||
id: 'completion',
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'h' },
|
||||
result: event
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
otherFeed.observe(OTHER)
|
||||
const screen = render(bridge(true))
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' }))
|
||||
screen.rerender(bridge(false))
|
||||
await flush()
|
||||
transport.dispatch.mockClear()
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
const promptB = fixture.items.find((item) => item.itemId === 'B')
|
||||
if (!promptB) {
|
||||
throw new Error('prompt B missing')
|
||||
}
|
||||
otherItems = [...otherItems, { ...promptB, itemId: 'C', sequence: ++otherSequence }]
|
||||
otherFeed.observe(OTHER)
|
||||
})
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2))
|
||||
act(() =>
|
||||
useAppStore.getState().acknowledgeAgents([SUBJECT, otherSubject], undefined, 'explicit')
|
||||
)
|
||||
await flush()
|
||||
expect(dismissIds()).toEqual([
|
||||
promptKey('A'),
|
||||
promptKey('B'),
|
||||
agentSessionPromptAttentionKey(SCOPE, OTHER, 'C')
|
||||
])
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,129 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Register preload/store seams before importing their consumers.
|
||||
import {
|
||||
fixture,
|
||||
WORKSPACE,
|
||||
SUBJECT,
|
||||
SCOPE,
|
||||
addPrompt,
|
||||
publishView,
|
||||
AttentionPolicy,
|
||||
readCalls,
|
||||
dismissIds
|
||||
} from './structured-attention-read-retirement.test-fixture'
|
||||
import { Fragment, StrictMode, createElement } from 'react'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import { agentSessionPromptAttentionKey } from '../../src/shared/agent-session-attention'
|
||||
import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture'
|
||||
import { useAppStore } from '@/store'
|
||||
import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge'
|
||||
import { findStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner'
|
||||
import { useStructuredAgentSessionRead } from '@/components/native-chat/use-structured-agent-session-read'
|
||||
|
||||
const PROMPT_A = agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')
|
||||
// Memoized like the pane's own target, so a rerender keeps the owner it already holds.
|
||||
const LOCAL: RuntimeClientTarget = { kind: 'local' }
|
||||
|
||||
/** The chat pane's read: shown means visible and viewed, as the transport derives both. */
|
||||
function Pane({ shown, target }: { shown: boolean; target: RuntimeClientTarget }): null {
|
||||
useStructuredAgentSessionRead({ sessionId: SESSION, target, isVisible: shown, isViewed: shown })
|
||||
return null
|
||||
}
|
||||
|
||||
function tree(options: {
|
||||
shown: boolean
|
||||
paneKey?: string
|
||||
strict?: boolean
|
||||
target?: RuntimeClientTarget
|
||||
}) {
|
||||
const inner = createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(Pane, {
|
||||
key: options.paneKey ?? 'pane',
|
||||
shown: options.shown,
|
||||
target: options.target ?? LOCAL
|
||||
})
|
||||
)
|
||||
return options.strict ? createElement(StrictMode, null, inner) : inner
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
})
|
||||
}
|
||||
|
||||
/** A prompt raised while the user is elsewhere and the pane is hidden. */
|
||||
async function promptWhileAway(options: { strict?: boolean } = {}) {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
const screen = render(tree({ shown: false, strict: options.strict }))
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await settle()
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion')
|
||||
return screen
|
||||
}
|
||||
|
||||
/** The user opens the chat: the pane reads its history and views it. */
|
||||
async function show(rerender: () => void): Promise<void> {
|
||||
fixture.hydrate = undefined
|
||||
act(() => useAppStore.getState().setActiveWorktree(WORKSPACE))
|
||||
rerender()
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await settle()
|
||||
}
|
||||
|
||||
function expectViewReadWithdraws(): void {
|
||||
expect(findStructuredAgentSessionReadOwner(SESSION, LOCAL)).toBeDefined()
|
||||
expect(readCalls()).toBeGreaterThan(0)
|
||||
expect(dismissIds()).toContain(PROMPT_A)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
}
|
||||
|
||||
it('a view read under StrictMode still finds its owner and withdraws the alert', async () => {
|
||||
const screen = await promptWhileAway({ strict: true })
|
||||
await show(() => screen.rerender(tree({ shown: true, strict: true })))
|
||||
expectViewReadWithdraws()
|
||||
})
|
||||
|
||||
it('a pane hidden by a worktree switch and shown again withdraws on view', async () => {
|
||||
const screen = await promptWhileAway()
|
||||
await show(() => screen.rerender(tree({ shown: true })))
|
||||
expect(dismissIds()).toContain(PROMPT_A)
|
||||
act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' }))
|
||||
screen.rerender(tree({ shown: false }))
|
||||
await settle()
|
||||
act(() => addPrompt('B'))
|
||||
await settle()
|
||||
// An owner that already holds history resumes its journal stream instead of re-reading it.
|
||||
act(() => useAppStore.getState().setActiveWorktree(WORKSPACE))
|
||||
screen.rerender(tree({ shown: true }))
|
||||
await settle()
|
||||
act(() => publishView())
|
||||
await settle()
|
||||
expectViewReadWithdraws()
|
||||
expect(dismissIds()).toContain(agentSessionPromptAttentionKey(SCOPE, SESSION, 'B'))
|
||||
})
|
||||
|
||||
it('a pane remounted in one commit (a tab move) keeps its owner findable', async () => {
|
||||
const screen = await promptWhileAway()
|
||||
// The new instance reads the still-held owner before the old one's cleanup releases it.
|
||||
screen.rerender(tree({ shown: false, paneKey: 'moved' }))
|
||||
await settle()
|
||||
await show(() => screen.rerender(tree({ shown: true, paneKey: 'moved' })))
|
||||
expectViewReadWithdraws()
|
||||
})
|
||||
|
||||
it('a new target object for the same host keeps the owner findable', async () => {
|
||||
const screen = await promptWhileAway()
|
||||
screen.rerender(tree({ shown: false, target: { kind: 'local' } }))
|
||||
await settle()
|
||||
await show(() => screen.rerender(tree({ shown: true, target: { kind: 'local' } })))
|
||||
expectViewReadWithdraws()
|
||||
})
|
||||
@@ -4,250 +4,423 @@ import {
|
||||
fixture,
|
||||
transport,
|
||||
WORKSPACE,
|
||||
TAB,
|
||||
SUBJECT,
|
||||
SCOPE,
|
||||
NOTIFICATION_SETTINGS,
|
||||
addPrompt,
|
||||
publishView,
|
||||
ReadSurface,
|
||||
AttentionPolicy,
|
||||
dismissIds,
|
||||
publishView,
|
||||
readCalls,
|
||||
dismissIds
|
||||
TAB,
|
||||
ReadSurface
|
||||
} from './structured-attention-read-retirement.test-fixture'
|
||||
import { Fragment, createElement } from 'react'
|
||||
import { Fragment, createElement, StrictMode } from 'react'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
import { expect, it, describe } from 'vitest'
|
||||
import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types'
|
||||
import {
|
||||
agentSessionAttentionSubjectPrefix,
|
||||
agentSessionPromptAttentionKey
|
||||
} from '../../src/shared/agent-session-attention'
|
||||
import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture'
|
||||
import {
|
||||
projectStructuredAgentSessionStatusState,
|
||||
structuredAgentSessionPaneKey
|
||||
} from '../../src/shared/structured-agent-session-projection'
|
||||
import { StructuredAgentSessionTurnCompletionFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed'
|
||||
import { createStructuredAttentionMobileDelivery } from '../../src/main/runtime/structured-agent-session-mobile-attention'
|
||||
import { makeUnifiedTab } from '@/store/slices/store-test-helpers'
|
||||
import { useAppStore } from '@/store'
|
||||
import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge'
|
||||
import { useStructuredAgentSessionRead } from '@/components/native-chat/use-structured-agent-session-read'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-client-target'
|
||||
import { findStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { StructuredNotificationRead } from '../../src/shared/notification-settings-types'
|
||||
import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types'
|
||||
import { agentSessionPromptAttentionKey } from '../../src/shared/agent-session-attention'
|
||||
import { RuntimeMobileNotificationController } from '../../src/main/runtime/runtime-mobile-notification-controller'
|
||||
import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture'
|
||||
import { useAppStore } from '@/store'
|
||||
import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge'
|
||||
|
||||
it('reads the first accepted history even after local unread and clock targets were cleared', async () => {
|
||||
addPrompt('A')
|
||||
render(
|
||||
createElement(
|
||||
describe('Explicit desktop read acknowledgements', () => {
|
||||
const TARGET = { kind: 'local' } as const
|
||||
|
||||
function Transcript({ visible }: { visible: boolean }): null {
|
||||
useStructuredAgentSessionRead({
|
||||
sessionId: SESSION,
|
||||
target: TARGET,
|
||||
isVisible: visible,
|
||||
isViewed: visible
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function bridge(visible?: boolean) {
|
||||
return createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
...(visible === undefined ? [] : [createElement(Transcript, { visible })])
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
expect(readCalls()).toBe(0)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes).toEqual({})
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() =>
|
||||
expect(dismissIds()).toEqual([agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')])
|
||||
)
|
||||
expect(readCalls()).toBe(1)
|
||||
})
|
||||
}
|
||||
|
||||
it('reads B on return while A keeps the already-read row clock unchanged', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
useAppStore
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
SUBJECT,
|
||||
{ state: 'blocked', prompt: 'Work', agentType: 'claude' },
|
||||
'Chat',
|
||||
{ updatedAt: 1000, stateStartedAt: 1000 },
|
||||
{ tabId: TAB, worktreeId: WORKSPACE }
|
||||
function settleTurn(): void {
|
||||
fixture.items = fixture.items.map((item): AgentJournalRenderItem =>
|
||||
item.body.kind === 'turn'
|
||||
? {
|
||||
...item,
|
||||
revision: item.revision + 1,
|
||||
body: { ...item.body, state: 'completed', outcome: 'success' }
|
||||
}
|
||||
: item
|
||||
)
|
||||
const screen = render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: false })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() => expect(fixture.journal).toBeTypeOf('function'))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
const stamp = useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT]
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
publishView()
|
||||
})
|
||||
expect(dismissIds()).toHaveLength(1)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion')
|
||||
act(() => useAppStore.setState({ activeWorktreeId: WORKSPACE }))
|
||||
screen.rerender(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(dismissIds()).toEqual(
|
||||
['A', 'B'].map((id) => agentSessionPromptAttentionKey(SCOPE, SESSION, id))
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT]).toBe(stamp)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
expect(readCalls()).toBe(2)
|
||||
})
|
||||
fixture.sequence += 1
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
}
|
||||
|
||||
it('reads a newly accepted visible prompt without sending an RPC for text-only updates', async () => {
|
||||
addPrompt('A')
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() => expect(readCalls()).toBe(1))
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
publishView()
|
||||
})
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(2))
|
||||
const calls = readCalls()
|
||||
act(() => {
|
||||
fixture.items = [
|
||||
...fixture.items,
|
||||
{
|
||||
itemId: 'text',
|
||||
revision: 1,
|
||||
sequence: ++fixture.sequence,
|
||||
observedAt: fixture.sequence,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'More output' }]
|
||||
}
|
||||
}
|
||||
]
|
||||
publishView()
|
||||
})
|
||||
await act(async () => {})
|
||||
expect(readCalls()).toBe(calls)
|
||||
})
|
||||
|
||||
it('keeps hydration unread while away and retries the read on presence return', async () => {
|
||||
transport.away.mockResolvedValue(true)
|
||||
addPrompt('A')
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(readCalls()).toBe(0)
|
||||
transport.away.mockResolvedValue(false)
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
})
|
||||
|
||||
it.each(['transport-error', 'false-result'] as const)(
|
||||
'retries a remote %s on a later read with the existing presence gate',
|
||||
async (failure) => {
|
||||
const target = { kind: 'environment', environmentId: 'retry-host' } as const
|
||||
const tab = useAppStore.getState().unifiedTabsByWorktree[WORKSPACE]?.[0]
|
||||
if (!tab) {
|
||||
throw new Error('chat tab missing')
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await act(async () => {})
|
||||
}
|
||||
}
|
||||
|
||||
const promptKey = (id: string) => agentSessionPromptAttentionKey(SCOPE, SESSION, id)
|
||||
|
||||
it('Mark read on a chat never opened withdraws the prompt alert it surfaced', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
render(bridge())
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit'))
|
||||
await flush()
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
expect(dismissIds()).toEqual([promptKey('A')])
|
||||
})
|
||||
|
||||
it('Mark read on a chat never opened withdraws its completion alert, as before prompt alerts', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
render(bridge())
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => settleTurn())
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit'))
|
||||
await flush()
|
||||
expect(dismissIds()).toEqual([
|
||||
`${agentSessionAttentionSubjectPrefix(SCOPE, SESSION)}turn:turn-1`
|
||||
])
|
||||
})
|
||||
|
||||
it('Mark read on a chat hidden since an earlier view covers the newer prompt it surfaced', async () => {
|
||||
const screen = render(bridge(true))
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' }))
|
||||
screen.rerender(bridge(false))
|
||||
await flush()
|
||||
transport.dispatch.mockClear()
|
||||
act(() => addPrompt('B'))
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit'))
|
||||
await flush()
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
expect(dismissIds()).toEqual([promptKey('A'), promptKey('B')])
|
||||
})
|
||||
|
||||
it('Mark read never covers a prompt raised after the click', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
render(bridge())
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(1))
|
||||
act(() => {
|
||||
useAppStore.getState().acknowledgeAgents([SUBJECT], undefined, 'explicit')
|
||||
addPrompt('B')
|
||||
})
|
||||
await flush()
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2))
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion')
|
||||
expect(dismissIds()).toEqual([promptKey('A')])
|
||||
})
|
||||
|
||||
it('Mark all read covers every chat it marks, viewed earlier or never opened', async () => {
|
||||
const OTHER = 'session-beta'
|
||||
const otherSubject = structuredAgentSessionPaneKey('chat-2', OTHER)
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: { [WORKSPACE]: [{ ...tab, executionHostId: 'runtime:retry-host' }] }
|
||||
})
|
||||
addPrompt('A')
|
||||
const original = transport.call.getMockImplementation()
|
||||
let failed = false
|
||||
transport.call.mockImplementation(async (...args) => {
|
||||
if (args[1] === 'agentSession.acknowledgeAttention' && !failed) {
|
||||
failed = true
|
||||
if (failure === 'transport-error') {
|
||||
throw new Error('scripted transient transport failure')
|
||||
}
|
||||
return { acknowledged: false }
|
||||
unifiedTabsByWorktree: {
|
||||
[WORKSPACE]: [
|
||||
...(useAppStore.getState().unifiedTabsByWorktree[WORKSPACE] ?? []),
|
||||
makeUnifiedTab({
|
||||
id: 'chat-2',
|
||||
worktreeId: WORKSPACE,
|
||||
groupId: 'group',
|
||||
contentType: 'agent-session',
|
||||
entityId: OTHER,
|
||||
agentSessionAgent: 'claude'
|
||||
})
|
||||
]
|
||||
}
|
||||
return original?.(...args)
|
||||
})
|
||||
// The second chat's host side: its own journal, the same phone delivery and renderer stream.
|
||||
let otherItems = fixture.items.map((item) => ({ ...item }))
|
||||
let otherSequence = fixture.sequence
|
||||
const otherFeed = new StructuredAgentSessionTurnCompletionFeed({
|
||||
sessions: new Map([
|
||||
[
|
||||
OTHER,
|
||||
{
|
||||
journal: { cursor: () => ({ epoch: 'journal-b', sequence: otherSequence }) },
|
||||
params: { location: SCOPE }
|
||||
}
|
||||
]
|
||||
]),
|
||||
readStatusState: () => projectStructuredAgentSessionStatusState(otherItems),
|
||||
now: () => 42
|
||||
})
|
||||
const delivery = createStructuredAttentionMobileDelivery({
|
||||
readNotificationSettings: () => ({ ...NOTIFICATION_SETTINGS, suppressWhenFocused: false }),
|
||||
readWorkspaceLabels: () => ({}),
|
||||
dispatch: (event) => fixture.controller.dispatch(event),
|
||||
reconcile: (state) => fixture.controller.reconcileStructuredPromptAttention(state),
|
||||
now: () => 42
|
||||
})
|
||||
otherFeed.subscribe({
|
||||
id: 'other',
|
||||
includePrompts: true,
|
||||
emit: (event) => {
|
||||
if (event.type !== 'end') {
|
||||
delivery.deliver(event, undefined)
|
||||
fixture.completion?.({
|
||||
id: 'completion',
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'h' },
|
||||
result: event
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
otherFeed.observe(OTHER)
|
||||
const screen = render(bridge(true))
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' }))
|
||||
screen.rerender(bridge(false))
|
||||
await flush()
|
||||
transport.dispatch.mockClear()
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
const promptB = fixture.items.find((item) => item.itemId === 'B')
|
||||
if (!promptB) {
|
||||
throw new Error('prompt B missing')
|
||||
}
|
||||
otherItems = [...otherItems, { ...promptB, itemId: 'C', sequence: ++otherSequence }]
|
||||
otherFeed.observe(OTHER)
|
||||
})
|
||||
await waitFor(() => expect(transport.dispatch).toHaveBeenCalledTimes(2))
|
||||
act(() =>
|
||||
useAppStore.getState().acknowledgeAgents([SUBJECT, otherSubject], undefined, 'explicit')
|
||||
)
|
||||
await flush()
|
||||
expect(dismissIds()).toEqual([
|
||||
promptKey('A'),
|
||||
promptKey('B'),
|
||||
agentSessionPromptAttentionKey(SCOPE, OTHER, 'C')
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Viewed structured session owner lifecycle', () => {
|
||||
const PROMPT_A = agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')
|
||||
// Memoized like the pane's own target, so a rerender keeps the owner it already holds.
|
||||
const LOCAL: RuntimeClientTarget = { kind: 'local' }
|
||||
|
||||
/** The chat pane's read: shown means visible and viewed, as the transport derives both. */
|
||||
function Pane({ shown, target }: { shown: boolean; target: RuntimeClientTarget }): null {
|
||||
useStructuredAgentSessionRead({ sessionId: SESSION, target, isVisible: shown, isViewed: shown })
|
||||
return null
|
||||
}
|
||||
|
||||
function tree(options: {
|
||||
shown: boolean
|
||||
paneKey?: string
|
||||
strict?: boolean
|
||||
target?: RuntimeClientTarget
|
||||
}) {
|
||||
const inner = createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(Pane, {
|
||||
key: options.paneKey ?? 'pane',
|
||||
shown: options.shown,
|
||||
target: options.target ?? LOCAL
|
||||
})
|
||||
)
|
||||
return options.strict ? createElement(StrictMode, null, inner) : inner
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
})
|
||||
}
|
||||
|
||||
/** A prompt raised while the user is elsewhere and the pane is hidden. */
|
||||
async function promptWhileAway(options: { strict?: boolean } = {}) {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
const screen = render(tree({ shown: false, strict: options.strict }))
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await settle()
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion')
|
||||
return screen
|
||||
}
|
||||
|
||||
/** The user opens the chat: the pane reads its history and views it. */
|
||||
async function show(rerender: () => void): Promise<void> {
|
||||
fixture.hydrate = undefined
|
||||
act(() => useAppStore.getState().setActiveWorktree(WORKSPACE))
|
||||
rerender()
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await settle()
|
||||
}
|
||||
|
||||
function expectViewReadWithdraws(): void {
|
||||
expect(findStructuredAgentSessionReadOwner(SESSION, LOCAL)).toBeDefined()
|
||||
expect(readCalls()).toBeGreaterThan(0)
|
||||
expect(dismissIds()).toContain(PROMPT_A)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
}
|
||||
|
||||
it('a view read under StrictMode still finds its owner and withdraws the alert', async () => {
|
||||
const screen = await promptWhileAway({ strict: true })
|
||||
await show(() => screen.rerender(tree({ shown: true, strict: true })))
|
||||
expectViewReadWithdraws()
|
||||
})
|
||||
|
||||
it('a pane hidden by a worktree switch and shown again withdraws on view', async () => {
|
||||
const screen = await promptWhileAway()
|
||||
await show(() => screen.rerender(tree({ shown: true })))
|
||||
expect(dismissIds()).toContain(PROMPT_A)
|
||||
act(() => useAppStore.setState({ activeWorktreeId: 'elsewhere' }))
|
||||
screen.rerender(tree({ shown: false }))
|
||||
await settle()
|
||||
act(() => addPrompt('B'))
|
||||
await settle()
|
||||
// An owner that already holds history resumes its journal stream instead of re-reading it.
|
||||
act(() => useAppStore.getState().setActiveWorktree(WORKSPACE))
|
||||
screen.rerender(tree({ shown: true }))
|
||||
await settle()
|
||||
act(() => publishView())
|
||||
await settle()
|
||||
expectViewReadWithdraws()
|
||||
expect(dismissIds()).toContain(agentSessionPromptAttentionKey(SCOPE, SESSION, 'B'))
|
||||
})
|
||||
|
||||
it('a pane remounted in one commit (a tab move) keeps its owner findable', async () => {
|
||||
const screen = await promptWhileAway()
|
||||
// The new instance reads the still-held owner before the old one's cleanup releases it.
|
||||
screen.rerender(tree({ shown: false, paneKey: 'moved' }))
|
||||
await settle()
|
||||
await show(() => screen.rerender(tree({ shown: true, paneKey: 'moved' })))
|
||||
expectViewReadWithdraws()
|
||||
})
|
||||
|
||||
it('a new target object for the same host keeps the owner findable', async () => {
|
||||
const screen = await promptWhileAway()
|
||||
screen.rerender(tree({ shown: false, target: { kind: 'local' } }))
|
||||
await settle()
|
||||
await show(() => screen.rerender(tree({ shown: true, target: { kind: 'local' } })))
|
||||
expectViewReadWithdraws()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accepted structured history read retirement', () => {
|
||||
it('reads the first accepted history even after local unread and clock targets were cleared', async () => {
|
||||
addPrompt('A')
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true, target: target })
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
expect(readCalls()).toBe(0)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes).toEqual({})
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(readCalls()).toBe(1)
|
||||
expect(dismissIds()).toEqual([])
|
||||
await act(async () => {})
|
||||
expect(readCalls()).toBe(1)
|
||||
if (failure === 'transport-error') {
|
||||
await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
} else {
|
||||
transport.away.mockResolvedValue(true)
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
expect(readCalls()).toBe(1)
|
||||
transport.away.mockResolvedValue(false)
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(dismissIds()).toEqual([agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')])
|
||||
)
|
||||
expect(readCalls()).toBe(2)
|
||||
expect(
|
||||
transport.call.mock.calls
|
||||
.filter(([, method]) => method === 'agentSession.acknowledgeAttention')
|
||||
.map(([owner]) => owner)
|
||||
).toEqual([target, target])
|
||||
}
|
||||
)
|
||||
expect(readCalls()).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a failed local desktop relay withdrawal on a later explicit read', async () => {
|
||||
const relayDirectory = mkdtempSync(join(tmpdir(), 'orca-relay-read-retry-'))
|
||||
try {
|
||||
addPrompt('A')
|
||||
const sent = fixture.events.find((event) => event.type === 'notification')
|
||||
if (!sent?.notificationId || sent.type !== 'notification') {
|
||||
throw new Error('prompt not sent')
|
||||
}
|
||||
const relay = new RuntimeMobileNotificationController()
|
||||
relay.configureDismissalStore(relayDirectory)
|
||||
relay.dispatch(sent)
|
||||
const withdrawals: string[] = []
|
||||
relay.onDispatched((event) => {
|
||||
if (event.type === 'dismiss') {
|
||||
withdrawals.push(event.notificationId)
|
||||
}
|
||||
it('reads B on return while A keeps the already-read row clock unchanged', async () => {
|
||||
useAppStore.setState({ activeWorktreeId: 'elsewhere' })
|
||||
useAppStore
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
SUBJECT,
|
||||
{ state: 'blocked', prompt: 'Work', agentType: 'claude' },
|
||||
'Chat',
|
||||
{ updatedAt: 1000, stateStartedAt: 1000 },
|
||||
{ tabId: TAB, worktreeId: WORKSPACE }
|
||||
)
|
||||
const screen = render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: false })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
act(() => addPrompt('A'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
await waitFor(() => expect(fixture.journal).toBeTypeOf('function'))
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
const stamp = useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT]
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
publishView()
|
||||
})
|
||||
transport.dismiss
|
||||
.mockImplementation(async (_ids, _panes, reads?: StructuredNotificationRead[]) => {
|
||||
for (const read of reads ?? []) {
|
||||
relay.retireStructuredAttention(read)
|
||||
}
|
||||
return { dismissed: 0 }
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('scripted local retirement failure'))
|
||||
expect(dismissIds()).toHaveLength(1)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBe('agent-completion')
|
||||
act(() => useAppStore.setState({ activeWorktreeId: WORKSPACE }))
|
||||
screen.rerender(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(dismissIds()).toEqual(
|
||||
['A', 'B'].map((id) => agentSessionPromptAttentionKey(SCOPE, SESSION, id))
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().acknowledgedAgentsByPaneKey[SUBJECT]).toBe(stamp)
|
||||
expect(useAppStore.getState().unreadAgentCompletionPanes[SUBJECT]).toBeUndefined()
|
||||
expect(readCalls()).toBe(2)
|
||||
})
|
||||
|
||||
it('reads a newly accepted visible prompt without sending an RPC for text-only updates', async () => {
|
||||
addPrompt('A')
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
@@ -259,69 +432,214 @@ it('retries a failed local desktop relay withdrawal on a later explicit read', a
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(dismissIds()).toHaveLength(1)
|
||||
expect(withdrawals).toEqual([])
|
||||
await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
await waitFor(() => expect(withdrawals).toEqual([sent.notificationId]))
|
||||
expect(transport.dismiss.mock.calls.filter(([, , reads]) => Array.isArray(reads))).toHaveLength(
|
||||
2
|
||||
)
|
||||
} finally {
|
||||
rmSync(relayDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('an older failed attempt cannot erase a newer success when the observation returns to A', async () => {
|
||||
addPrompt('A')
|
||||
const original = transport.call.getMockImplementation()
|
||||
let release: (() => void) | undefined
|
||||
let first = true
|
||||
transport.call.mockImplementation(async (...args) => {
|
||||
if (args[1] === 'agentSession.acknowledgeAttention' && first) {
|
||||
first = false
|
||||
return await new Promise((resolve) => {
|
||||
release = () => resolve({ acknowledged: false })
|
||||
})
|
||||
}
|
||||
return original?.(...args)
|
||||
})
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(readCalls()).toBe(1)
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
expect(readCalls()).toBe(1)
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
publishView()
|
||||
})
|
||||
await waitFor(() => expect(readCalls()).toBe(2))
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
fixture.items = fixture.items.map((item): AgentJournalRenderItem =>
|
||||
item.itemId === 'B' && item.body.kind === 'approval'
|
||||
? {
|
||||
...item,
|
||||
revision: item.revision + 1,
|
||||
body: { ...item.body, resolution: { ...item.body.resolution, state: 'resolved' } }
|
||||
await waitFor(() => expect(readCalls()).toBe(1))
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
publishView()
|
||||
})
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(2))
|
||||
const calls = readCalls()
|
||||
act(() => {
|
||||
fixture.items = [
|
||||
...fixture.items,
|
||||
{
|
||||
itemId: 'text',
|
||||
revision: 1,
|
||||
sequence: ++fixture.sequence,
|
||||
observedAt: fixture.sequence,
|
||||
body: {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'More output' }]
|
||||
}
|
||||
: item
|
||||
}
|
||||
]
|
||||
publishView()
|
||||
})
|
||||
await act(async () => {})
|
||||
expect(readCalls()).toBe(calls)
|
||||
})
|
||||
|
||||
it('keeps hydration unread while away and retries the read on presence return', async () => {
|
||||
transport.away.mockResolvedValue(true)
|
||||
addPrompt('A')
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
fixture.sequence += 1
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishView()
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(readCalls()).toBe(0)
|
||||
transport.away.mockResolvedValue(false)
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
await waitFor(() => expect(dismissIds()).toHaveLength(1))
|
||||
})
|
||||
|
||||
it.each(['transport-error', 'false-result'] as const)(
|
||||
'retries a remote %s on a later read with the existing presence gate',
|
||||
async (failure) => {
|
||||
const target = { kind: 'environment', environmentId: 'retry-host' } as const
|
||||
const tab = useAppStore.getState().unifiedTabsByWorktree[WORKSPACE]?.[0]
|
||||
if (!tab) {
|
||||
throw new Error('chat tab missing')
|
||||
}
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: { [WORKSPACE]: [{ ...tab, executionHostId: 'runtime:retry-host' }] }
|
||||
})
|
||||
addPrompt('A')
|
||||
const original = transport.call.getMockImplementation()
|
||||
let failed = false
|
||||
transport.call.mockImplementation(async (...args) => {
|
||||
if (args[1] === 'agentSession.acknowledgeAttention' && !failed) {
|
||||
failed = true
|
||||
if (failure === 'transport-error') {
|
||||
throw new Error('scripted transient transport failure')
|
||||
}
|
||||
return { acknowledged: false }
|
||||
}
|
||||
return original?.(...args)
|
||||
})
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true, target: target })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(readCalls()).toBe(1)
|
||||
expect(dismissIds()).toEqual([])
|
||||
await act(async () => {})
|
||||
expect(readCalls()).toBe(1)
|
||||
if (failure === 'transport-error') {
|
||||
await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
} else {
|
||||
transport.away.mockResolvedValue(true)
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
expect(readCalls()).toBe(1)
|
||||
transport.away.mockResolvedValue(false)
|
||||
await act(async () => window.dispatchEvent(new Event('focus')))
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(dismissIds()).toEqual([agentSessionPromptAttentionKey(SCOPE, SESSION, 'A')])
|
||||
)
|
||||
expect(readCalls()).toBe(2)
|
||||
expect(
|
||||
transport.call.mock.calls
|
||||
.filter(([, method]) => method === 'agentSession.acknowledgeAttention')
|
||||
.map(([owner]) => owner)
|
||||
).toEqual([target, target])
|
||||
}
|
||||
)
|
||||
|
||||
it('retries a failed local desktop relay withdrawal on a later explicit read', async () => {
|
||||
const relayDirectory = mkdtempSync(join(tmpdir(), 'orca-relay-read-retry-'))
|
||||
try {
|
||||
addPrompt('A')
|
||||
const sent = fixture.events.find((event) => event.type === 'notification')
|
||||
if (!sent?.notificationId || sent.type !== 'notification') {
|
||||
throw new Error('prompt not sent')
|
||||
}
|
||||
const relay = new RuntimeMobileNotificationController()
|
||||
relay.configureDismissalStore(relayDirectory)
|
||||
relay.dispatch(sent)
|
||||
const withdrawals: string[] = []
|
||||
relay.onDispatched((event) => {
|
||||
if (event.type === 'dismiss') {
|
||||
withdrawals.push(event.notificationId)
|
||||
}
|
||||
})
|
||||
transport.dismiss
|
||||
.mockImplementation(async (_ids, _panes, reads?: StructuredNotificationRead[]) => {
|
||||
for (const read of reads ?? []) {
|
||||
relay.retireStructuredAttention(read)
|
||||
}
|
||||
return { dismissed: 0 }
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('scripted local retirement failure'))
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(dismissIds()).toHaveLength(1)
|
||||
expect(withdrawals).toEqual([])
|
||||
await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
await waitFor(() => expect(withdrawals).toEqual([sent.notificationId]))
|
||||
expect(
|
||||
transport.dismiss.mock.calls.filter(([, , reads]) => Array.isArray(reads))
|
||||
).toHaveLength(2)
|
||||
} finally {
|
||||
rmSync(relayDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('an older failed attempt cannot erase a newer success when the observation returns to A', async () => {
|
||||
addPrompt('A')
|
||||
const original = transport.call.getMockImplementation()
|
||||
let release: (() => void) | undefined
|
||||
let first = true
|
||||
transport.call.mockImplementation(async (...args) => {
|
||||
if (args[1] === 'agentSession.acknowledgeAttention' && first) {
|
||||
first = false
|
||||
return await new Promise((resolve) => {
|
||||
release = () => resolve({ acknowledged: false })
|
||||
})
|
||||
}
|
||||
return original?.(...args)
|
||||
})
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: true })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.hydrate).toBeTypeOf('function'))
|
||||
await act(async () => fixture.hydrate?.())
|
||||
expect(readCalls()).toBe(1)
|
||||
act(() => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
expect(readCalls()).toBe(1)
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
publishView()
|
||||
})
|
||||
await waitFor(() => expect(readCalls()).toBe(2))
|
||||
await act(async () => {})
|
||||
act(() => {
|
||||
fixture.items = fixture.items.map((item): AgentJournalRenderItem =>
|
||||
item.itemId === 'B' && item.body.kind === 'approval'
|
||||
? {
|
||||
...item,
|
||||
revision: item.revision + 1,
|
||||
body: { ...item.body, resolution: { ...item.body.resolution, state: 'resolved' } }
|
||||
}
|
||||
: item
|
||||
)
|
||||
fixture.sequence += 1
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishView()
|
||||
})
|
||||
await waitFor(() => expect(readCalls()).toBe(3))
|
||||
await act(async () => release?.())
|
||||
await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
expect(readCalls()).toBe(3)
|
||||
expect(dismissIds()).toHaveLength(2)
|
||||
})
|
||||
await waitFor(() => expect(readCalls()).toBe(3))
|
||||
await act(async () => release?.())
|
||||
await act(async () => useAppStore.getState().acknowledgeAgents([SUBJECT]))
|
||||
expect(readCalls()).toBe(3)
|
||||
expect(dismissIds()).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Register preload/store seams before importing their consumers.
|
||||
import {
|
||||
fixture,
|
||||
REMOTE_TARGET,
|
||||
NOTIFICATION_SETTINGS,
|
||||
transport,
|
||||
WORKSPACE,
|
||||
TAB,
|
||||
addPrompt,
|
||||
publishStatus,
|
||||
publishView,
|
||||
ReadSurface,
|
||||
AttentionPolicy,
|
||||
readCalls
|
||||
} from './structured-attention-read-retirement.test-fixture'
|
||||
import { Fragment, createElement } from 'react'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import type { NotificationDispatchRequest } from '../../src/shared/notification-settings-types'
|
||||
import type { AgentJournalRenderItem } from '../../src/shared/agent-session-journal-types'
|
||||
import type { AgentSessionExecutionLocation } from '../../src/shared/agent-session-record'
|
||||
import { RuntimeMobileNotificationController } from '../../src/main/runtime/runtime-mobile-notification-controller'
|
||||
import { MobileNotificationDismissalStore } from '../../src/main/runtime/mobile-notification-dismissal-store'
|
||||
import { createNotificationDeliveryService } from '../../src/main/notifications/notification-delivery-service'
|
||||
import { SESSION } from '../../src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture'
|
||||
import { makeUnifiedTab, makeWorktree } from '@/store/slices/store-test-helpers'
|
||||
import { useAppStore } from '@/store'
|
||||
import { StructuredAgentSessionAttentionBridge } from '@/components/native-chat/StructuredAgentSessionAttentionBridge'
|
||||
|
||||
/** A remote-host chat this desktop relays to its own phones while the user is away. */
|
||||
async function mountRemoteRelay(options?: { promptStatus?: false; raise?: false }) {
|
||||
const relayDirectory = join(fixture.directory, 'relay')
|
||||
const relay = new RuntimeMobileNotificationController()
|
||||
relay.configureDismissalStore(relayDirectory)
|
||||
const delivery = createNotificationDeliveryService({
|
||||
readNotificationSettings: () => NOTIFICATION_SETTINGS,
|
||||
findActiveWindow: () => null,
|
||||
isWindowVisible: () => false,
|
||||
setTrayAttention: () => {},
|
||||
isNotificationSupported: () => false,
|
||||
dispatchMobileNotification: relay.dispatch.bind(relay),
|
||||
readAuthorizationStatus: async () => 'authorized',
|
||||
recordDeliveryOutcome: () => {},
|
||||
deliverNative: () => ({ delivered: true }),
|
||||
platform: 'linux',
|
||||
now: () => 20_000
|
||||
})
|
||||
transport.dispatch.mockImplementation(async (request: NotificationDispatchRequest) =>
|
||||
delivery.dispatch(request)
|
||||
)
|
||||
// What the notifications:settleStructuredPrompts handler does with it.
|
||||
transport.settle.mockImplementation(async (scope: AgentSessionExecutionLocation, id: string) =>
|
||||
relay.reconcileStructuredPromptAttention({ scope, sessionId: id, pendingPromptIds: [] })
|
||||
)
|
||||
transport.away.mockResolvedValue(true)
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: 'elsewhere',
|
||||
activeWorkspaceExecutionHostId: 'runtime:relay-host',
|
||||
worktreesByRepo: {
|
||||
repo1: [
|
||||
makeWorktree({
|
||||
id: WORKSPACE,
|
||||
repoId: 'repo1',
|
||||
hostId: 'runtime:relay-host',
|
||||
runtimeOwnerEnvironmentId: 'relay-host'
|
||||
})
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WORKSPACE]: [
|
||||
makeUnifiedTab({
|
||||
id: TAB,
|
||||
worktreeId: WORKSPACE,
|
||||
groupId: 'group',
|
||||
contentType: 'agent-session',
|
||||
entityId: SESSION,
|
||||
agentSessionAgent: 'claude',
|
||||
executionHostId: 'runtime:relay-host'
|
||||
})
|
||||
]
|
||||
},
|
||||
runtimeEnvironments: [
|
||||
{
|
||||
id: 'relay-host',
|
||||
name: 'Remote',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
endpoints: [],
|
||||
preferredEndpointId: 'endpoint'
|
||||
}
|
||||
]
|
||||
})
|
||||
render(
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(StructuredAgentSessionAttentionBridge),
|
||||
createElement(AttentionPolicy),
|
||||
createElement(ReadSurface, { viewed: false, target: REMOTE_TARGET })
|
||||
)
|
||||
)
|
||||
await waitFor(() => expect(fixture.completion).toBeTypeOf('function'))
|
||||
await waitFor(() => expect(fixture.status).toBeTypeOf('function'))
|
||||
act(() => publishStatus())
|
||||
const live = (kind: 'prompt' | 'completion') =>
|
||||
new MobileNotificationDismissalStore(relayDirectory)
|
||||
.liveDeliveries()
|
||||
.filter((entry) => entry.structuredOrigin?.cause.kind === kind)
|
||||
if (options?.raise === false) {
|
||||
return { live }
|
||||
}
|
||||
act(() => {
|
||||
addPrompt('A')
|
||||
if (options?.promptStatus !== false) {
|
||||
publishStatus()
|
||||
}
|
||||
publishView()
|
||||
})
|
||||
await waitFor(() => expect(live('prompt')).toHaveLength(1))
|
||||
return { live }
|
||||
}
|
||||
|
||||
function revise(update: (item: AgentJournalRenderItem) => AgentJournalRenderItem): void {
|
||||
fixture.sequence += 1
|
||||
fixture.items = fixture.items.map((item) => {
|
||||
const next = update(item)
|
||||
return next === item
|
||||
? item
|
||||
: { ...next, revision: item.revision + 1, sequence: fixture.sequence }
|
||||
})
|
||||
}
|
||||
|
||||
const resolvePrompt =
|
||||
(id: string, state: 'resolved' | 'cancelled') =>
|
||||
(item: AgentJournalRenderItem): AgentJournalRenderItem =>
|
||||
item.itemId === id && item.body.kind === 'approval'
|
||||
? {
|
||||
...item,
|
||||
body: {
|
||||
...item.body,
|
||||
resolution: {
|
||||
state,
|
||||
selectedOptionId: state === 'resolved' ? 'yes' : null,
|
||||
resolvedBy: state === 'resolved' ? 'second-client' : null,
|
||||
resolvedAt: fixture.sequence
|
||||
}
|
||||
}
|
||||
}
|
||||
: item
|
||||
|
||||
const resolveA = (state: 'resolved' | 'cancelled') => resolvePrompt('A', state)
|
||||
|
||||
const interrupt = (item: AgentJournalRenderItem): AgentJournalRenderItem =>
|
||||
item.body.kind === 'turn'
|
||||
? { ...item, body: { ...item.body, state: 'interrupted', outcome: 'cancellation' } }
|
||||
: item
|
||||
|
||||
/** A frame the status socket queued before A's commit: another session's row. */
|
||||
function otherSessionStatus(): void {
|
||||
fixture.status?.({
|
||||
id: 'status',
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'h' },
|
||||
result: {
|
||||
type: 'status',
|
||||
session: {
|
||||
sessionId: 'other-session',
|
||||
workspaceId: WORKSPACE,
|
||||
agent: 'claude',
|
||||
status: 'working',
|
||||
latestPrompt: '',
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function endStatusStream(): void {
|
||||
fixture.status?.({ id: 'status', ok: true, _meta: { runtimeId: 'h' }, result: { type: 'end' } })
|
||||
}
|
||||
|
||||
it.each([
|
||||
['answered from another client', () => revise(resolveA('resolved'))],
|
||||
['turn cancelled', () => revise((item) => interrupt(resolveA('cancelled')(item)))]
|
||||
] as const)(
|
||||
'withdraws the relayed prompt alert once the remote host reports none pending: %s',
|
||||
async (_mode, resolve) => {
|
||||
const relay = await mountRemoteRelay()
|
||||
act(() => {
|
||||
resolve()
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishStatus()
|
||||
})
|
||||
await waitFor(() => expect(relay.live('prompt')).toEqual([]))
|
||||
expect(readCalls()).toBe(0)
|
||||
expect(transport.settle).toHaveBeenCalledOnce()
|
||||
// Prompt settlement never touches a completion alert.
|
||||
const relayedCompletions = transport.dispatch.mock.calls.filter(
|
||||
([request]: [NotificationDispatchRequest]) =>
|
||||
request.structuredOrigin?.cause.kind === 'completion'
|
||||
)
|
||||
expect(relay.live('completion')).toHaveLength(relayedCompletions.length)
|
||||
}
|
||||
)
|
||||
|
||||
it('withdraws it when a restarted remote host comes back with the prompt cancelled', async () => {
|
||||
const relay = await mountRemoteRelay()
|
||||
const before = fixture.status
|
||||
act(() => endStatusStream())
|
||||
revise((item) => interrupt(resolveA('cancelled')(item)))
|
||||
await waitFor(() => expect(fixture.status).not.toBe(before))
|
||||
act(() => publishStatus())
|
||||
await waitFor(() => expect(relay.live('prompt')).toEqual([]))
|
||||
})
|
||||
|
||||
it('keeps the relayed prompt alert while the host still reports a prompt pending', async () => {
|
||||
const relay = await mountRemoteRelay()
|
||||
act(() => {
|
||||
revise((item) => item)
|
||||
publishStatus()
|
||||
})
|
||||
await act(async () => {})
|
||||
expect(transport.settle).not.toHaveBeenCalled()
|
||||
expect(relay.live('prompt')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('never settles from a status the desktop lost contact with', async () => {
|
||||
// The mirror still holds the row from before A, which reads no prompt; it goes unverifiable.
|
||||
const relay = await mountRemoteRelay({ promptStatus: false })
|
||||
act(() => endStatusStream())
|
||||
await act(async () => {})
|
||||
expect(transport.settle).not.toHaveBeenCalled()
|
||||
expect(relay.live('prompt')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles once per relayed prompt, not on every later status update', async () => {
|
||||
await mountRemoteRelay()
|
||||
act(() => {
|
||||
revise(resolveA('resolved'))
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishStatus()
|
||||
})
|
||||
await waitFor(() => expect(transport.settle).toHaveBeenCalledOnce())
|
||||
act(() => {
|
||||
revise((item) => item)
|
||||
publishStatus()
|
||||
})
|
||||
await act(async () => {})
|
||||
expect(transport.settle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Status and edges ride separate sockets: the pre-prompt row can still be draining after the edge.
|
||||
it('keeps the pending prompt alert when the edge outruns its status and another frame lands', async () => {
|
||||
const relay = await mountRemoteRelay({ promptStatus: false })
|
||||
act(() => otherSessionStatus())
|
||||
await act(async () => {})
|
||||
act(() => publishStatus())
|
||||
await act(async () => {})
|
||||
expect(transport.settle).not.toHaveBeenCalled()
|
||||
expect(relay.live('prompt')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps it when a queued frame lands in the same task as the edge', async () => {
|
||||
const relay = await mountRemoteRelay({ promptStatus: false })
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
otherSessionStatus()
|
||||
})
|
||||
await act(async () => {})
|
||||
expect(transport.settle).not.toHaveBeenCalled()
|
||||
expect(relay.live('prompt')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('retries a settlement that failed on the next qualifying row', async () => {
|
||||
const relay = await mountRemoteRelay()
|
||||
const settle = transport.settle.getMockImplementation()
|
||||
transport.settle.mockRejectedValueOnce(new Error('main unavailable'))
|
||||
act(() => {
|
||||
revise(resolveA('resolved'))
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishStatus()
|
||||
})
|
||||
await waitFor(() => expect(transport.settle).toHaveBeenCalledOnce())
|
||||
expect(relay.live('prompt')).toHaveLength(1)
|
||||
transport.settle.mockImplementation(settle ?? (async () => {}))
|
||||
act(() => {
|
||||
revise((item) => item)
|
||||
publishStatus()
|
||||
})
|
||||
await waitFor(() => expect(relay.live('prompt')).toEqual([]))
|
||||
expect(transport.settle).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('settles a prompt answered while the previous settle call was still out', async () => {
|
||||
const relay = await mountRemoteRelay()
|
||||
const reconcile = transport.settle.getMockImplementation()
|
||||
const replies: (() => void)[] = []
|
||||
// Main reconciles on arrival; only the reply is late.
|
||||
transport.settle.mockImplementation((scope: AgentSessionExecutionLocation, id: string) => {
|
||||
void reconcile?.(scope, id)
|
||||
return new Promise<void>((resolve) => replies.push(resolve))
|
||||
})
|
||||
act(() => {
|
||||
revise(resolveA('resolved'))
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishStatus()
|
||||
})
|
||||
await waitFor(() => expect(replies).toHaveLength(1))
|
||||
act(() => {
|
||||
addPrompt('B')
|
||||
publishStatus()
|
||||
})
|
||||
await waitFor(() => expect(relay.live('prompt')).toHaveLength(1))
|
||||
act(() => {
|
||||
revise(resolvePrompt('B', 'resolved'))
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishStatus()
|
||||
})
|
||||
await act(async () => {})
|
||||
expect(replies).toHaveLength(1)
|
||||
// The host goes quiet; the reply alone must re-judge the mirror.
|
||||
await act(async () => replies[0]?.())
|
||||
await waitFor(() => expect(relay.live('prompt')).toEqual([]))
|
||||
})
|
||||
|
||||
it('settles a prompt whose edge arrives after its own resolution row', async () => {
|
||||
const relay = await mountRemoteRelay({ raise: false })
|
||||
const deliver = fixture.completion
|
||||
const held: Parameters<NonNullable<typeof deliver>>[0][] = []
|
||||
fixture.completion = (response) => held.push(response)
|
||||
act(() => {
|
||||
addPrompt('A')
|
||||
publishStatus()
|
||||
revise(resolveA('resolved'))
|
||||
fixture.hostFeed.observe(SESSION)
|
||||
publishStatus()
|
||||
})
|
||||
fixture.completion = deliver
|
||||
act(() => held.forEach((response) => deliver?.(response)))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
transport.dispatch.mock.calls.some(
|
||||
([request]: [NotificationDispatchRequest]) =>
|
||||
request.structuredOrigin?.cause.kind === 'prompt'
|
||||
)
|
||||
).toBe(true)
|
||||
)
|
||||
await waitFor(() => expect(relay.live('prompt')).toEqual([]))
|
||||
expect(transport.settle).toHaveBeenCalledOnce()
|
||||
})
|
||||
Reference in New Issue
Block a user