Files
orca/src/main/codex/codex-structured-question-order.test.ts
T
Brennan Benson 97fa6aee74 fix(native-chat): show a message Orca accepted and then failed to deliver as "Not sent" in the chat (#24710)
* 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.
2026-10-04 14:28:10 -07:00

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)']
])
})
})