fix(native-chat): hide activity while awaiting input (#20496)

* fix(native-chat): hide activity while awaiting input

* fix(native-chat): keep approval turns cancellable

* test(native-chat): satisfy split PR quality gate

* fix(native-chat): catalog approval cancellation label

* fix(native-chat): include approval cancellation runtime label

* fix(codex): settle prompts when cancelled turns complete

* fix(codex): settle prompt registry fallbacks

* test(native-chat): cover pending interaction fallbacks

* test(native-chat): split prompt state coverage

* test(native-chat): keep prompt state isolated

* fix(native-chat): bound prompt turn backfill

* refactor(codex): centralize prompt registry bounds

---------

Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
Brennan Benson
2026-09-14 10:42:38 -07:00
committed by GitHub
co-authored by Merge Sim
parent eba56f2f69
commit c6a7216984
25 changed files with 749 additions and 105 deletions
@@ -85,6 +85,9 @@ type Overrides = {
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
agentWorking?: boolean
canStop?: boolean
ask?: Parameters<typeof MobileNativeChatView>[0]['ask']
question?: Parameters<typeof MobileNativeChatView>[0]['question']
permission?: Parameters<typeof MobileNativeChatView>[0]['permission']
sendSurfaceId?: string
keyboardInset?: number
hasMore?: boolean
@@ -681,6 +684,72 @@ describe('MobileNativeChatView', () => {
expect(workingIndicators()).toHaveLength(0)
})
it.each([
{
label: 'structured question',
cardType: 'ChatAsk',
interaction: {
ask: {
questions: [
{
question: 'Pick destination',
multiSelect: false,
options: [{ label: 'Choice A' }, { label: 'Choice B' }]
}
]
}
}
},
{
label: 'question',
cardType: 'ChatQuestion',
interaction: {
question: {
question: 'Pick destination',
options: ['Choice A', 'Choice B'],
multiSelect: false,
allowOther: true,
optionTokens: ['choice-a', 'choice-b']
}
}
},
{
label: 'approval',
cardType: 'ChatPermission',
interaction: {
permission: {
title: 'Allow command?',
detail: 'pnpm test',
options: [
{ label: 'Allow', send: 'allow' },
{ label: 'Deny', send: 'deny' }
]
}
}
}
])('hides live turn activity for a pending $label without settling it', async (testCase) => {
const folded = [userTurn('u1', 'go'), assistantTurn('a1', 'waiting for input')]
const working = {
messages: folded,
folded,
structuredActivityUi: true,
agentWorking: true,
canStop: true
}
await render({ ...working, ...testCase.interaction })
expect(footerProps()).toBeNull()
expect(rowProps('a1').activeTurnIsWorking).toBe(true)
expect(
renderer!.root.findAll((node) => node.props.accessibilityLabel === 'Stop the agent')
).toHaveLength(1)
expect(renderer!.root.findAll((node) => node.type === testCase.cardType)).toHaveLength(1)
await update(working)
expect(footerProps()).toMatchObject({ thinking: false, workedSeconds: null })
expect(rowProps('a1').activeTurnIsWorking).toBe(true)
})
it('reports the live turn as thinking only when its journal says it is reasoning', async () => {
const folded = [userTurn('u1', 'go')]
await render({
+6 -1
View File
@@ -266,6 +266,8 @@ export function MobileNativeChatView({
activityText: turnIndicator?.activityText ?? null,
scopeKey: sendSurfaceId
})
const hasPendingStructuredInteraction =
structuredActivityUi && (ask != null || permission != null || question != null)
const renderItem = useCallback(
({ item, index }: { item: NativeChatMessage; index: number }) => (
@@ -329,7 +331,10 @@ export function MobileNativeChatView({
) : null
}
ListFooterComponent={
structuredActivityUi && agentWorking && turns.active ? (
structuredActivityUi &&
agentWorking &&
!hasPendingStructuredInteraction &&
turns.active ? (
<MobileNativeChatTurnStatus
startedAt={turns.active.startedAt}
thinking={turns.active.thinking}
@@ -13,6 +13,53 @@ export const CODEX_PROMPT_MAX_ANSWER_BYTES = 64 * 1024
export const MAX_CODEX_PROMPT_REGISTRY_ENTRIES = 128
export const MAX_CODEX_PROMPT_JOURNAL_BINDINGS = 256
export const MAX_CODEX_PROMPT_REGISTRY_BYTES = 4 * 1024 * 1024
const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = 512
type CodexPromptRegistryEntryBounds = {
threadId: string
turnId: string | null
turnIdDigest?: string
codexItemId: string
promptKey: string
questionIds: readonly string[]
optionAnswers: ReadonlyMap<string, { questionId: string; answer: string }>
answers: ReadonlyMap<string, string>
}
export function codexPromptRegistryEntryBytes(prompt: CodexPromptRegistryEntryBounds): number {
let bytes = 0
for (const value of [prompt.threadId, prompt.codexItemId, prompt.promptKey]) {
bytes += Buffer.byteLength(value, 'utf8')
}
const turnId = prompt.turnId ?? prompt.turnIdDigest
bytes += turnId ? Buffer.byteLength(turnId, 'utf8') : CODEX_PROMPT_TURN_ID_RESERVED_BYTES
for (const id of prompt.questionIds) {
bytes += Buffer.byteLength(id, 'utf8')
}
for (const entry of prompt.optionAnswers.values()) {
bytes += Buffer.byteLength(entry.questionId, 'utf8') + Buffer.byteLength(entry.answer, 'utf8')
}
for (const value of prompt.answers.values()) {
bytes += Buffer.byteLength(value, 'utf8')
}
return bytes
}
export function codexPromptTurnIdentity(turnId: string): {
turnId: string | null
turnIdDigest?: string
} {
return Buffer.byteLength(turnId, 'utf8') <= CODEX_PROMPT_TURN_ID_RESERVED_BYTES
? { turnId }
: { turnId: null, turnIdDigest: digestPayload(turnId) }
}
export function codexPromptMatchesTurn(
prompt: Pick<CodexPromptRegistryEntryBounds, 'turnId' | 'turnIdDigest'>,
turnId: string
): boolean {
return prompt.turnId === turnId || prompt.turnIdDigest === digestPayload(turnId)
}
export function codexJournalPromptIdPart(value: string): string {
if (Buffer.byteLength(value, 'utf8') <= CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES) {
@@ -8,7 +8,13 @@ export type CodexJournalTranslatorDeps = {
/** Keys restored lifecycle rows to the live identity; without it history restore skips them. */
sessionId?: string
now?: () => number
bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void
bindPromptItemId?: (
journalItemId: string,
threadId: string,
promptKey: string,
turnId?: string | null
) => void
clearPromptTurn?: (threadId: string, turnId: string) => void
primaryThreadId?: () => string | null
subagentExecutions?: CodexSubagentExecutions
coalesceMs?: number
@@ -18,13 +18,15 @@ import {
publishCodexLifecycle
} from './codex-structured-journal-sink'
import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement'
import { readCodexTurnId } from './codex-structured-thread-facts'
export class CodexJournalPrompts {
readonly pending = new Map<string, CodexPendingJournalPrompt>()
constructor(
private readonly deps: Pick<CodexJournalTranslatorDeps, 'sink' | 'bindPromptItemId'>,
private readonly detailFor: (threadId: string, itemId: string) => string | null
private readonly detailFor: (threadId: string, itemId: string) => string | null,
private readonly activeTurn: (threadId: string) => string | null
) {}
handle(event: {
@@ -34,6 +36,7 @@ export class CodexJournalPrompts {
codexItemId: string
promptKey: string
}): CodexJournalTranslationAdmission {
const turnId = readCodexTurnId(event.params) ?? this.activeTurn(event.threadId)
if (event.method === CODEX_USER_INPUT_METHOD) {
const questions = codexQuestionItems({
threadId: event.threadId,
@@ -47,12 +50,17 @@ export class CodexJournalPrompts {
}
for (const question of promptItems) {
const itemId = agentJournalItemKey(question.identity)
this.pending.set(itemId, { identity: question.identity, body: question.body })
this.pending.set(itemId, {
threadId: event.threadId,
turnId,
identity: question.identity,
body: question.body
})
const trimAdmission = this.trim()
if (!trimAdmission.accepted) {
return trimAdmission
}
this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey)
this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey, turnId)
}
return CODEX_JOURNAL_ADMITTED
}
@@ -70,12 +78,17 @@ export class CodexJournalPrompts {
return admission
}
const itemId = agentJournalItemKey(identity)
this.pending.set(itemId, { identity, body })
this.pending.set(itemId, {
threadId: event.threadId,
turnId,
identity,
body
})
const trimAdmission = this.trim()
if (!trimAdmission.accepted) {
return trimAdmission
}
this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey)
this.deps.bindPromptItemId?.(itemId, event.threadId, event.promptKey, turnId)
return CODEX_JOURNAL_ADMITTED
}
@@ -89,7 +102,7 @@ export class CodexJournalPrompts {
private admit(
event: { method: string; threadId: string; promptKey: string },
items: readonly CodexPendingJournalPrompt[]
items: readonly Pick<CodexPendingJournalPrompt, 'identity' | 'body'>[]
): CodexJournalTranslationAdmission {
return admitCodexLifecycleItems(
this.deps.sink,
@@ -3,7 +3,6 @@ import type {
AgentJournalItemIdentity,
AgentJournalTurnLifecycle
} from '../../shared/agent-session-journal-types'
import { partitionJournalLifecycleMutations } from '../native-chat/agent-session-journal/journal-lifecycle-batch-partition'
import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders'
import type {
StructuredAgentSessionEventSink,
@@ -23,6 +22,7 @@ import {
codexTurnLifecycleBody,
codexTurnLifecycleIdentity
} from './codex-structured-journal-translation-turns'
import { appendCodexLifecycleMutations } from './codex-structured-journal-sink'
export type CodexActiveJournalItem = {
threadId: string
@@ -32,6 +32,8 @@ export type CodexActiveJournalItem = {
}
export type CodexPendingJournalPrompt = {
threadId: string
turnId: string | null
identity: AgentJournalItemIdentity
body: AgentJournalItemBody
}
@@ -85,7 +87,11 @@ export function settleCodexJournalSession(input: {
turnOrdinalsToForget.push({ threadId, turnId })
}
}
const admission = appendLifecycleMutations(input.sink, exitSettlementId(input.event), mutations)
const admission = appendCodexLifecycleMutations(
input.sink,
exitSettlementId(input.event),
mutations
)
if (!admission.accepted) {
return admission
}
@@ -104,9 +110,13 @@ export function settleCodexJournalTurn(input: {
sink: StructuredAgentSessionEventSink
streams: CodexStructuredItemStreams
activeItems: Map<string, CodexActiveJournalItem>
pendingPrompts?: Map<string, CodexPendingJournalPrompt>
clearPromptTurn?: (threadId: string, turnId: string) => void
}): StructuredAgentSessionSinkAdmission {
const mutations: JournalLifecycleMutationInput[] = []
const activeItemsToForget: { key: string; threadId: string; itemId: string }[] = []
const pendingPromptsToForget: string[] = []
const pendingPrompts = input.pendingPrompts ?? new Map<string, CodexPendingJournalPrompt>()
for (const [key, active] of input.activeItems) {
if (active.threadId !== input.threadId || active.turnId !== input.turnId) {
continue
@@ -124,6 +134,16 @@ export function settleCodexJournalTurn(input: {
}
activeItemsToForget.push({ key, threadId: active.threadId, itemId: active.item.id })
}
for (const [key, prompt] of pendingPrompts) {
if (prompt.threadId !== input.threadId || prompt.turnId !== input.turnId) {
continue
}
const body = cancelledJournalPromptBody(prompt.body)
if (body) {
mutations.push({ kind: 'item', identity: prompt.identity, body })
}
pendingPromptsToForget.push(key)
}
// Revised, never tombstoned: the terminal row keeps the turn's duration durable.
if (input.turnLifecycle) {
mutations.push({
@@ -132,10 +152,7 @@ export function settleCodexJournalTurn(input: {
body: codexTurnLifecycleBody(input.turnLifecycle)
})
}
if (mutations.length === 0) {
return ADMITTED
}
const admission = appendLifecycleMutations(
const admission = appendCodexLifecycleMutations(
input.sink,
`turn-completed:${input.sessionId}:${input.threadId}:${input.turnId}`,
mutations
@@ -147,6 +164,10 @@ export function settleCodexJournalTurn(input: {
input.streams.forget(active.threadId, active.itemId)
input.activeItems.delete(active.key)
}
for (const key of pendingPromptsToForget) {
pendingPrompts.delete(key)
}
input.clearPromptTurn?.(input.threadId, input.turnId)
return ADMITTED
}
@@ -182,7 +203,7 @@ export function settleCodexOversizedNotification(input: {
if (mutations.length === 0) {
return ADMITTED
}
const admission = appendLifecycleMutations(
const admission = appendCodexLifecycleMutations(
input.sink,
`oversized-notification:${input.sessionId}:${input.threadId}:${input.method}`,
mutations
@@ -225,54 +246,6 @@ function oversizedStreamItemType(method: string): CodexThreadItem['type'] | null
return null
}
function appendLifecycleMutations(
sink: StructuredAgentSessionEventSink,
settlementId: string,
mutations: readonly JournalLifecycleMutationInput[]
): StructuredAgentSessionSinkAdmission {
const chunks = partitionJournalLifecycleMutations(settlementId, mutations)
for (const { settlementId: id, mutations: chunk } of chunks) {
let admission: StructuredAgentSessionSinkAdmission = ADMITTED
if (sink.tryAppendLifecycleBatch) {
admission = sink.tryAppendLifecycleBatch(id, chunk, { lifecycle: true })
} else if (sink.appendLifecycleBatch) {
admission = sink.appendLifecycleBatch(id, chunk, { lifecycle: true }) ?? ADMITTED
} else {
for (const mutation of chunk) {
if (mutation.kind === 'item') {
if (sink.tryAppendItem) {
admission = sink.tryAppendItem(mutation.identity, mutation.body, { lifecycle: true })
if (!admission.accepted) {
return admission
}
} else {
sink.appendItem(mutation.identity, mutation.body, { lifecycle: true })
}
} else {
if (sink.tryAppendTombstone) {
admission = sink.tryAppendTombstone(mutation.identity, { lifecycle: true })
if (!admission.accepted) {
return admission
}
} else {
sink.appendTombstone(mutation.identity, { lifecycle: true })
}
}
}
}
if (!admission.accepted) {
return admission
}
const publishAdmission = sink.tryPublish
? sink.tryPublish({ lifecycle: true })
: (sink.publish({ lifecycle: true }), ADMITTED)
if (!publishAdmission.accepted) {
return publishAdmission
}
}
return ADMITTED
}
function interruptedBody(body: AgentJournalItemBody | null): AgentJournalItemBody | null {
if (!body) {
return null
@@ -7,10 +7,62 @@ import type {
StructuredAgentSessionLifecycleIdentityResolver,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { partitionJournalLifecycleMutations } from '../native-chat/agent-session-journal/journal-lifecycle-batch-partition'
import type { JournalLifecycleMutationInput } from '../native-chat/agent-session-journal/journal-row-builders'
import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement'
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
import { CODEX_JOURNAL_ADMITTED } from './codex-structured-journal-contracts'
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
export function appendCodexLifecycleMutations(
sink: StructuredAgentSessionEventSink,
settlementId: string,
mutations: readonly JournalLifecycleMutationInput[]
): StructuredAgentSessionSinkAdmission {
const chunks = partitionJournalLifecycleMutations(settlementId, mutations)
for (const { settlementId: id, mutations: chunk } of chunks) {
let admission: StructuredAgentSessionSinkAdmission = ADMITTED
if (sink.tryAppendLifecycleBatch) {
admission = sink.tryAppendLifecycleBatch(id, chunk, { lifecycle: true })
} else if (sink.appendLifecycleBatch) {
admission = sink.appendLifecycleBatch(id, chunk, { lifecycle: true }) ?? ADMITTED
} else {
for (const mutation of chunk) {
if (mutation.kind === 'item') {
if (sink.tryAppendItem) {
admission = sink.tryAppendItem(mutation.identity, mutation.body, { lifecycle: true })
if (!admission.accepted) {
return admission
}
} else {
sink.appendItem(mutation.identity, mutation.body, { lifecycle: true })
}
} else {
if (sink.tryAppendTombstone) {
admission = sink.tryAppendTombstone(mutation.identity, { lifecycle: true })
if (!admission.accepted) {
return admission
}
} else {
sink.appendTombstone(mutation.identity, { lifecycle: true })
}
}
}
}
if (!admission.accepted) {
return admission
}
const publishAdmission = sink.tryPublish
? sink.tryPublish({ lifecycle: true })
: (sink.publish({ lifecycle: true }), ADMITTED)
if (!publishAdmission.accepted) {
return publishAdmission
}
}
return ADMITTED
}
function criticalAdmission(
admission: StructuredAgentSessionSinkAdmission
): CodexJournalTranslationAdmission {
@@ -57,7 +109,7 @@ export function publishCodexLifecycle(
export function admitCodexLifecycleItems(
sink: StructuredAgentSessionEventSink,
settlementId: string,
items: readonly CodexPendingJournalPrompt[]
items: readonly Pick<CodexPendingJournalPrompt, 'identity' | 'body'>[]
): CodexJournalTranslationAdmission {
if (items.length === 0) {
return { accepted: false, reason: 'untranslated' }
@@ -12,6 +12,7 @@ import {
codexTurnUserItemId,
publishCodexTurnLifecycle
} from './codex-structured-journal-translation-turns'
import type { CodexPendingJournalPrompt } from './codex-structured-journal-settlement'
import {
readCodexTurnDurationMs,
readCodexTurnId,
@@ -33,6 +34,8 @@ export class CodexJournalTurnBoundaries {
primaryThreadId: () => string | null
activeTurns: CodexJournalActiveTurns
items: Pick<CodexJournalItems, 'streams' | 'activeItems' | 'ordinals'>
pendingPrompts: Map<string, CodexPendingJournalPrompt>
clearPromptTurn?: (threadId: string, turnId: string) => void
flushSuppression: () => CodexJournalTranslationAdmission
resetActivity: (threadId: string) => void
now?: () => number
@@ -93,7 +96,9 @@ export class CodexJournalTurnBoundaries {
)
: null,
streams: this.deps.items.streams,
activeItems: this.deps.items.activeItems
activeItems: this.deps.items.activeItems,
pendingPrompts: this.deps.pendingPrompts,
...(this.deps.clearPromptTurn ? { clearPromptTurn: this.deps.clearPromptTurn } : {})
})
if (admission.accepted) {
this.deps.items.ordinals.forgetTurn(event.threadId, turnId)
@@ -14,6 +14,11 @@ import {
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import type { CodexAppServerConnection } from './codex-app-server-connection'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import {
CODEX_COMMAND_APPROVAL_METHOD,
CODEX_USER_INPUT_METHOD,
CodexPromptRegistry
} from './codex-structured-prompt-replies'
import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
import type { CodexSession } from './codex-structured-session-state'
@@ -85,6 +90,123 @@ afterEach(async () => {
})
describe('codex turn lifecycle rows', () => {
it('binds a prompt without a provider turn id to the active turn before cleanup', () => {
const tap = recorder()
const registry = new CodexPromptRegistry()
registry.register({
id: 1,
method: CODEX_COMMAND_APPROVAL_METHOD,
params: {
itemId: 'exec-fallback',
approvalId: 'approval-fallback',
threadId: THREAD_ID
}
})
const translator = createCodexJournalTranslator({
sink: tap.sink,
primaryThreadId: () => THREAD_ID,
bindPromptItemId: (journalItemId, threadId, promptKey, turnId) =>
registry.bindJournalItemId(journalItemId, threadId, promptKey, turnId),
clearPromptTurn: (threadId, turnId) => registry.clearTurn(threadId, turnId)
})
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
translator.handle({
type: 'prompt',
sessionId: SESSION_ID,
threadId: THREAD_ID,
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { availableDecisions: ['accept', 'decline'] },
codexItemId: 'exec-fallback',
promptKey: 'approval-fallback'
})
expect(registry.find('approval-fallback')?.turnId).toBe(TURN_ID)
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
expect(registry.find('approval-fallback')).toBeNull()
})
it('settles prompts when a turn completes while awaiting approval', () => {
const tap = recorder()
const clearPromptTurn = vi.fn()
const translator = createCodexJournalTranslator({
sink: tap.sink,
primaryThreadId: () => THREAD_ID,
clearPromptTurn
})
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
translator.handle({
type: 'prompt',
sessionId: SESSION_ID,
threadId: THREAD_ID,
method: CODEX_COMMAND_APPROVAL_METHOD,
params: { turnId: TURN_ID, availableDecisions: ['accept', 'decline'] },
codexItemId: 'exec-cancelled',
promptKey: 'approval-cancelled'
})
expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({
accepted: true
})
expect(tap.rows.map((row) => row.body)).toEqual([
expect.objectContaining({ kind: 'turn', state: 'running' }),
expect.objectContaining({
kind: 'approval',
resolution: expect.objectContaining({ state: 'pending' })
}),
expect.objectContaining({
kind: 'approval',
resolution: expect.objectContaining({ state: 'cancelled' })
}),
expect.objectContaining({ kind: 'turn', state: 'completed' })
])
expect(clearPromptTurn).toHaveBeenCalledWith(THREAD_ID, TURN_ID)
})
it('settles questions when a turn completes while awaiting input', () => {
const tap = recorder()
const clearPromptTurn = vi.fn()
const translator = createCodexJournalTranslator({
sink: tap.sink,
primaryThreadId: () => THREAD_ID,
clearPromptTurn
})
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
translator.handle({
type: 'prompt',
sessionId: SESSION_ID,
threadId: THREAD_ID,
method: CODEX_USER_INPUT_METHOD,
params: {
turnId: TURN_ID,
questions: [
{ id: 'question-cancelled', question: 'Continue?', options: [{ label: 'yes' }] }
]
},
codexItemId: 'exec-question-cancelled',
promptKey: 'question-cancelled'
})
expect(translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))).toEqual({
accepted: true
})
expect(tap.rows.map((row) => row.body)).toEqual([
expect.objectContaining({ kind: 'turn', state: 'running' }),
expect.objectContaining({
kind: 'question',
resolution: expect.objectContaining({ state: 'pending' })
}),
expect.objectContaining({
kind: 'question',
resolution: expect.objectContaining({ state: 'cancelled' })
}),
expect.objectContaining({ kind: 'turn', state: 'completed' })
])
expect(clearPromptTurn).toHaveBeenCalledWith(THREAD_ID, TURN_ID)
})
it('opens the running row with the host receipt time and pins the row time to it', async () => {
const journal = await journals.open({
identity: {
@@ -59,8 +59,10 @@ export function createCodexJournalTranslator(
(threadId, turnId) => genericFrames.suppress(threadId, turnId)
)
const settleOversizedNotification = createCodexOversizedNotificationSettler(deps, items)
const prompts = new CodexJournalPrompts(deps, (threadId, itemId) =>
items.detailFor(threadId, itemId)
const prompts = new CodexJournalPrompts(
deps,
(threadId, itemId) => items.detailFor(threadId, itemId),
(threadId) => activeTurns.current(threadId)
)
const subagents = new CodexSubagentRoster({
sink: deps.sink,
@@ -82,6 +84,8 @@ export function createCodexJournalTranslator(
primaryThreadId: () => deps.primaryThreadId?.() ?? null,
activeTurns,
items,
pendingPrompts: prompts.pending,
...(deps.clearPromptTurn ? { clearPromptTurn: deps.clearPromptTurn } : {}),
flushSuppression: () => genericFrames.flush(),
resetActivity,
...(deps.now ? { now: deps.now } : {})
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
applyCodexPromptAnswer,
CodexPromptRegistry,
MAX_CODEX_PROMPT_REGISTRY_BYTES,
MAX_CODEX_PROMPT_REGISTRY_ENTRIES,
codexJournalPromptIdPart,
decodeCodexQuestionOptionId,
@@ -94,6 +95,49 @@ describe('CodexPromptRegistry', () => {
expect(registry.find('codex-item-1')).toBeNull()
})
it('clears only prompts belonging to a settled turn', () => {
const registry = new CodexPromptRegistry()
registry.register({
id: 1,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'root-item', threadId: 'thread-1' }
})
registry.register({
id: 2,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'other-item', threadId: 'thread-1', turnId: 'turn-2' }
})
registry.register({
id: 3,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'other-thread-item', threadId: 'thread-2', turnId: 'turn-1' }
})
registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', 'turn-1')
registry.clearTurn('thread-1', 'turn-1')
expect(registry.find('root-item')).toBeNull()
expect(registry.find('journal-root')).toBeNull()
expect(registry.find('other-item')?.requestId).toBe(2)
expect(registry.find('other-thread-item')?.requestId).toBe(3)
})
it('bounds an oversized backfilled turn id and still clears its prompt', () => {
const registry = new CodexPromptRegistry()
const turnId = 'turn-'.padEnd(MAX_CODEX_PROMPT_REGISTRY_BYTES + 1, 'x')
registry.register({
id: 1,
method: 'item/commandExecution/requestApproval',
params: { itemId: 'root-item', threadId: 'thread-1' }
})
registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', turnId)
expect(registry.bytes).toBeLessThanOrEqual(MAX_CODEX_PROMPT_REGISTRY_BYTES)
registry.clearTurn('thread-1', turnId)
expect(registry.find('journal-root')).toBeNull()
})
it('addresses a prompt by its journal item id once bound, and forgets both', () => {
const registry = new CodexPromptRegistry()
const prompt = registry.register(userInputRequest(['q1']))
@@ -4,6 +4,9 @@ import {
MAX_CODEX_PROMPT_JOURNAL_BINDINGS,
MAX_CODEX_PROMPT_REGISTRY_BYTES,
MAX_CODEX_PROMPT_REGISTRY_ENTRIES,
codexPromptMatchesTurn,
codexPromptRegistryEntryBytes,
codexPromptTurnIdentity,
codexJournalPromptIdPart,
readQuestionIds,
readQuestionOptionAnswers
@@ -35,6 +38,8 @@ export type CodexPendingPrompt = {
method: string
threadId: string
turnId: string | null
/** Oversized compatibility turn ids stay comparable without escaping the registry byte cap. */
turnIdDigest?: string
codexItemId: string
/** What addresses this prompt. One tool item can ask more than once — a shell
* bridge re-asks per command under the same `itemId` — so the request's own
@@ -109,25 +114,7 @@ export class CodexPromptRegistry {
}
private promptBytes(prompt: CodexPendingPrompt): number {
let bytes = 0
for (const value of [
prompt.threadId,
prompt.turnId ?? '',
prompt.codexItemId,
prompt.promptKey
]) {
bytes += Buffer.byteLength(value, 'utf8')
}
for (const id of prompt.questionIds) {
bytes += Buffer.byteLength(id, 'utf8')
}
for (const entry of prompt.optionAnswers.values()) {
bytes += Buffer.byteLength(entry.questionId, 'utf8') + Buffer.byteLength(entry.answer, 'utf8')
}
for (const value of prompt.answers.values()) {
bytes += Buffer.byteLength(value, 'utf8')
}
return bytes
return codexPromptRegistryEntryBytes(prompt)
}
private retainedPromptBytes(): number {
@@ -222,7 +209,12 @@ export class CodexPromptRegistry {
}
/** Called by the translation module once the prompt has a journal id. */
bindJournalItemId(journalItemId: string, threadId: string, promptKey: string): void {
bindJournalItemId(
journalItemId: string,
threadId: string,
promptKey: string,
turnId?: string | null
): void {
const existing = this.journalItemIds.get(journalItemId)
if (existing) {
this.boundPrompts.delete(journalItemId)
@@ -233,6 +225,9 @@ export class CodexPromptRegistry {
if (!prompt) {
return
}
if (prompt.turnId === null && prompt.turnIdDigest === undefined && turnId) {
Object.assign(prompt, codexPromptTurnIdentity(turnId))
}
this.journalItemIds.set(journalItemId, address)
this.boundPrompts.set(journalItemId, prompt)
this.trim()
@@ -264,6 +259,18 @@ export class CodexPromptRegistry {
}
}
/** Drops requests that belonged to a turn which the provider has settled. */
clearTurn(threadId: string, turnId: string): void {
const prompts = new Set(
[...this.byAddress.values(), ...this.boundPrompts.values()].filter(
(prompt) => prompt.threadId === threadId && codexPromptMatchesTurn(prompt, turnId)
)
)
for (const prompt of prompts) {
this.forget(prompt)
}
}
clear(): void {
this.byAddress.clear()
this.journalItemIds.clear()
@@ -88,8 +88,9 @@ export async function acquireCodexStructuredSession(input: {
...(deps.now ? { now: deps.now } : {}),
primaryThreadId: () => primaryThreadId,
subagentExecutions,
bindPromptItemId: (journalItemId, threadId, promptKey) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey)
bindPromptItemId: (journalItemId, threadId, promptKey, turnId) =>
acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey, turnId),
clearPromptTurn: (threadId, turnId) => acquisition.prompts.clearTurn(threadId, turnId)
})
: null
const open = deps.openConnection ?? openCodexAppServerConnection
@@ -179,10 +179,20 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
sessionId
) => this.sessions.get(sessionId)?.backgroundTasks.state
bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void =>
bindPromptItemId = (
sessionId: string,
journalItemId: string,
promptKey: string,
turnId?: string | null
): void =>
this.sessions
.get(sessionId)
?.prompts.bindJournalItemId(journalItemId, this.session(sessionId).threadId, promptKey)
?.prompts.bindJournalItemId(
journalItemId,
this.session(sessionId).threadId,
promptKey,
turnId
)
async dispatch(input: {
sessionId: string
@@ -0,0 +1,29 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
describe('NativeChatApprovalCard', () => {
it('exposes cancellation while it owns the composer region', () => {
const onCancel = vi.fn()
render(
<NativeChatApprovalCard
approval={{
title: 'Allow command?',
detail: 'pnpm test',
options: [
{ label: 'Allow', send: 'allow' },
{ label: 'Deny', send: 'deny' }
]
}}
onChoose={() => {}}
onCancel={onCancel}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onCancel).toHaveBeenCalledOnce()
})
})
@@ -1,11 +1,14 @@
import { ShieldQuestion } from 'lucide-react'
import { ShieldQuestion, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { ChatApproval } from './native-chat-interactive-prompt'
export type NativeChatApprovalCardProps = {
approval: ChatApproval
/** Send the chosen option's literal string to the agent's PTY. */
onChoose: (send: string) => void
/** Cancel the active provider turn while this card owns the composer region. */
onCancel?: () => void
}
/**
@@ -16,7 +19,8 @@ export type NativeChatApprovalCardProps = {
*/
export function NativeChatApprovalCard({
approval,
onChoose
onChoose,
onCancel
}: NativeChatApprovalCardProps): React.JSX.Element {
return (
<div className="shrink-0 bg-background">
@@ -24,7 +28,7 @@ export function NativeChatApprovalCard({
<div className="flex w-full flex-col gap-2 rounded-lg border border-input bg-card px-4 py-3 shadow-xs">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
{approval.detail ? (
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
@@ -32,6 +36,16 @@ export function NativeChatApprovalCard({
</p>
) : null}
</div>
{onCancel ? (
<button
type="button"
onClick={onCancel}
aria-label={translate('components.native-chat.approval.cancel', 'Cancel')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</button>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{approval.options.map((opt, i) => (
@@ -53,6 +53,7 @@ export function NativeChatMessageList({
settledTurns,
failedDeliveryMessageIds,
showTurnStatus = true,
showLiveTurnActivity = true,
turnActivity,
runtimeContext
}: {
@@ -71,6 +72,8 @@ export function NativeChatMessageList({
failedDeliveryMessageIds?: ReadonlySet<string>
/** Turn timing and disclosure are available on structured agent sessions. */
showTurnStatus?: boolean
/** Whether the active turn's foreground activity row should be visible. */
showLiveTurnActivity?: boolean
turnActivity?: NativeChatTurnActivity | null
runtimeContext?: RuntimeFileOperationArgs | null
}): React.JSX.Element {
@@ -285,7 +288,7 @@ export function NativeChatMessageList({
context={rowContext}
window={transcriptWindow}
/>
{showTurnStatus && isWorking ? (
{showTurnStatus && showLiveTurnActivity && isWorking ? (
<NativeChatTurnActivityLine
activity={turnActivity}
status={turnStatuses.active}
@@ -138,6 +138,43 @@ describe('NativeChatMessageList turn indicator', () => {
expect(spinner).toHaveClass('animate-spin', 'motion-reduce:animate-none')
})
it('hides foreground turn activity without settling live tool state', () => {
const { container } = render(
<NativeChatMessageList
session={{
...session,
status: 'working',
messages: [
{
id: 'assistant-running-tool',
role: 'assistant',
blocks: [
{
type: 'tool-call',
name: 'shell',
input: { command: 'pnpm test' },
state: 'running'
}
],
timestamp: 1,
source: 'transcript'
}
]
}}
journalItems={[journalItem(1, turnItem), journalItem(2, reasoningRow)]}
isWorking
showLiveTurnActivity={false}
expandSignal={false}
fontScale={1}
/>
)
expect(container.querySelector('[data-native-chat-turn-activity]')).toBeNull()
expect(screen.queryByText(/Working for/)).toBeNull()
expect(screen.queryByText('Thinking')).toBeNull()
expect(screen.getByText('Running pnpm test')).toHaveClass('animate-pulse')
})
it('keeps the live row up after a tool settles', () => {
render(
<NativeChatMessageList
@@ -2,12 +2,25 @@ import { forwardRef, useImperativeHandle, useRef } from 'react'
import { vi, type Mock } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionBackgroundTask } from '../../../../shared/agent-session-wire'
import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard'
import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard'
import type { NativeChatLaunchSeed } from './native-chat-composer-types'
// Why: a named spy type keeps the harness's inferred return type portable across the test files.
type StructuredSessionSpy = Mock
type StructuredSessionMessageListProps = {
allowFileUriLinks?: boolean
onLinkClick?: (...args: unknown[]) => void
showTurnStatus?: boolean
showLiveTurnActivity?: boolean
isWorking?: boolean
runtimeContext?: unknown
}
const initialMessageListProps: StructuredSessionMessageListProps | null = null
const initialApprovalCardProps: NativeChatApprovalCardProps | null = null
/**
* Shared mock state and `vi.mock` factories for the NativeChatStructuredSession test files.
* Load it through `await vi.hoisted(async () => (await import(...)).createStructuredSessionMocks())`
@@ -20,20 +33,17 @@ export function createStructuredSessionMocks() {
mode: 'static' as 'static' | 'outbox',
status: 'ready' as 'idle' | 'loading' | 'ready' | 'error',
messages: null as null | unknown[],
messageListProps: null as null | {
allowFileUriLinks?: boolean
onLinkClick?: (...args: unknown[]) => void
showTurnStatus?: boolean
runtimeContext?: unknown
},
messageListProps: initialMessageListProps,
composerProps: null as null | {
launchSeed?: NativeChatLaunchSeed
structuredTransport?: Record<string, unknown>
isWorking?: boolean
},
approvalCardProps: initialApprovalCardProps,
questionCardProps: null as NativeChatQuestionCardProps | null,
promptItems: [] as AgentJournalRenderItem[],
respond: vi.fn() as StructuredSessionSpy,
cancel: vi.fn() as StructuredSessionSpy,
handlePasteEvent: vi.fn() as StructuredSessionSpy,
pasteFromClipboard: vi.fn() as StructuredSessionSpy,
submissions: [] as unknown[],
@@ -105,7 +115,7 @@ export function createStructuredSessionMocks() {
supportsStopAll: mocks.supportsBackgroundTaskStopAll
},
turnId: mocks.turnId,
cancel: vi.fn() as StructuredSessionSpy,
cancel: mocks.cancel,
stopBackgroundTask: (taskId?: string) =>
mocks.stopBackgroundTask(props.sessionId, taskId),
respond: mocks.respond,
@@ -171,7 +181,12 @@ export function createStructuredSessionMocks() {
})
}),
nativeChatEmptyState: () => ({ NativeChatEmptyState: () => null }),
nativeChatApprovalCard: () => ({ NativeChatApprovalCard: () => null }),
nativeChatApprovalCard: () => ({
NativeChatApprovalCard: (props: NativeChatApprovalCardProps) => {
mocks.approvalCardProps = props
return null
}
}),
nativeChatQuestionCard: () => ({
NativeChatQuestionCard: (props: NativeChatQuestionCardProps) => {
mocks.questionCardProps = props
@@ -187,9 +202,11 @@ export function createStructuredSessionMocks() {
mocks.messages = null
mocks.messageListProps = null
mocks.composerProps = null
mocks.approvalCardProps = null
mocks.questionCardProps = null
mocks.promptItems = []
mocks.respond.mockReset()
mocks.cancel.mockReset()
mocks.handlePasteEvent.mockReset()
mocks.pasteFromClipboard.mockReset()
mocks.submissions = []
@@ -3,6 +3,7 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { decodeAgentSessionQuestionAnswers } from '../../../../shared/agent-session-question-answer'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import { useAppStore } from '@/store'
import {
claudeGroupedQuestionPromptItems,
@@ -154,6 +155,104 @@ describe('NativeChatStructuredSession', () => {
}
)
it('suppresses live turn activity for a pending question without ending the turn', () => {
mocks.isWorking = true
mocks.turnId = 'turn-question'
mocks.promptItems = legacySingleQuestionPromptItems
const view = () => (
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-question"
sessionId="session-question"
target={{ kind: 'local' }}
agent="codex"
/>
)
const { rerender } = render(view())
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
showLiveTurnActivity: false
})
expect(
document
.querySelector('[data-native-chat-root="true"]')
?.getAttribute('data-native-chat-working')
).toBe('true')
expect(mocks.questionCardProps).not.toBeNull()
expect(screen.queryByTestId('structured-composer')).toBeNull()
act(() => mocks.questionCardProps?.onCancel())
expect(mocks.cancel).toHaveBeenCalledWith('turn-question')
expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false)
mocks.promptItems = []
rerender(view())
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
showLiveTurnActivity: true
})
expect(screen.getByTestId('structured-composer')).toBeTruthy()
expect(mocks.composerProps?.isWorking).toBe(true)
})
it('suppresses live turn activity for a pending approval but keeps background work visible', () => {
const approvalItems: AgentJournalRenderItem[] = [
{
itemId: 'approval-item',
revision: 1,
sequence: 1,
observedAt: 1,
body: {
kind: 'approval',
title: 'Allow command?',
detail: 'pnpm test',
options: [
{ id: 'allow', label: 'Allow' },
{ id: 'deny', label: 'Deny' }
],
resolution: {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
}
]
mocks.isWorking = true
mocks.turnId = 'turn-approval'
mocks.promptItems = approvalItems
mocks.monitoringBackgroundTasks = true
render(
<NativeChatStructuredSession
isVisible
isFocusedGroup
tabId="structured-approval"
sessionId="session-approval"
target={{ kind: 'local' }}
agent="claude"
/>
)
expect(mocks.messageListProps).toMatchObject({
isWorking: true,
showLiveTurnActivity: false
})
expect(mocks.approvalCardProps?.approval.title).toBe('Allow command?')
expect(screen.queryByTestId('structured-composer')).toBeNull()
expect(document.querySelector('[data-native-chat-background-tasks="true"]')).not.toBeNull()
act(() => mocks.approvalCardProps?.onChoose('allow'))
expect(mocks.respond).toHaveBeenCalledWith(approvalItems[0], 'allow')
expect(mocks.messageListProps?.showLiveTurnActivity).toBe(false)
act(() => mocks.approvalCardProps?.onCancel?.())
expect(mocks.cancel).toHaveBeenCalledWith('turn-approval')
})
// Every background-task test mounts the same local Claude session; only the ids
// differ. A fresh element per call also matters for the rerenders below: React
// bails out of re-rendering an identical one.
@@ -227,6 +227,7 @@ export function NativeChatStructuredSession(
workingStartedAt={controller.workingStartedAt}
settledTurns={controller.settledTurns}
showTurnStatus
showLiveTurnActivity={prompt === null}
turnActivity={controller.turnActivity}
onLinkClick={onLinkClick}
allowFileUriLinks={onLinkClick !== undefined}
@@ -245,6 +246,11 @@ export function NativeChatStructuredSession(
}))
}}
onChoose={(optionId) => void controller.respond(prompt, optionId)}
onCancel={() => {
if (controller.turnId) {
void controller.cancel(controller.turnId)
}
}}
/>
) : null}
{prompt && questionBody ? (
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import { useStructuredAgentSession } from './use-structured-agent-session'
const items: AgentJournalRenderItem[] = [
{
itemId: 'turn-1',
revision: 1,
sequence: 1,
observedAt: 1,
body: { kind: 'turn', turnId: 'provider-turn', state: 'running' }
},
{
itemId: 'question-1',
revision: 1,
sequence: 2,
observedAt: 2,
body: {
kind: 'question',
question: 'Which approach?',
options: [{ id: 'focused', label: 'Focused' }],
resolution: {
state: 'pending',
selectedOptionId: null,
resolvedBy: null,
resolvedAt: null
}
}
}
]
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn().mockResolvedValue(null)
}))
vi.mock('./use-structured-agent-session-read', () => ({
useStructuredAgentSessionRead: () => ({
state: {
fence: 3,
items,
submissions: [],
status: 'ready',
error: null,
hasOlder: false,
handoff: null
},
loadingOlder: false,
loadOlder: vi.fn()
})
}))
vi.mock('./use-structured-agent-session-outbox', () => ({
useStructuredAgentSessionOutbox: () => ({
outbox: [],
blockedClientMessageId: null,
error: null,
send: vi.fn(),
retry: vi.fn()
})
}))
it('keeps a prompted provider turn working and cancellable beneath presentation policy', () => {
const { result } = renderHook(() =>
useStructuredAgentSession({
sessionId: 'session-1',
agent: 'codex',
target: { kind: 'local' },
isVisible: true
})
)
expect(result.current.isWorking).toBe(true)
expect(result.current.turnId).toBe('provider-turn')
expect(result.current.prompts).toHaveLength(1)
})
@@ -120,7 +120,6 @@ describe('useStructuredAgentSession working state', () => {
beforeEach(() => {
vi.clearAllMocks()
fence = 3
items = []
submissions = []
mocks.call.mockResolvedValue(null)
})
+3
View File
@@ -2610,6 +2610,9 @@
},
"components": {
"native-chat": {
"approval": {
"cancel": "Cancel"
},
"composer": {
"effort": "Effort"
},
+2 -1
View File
@@ -17241,7 +17241,8 @@
"approval": {
"title": "Allow {{value0}}?",
"allow": "Allow",
"deny": "Deny"
"deny": "Deny",
"cancel": "Cancel"
},
"launchPromptNotDelivered": "Not delivered — check the terminal",
"structuredSessionCloseFailed": "Could not close this chat session",