mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
* fix(native-chat): keep a message the host accepted then rejected in the desktop chat as not sent Draw it in place from the host's history, so a crash that loses the outbox no longer makes it vanish. A later copy of the same body supersedes it; the outbox row wins while it holds the message; the phone is unchanged. * test(native-chat): pin the same-id rule apart from the body match * test(native-chat): type the rejected-in-place fixture body as a text block * fix(native-chat): let the host's row own a message it recorded and then rejected Once the host's journal records a send as rejected, the desktop outbox lets it go, as it already does for delivered and Stop-withdrawn sends: the host's row shows it as not sent, with the host's reason and no Retry. The outbox keeps only sends the host refused before recording them, which keep their Retry. A send whose own reply says it was rejected is drawn by its outbox entry, with no Retry, until the journal carries the row; a copy left by an earlier session is dropped when the chat opens. - the transcript no longer hides a host row behind an outbox entry with the same id or the same text; those rules and their cache are gone - a rejected message the queue holds (a draft's hand-off, or a live card under its id) is drawn as its card, not as a row - a later copy of the same text hides a rejected row only when it was sent once the rejection was known, so a deliberate repeat stays - delivery notices read the same visibility rule as the transcript; a chat whose only rejection a Stop withdrew no longer rebuilds them per batch - the body fingerprint helper goes back to the host, its only user * test(native-chat): keep one row when copies of a rejected message share an instant * refactor(native-chat): let the host's notice replace the outbox's under the same id * test(native-chat): pass the queued card ids in the tool-stream cost transcript * fix(native-chat): keep the host's record as what lets a rejected message go - the outbox no longer drops a host-rejected message when a chat opens; the reconcile lets it go once the journal's submissions say it was rejected, and that drop is written to storage, so nothing reads as still owed - a message the host rejected while the chat watched waits for its journal row with no Retry; one read back from storage with no row loaded keeps its Retry under a new id, since the host may have lost it - the delivery notices keep the same map and notice objects across a batch that words every row the same, so a submission batch re-renders no row - a rejected command such as /compact stays hidden: its own reply reports it - the desktop transcript requires the queued card ids, with a controller-level test that a card holding a rejected message keeps its row hidden * fix(native-chat): draw a queued message where the host rejected it A message accepted to hand over later and rejected before any handover now sits at its rejection, as a handover places one: what the agent did while it waited happened before it, and the newest history page holds it. One handed over, or dispatched as it was recorded, keeps its place. An older host does not move it, so it stays at its submission, still drawn. A failed start now rejects the queued messages and writes its row in ONE journal append, the messages first: no reader ever meets one without the other, and the messages still draw above the row that says why. * test(native-chat): pin that rows written together roll back together * fix(native-chat): draw every rejected message where it was rejected Not only a queued message: one handed over into a turn and then rejected, or sent directly and rejected, also sits at its rejection, in no turn. A message in doubt stays where it was, a plain bubble: it may have reached the agent. * fix(native-chat): decide a rejected message's Retry from the host's stored fact - a message the host recorded and then rejected has no Retry on any mount, however that mount learned of it, and a Dismiss that clears it from storage; a send refused before the host recorded it keeps its Retry - the rule that keeps a rejected command such as /compact out of the transcript moves into the one visibility function rows and notices share - the outbox state docs say what lets a recorded message go: the client holding its rejected submission, whose row the host places at the rejection * fix(native-chat): write no start-failure row when a Stop withdrew every queued message first * test(native-chat): pass the Dismiss action in the delivery-notice hook tests * fix(native-chat): keep a rejected message's outbox copy until its row loads An older host leaves a rejected message where it was sent, which may be older than the loaded window: the chat then holds the rejected submission but not the row that draws it. The outbox copy now stays until that row loads, marked as the host recorded it (Dismiss, no Retry, in the host's words), and leaves once the page holding the row is loaded. Derived from the loaded rows each time. Tests that label their projection as the phone's now pass the phone's own setting. * test(native-chat): type the outbox hook props that carry loaded rows * fix(native-chat): write nothing when a journal batch settles nothing in the outbox The outbox re-reads the journal on every batch since it waits for a rejected message's row to load. Its reconcile now returns each unchanged entry, and the list, as themselves (a message left in doubt included), so a batch that changes nothing writes nothing to storage. The reconcile moves to its own module. A copy the host recorded and rejected owes no delivery, so it no longer keeps a hidden pane reading the journal. * test(native-chat): count storage writes on the outbox's own storage object * fix(native-chat): let a recorded rejected message's outbox copy leave on its own, with no Dismiss The outbox copy of a message the host recorded and then rejected draws it only while the host's row is not loaded, and leaves on the batch or page that loads that row. It owes no delivery and offers no control: sending it again is a new message. The Dismiss that let the user clear it is gone, from the outbox, the notices and the session controller.
266 lines
9.3 KiB
TypeScript
266 lines
9.3 KiB
TypeScript
// A Codex ask with several questions, driven from the real host journal through
|
|
// the client's session reducer to the rows the transcript list draws.
|
|
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 { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
|
|
import {
|
|
AGENT_JOURNAL_THREAD_SCOPE,
|
|
type AgentJournalRenderItem
|
|
} from '../../shared/agent-session-journal-types'
|
|
import {
|
|
EMPTY_STRUCTURED_AGENT_SESSION,
|
|
reduceStructuredAgentSession,
|
|
type StructuredAgentSessionState
|
|
} from '../../shared/structured-agent-session-reducer'
|
|
import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection'
|
|
import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection'
|
|
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
|
|
import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness'
|
|
import { CodexJournalPrompts } from './codex-structured-journal-prompts'
|
|
import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies'
|
|
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
|
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
|
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
|
|
import {
|
|
HOST_TEST_NOW,
|
|
HOST_TEST_SESSION as SESSION,
|
|
HOST_TEST_THREAD as THREAD,
|
|
hostTestAttachParams,
|
|
hostTestOperationId,
|
|
resetHostTestOperationIds
|
|
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
|
|
import {
|
|
projectStructuredQuestionMessages,
|
|
structuredQuestionTranscript
|
|
} from '../../renderer/src/components/native-chat/structured-agent-question-projection'
|
|
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
|
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
|
|
|
const CALLER = { callerKey: 'client-1' }
|
|
|
|
type Asked = readonly { id: string; question: string }[]
|
|
|
|
// Codex's question ids are the model's own words, so their text order is not
|
|
// the order it asked in. These two asks spell the two orders live QA saw.
|
|
const ASKED: Asked = [
|
|
{ id: 'scope', question: 'Which files are in scope?' },
|
|
{ id: 'priority', question: 'What matters most?' },
|
|
{ id: 'deadline', question: 'When is it due?' }
|
|
]
|
|
const ASKED_OUT_OF_ORDER: Asked = [
|
|
{ id: 'format', question: 'Which format?' },
|
|
{ id: 'audience', question: 'Who reads it?' },
|
|
{ id: 'length', question: 'How long?' }
|
|
]
|
|
|
|
let root: string
|
|
let store: AgentSessionRecordStore
|
|
let host: StructuredAgentSessionHost
|
|
let sink: StructuredAgentSessionEventSink | null
|
|
let client: StructuredAgentSessionState
|
|
let clock: number
|
|
|
|
beforeEach(async () => {
|
|
root = await mkdtemp(join(tmpdir(), 'orca-codex-question-order-'))
|
|
resetHostTestOperationIds()
|
|
sink = null
|
|
clock = HOST_TEST_NOW
|
|
const adapter: StructuredAgentSessionAdapter = {
|
|
acquire: vi.fn<StructuredAgentSessionAdapter['acquire']>(async ({ fence, events }) => {
|
|
sink = events ?? null
|
|
return {
|
|
process: {
|
|
hostId: 'local',
|
|
pid: 4242,
|
|
processStartTimeMs: 1_700_000_000_000,
|
|
spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a'
|
|
},
|
|
link: {
|
|
linkId: `link-${fence}`,
|
|
handle: { provider: 'codex', threadId: THREAD },
|
|
origin: 'created',
|
|
mintedAtFence: fence,
|
|
observedAt: HOST_TEST_NOW
|
|
}
|
|
}
|
|
}),
|
|
releaseAcquisition: vi.fn(async () => true),
|
|
dispatch: vi.fn(),
|
|
cancelTurn: vi.fn(async () => ({ cancelled: true })),
|
|
answerPrompt: vi.fn(async ({ commit }) => commit()),
|
|
setOption: vi.fn(async () => undefined)
|
|
}
|
|
store = await openTestAgentSessionRecordStore(root)
|
|
host = new StructuredAgentSessionHost({
|
|
logger: createStructuredAgentSessionLogger(),
|
|
store,
|
|
adapter,
|
|
journalDatabase: openTestJournalHostDatabase(root),
|
|
claimKeyId: 'key-1',
|
|
mintSpawnToken: () => 'spawn-a',
|
|
// Every write lands on its own millisecond, as it does live.
|
|
now: () => (clock += 1)
|
|
})
|
|
expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true)
|
|
const page = await host.history({ sessionId: SESSION, direction: 'tail' })
|
|
if (!page.ok) {
|
|
throw new Error('no history page')
|
|
}
|
|
client = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, {
|
|
type: 'history-page',
|
|
page: page.page
|
|
})
|
|
host.subscribe({
|
|
id: 'client',
|
|
sessionId: SESSION,
|
|
cursor: page.page.liveCursor ?? page.page.window.nextCursor,
|
|
emit: (event) => {
|
|
client = reduceStructuredAgentSession(client, { type: 'event', event })
|
|
}
|
|
})
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await host.flushAllStreamedEvents()
|
|
await rm(root, { recursive: true, force: true })
|
|
})
|
|
|
|
function codexPrompts(): CodexJournalPrompts {
|
|
if (!sink) {
|
|
throw new Error('session was never acquired')
|
|
}
|
|
return new CodexJournalPrompts(
|
|
{ sink, attributionFor: () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE }) },
|
|
() => null,
|
|
() => 'turn-1'
|
|
)
|
|
}
|
|
|
|
async function ask(prompts: CodexJournalPrompts, asked: Asked = ASKED): Promise<void> {
|
|
prompts.handle({
|
|
threadId: THREAD,
|
|
method: CODEX_USER_INPUT_METHOD,
|
|
codexItemId: 'codex-item-1',
|
|
promptKey: '7',
|
|
params: {
|
|
threadId: THREAD,
|
|
turnId: 'turn-1',
|
|
questions: asked.map((question) => ({
|
|
...question,
|
|
options: [
|
|
{ label: 'Yes', description: '' },
|
|
{ label: 'No', description: '' }
|
|
]
|
|
}))
|
|
}
|
|
})
|
|
await host.flushStreamedEvents(SESSION)
|
|
}
|
|
|
|
function questionItem(question: string): AgentJournalRenderItem {
|
|
const item = client.items.find(
|
|
(candidate) => candidate.body.kind === 'question' && candidate.body.question === question
|
|
)
|
|
if (!item || item.body.kind !== 'question') {
|
|
throw new Error(`no journal item for ${question}`)
|
|
}
|
|
return item
|
|
}
|
|
|
|
async function answer(question: string): Promise<void> {
|
|
const item = questionItem(question)
|
|
if (item.body.kind !== 'question') {
|
|
return
|
|
}
|
|
const fields = {
|
|
itemId: item.itemId,
|
|
expectedRevision: item.revision,
|
|
optionId: item.body.options[0]!.id
|
|
}
|
|
const result = await host.respondToPrompt(CALLER, {
|
|
envelope: {
|
|
sessionId: SESSION,
|
|
clientOperationId: hostTestOperationId(),
|
|
expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
|
|
payloadFingerprint: computeAgentSessionPayloadFingerprint({
|
|
method: 'agentSession.respondTo:question',
|
|
sessionId: SESSION,
|
|
fields
|
|
})
|
|
},
|
|
kind: 'question',
|
|
...fields
|
|
})
|
|
expect(result.ok).toBe(true)
|
|
await host.flushStreamedEvents(SESSION)
|
|
}
|
|
|
|
/** The prompt rows the transcript list draws, top to bottom, as the questions each one shows. */
|
|
function drawnPromptRows(): string[][] {
|
|
const { receipts } = structuredQuestionTranscript(client.items)
|
|
// The desktop list's projection: its comparator adds only a rank for rows the host never writes.
|
|
const rows = projectNativeChatTranscriptMessages(
|
|
projectStructuredAgentSessionMessages(
|
|
client.items,
|
|
[],
|
|
client.submissions,
|
|
{ rejectedInPlace: true },
|
|
projectStructuredQuestionMessages
|
|
)
|
|
)
|
|
return rows.flatMap((row) => {
|
|
const prompt = receipts.get(row.id)
|
|
if (!prompt || prompt.kind !== 'question') {
|
|
return []
|
|
}
|
|
const questions = prompt.questions?.length ? prompt.questions : [prompt]
|
|
return [questions.map(({ question }) => `${question} (${prompt.resolution.state})`)]
|
|
})
|
|
}
|
|
|
|
describe('a Codex ask with several questions', () => {
|
|
it('keeps the pending rest of the ask below the question already answered', async () => {
|
|
await ask(codexPrompts())
|
|
await answer(ASKED[0]!.question)
|
|
|
|
expect(drawnPromptRows()).toEqual([
|
|
['Which files are in scope? (resolved)'],
|
|
['What matters most? (pending)', 'When is it due? (pending)']
|
|
])
|
|
})
|
|
|
|
it('draws every answered question in the order Codex asked it', async () => {
|
|
await ask(codexPrompts())
|
|
for (const { question } of ASKED) {
|
|
await answer(question)
|
|
}
|
|
|
|
expect(drawnPromptRows()).toEqual([
|
|
['Which files are in scope? (resolved)'],
|
|
['What matters most? (resolved)'],
|
|
['When is it due? (resolved)']
|
|
])
|
|
// Mobile draws the shared projection in journal order, one row per question.
|
|
expect(
|
|
projectStructuredAgentSessionMessages(client.items, [], client.submissions, {
|
|
rejectedInPlace: false
|
|
}).map(({ blocks }) => (blocks[0]?.type === 'text' ? blocks[0].text.split('\n')[0] : null))
|
|
).toEqual(ASKED.map(({ question }) => question))
|
|
})
|
|
|
|
it('draws a cancelled ask in the order Codex asked it', async () => {
|
|
const prompts = codexPrompts()
|
|
await ask(prompts, ASKED_OUT_OF_ORDER)
|
|
prompts.cancel(questionItem(ASKED_OUT_OF_ORDER[0]!.question).itemId)
|
|
await host.flushStreamedEvents(SESSION)
|
|
|
|
expect(drawnPromptRows()).toEqual([
|
|
['Which format? (cancelled)'],
|
|
['Who reads it? (cancelled)'],
|
|
['How long? (cancelled)']
|
|
])
|
|
})
|
|
})
|