mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* fix(native-chat): retire acknowledged send echoes after catch-up * test: follow phone lint rules in reconnect regression * test: format reconnect message expectation * Keep current chat requests independent of loaded history * Read current chat requests in bounded cursor pieces * Bound desktop catch-up after independent request support * Fix current request subscription static checks * Match generated request schema import ordering * fix(native-chat): correct current request type boundaries * Accept readonly client capability fixtures * Exercise current request reads in wire compatibility inventory * Align current request fixtures with provider commit and RPC surface * test(native-chat): include current request browser capability * Keep the mobile chat session hook within its line budget * Format mobile current request readers * fix(native-chat): reopen once for a current-request version whose read keeps failing A deterministic read failure reopened the subscription, whose first frame re-read the same version and reset the backoff, so the pane resubscribed every 750 ms forever. * test(native-chat): type the current-request snapshot fixture * test(native-chat): wait for the hydrated open before driving the subscription
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
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 {
|
|
AgentSessionHistoryRequest,
|
|
AgentSessionHistoryResult,
|
|
AgentSessionStatusSummary,
|
|
AgentSessionSubscribeEvent
|
|
} from '../../src/shared/agent-session-wire'
|
|
import type { AgentJournalCursor } from '../../src/shared/agent-session-journal-types'
|
|
import {
|
|
EMPTY_STRUCTURED_AGENT_SESSION,
|
|
reduceStructuredAgentSession
|
|
} from '../../src/shared/structured-agent-session-reducer'
|
|
import {
|
|
hasUnansweredStructuredAgentSessionDispatch,
|
|
projectStructuredAgentSessionStatus
|
|
} from '../../src/shared/structured-agent-session-projection'
|
|
import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support'
|
|
import { readAgentSessionHistory } from '../../src/main/native-chat/agent-session-wire/agent-session-history-page'
|
|
import { AgentSessionSubscribers } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-subscribers'
|
|
import { StructuredAgentSessionStatusFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed'
|
|
|
|
const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() }))
|
|
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
|
callStructuredAgentSession: mocks.call,
|
|
subscribeStructuredAgentSession: mocks.subscribe
|
|
}))
|
|
|
|
import {
|
|
getStructuredAgentSessionReadOwner,
|
|
resetStructuredAgentSessionReadOwnersForTests
|
|
} from '../../src/renderer/src/components/native-chat/structured-agent-session-read-owner'
|
|
import { createStructuredAgentSessionLogger } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-logger'
|
|
import { codexProviderHandle } from '../../src/shared/agent-session-provider-handle-encoding'
|
|
|
|
const SESSION = 'cursor-body-regression'
|
|
const target = { kind: 'local' } as const
|
|
const journals = createTrackedJournalOpener()
|
|
let root: string
|
|
|
|
beforeEach(async () => {
|
|
vi.resetAllMocks()
|
|
root = await mkdtemp(join(tmpdir(), 'orca-cursor-body-'))
|
|
})
|
|
afterEach(async () => {
|
|
resetStructuredAgentSessionReadOwnersForTests()
|
|
await journals.closeAll()
|
|
await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
async function fixture() {
|
|
const journal = await journals.open({
|
|
identity: {
|
|
sessionId: SESSION,
|
|
workspaceId: 'folder-workspace',
|
|
hostId: 'local',
|
|
agent: 'codex',
|
|
providerHandle: codexProviderHandle('thread-1')
|
|
},
|
|
stateDirectory: join(root, 'journal')
|
|
})
|
|
async function appendOutput(index: number) {
|
|
await journal.appendItem(
|
|
{ provider: 'orca', clientMessageId: `output-${index}` },
|
|
{ kind: 'status', text: `Tool output ${index}` },
|
|
{ fence: 1 }
|
|
)
|
|
}
|
|
for (let index = 1; index < 99; index += 1) {
|
|
await appendOutput(index)
|
|
}
|
|
await journal.appendSubmission({
|
|
clientMessageId: 'pending-send',
|
|
payloadFingerprint: 'prompt',
|
|
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Run tools' }] },
|
|
fence: 1
|
|
})
|
|
expect(journal.cursor().sequence).toBe(100)
|
|
const initial = structuredClone(
|
|
readAgentSessionHistory(journal, { sessionId: SESSION, direction: 'tail' })
|
|
)
|
|
const accept = () =>
|
|
journal.resolveDispatch({
|
|
clientMessageId: 'pending-send',
|
|
fence: 1,
|
|
state: 'accepted',
|
|
providerIdentity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }
|
|
})
|
|
return { journal, initial, appendOutput, accept }
|
|
}
|
|
|
|
describe('structured session cursor/body regression', () => {
|
|
it('replaces retained pending submissions together with a real bounded snapshot at 140', async () => {
|
|
const { journal, initial, appendOutput, accept } = await fixture()
|
|
const retained = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'event',
|
|
event: { type: 'snapshot', sessionId: SESSION, page: initial.page, fence: 1 }
|
|
})
|
|
expect(retained.submissions[0]?.dispatchState).toBe('pending')
|
|
await accept()
|
|
for (let index = 102; index <= 140; index += 1) {
|
|
await appendOutput(index)
|
|
}
|
|
const bounded = readAgentSessionHistory(journal, {
|
|
sessionId: SESSION,
|
|
direction: 'tail',
|
|
limit: 1
|
|
}).page
|
|
expect(bounded.liveCursor?.sequence).toBe(140)
|
|
expect(bounded.items).not.toContainEqual(retained.items.at(-1))
|
|
expect(bounded.submissions).toEqual([])
|
|
|
|
const replaced = reduceStructuredAgentSession(retained, {
|
|
type: 'event',
|
|
event: { type: 'snapshot', sessionId: SESSION, page: bounded, fence: 1 }
|
|
})
|
|
expect(replaced.cursor).toEqual(bounded.liveCursor)
|
|
expect(replaced.submissions).toEqual(bounded.submissions)
|
|
expect(hasUnansweredStructuredAgentSessionDispatch(replaced.submissions, 1)).toBe(false)
|
|
})
|
|
|
|
it.each([40, 401])(
|
|
'catches up an off-page dispatch after %i missed rows without stranding pending state',
|
|
async (missedRows) => {
|
|
const { journal, appendOutput, accept } = await fixture()
|
|
let hostSummary: AgentSessionStatusSummary | undefined
|
|
const feed = new StructuredAgentSessionStatusFeed({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
sessions: new Map([
|
|
[
|
|
SESSION,
|
|
{
|
|
journal,
|
|
fence: 1,
|
|
params: { location: { workspaceId: 'folder-workspace' }, provider: 'codex' }
|
|
}
|
|
]
|
|
]),
|
|
getRecord: () => null,
|
|
now: () => 1_000,
|
|
onStatusChanged: (summary) => {
|
|
hostSummary = summary
|
|
}
|
|
})
|
|
const subscribers = new AgentSessionSubscribers({
|
|
onJournalPublished: (sessionId, published) => feed.publish(sessionId, published)
|
|
})
|
|
const delayedOlder = Promise.withResolvers<AgentSessionHistoryResult>()
|
|
let warm = false
|
|
mocks.call.mockImplementation((_target, _method, request: AgentSessionHistoryRequest) => {
|
|
// Hold the measured bounded page before its asynchronous older-page fill can mask it.
|
|
if (warm && missedRows === 40 && request.direction === 'before') {
|
|
return delayedOlder.promise
|
|
}
|
|
const result = readAgentSessionHistory(journal, {
|
|
...request,
|
|
...(warm && missedRows === 40 ? { limit: 1 } : {})
|
|
})
|
|
return Promise.resolve(
|
|
structuredClone({
|
|
...result,
|
|
page: { ...result.page, fence: 1, hostNow: 1234 },
|
|
providerSession: { key: 'session_id', id: 'provider-1' }
|
|
})
|
|
)
|
|
})
|
|
mocks.subscribe.mockImplementation(
|
|
(
|
|
_target,
|
|
request: { cursor?: AgentJournalCursor },
|
|
onEvent: (event: AgentSessionSubscribeEvent) => void
|
|
) =>
|
|
Promise.resolve({
|
|
unsubscribe: subscribers.open({
|
|
id: 'pane',
|
|
sessionId: SESSION,
|
|
journal,
|
|
fence: 1,
|
|
cursor: request.cursor,
|
|
emit: (event) => onEvent(structuredClone(event))
|
|
})
|
|
})
|
|
)
|
|
const owner = getStructuredAgentSessionReadOwner(SESSION, target)
|
|
const unlisten = owner.subscribe(() => {})
|
|
const deactivate = owner.activate()
|
|
await vi.waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(1))
|
|
expect(owner.getSnapshot().state.cursor?.sequence).toBe(100)
|
|
expect(owner.getSnapshot().state.items.at(-1)?.body).toMatchObject({ role: 'user' })
|
|
expect(owner.getSnapshot().state.submissions[0]?.dispatchState).toBe('pending')
|
|
expect(owner.getSnapshot().providerSession).toEqual({ key: 'session_id', id: 'provider-1' })
|
|
expect(owner.getSnapshot().state.hostClock?.hostNow).toBe(1234)
|
|
expect(mocks.call).toHaveBeenCalledTimes(1)
|
|
deactivate()
|
|
|
|
await accept()
|
|
for (let index = 102; index <= 100 + missedRows; index += 1) {
|
|
await appendOutput(index)
|
|
}
|
|
const tail = readAgentSessionHistory(journal, {
|
|
sessionId: SESSION,
|
|
direction: 'tail',
|
|
limit: missedRows === 40 ? 1 : 200
|
|
}).page
|
|
expect(tail.liveCursor?.sequence).toBe(100 + missedRows)
|
|
expect(tail.submissions).toEqual([])
|
|
feed.publish(SESSION, journal)
|
|
// IPC/RPC copies values; the journal mutates its own submission records in place.
|
|
expect(owner.getSnapshot().state.submissions[0]?.dispatchState).toBe('pending')
|
|
warm = true
|
|
const stop = owner.activate()
|
|
|
|
await vi.waitFor(() => expect(owner.getSnapshot().state.cursor).toEqual(journal.cursor()))
|
|
const caughtUp = owner.getSnapshot().state
|
|
if (missedRows === 40) {
|
|
expect({
|
|
cursor: caughtUp.cursor?.sequence,
|
|
dispatch: caughtUp.submissions[0]?.dispatchState,
|
|
unansweredDispatch: hasUnansweredStructuredAgentSessionDispatch(caughtUp.submissions, 1)
|
|
}).toEqual({ cursor: 140, dispatch: 'accepted', unansweredDispatch: false })
|
|
}
|
|
|
|
await journal.appendItem(
|
|
{ provider: 'orca', clientMessageId: 'completed-turn' },
|
|
{ kind: 'turn', turnId: 'turn-1', state: 'completed' },
|
|
{ fence: 1 }
|
|
)
|
|
subscribers.publish(SESSION, journal)
|
|
await vi.waitFor(() => expect(owner.getSnapshot().state.cursor).toEqual(journal.cursor()))
|
|
const settled = owner.getSnapshot().state
|
|
expect(hostSummary?.status).toBe('idle')
|
|
expect(
|
|
projectStructuredAgentSessionStatus(settled.items, settled.submissions, settled.fence)
|
|
).toBe(hostSummary?.status)
|
|
expect(settled.submissions).toEqual(
|
|
missedRows > 200 ? tail.submissions : journal.snapshot().submissions
|
|
)
|
|
expect({
|
|
cursor: caughtUp.cursor?.sequence,
|
|
dispatch: caughtUp.submissions[0]?.dispatchState,
|
|
unansweredDispatch: hasUnansweredStructuredAgentSessionDispatch(caughtUp.submissions, 1)
|
|
}).toEqual({
|
|
cursor: 100 + missedRows,
|
|
dispatch: missedRows > 200 ? undefined : 'accepted',
|
|
unansweredDispatch: false
|
|
})
|
|
expect(mocks.call).toHaveBeenCalledTimes(1)
|
|
expect(mocks.subscribe).toHaveBeenCalledTimes(2)
|
|
expect(mocks.subscribe.mock.calls[1]?.[1]).toEqual({
|
|
sessionId: SESSION,
|
|
cursor: { epoch: journal.cursor().epoch, sequence: 100 }
|
|
})
|
|
|
|
stop()
|
|
unlisten()
|
|
}
|
|
)
|
|
})
|