mirror of
https://github.com/stablyai/orca.git
synced 2026-10-28 16:02:55 +00:00
* refactor(native-chat): keep the provider resume handle opaque to shared code
Shared structured-chat code parsed each provider's resume handle: Claude's
session id and branch leaf, Codex's thread id, through a 'claude' | 'codex'
union every new agent had to widen. The in-memory handle is now
{ transport, agent, nativeId, providerData? }: shared readers use nativeId,
lease and handle-chain checks compare transport and agent, and only the
Claude adapter reads its leaf (providerData).
Stored and wire forms are unchanged for Claude and Codex. One encoding
module writes their typed shapes and decodes both those and the neutral
shape a new transport uses, which an older build refuses as unreadable
rather than reading as Codex. Key and root strings, which fork seeds,
superseded creations and resume offers persist, stay byte-identical.
The journal's own handle type becomes the journal-row and attach-wire
encoding of the same handle, and the journal identity carries the
neutral handle (null before the provider proves one).
No user-visible change.
* fix(native-chat): derive journal-row provider handles from the journal identity
The journal row converter now takes the identity every caller already holds,
so a row's handle has one obvious constructor. Tests that wrote the in-memory
handle straight into journal rows now build it through that converter, and the
processless Claude fixture names a not-yet-proved handle as null.
* fix(native-chat): refuse a stored provider handle written in both forms
A typed Claude or Codex handle that also carries the neutral form's
transport, agent, native id or provider data named two identities; it
was read as Claude or Codex and the next write dropped the other one.
Such a row now stays unreadable and is set aside untouched.
* test(native-chat): use opaque handle in queued rejection fixture
* test(native-chat): share one Codex journal identity in the integration suite
Main grew the suite to the 800-line limit; the opaque-handle import pushed it
over. The two tests built the same identity inline.
* refactor(agent-session): name the handle's adapter state resumeCursor
Rename the neutral provider handle's providerData to resumeCursor before any
row persists the neutral form: it is an adapter-owned resume position (Claude's
transcript leaf), never identity. Claude/Codex stored and wire bytes are
unchanged; their typed shapes never carried the field.
State the stored-form contract (a handle's field set is closed; later per-link
data goes on the chain link, which every build preserves) and pin it with a
record round-trip test. Document that transport records the id space the
native id was minted in, which can differ from the agent's current transport.
255 lines
10 KiB
TypeScript
255 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])(
|
|
'replays 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(journal.snapshot().submissions)
|
|
expect({
|
|
cursor: caughtUp.cursor?.sequence,
|
|
dispatch: caughtUp.submissions[0]?.dispatchState,
|
|
unansweredDispatch: hasUnansweredStructuredAgentSessionDispatch(caughtUp.submissions, 1)
|
|
}).toEqual({ cursor: 100 + missedRows, dispatch: '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()
|
|
}
|
|
)
|
|
})
|