mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
co-authored by
Merge Sim
parent
eba56f2f69
commit
c6a7216984
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-8
@@ -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 ? (
|
||||
|
||||
+78
@@ -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
@@ -2610,6 +2610,9 @@
|
||||
},
|
||||
"components": {
|
||||
"native-chat": {
|
||||
"approval": {
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"composer": {
|
||||
"effort": "Effort"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user