Fix Codex goal Stop and selected model settings (#33631)

* Fix Codex goal Stop and selected model settings

* Preserve settings evidence and captured Stop outcomes

* Preserve refused goal pauses and bound Stop retry diagnostics

* Use checked settings state in goal contract tests

* Narrow captured Stop child and type its timing fixture

* Coordinate Stop timing fixture with deferred phases

* Fix typed mobile warning icon assertion

* Align Stop tests with reconciled controls and owned shutdown

* Check integration attach parameters without a type cast

* Match restart offer close calls to optional observer contract
This commit is contained in:
Brennan Benson
2026-10-26 01:01:19 -07:00
committed by GitHub
parent d249c21b7f
commit c0714b9ce0
68 changed files with 2156 additions and 141 deletions
@@ -1,4 +1,5 @@
import { createElement } from 'react'
import { AlertTriangle } from 'lucide-react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentJournalSubmissionKey } from '../../../src/shared/agent-session-journal-item-key'
@@ -9,6 +10,9 @@ import type {
AgentJournalTurnScope
} from '../../../src/shared/agent-session-journal-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { agentSessionFailureFact } from '../../../src/shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../src/shared/agent-session-failure-words'
import { AGENT_SESSION_GOAL_CONTROL_COPY } from '../../../src/shared/agent-session-goal-control-copy'
import { DISPATCH_REJECTED_CANCELLED } from '../../../src/shared/structured-agent-session-dispatch-rejection'
import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection'
import { selectStructuredAgentSettledTurns } from '../../../src/shared/structured-agent-session-turn-timing'
@@ -39,6 +43,7 @@ vi.mock('react-native', async () => {
})
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('lucide-react-native', () => ({
AlertTriangle: 'AlertTriangle',
ArrowUp: 'ArrowUp',
Bot: 'Bot',
Brain: 'Brain',
@@ -153,6 +158,34 @@ describe('a send a Stop took back before the agent started it, on the phone', ()
renderer = null
})
it.each(['pauseUnconfirmed', 'stopUnconfirmed'] as const)(
'renders the host %s warning once after projection',
(stopNotice) => {
const messages = projectStructuredAgentSessionMessages(
[
item('stop-outcome', {
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), {
surface: 'row',
stopNotice
})
})
],
[],
[]
)
expect(messages).toHaveLength(1)
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message: messages[0]! }))
})
expect(
renderer!.root.findAll((node) => String(node.type) === 'Text').map((node) => node.children)
).toEqual([[AGENT_SESSION_GOAL_CONTROL_COPY[stopNotice]]])
expect(renderer!.root.findAllByType(AlertTriangle)).toHaveLength(1)
}
)
it('stays where it was sent, with the stop row after it and no turn status of its own', () => {
const messages = projectStructuredAgentSessionMessages(items, [], submissions)
expect(messages.map((message) => message.id)).toEqual([
@@ -127,7 +127,7 @@ describe('a Claude retrying a refused request', () => {
clock += STRUCTURED_AGENT_SESSION_IDLE_MS
await vi.waitFor(() => {
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(host.hasSession(SESSION)).toBe(false)
})
})
@@ -5,6 +5,7 @@ import type { spawnProcess } from '@orca/process-host'
import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
import { providerRequestTimeoutOf } from '../provider-process/provider-request-timeout'
import { openCodexAppServerConnection } from './codex-app-server-connection'
import { CodexAppServerTimeoutError } from './codex-app-server-session'
import { codexTurnLifecycleRig } from './codex-structured-dispatch-test-support'
const teardown = vi.hoisted(() => {
@@ -64,19 +65,47 @@ describe('Codex process-tree diagnostic after a forced close', () => {
)
const original = session.connection
session.connection = connection
const acceptedAt = Date.now()
const stopping = rig.adapter.cancelTurn({
sessionId: 'session-1',
fence: 7,
turnId,
stoppedAt: Date.now() - 4_900
})
const timedOut = expect(stopping).rejects.toThrow('exceeded 100ms')
await vi.advanceTimersByTimeAsync(100)
await timedOut
await expect(stopping.then(() => null, providerRequestTimeoutOf)).resolves.toEqual({
method: 'turn/interrupt',
timeoutMs: 100
let settled = false
const observed = stopping.then((outcome) => {
settled = true
return outcome
})
await vi.advanceTimersByTimeAsync(99)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
const outcome = await observed
expect(Date.now() - acceptedAt).toBe(100)
expect(outcome).toMatchObject({
cancelled: false,
stop: {
pause: 'unconfirmed',
interrupt: 'unconfirmed',
root: 'unproven',
workload: 'unproven'
}
})
const timeouts = outcome.stop?.errors.flatMap((error) =>
error instanceof CodexAppServerTimeoutError && 'providerRequestTimeout' in error
? [error.providerRequestTimeout]
: []
)
expect(timeouts).toEqual([
{ method: 'thread/goal/get', timeoutMs: expect.closeTo(100 / 3, 3) },
{ method: 'turn/interrupt', timeoutMs: 67 }
])
expect(providerRequestTimeoutOf(outcome.stop?.errors[1])).toEqual({
method: 'turn/interrupt',
timeoutMs: 67
})
expect(outcome.stop?.errors).toHaveLength(3)
expect(outcome.stop?.errors[2]).toEqual(new Error('codex Stop reconciliation unavailable'))
const replacement = {
...session,
connection: original,
@@ -135,7 +135,7 @@ describe('a Codex reconnecting a dropped stream', () => {
clock += STRUCTURED_AGENT_SESSION_IDLE_MS
await host.collaboratorsForTests().lifetime.idleSweep.tick()
await vi.waitFor(() => {
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(host.hasSession(SESSION)).toBe(false)
})
})
+1 -1
View File
@@ -14,7 +14,7 @@ export function codexStoppedRequestEndWait(
): (sessionId: string, stoppedAt: number) => Promise<void> {
return async (sessionId, stoppedAt) => {
const session = sessions.get(sessionId)
if (!session || !requestInFlight(session)) {
if (!session || !requestInFlight(session) || stoppedAt + CODEX_STOP_GRACE_MS <= Date.now()) {
return
}
let elapsed = false
@@ -12,6 +12,8 @@ const THREAD = CODEX_SCRIPTED_THREAD
type ScriptedCodexRoute = (params: Record<string, unknown> | undefined) => unknown
export type ScriptedCodexChild = ScriptedAgentChild & {
notify(method: string, params: unknown): void
closeEvidence: NonNullable<CodexAppServerConnection['closeResult']>
requests(): readonly { method: string; params?: Record<string, unknown> }[]
}
@@ -44,6 +46,8 @@ function heldAnswer(
export const codexScriptedChild = (
routes: Readonly<Record<string, ScriptedCodexRoute>> = {}
): ScriptedCodexChild => {
let notify: ScriptedCodexChild['notify'] = () => {}
let closeEvidence: ScriptedCodexChild['closeEvidence'] = { root: 'exited', tree: 'exited' }
let holdHandshakes = true
let completeTurns = true
let spawned = 0
@@ -53,6 +57,7 @@ export const codexScriptedChild = (
const requests: { method: string; params?: Record<string, unknown> }[] = []
let newest: ReturnType<typeof Promise.withResolvers<unknown>> | undefined
const open: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => {
notify = (method, params) => handlers.onNotification?.(method, params)
spawned += 1
const gate = Promise.withResolvers<unknown>()
newest = gate
@@ -80,7 +85,8 @@ export const codexScriptedChild = (
resumed += 1
}
if (route) {
return route(params)
const answer = route(params)
return answer instanceof Promise ? heldAnswer(answer, method, options?.timeoutMs) : answer
}
if (method === 'thread/start' || method === 'thread/resume') {
return { thread: { id: THREAD }, model: 'scripted-model' }
@@ -134,6 +140,9 @@ export const codexScriptedChild = (
notify: () => {},
respond: () => {},
respondWithError: () => {},
get closeResult() {
return ended ? closeEvidence : undefined
},
close: async () => {
if (!ended) {
ended = true
@@ -150,6 +159,13 @@ export const codexScriptedChild = (
return connection
}
return {
notify: (method, params) => notify(method, params),
get closeEvidence() {
return closeEvidence
},
set closeEvidence(value) {
closeEvidence = value
},
deps: {
openCodexConnection: open,
readProcessStartTime: async () => 1_700_000_000_000,
@@ -1,3 +1,4 @@
import { readRecord } from './codex-item-field-readers'
import type { CodexAppServerConnectionHandlers } from './app-server/codex-app-server-connection-types'
import type { CodexAcquisitionWindow } from './codex-structured-acquisition-window'
import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo'
@@ -29,6 +30,15 @@ export function codexAcquisitionNotificationHandler(input: {
if (method === 'thread/status/changed' && threadId && threadId === input.primaryThreadId()) {
input.dispatchEchoes.observeStatus(codexThreadIsActive(params))
}
if (method === 'thread/settings/updated' && threadId && threadId === input.primaryThreadId()) {
const settings = readRecord(readRecord(params).threadSettings)
if (
typeof settings.model === 'string' &&
(typeof settings.effort === 'string' || settings.effort === null)
) {
input.acquisition.appliedSettings.observe(settings)
}
}
input.deliver(
input.acquisition,
input.sessionId,
@@ -1,3 +1,4 @@
import { CodexAppliedSettings } from './codex-structured-applied-settings'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
import { CodexPromptRegistry } from './codex-structured-prompt-replies'
@@ -6,6 +7,7 @@ export const MAX_CODEX_ACQUISITION_BUFFER_OPERATIONS = 1024
export const MAX_CODEX_ACQUISITION_BUFFER_BYTES = 4 * 1024 * 1024
export class CodexAcquisitionWindow {
readonly appliedSettings = new CodexAppliedSettings()
readonly prompts = new CodexPromptRegistry()
/** Null until the spawn resolves; the handshake can already emit events. */
connection: CodexAppServerConnection | null = null
@@ -0,0 +1,181 @@
import { throwIfSignalAborted, waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
import { AGENT_SESSION_GOAL_CONTROL_COPY } from '../../shared/agent-session-goal-control-copy'
import { isCodexAppServerUnsupportedError } from './codex-app-server-session'
import { readRecord } from './codex-item-field-readers'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import type { CodexAppServerConnection } from './app-server/codex-app-server-connection-types'
type Settings = { model?: string; effort?: string | null }
/** Only explicit method declarations permit compatibility fallback. */
export function codexMethodUnavailable(error: unknown, method: string): boolean {
if (isCodexAppServerUnsupportedError(error) && error instanceof Error) {
return error.message.includes(method)
}
return (
isCodexAppServerRequestError(error) &&
error.method === method &&
(error.code === -32601 ||
(error.message.includes(method) &&
/(?:method|capability).*(?:unavailable|unsupported|not found)/i.test(error.message)))
)
}
/** Receipt-level evidence must not wait behind the host mutation that needs it. */
export class CodexAppliedSettings {
private snapshot: Settings = {}
private readonly pending = new Set<Settings>()
private readonly listeners = new Set<() => void>()
private ended = false
private unsupported = false
observe(value: unknown): void {
const row = readRecord(value)
const previous = this.snapshot
this.snapshot = {
...(typeof row.model === 'string' ? { model: row.model } : {}),
...(typeof row.effort === 'string' || row.effort === null ? { effort: row.effort } : {})
}
const changed = new Set(
(['model', 'effort'] as const).filter((key) => previous[key] !== this.snapshot[key])
)
if (changed.size === 0) {
return
}
let through: Settings | undefined
// Each changed field proves its earliest possible source; combine their FIFO lower bounds.
for (const write of this.pending) {
for (const key of changed) {
if (write[key] !== undefined && write[key] === this.snapshot[key]) {
through = write
changed.delete(key)
}
}
if (changed.size === 0) {
break
}
}
if (through) {
for (const write of this.pending) {
this.pending.delete(write)
if (write === through) {
break
}
}
}
for (const listener of this.listeners) {
listener()
}
}
end(): void {
this.ended = true
this.snapshot = {}
this.pending.clear()
for (const listener of this.listeners) {
listener()
}
}
attempt(choices: Settings): () => void {
if (this.equivalent(choices)) {
return () => {}
}
const write = { model: choices.model, effort: choices.effort }
this.pending.add(write)
return () => this.pending.delete(write)
}
private equivalent(choices: Settings): boolean {
const keys = ['model', 'effort'] as const
if (keys.some((key) => choices[key] !== undefined && choices[key] !== this.snapshot[key])) {
return false
}
for (const write of this.pending) {
if (
keys.some(
(key) =>
choices[key] !== undefined && write[key] !== undefined && write[key] !== choices[key]
)
) {
return false
}
}
return true
}
async install(
connection: CodexAppServerConnection,
threadId: string,
choices: Settings,
timeoutMs = 30_000,
signal?: AbortSignal
): Promise<{ ok: true } | { ok: false; rejected: string }> {
throwIfSignalAborted(signal)
if (this.unsupported || this.equivalent(choices)) {
return { ok: true }
}
const deadline = Date.now() + timeoutMs
const changed = Promise.withResolvers<void>()
let replied = false
const check = () => {
if (this.ended) {
expired.reject(new Error('codex settings connection ended'))
} else if (replied && this.equivalent(choices)) {
changed.resolve()
}
}
void changed.promise.catch(() => {})
this.listeners.add(check)
const discard = this.attempt(choices)
const expired = Promise.withResolvers<never>()
const timer = setTimeout(
() => expired.reject(new Error('codex settings application timed out')),
timeoutMs
)
let rejected = false
try {
const work = (async () => {
try {
await connection.request(
'thread/settings/update',
{ threadId, ...choices },
{
timeoutMs: Math.max(1, deadline - Date.now())
}
)
} catch (error) {
if (
isCodexAppServerRequestError(error) ||
codexMethodUnavailable(error, 'thread/settings/update')
) {
discard()
if (codexMethodUnavailable(error, 'thread/settings/update')) {
this.unsupported = true
return
}
rejected = true
}
throw error
}
replied = true
check()
await changed.promise
})()
await waitForPromiseWithSignal(Promise.race([work, expired.promise]), signal)
return { ok: true }
} catch {
throwIfSignalAborted(signal)
return {
ok: false,
rejected: rejected
? AGENT_SESSION_GOAL_CONTROL_COPY.settingsRejected
: AGENT_SESSION_GOAL_CONTROL_COPY.settingsUnconfirmed
}
} finally {
clearTimeout(timer)
this.listeners.delete(check)
changed.reject(new Error('codex settings wait ended'))
}
}
}
@@ -0,0 +1,89 @@
import type {
AgentSessionCancelOutcome,
AgentSessionStopOutcome
} from '../native-chat/agent-session-wire/structured-agent-session-adapter-stop'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import { readRecord } from './codex-item-field-readers'
import { codexMethodUnavailable } from './codex-structured-applied-settings'
import type { CodexSession } from './codex-structured-session-state'
import { CODEX_STOP_GRACE_MS } from './codex-request-end-wait'
/** RPC ordering excludes earlier goal handlers, but cannot certify model-tool write quiescence. */
export async function stopCodexGoal(input: {
session: CodexSession
stoppedAt: number
timeoutMs?: number
current: () => boolean
interrupt: () => Promise<AgentSessionCancelOutcome>
}): Promise<AgentSessionCancelOutcome> {
const { session, current } = input
const deadline = input.stoppedAt + CODEX_STOP_GRACE_MS
const facts: AgentSessionStopOutcome = {
pause: 'unconfirmed',
interrupt: 'not-needed',
root: 'unproven',
workload: 'unproven',
errors: []
}
const pause = async (phaseDeadline: number) => {
const rpc = (method: string, params: Record<string, unknown>) => {
if (!current() || session.connection.closed || phaseDeadline <= Date.now()) {
throw new Error('codex Stop reconciliation unavailable')
}
return session.connection.request(method, params, {
timeoutMs: Math.min(input.timeoutMs ?? 30_000, phaseDeadline - Date.now())
})
}
try {
const result = readRecord(await rpc('thread/goal/get', { threadId: session.threadId }))
if (!Object.hasOwn(result, 'goal')) {
throw new Error('codex goal read did not report a goal')
}
if (readRecord(result.goal).status === 'active') {
await rpc('thread/goal/set', {
threadId: session.threadId,
status: 'paused',
origin: 'user'
})
}
if (facts.errors.length === 0) {
facts.pause = 'acknowledged'
}
} catch (error) {
if (
codexMethodUnavailable(error, 'thread/goal/get') ||
(isCodexAppServerRequestError(error) &&
error.method === 'thread/goal/get' &&
/(?:^|: )goals feature is disabled[.!]?$/i.test(error.message))
) {
facts.pause = 'not-needed'
} else {
facts.pause = 'unconfirmed'
facts.errors.push(error)
}
}
}
await pause(Math.min(deadline, Date.now() + Math.max(0, deadline - Date.now()) / 3))
let outcome: AgentSessionCancelOutcome = { cancelled: false }
try {
if (!current()) {
throw new Error('codex Stop acquisition ended')
}
outcome = await input.interrupt()
facts.interrupt = outcome.cancelled
? 'accepted'
: outcome.refusal
? 'unconfirmed'
: 'not-needed'
if (outcome.refusal) {
facts.errors.push(outcome.refusal)
}
} catch (error) {
facts.interrupt = 'unconfirmed'
facts.errors.push(error)
}
if (facts.pause !== 'not-needed') {
await pause(deadline)
}
return { ...outcome, stop: facts }
}
@@ -6,6 +6,27 @@ import { CODEX_DEFAULT_SERVICE_TIER } from './codex-structured-service-tier'
import type { CodexSession } from './codex-structured-session-state'
import { applyCodexStructuredSessionOption } from './codex-structured-session-options'
function codexModelDefaults(session: CodexSession, selected?: string) {
const listing = codexAcquireCatalogListing(session.catalogAccess)
const model =
selected ??
session.reportedOptions.model ??
listing?.models.find((entry) => entry.isDefault)?.id
const defaultEffort =
(model === session.reportedOptions.model ? session.reportedOptions.effort : undefined) ??
listing?.models.find((entry) => entry.id === model)?.defaultEffort
return { model, defaultEffort }
}
export function resolvedCodexGoalSettings(
session: CodexSession,
choices: AgentSessionExecutionOptions = {}
) {
const { model, defaultEffort } = codexModelDefaults(session, choices.model)
const effort = choices.effort ?? defaultEffort
return { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }
}
/** Resolve per-message overrides without replacing the session's saved picks. */
export async function resolvedCodexMessageOptions(
session: CodexSession,
@@ -14,19 +35,13 @@ export async function resolvedCodexMessageOptions(
if (choices === undefined) {
return undefined
}
const listing = codexAcquireCatalogListing(session.catalogAccess)
const priorModel = session.options.get('model')
const priorEffort = session.options.get('effort')
// Keep the thread's effort unless switching models.
const thread = session.reportedOptions
const model =
choices.model ?? thread.model ?? listing?.models.find((entry) => entry.isDefault)?.id
const { model, defaultEffort } = codexModelDefaults(session, choices.model)
if (!model && (choices.effort || priorModel || priorEffort)) {
throw new AgentSessionOptionRejectedError(new Error('codex default model is unavailable'))
}
const listedModel = listing?.models.find((entry) => entry.id === model)
const threadEffort = model === thread.model ? thread.effort : undefined
const defaultEffort = threadEffort ?? listedModel?.defaultEffort
if (choices.effort === undefined && priorEffort && !defaultEffort) {
throw new AgentSessionOptionRejectedError(new Error('codex default effort is unavailable'))
}
@@ -13,6 +13,7 @@ import {
import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state'
import { interruptCodexTurn } from './codex-structured-turn-cancellation'
import { codexStopTarget, interruptOpeningCodexTurn } from './codex-structured-turn-open-wait'
import { stopCodexGoal } from './codex-structured-goal-stop'
import { CODEX_STOP_GRACE_MS } from './codex-request-end-wait'
type CancelInput = Parameters<StructuredAgentSessionAdapter['cancelTurn']>[0]
@@ -89,6 +90,20 @@ export async function cancelCodexStructuredTurn(input: {
}): Promise<AgentSessionCancelOutcome> {
const { request, sessions, requestTimeoutMs } = input
const session = requireLiveCodexSession(sessions, request.sessionId)
if (request.stoppedAt !== undefined && !request.prompt) {
const generation = session.acquisitionGeneration
return stopCodexGoal({
session,
stoppedAt: request.stoppedAt,
timeoutMs: requestTimeoutMs,
current: () =>
sessions.get(request.sessionId) === session &&
!session.ended &&
session.fence === request.fence &&
session.acquisitionGeneration === generation,
interrupt: () => cancelCodexConversation(input, session)
})
}
const prompt = request.prompt
const requestedTurnId = request.turnId
if (requestedTurnId === undefined) {
@@ -156,6 +156,7 @@ export async function acquireCodexStructuredSession(input: {
),
onSpawned: spawnIdentity.onSpawned,
onExit: (error, exit) => {
acquisition.appliedSettings.end()
try {
if (attempt.acquisitionGeneration && !sessions.has(sessionId)) {
attempt.exitProven = true
@@ -39,7 +39,7 @@ import * as turnEnd from './codex-structured-turn-end-settlement'
import { createCodexStructuredNotificationRetry } from './codex-structured-notification-retry'
import { acquireCodexStructuredSession } from './codex-structured-session-acquire'
import { codexStoppedRequestEndWait, settleCodexRequestEndWaiters } from './codex-request-end-wait'
import { changeCodexThreadGoal } from './codex-structured-thread-goal'
import { changeCodexThreadGoalWithSettings } from './codex-structured-thread-goal'
import { startCodexTerminalStopProbe } from './codex-background-terminals'
import { codexBackgroundTaskStops, stopCodexBackgroundTasks } from './codex-background-task-stops'
import {
@@ -293,14 +293,15 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
}
}
changeThreadGoal: NonNullable<StructuredAgentSessionAdapter['changeThreadGoal']> = (input) =>
changeCodexThreadGoal(
this.session(input.sessionId),
input.change,
input.replacesGoal,
changeThreadGoal: NonNullable<StructuredAgentSessionAdapter['changeThreadGoal']> = (input) => {
const session = this.session(input.sessionId)
return changeCodexThreadGoalWithSettings(
session,
input,
this.deps.requestTimeoutMs,
input.signal
() => this.sessions.get(input.sessionId) === session
)
}
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) =>
answerCodexStructuredPrompt({ request, sessions: this.sessions })
@@ -324,11 +325,11 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
prepareReadOptions = (input: { sessionId: string; fence: number }) =>
prepareLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs)
closeSession: NonNullable<StructuredAgentSessionAdapter['closeSession']> = (sessionId) =>
this.teardown.close(sessionId)
closeSession: NonNullable<StructuredAgentSessionAdapter['closeSession']> = (...args) =>
this.teardown.close(...args)
forceCloseSession = (sessionId: string): Promise<boolean> => this.teardown.forceClose(sessionId)
disposeSession: NonNullable<StructuredAgentSessionAdapter['disposeSession']> = (sessionId) =>
this.teardown.close(sessionId)
disposeSession: NonNullable<StructuredAgentSessionAdapter['disposeSession']> = (...args) =>
this.teardown.close(...args)
closeAll = (): Promise<void> => this.teardown.closeAll()
releaseAcquisition = (input: { sessionId: string }): Promise<boolean> =>
this.teardown.close(input.sessionId)
@@ -106,6 +106,7 @@ export async function openCodexStructuredSession(
)
current()
start.setPrimaryThreadId(opened.threadId)
attempt.window.appliedSettings.observe(opened)
const restored = translator?.restoreThread(opened.threadId, opened.thread ?? {})
if (restored && !restored.accepted) {
throw AgentSessionAcquisitionRefusal.historyTooLarge(
@@ -147,6 +148,7 @@ export async function openCodexStructuredSession(
prompts: attempt.window.prompts,
options: restoredCodexSessionOptions(input.options),
reportedOptions: reportedCodexThreadOptions(opened),
appliedSettings: attempt.window.appliedSettings,
...(catalogAccess ? { catalogAccess } : {}),
dispatchEchoes: start.dispatchEchoes,
translator,
@@ -1,3 +1,4 @@
import type { CodexAppliedSettings } from './codex-structured-applied-settings'
import type { StructuredAgentSessionChildWorkProducer } from '../native-chat/agent-session-wire/structured-agent-session-subagent-rows'
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import type { StructuredAgentSessionAtRestCommands } from '../native-chat/agent-session-wire/structured-agent-session-at-rest-commands'
@@ -162,6 +163,7 @@ export type CodexSession = {
nativeDefaults?: { personality?: string; serviceTier?: string }
/** Overrides actually attempted on this child, independently of future saved picks. */
appliedTurnOptions?: Readonly<Record<string, string>>
appliedSettings?: CodexAppliedSettings
/** Optional for adapters without a host catalog store. */
catalogAccess?: CodexSessionCatalogAccess
dispatchEchoes: CodexDispatchEchoes
@@ -1,3 +1,4 @@
import type { AgentSessionCloseObserver } from '../native-chat/agent-session-wire/structured-agent-session-adapter-stop'
import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import {
@@ -22,28 +23,35 @@ export type CodexStructuredSessionTeardownDeps = {
export class CodexStructuredSessionTeardown {
constructor(private readonly deps: CodexStructuredSessionTeardownDeps) {}
close = async (sessionId: string): Promise<boolean> => {
close = async (sessionId: string, observeClose?: AgentSessionCloseObserver): Promise<boolean> => {
const connection =
this.deps.sessions.get(sessionId)?.connection ??
this.deps.acquisitions.get(sessionId)?.window.connection
const closed = this.settled(
sessionId,
await closeCodexSession(
try {
const closed = this.settled(
sessionId,
this.deps.sessions,
this.deps.acquisitions,
this.deps.onEvent,
this.deps.logger,
true
await closeCodexSession(
sessionId,
this.deps.sessions,
this.deps.acquisitions,
this.deps.onEvent,
this.deps.logger,
true
)
)
)
if (closed && connection?.processTreeUnproven) {
// Root exit settles the close; report unproven descendants separately.
throw new AgentSessionAcquisitionRootExitObservedError(
new Error('codex app-server exited, but its process tree was not proven gone')
if (closed && connection?.processTreeUnproven) {
// Root exit settles the close; report unproven descendants separately.
throw new AgentSessionAcquisitionRootExitObservedError(
new Error('codex app-server exited, but its process tree was not proven gone')
)
}
return closed
} finally {
const result = connection?.closeResult
observeClose?.(
result ? { root: result.root, workload: result.tree ?? 'unverifiable' } : undefined
)
}
return closed
}
forceClose = async (sessionId: string): Promise<boolean> => {
@@ -1,10 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { readRecord } from './codex-item-field-readers'
import { CodexAppliedSettings } from './codex-structured-applied-settings'
import { CodexAppServerRequestError } from './app-server/codex-app-server-request-error'
import { changeCodexThreadGoal, codexThreadGoalRequests } from './codex-structured-thread-goal'
import type { CodexSession } from './codex-structured-session-state'
const THREAD = 'thread-1'
type AcquiredSettingsTestSeam = {
settings: CodexAppliedSettings
pending: Set<unknown>
listeners: Set<unknown>
readonly snapshot: Record<string, unknown>
}
function acquiredSettingsTestSeam(adapter: unknown): AcquiredSettingsTestSeam {
const sessions = readRecord(adapter).sessions
if (!(sessions instanceof Map)) {
throw new Error('missing acquired sessions')
}
const settings = readRecord(sessions.get('session-1')).appliedSettings
if (!(settings instanceof CodexAppliedSettings)) {
throw new Error('missing acquired settings')
}
const state = readRecord(settings)
if (!(state.pending instanceof Set) || !(state.listeners instanceof Set)) {
throw new Error('missing acquired settings collections')
}
return {
settings,
pending: state.pending,
listeners: state.listeners,
get snapshot() {
const snapshot = readRecord(settings).snapshot
if (typeof snapshot !== 'object' || snapshot === null) {
throw new Error('missing acquired settings snapshot')
}
return readRecord(snapshot)
}
}
}
function session(request: (...args: unknown[]) => Promise<unknown>) {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the goal path reads only these two fields.
return { threadId: THREAD, connection: { request } } as unknown as Pick<
@@ -134,3 +170,406 @@ describe('codex thread goal requests', () => {
)
})
})
describe('direct goal settings application', () => {
async function fixture(timeout = 1_000) {
const { fakeCodex, adapterFor, acquireReadyCodexForTest, identityFor } =
await import('./codex-structured-session-adapter-fixture')
const codex = fakeCodex()
const adapter = adapterFor(codex, {}, [], { requestTimeoutMs: timeout })
await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn'
})
const apply = (model = 'gpt-live', effort: string | null = 'medium') =>
codex.connections[0].handlers.onNotification?.('thread/settings/updated', {
threadId: 'thread-abc',
threadSettings: { model, effort }
})
const goal = (model = 'gpt-live', effort?: string) =>
adapter.changeThreadGoal({
sessionId: 'session-1',
fence: 7,
change: { kind: 'status', status: 'active' },
replacesGoal: false,
executionOptions: { model, ...(effort ? { effort } : {}) }
})
const state = acquiredSettingsTestSeam(adapter)
return { codex, adapter, apply, goal, settings: state.settings, state }
}
it('retains authoritative A after a definitely rejected turn B, so a no-op Resume A needs no notification', async () => {
const { codex, adapter, goal } = await fixture()
codex.routes['turn/start'] = () => {
throw new CodexAppServerRequestError('turn/start', -32602, 'invalid model')
}
expect(
await adapter.dispatch({
sessionId: 'session-1',
fence: 7,
clientMessageId: 'message',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Try B' }] },
executionOptions: { model: 'model-b', effort: 'high' }
})
).toMatchObject({ state: 'rejected' })
expect(await goal()).toEqual({ ok: true })
expect(codex.connections[0].calls.some((r) => r.method === 'thread/settings/update')).toBe(
false
)
await adapter.closeAll()
})
it.each(['success', 'rejection', 'stale'] as const)(
'rechecks current application after its RPC reply, preserving pre-reply evidence (%s)',
async (mode) => {
const { codex, adapter, apply, goal, state } = await fixture(10)
vi.useFakeTimers()
try {
const answer = Promise.withResolvers<unknown>()
codex.routes['thread/settings/update'] = () => {
apply('model-b', 'high')
return answer.promise
}
const pending = goal('model-b', 'high')
await Promise.resolve()
expect(codex.connections[0].calls.some((r) => r.method === 'thread/goal/set')).toBe(false)
if (mode === 'rejection') {
answer.reject(
new CodexAppServerRequestError('thread/settings/update', -32602, 'invalid model')
)
} else {
if (mode === 'stale') {
apply('model-c', 'low')
}
answer.resolve({})
}
if (mode === 'stale') {
await vi.advanceTimersByTimeAsync(10)
}
expect(await pending).toMatchObject({ ok: mode === 'success' })
expect(codex.connections[0].calls.some((r) => r.method === 'thread/goal/set')).toBe(
mode === 'success'
)
expect(state.listeners.size).toBe(0)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
await adapter.closeAll()
}
}
)
it('retires an unknown turn prefix on uniquely applied C and retries C without another settings RPC', async () => {
const { codex, adapter, apply, goal } = await fixture()
codex.routes['turn/start'] = () => {
throw new Error('reply lost after enqueue')
}
await expect(
adapter.dispatch({
sessionId: 'session-1',
fence: 7,
clientMessageId: 'unknown-turn',
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'B' }] },
executionOptions: { model: 'B', effort: 'high' }
})
).rejects.toThrow('reply lost after enqueue')
codex.routes['thread/settings/update'] = () => {
apply('C', 'high')
return {}
}
expect(await goal('C', 'high')).toMatchObject({ ok: true })
expect(await goal('C', 'high')).toMatchObject({ ok: true })
expect(
codex.connections[0].calls.filter((r) => r.method === 'thread/settings/update')
).toHaveLength(1)
await adapter.closeAll()
})
it.each([
{
name: 'stronger unique model after earlier effort match',
writes: [
{ model: 'B', effort: 'high' },
{ model: 'D', effort: 'low' },
{ model: 'C', effort: 'high' }
],
observed: ['C', 'high'],
query: ['C', 'high'],
ok: true,
retained: 0
},
{
name: 'reused/coalesced A/B/A stays ambiguous',
writes: [
{ model: 'A', effort: 'high' },
{ model: 'B', effort: 'high' },
{ model: 'A', effort: 'high' }
],
observed: ['A', 'high'],
query: ['A', 'high'],
ok: false,
retained: 2
},
{
name: 'model-only prefix proves earlier effort completed',
writes: [{ effort: 'high' }, { model: 'C' }],
observed: ['C', 'medium'],
query: ['C', 'medium'],
ok: true,
retained: 0
},
{
name: 'later effort writer remains unresolved',
writes: [{ model: 'B', effort: 'high' }, { model: 'C' }, { effort: 'low' }],
observed: ['C', 'medium'],
query: ['C', 'medium'],
ok: false,
retained: 1
},
{
name: 'omitted effort ignores later effort writer',
writes: [{ model: 'B', effort: 'high' }, { model: 'C' }, { effort: 'low' }],
observed: ['C', 'medium'],
query: ['C'],
ok: true,
retained: 1
}
])('$name', async ({ writes, observed, query, ok, retained }) => {
const { adapter, apply, goal, settings, state } = await fixture(10)
vi.useFakeTimers()
try {
apply('S', 'medium')
for (const write of writes) {
settings.attempt(write)
}
apply(observed[0], observed[1])
expect(state.pending.size).toBe(retained)
const result = goal(query[0], query[1])
if (!ok) {
await vi.advanceTimersByTimeAsync(10)
}
expect(await result).toMatchObject({ ok })
expect(state.listeners.size).toBe(0)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
await adapter.closeAll()
}
})
it.each(
[100, 1000].flatMap((n) =>
['identical', 'alternating', 'unique'].map((pattern) => ({ n, pattern }))
)
)(
'bounds actual unresolved evidence/trigger work at $n $pattern controls and clears recovery/end',
async ({ n, pattern }) => {
const { codex, adapter, apply, settings, state } = await fixture(10)
vi.useFakeTimers()
try {
apply('S', 'medium')
const pending = state.pending
let fields = 0,
visits = 0
const add = pending.add.bind(pending)
vi.spyOn(pending, 'add').mockImplementation((write) => {
if (typeof write !== 'object' || write === null) {
throw new Error('invalid settings attempt')
}
const { model, effort } = readRecord(write)
if (
(model !== undefined && typeof model !== 'string') ||
(effort !== undefined && effort !== null && typeof effort !== 'string')
) {
throw new Error('invalid settings projection')
}
Object.defineProperties(write, {
model: {
get: () => {
fields++
return model
}
},
effort: {
get: () => {
fields++
return effort
}
}
})
return add(write)
})
const iterate = pending[Symbol.iterator].bind(pending)
vi.spyOn(pending, Symbol.iterator).mockImplementation(() => {
const iterator = iterate()
const next = iterator.next.bind(iterator)
vi.spyOn(iterator, 'next').mockImplementation(() => {
const result = next()
if (!result.done) {
visits++
}
return result
})
return iterator
})
for (let i = 0; i < n; i++) {
settings.attempt({
get model() {
fields++
return pattern === 'unique' ? `M${i}` : 'S'
},
get effort() {
fields++
return pattern === 'unique'
? 'medium'
: pattern === 'identical' || i % 2 === 0
? 'high'
: 'low'
}
})
}
expect(pending.size).toBe(n)
const buildFields = fields
expect(fields).toBeLessThanOrEqual(8 * n)
expect(visits).toBe(0) // Snapshot mismatches never enumerate/copy prior tokens.
const q = { model: 'unique-final-C', effort: 'high' }
const install = settings.install(codex.connections[0], 'thread-abc', q, 10)
await Promise.resolve()
expect(state.listeners.size).toBe(1)
expect(vi.getTimerCount()).toBe(1)
fields = 0
visits = 0
for (let i = 0; i < n; i++) {
apply('S', 'medium')
}
expect({ fields, visits }).toEqual({ fields: 0, visits: 0 })
apply(q.model, q.effort)
expect(await install).toEqual({ ok: true })
const application = { fields, visits }
expect(fields).toBeLessThanOrEqual(8 * (n + 1))
expect(visits).toBeLessThanOrEqual(2 * (n + 1))
expect(pending.size).toBe(0)
fields = 0
visits = 0
const calls = codex.connections[0].calls.length
expect(await settings.install(codex.connections[0], 'thread-abc', q, 10)).toEqual({
ok: true
})
expect(codex.connections[0].calls).toHaveLength(calls)
expect({ fields, visits }).toEqual({ fields: 0, visits: 0 })
expect(state.listeners.size).toBe(0)
expect(vi.getTimerCount()).toBe(0)
for (let i = 0; i < n; i++) {
settings.attempt({ model: `unended-${i}` })
}
expect(pending.size).toBe(n)
await adapter.closeSession('session-1')
expect(pending.size).toBe(0)
console.info('settings-perf', {
n,
pattern,
buildFields,
buildVisits: 0,
noiseFields: 0,
noiseVisits: 0,
application,
retainedUnknown: n,
retainedAfterProof: 0,
retainedAtEnd: pending.size
})
expect(state.snapshot).toEqual({})
expect(state.listeners.size).toBe(0)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
await adapter.closeAll()
}
}
)
it('keeps an earlier unknown token when a retry reuses the same choice object and is definitely rejected', async () => {
const { codex, adapter, apply, settings, state } = await fixture(10)
apply('S', 'medium')
const choice = { model: 'B', effort: 'high' }
settings.attempt(choice)
codex.routes['thread/settings/update'] = () => {
throw new CodexAppServerRequestError('thread/settings/update', -32602, 'refused')
}
expect(await settings.install(codex.connections[0], 'thread-abc', choice, 10)).toMatchObject({
ok: false
})
expect(state.pending.size).toBe(1)
await adapter.closeAll()
})
it('bounds an acknowledged but unapplied update, and cancels its waiter on connection end', async () => {
const { codex, adapter, goal, apply, state } = await fixture(10)
vi.useFakeTimers()
try {
const pending = goal('model-b', 'high')
await vi.advanceTimersByTimeAsync(10)
expect(await pending).toMatchObject({
ok: false,
rejected: expect.stringContaining('did not confirm')
})
expect(codex.connections[0].calls.some((r) => r.method === 'thread/goal/set')).toBe(false)
expect(state.listeners.size).toBe(0)
expect(vi.getTimerCount()).toBe(0)
apply('model-b', null)
// Null is known provider state; an omitted effort is no override.
expect(await goal('model-b')).toEqual({ ok: true })
const closing = goal('model-c', 'high')
await adapter.closeSession('session-1')
expect(await closing).toMatchObject({
ok: false,
rejected: expect.stringContaining('did not confirm')
})
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
await adapter.closeAll()
}
})
})
it('stops a settings wait as a definite pre-goal rejection without leaving an active waiter', async () => {
const { fakeCodex, adapterFor, acquireReadyCodexForTest, identityFor } =
await import('./codex-structured-session-adapter-fixture')
const codex = fakeCodex()
const adapter = adapterFor(codex)
await acquireReadyCodexForTest(adapter, {
identity: identityFor('session-1'),
fence: 7,
spawnToken: 'spawn'
})
const state = acquiredSettingsTestSeam(adapter)
vi.useFakeTimers()
try {
const signal = new AbortController()
const result = adapter.changeThreadGoal({
sessionId: 'session-1',
fence: 7,
change: { kind: 'set', objective: 'not sent' },
replacesGoal: true,
executionOptions: { model: 'new-model' },
signal: signal.signal
})
signal.abort(new Error('stopped'))
expect(await result).toMatchObject({
ok: false,
rejected: expect.stringContaining('This goal change was not sent.')
})
expect(state.listeners.size).toBe(0)
expect(vi.getTimerCount()).toBe(0)
expect(state.pending.size).toBe(1)
codex.connections[0].handlers.onNotification?.('thread/settings/updated', {
threadId: 'thread-abc',
threadSettings: { model: 'new-model', effort: null }
})
expect(codex.connections[0].calls.some((r) => r.method.startsWith('thread/goal/'))).toBe(false)
} finally {
vi.useRealTimers()
await adapter.closeAll()
}
})
@@ -1,3 +1,6 @@
import { AGENT_SESSION_GOAL_CONTROL_COPY } from '../../shared/agent-session-goal-control-copy'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { resolvedCodexGoalSettings } from './codex-structured-message-options'
import { throwIfSignalAborted } from '../../shared/abort-signal-reason'
import type { AgentSessionThreadGoalChange } from '../../shared/agent-session-thread-goal-wire'
import { isCodexAppServerRequestError } from './app-server/codex-app-server-request-error'
@@ -62,3 +65,45 @@ export async function changeCodexThreadGoal(
}
return { ok: true }
}
export async function changeCodexThreadGoalWithSettings(
session: CodexSession,
input: Parameters<NonNullable<StructuredAgentSessionAdapter['changeThreadGoal']>>[0],
timeoutMs: number | undefined,
current: () => boolean
): Promise<{ ok: true } | { ok: false; rejected: string }> {
const generation = session.acquisitionGeneration
const startsWork =
input.change.kind === 'set' ||
(input.change.kind === 'status' && input.change.status === 'active')
try {
if (startsWork) {
const installed = await session.appliedSettings?.install(
session.connection,
session.threadId,
resolvedCodexGoalSettings(session, input.executionOptions),
timeoutMs,
input.signal
)
if (installed && !installed.ok) {
return installed
}
}
throwIfSignalAborted(input.signal)
if (
!current() ||
session.ended ||
session.fence !== input.fence ||
session.acquisitionGeneration !== generation
) {
throw new Error('codex goal acquisition ended')
}
} catch (error) {
if (!startsWork) {
throw error
}
// No goal write has been issued, even if the preceding settings write is uncertain.
return { ok: false, rejected: AGENT_SESSION_GOAL_CONTROL_COPY.settingsUnconfirmed }
}
return changeCodexThreadGoal(session, input.change, input.replacesGoal, timeoutMs, input.signal)
}
@@ -13,7 +13,7 @@ export type CodexOpenedThread = {
thread?: Record<string, unknown>
historyMode?: 'legacy' | 'paginated'
model?: string
effort?: string
effort?: string | null
/** Present, including null, only when this app-server reports the effective tier. */
serviceTier?: string | null
}
@@ -138,7 +138,7 @@ export async function openCodexThread(
? { historyMode: thread.historyMode }
: {}),
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(effort || result.reasoningEffort === null ? { effort } : {}),
...(serviceTierKnown ? { serviceTier } : {})
}
}
@@ -46,6 +46,7 @@ export type CodexTurnHost = {
threadId: string
options: Map<string, string>
appliedTurnOptions?: Readonly<Record<string, string>>
appliedSettings?: CodexSession['appliedSettings']
reportedOptions?: CodexSession['reportedOptions']
catalogAccess?: CodexSession['catalogAccess']
dispatchEchoes: CodexDispatchEchoes
@@ -161,6 +162,7 @@ export async function startCodexTurn(
host.appliedTurnOptions = input.turnOptions ?? Object.fromEntries(host.options)
const requested = codexTurnRequestOptions(host)
let answer: unknown
const discardSettingsAttempt = host.appliedSettings?.attempt(requested)
try {
answer = await host.connection.request(
'turn/start',
@@ -173,6 +175,9 @@ export async function startCodexTurn(
{ timeoutMs: input.timeoutMs, onReply: input.onReply }
)
} catch (error) {
if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) {
discardSettingsAttempt?.()
}
if (
previousOptions &&
(isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error))
@@ -176,17 +176,22 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
return adapter.copyConversation(input)
}
closeSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) => adapter.closeSession?.(sessionId))
closeSession: NonNullable<StructuredAgentSessionAdapter['closeSession']> = (
sessionId,
observeClose
) => this.stopSession(sessionId, (adapter) => adapter.closeSession?.(sessionId, observeClose))
forceCloseSession = (sessionId: string): Promise<boolean> =>
this.stopSession(sessionId, (adapter) =>
(adapter.forceCloseSession ?? adapter.closeSession)?.call(adapter, sessionId)
)
disposeSession = (sessionId: string): Promise<boolean> =>
disposeSession: NonNullable<StructuredAgentSessionAdapter['disposeSession']> = (
sessionId,
observeClose
) =>
this.stopSession(sessionId, (adapter) =>
(adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId)
(adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId, observeClose)
)
private async stopSession(
@@ -11,9 +11,26 @@ import type {
} from '../../../shared/agent-session-journal-types'
import type { ProviderDiagnostic } from '../../../shared/agent-session-failure'
export type AgentSessionCloseEvidence = {
root: 'live' | 'unverifiable' | 'exited'
workload: 'live' | 'unverifiable' | 'exited'
}
export type AgentSessionCloseObserver = (evidence: AgentSessionCloseEvidence | undefined) => void
/** In-process completion evidence is separate from durable command acceptance. */
export type AgentSessionStopOutcome = {
/** Read/pause acknowledgement, not universal durable-write quiescence. */
pause: 'acknowledged' | 'not-needed' | 'unconfirmed'
interrupt: 'accepted' | 'not-needed' | 'unconfirmed'
root: 'exited' | 'unproven'
workload: 'exited' | 'unproven'
errors: unknown[]
}
/** Distinguish refused interrupts, absent turns with possible pending openers, and the journal turn actually stopped. */
export type AgentSessionCancelOutcome = {
cancelled: boolean
stop?: AgentSessionStopOutcome
refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true; turnMayOpen?: true }
turnId?: string
}
@@ -24,7 +24,10 @@ import type { StructuredAgentSessionAtRestCommands } from './structured-agent-se
import type { AgentSessionSlashCommand } from '../../../shared/agent-session-wire'
import type { AgentSessionThreadGoalChange } from '../../../shared/agent-session-thread-goal-wire'
import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure'
import type { StructuredAgentSessionAdapterStop } from './structured-agent-session-adapter-stop'
import type {
AgentSessionCloseObserver,
StructuredAgentSessionAdapterStop
} from './structured-agent-session-adapter-stop'
export type { AgentSessionCancelOutcome } from './structured-agent-session-adapter-stop'
export type {
StructuredAgentSessionChildEndCause,
@@ -224,6 +227,7 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
change: AgentSessionThreadGoalChange
/** Existing recorded goals require set to create a new objective, never rewrite the old one. */
replacesGoal: boolean
executionOptions?: AgentSessionExecutionOptions
signal?: AbortSignal
}): Promise<{ ok: true } | { ok: false; rejected: string }>
/** Derive unanswered retained work from the live child; false without one. */
@@ -276,11 +280,11 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop &
accountHome: AgentSessionAccountHome
}): Promise<AiVaultConversationCopy>
/** True proves child exit; root/processless verdicts throw only after finalization (stopAgentSessionProviderRoot). */
closeSession?(sessionId: string): Promise<boolean>
closeSession?(sessionId: string, observeClose?: AgentSessionCloseObserver): Promise<boolean>
/** Sink-failure shutdown leaves exit recovery unexpected. */
forceCloseSession?(sessionId: string): Promise<boolean>
/** Teardown without requiring a future-resume cursor. */
disposeSession?(sessionId: string): Promise<boolean>
disposeSession?(sessionId: string, observeClose?: AgentSessionCloseObserver): Promise<boolean>
/** Acknowledge only after dead-child, lease and journal ownership release. */
acknowledgeSessionRelease?(
sessionId: string,
@@ -61,7 +61,12 @@ export function captureChatStopTarget(
return { ...target, reach: 'starting', marks: true }
}
// An aborted send remains this Stop's work even after its rejected answer reaches the fold.
if (!child || (turnId === undefined && !input.stoppedSend && !isMainAgentWorking(ctx))) {
const canHaveGoal =
ctx.agent !== undefined && ctx.agents.capabilities(ctx.agent)?.threadGoal === true
if (
!child ||
(turnId === undefined && !input.stoppedSend && !isMainAgentWorking(ctx) && !canHaveGoal)
) {
return queued || input.stoppedPrePrompt ? { ...target, reach: 'withdraw', marks: true } : null
}
// Repeated Stop must not pause cards queued after the first press.
@@ -33,7 +33,10 @@ import {
import { performCancel, type AcceptedChatStop } from './structured-agent-session-turns-cancel'
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
import { structuredAgentSessionCommandWasStopped } from './structured-agent-session-command-turn'
import {
structuredAgentSessionStopNoteIdentity,
structuredAgentSessionCommandWasStopped
} from './structured-agent-session-command-turn'
type ChatStopOutcome = TurnOutcome<AgentSessionCancelResult>
@@ -70,6 +73,7 @@ export function mutateWithChatStop<TValue>(
context.deps.logger.warn('ending the agent process on Stop failed', {
scope: 'stop-child',
sessionId,
operationId: envelope.clientOperationId,
error
})
// Failed durable acceptance must leave the agent running.
@@ -121,18 +125,62 @@ export function mutateWithChatStop<TValue>(
): Promise<ChatStopOutcome> => {
// Turns may advance during acceptance; only a replacement child escapes this Stop.
const current = context.sessions.get(ctx.sessionId)?.child
if (target.reach === 'withdraw' || current !== target.child) {
if (target.reach === 'withdraw' || !target.child || current !== target.child) {
return { ok: true, value: { ...named, cancelled: settled } }
}
const { child } = target
const feedbackId = `child:${child.fence}:${child.generation ?? 'unknown'}`
let stopOutcome = child.close?.stopOutcome
const stopChild = async () => {
if (context.sessions.get(sessionId)?.child === child) {
await context.stopAgent(sessionId, { recorded: 'user-stop' })
try {
await context.stopAgent(sessionId, {
recorded: 'user-stop',
observeClose: (evidence) => {
if (stopOutcome) {
if (evidence?.root === 'exited') {
stopOutcome.root = 'exited'
}
if (evidence?.workload === 'exited') {
stopOutcome.workload = 'exited'
}
}
}
})
} finally {
if (child.close) {
child.close.stopOutcome ??= stopOutcome
}
}
}
}
if (target.reach === 'close') {
// Join existing close and retry exit proof; the child already refuses input.
await stopChild().catch(logChildEnd)
if (ctx.agent === 'codex') {
stopOutcome ??= {
pause: 'unconfirmed',
interrupt: 'unconfirmed',
root: 'unproven',
workload: 'unproven',
errors: []
}
windDown = {
ctx,
stopChild,
owed: {
outcome: stopOutcome,
feedbackId,
operationId: envelope.clientOperationId,
noteFence: () =>
context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? ctx.fence,
waitsForProvider: false,
stoppedAt: Date.now(),
stopNote: structuredAgentSessionStopNoteIdentity(envelope.clientOperationId)
}
}
} else {
await stopChild().catch(logChildEnd)
}
return { ok: true, value: { ...named, cancelled: settled } }
}
if (target.reach === 'starting') {
@@ -152,6 +200,8 @@ export function mutateWithChatStop<TValue>(
// Host drops the child only after proven exit; no other work runs meanwhile.
childReleased: () => context.sessions.get(sessionId)?.child !== child,
endSession: (owed) => {
stopOutcome = owed.outcome
owed.feedbackId = feedbackId
windDown = { owed, ctx, stopChild }
},
withdrewQueued: Promise.resolve(settled),
@@ -239,7 +289,7 @@ export function mutateWithChatStop<TValue>(
openForWrite(context, envelope)
)
// Queue in the mutation’s tick so intervening sends wait behind child shutdown.
void context.serialize(sessionId, async () => {
const shutdown = context.serialize(sessionId, async () => {
if (windDown) {
const { owed, ctx, stopChild } = windDown
await endStoppedStructuredAgentSession(
@@ -255,5 +305,6 @@ export function mutateWithChatStop<TValue>(
)
}
})
void shutdown.catch(logChildEnd)
return result
}
@@ -1,6 +1,7 @@
// Keep close state on the exact child. Callers join adapter-bounded attempts and retry unproven exits;
// root-exit proof reaches the single exit handler, including late adapter reports.
import type { AgentSessionCloseObserver } from './structured-agent-session-adapter-stop'
import { refuse } from '../../../shared/agent-session-wire-refusals'
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime'
@@ -16,11 +17,12 @@ export type StructuredAgentSessionChildCloseVerdict = 'exited' | 'unverifiable'
export async function joinStructuredAgentSessionChildClose(
context: StructuredAgentSessionLifetimeContext,
sessionId: string,
child: StructuredAgentSessionProviderChild
child: StructuredAgentSessionProviderChild,
observeClose?: AgentSessionCloseObserver
): Promise<StructuredAgentSessionChildCloseVerdict> {
// Capture startup state before provider release loses that evidence.
const unanswered = child.phase === 'starting'
if (!(await closeProviderRoot(context, sessionId))) {
if (!(await closeProviderRoot(context, sessionId, observeClose))) {
return 'unverifiable'
}
await endClosedStructuredAgentSessionChild(context, sessionId, child, unanswered)
@@ -83,7 +85,8 @@ export async function joinClosingStructuredAgentSessionChild(
function closeProviderRoot(
context: StructuredAgentSessionLifetimeContext,
sessionId: string
sessionId: string,
observeClose?: AgentSessionCloseObserver
): Promise<boolean> {
const { adapter, logger } = context.deps
// Missing close means no owned process; implemented closes must prove exit.
@@ -93,7 +96,7 @@ function closeProviderRoot(
return stopAgentSessionProviderRoot(
async () => {
const close = adapter.disposeSession ?? adapter.closeSession
return (await close?.call(adapter, sessionId)) === true
return (await close?.call(adapter, sessionId, observeClose)) === true
},
// Proven exit stands; descendant/follow-up failures are log-only.
(error) =>
@@ -147,7 +147,14 @@ export async function stopStructuredAgentSessionAgentUnderSerialize(
() => context.restartWitness?.beforeStop(sessionId)
)
}
if ((await joinStructuredAgentSessionChildClose(context, sessionId, child)) !== 'exited') {
if (
(await joinStructuredAgentSessionChildClose(
context,
sessionId,
child,
'recorded' in ending ? ending.observeClose : undefined
)) !== 'exited'
) {
throw new StructuredAgentSessionEvictionError(
'stop-provider-child',
sessionId,
@@ -1,3 +1,4 @@
import type { AgentSessionCloseObserver } from './structured-agent-session-adapter-stop'
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
@@ -12,7 +13,7 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-fail
/** Personal Stop already records its event; other host stops record cause, while quit uses cut-turn rows. */
export type StructuredAgentSessionStopEnding =
| { recorded: 'user-stop' }
| { recorded: 'user-stop'; observeClose?: AgentSessionCloseObserver }
| {
cause: Exclude<StructuredAgentSessionStopCause, 'user-stop'>
reason?: string
@@ -1,3 +1,4 @@
import type { AgentSessionStopOutcome } from './structured-agent-session-adapter-stop'
import type { StructuredAgentSessionRequestFailure } from './structured-agent-session-request-failures'
import type { StructuredAgentSessionStatusObserverOptions } from './structured-agent-session-status-observation'
import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure'
@@ -57,6 +58,7 @@ export type StructuredAgentSessionChildClose = {
readonly quit?: true
readonly reason: string | null
readonly failure?: SubmissionRejectionFact
stopOutcome?: AgentSessionStopOutcome
/** Fold Stop before settling work; repeated unnamed personal closes reopen settlement. */
recorded: Promise<JournalStopSettle | null>
/** Order child exit at the Stop request; repeated requests advance this boundary. */
@@ -73,7 +73,9 @@ describe('the idle sweep', () => {
await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit })
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
await vi.waitFor(() => expect(rig.store.getRecord(SESSION)?.lease.claimStatus).toBe('released'))
await vi.waitFor(() => expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledOnce())
expect(rig.adapter.acknowledgeSessionRelease).toHaveBeenCalledWith(SESSION)
@@ -130,7 +132,9 @@ describe('the idle sweep', () => {
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
reportChildren(childEdge('subagent-1', 'agent', 'done'))
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
})
// Leave time for the lead’s wake-up turn after child completion, before its first journal row.
@@ -144,7 +148,9 @@ describe('the idle sweep', () => {
await sweepTicks()
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
})
// Recheck each tick so late-finishing work still gets a full idle window.
@@ -161,7 +167,9 @@ describe('the idle sweep', () => {
await sweepTicks()
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
rig.clock.now += IDLE_MS
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
})
it('stops an agent whose child records hold only children that went idle or finished', async () => {
@@ -174,7 +182,9 @@ describe('the idle sweep', () => {
)
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
})
it('never stops an agent while its lead turn runs, however quiet (P2-10)', async () => {
@@ -197,7 +207,9 @@ describe('the idle sweep', () => {
await rig.host.subscribe({ id: 'on-screen', sessionId: SESSION, emit: reader.emit })
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
expect(reader.events.some((event) => event.type === 'end')).toBe(false)
})
@@ -215,7 +227,9 @@ describe('the idle sweep', () => {
await sweepTicks()
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
rig.clock.now += IDLE_MS
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
})
it('never stops the retry whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => {
@@ -231,7 +245,9 @@ describe('the idle sweep', () => {
expect(hasOpenDispatch).toHaveBeenCalledWith(expect.objectContaining({ sessionId: SESSION }))
open = false
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
})
// Rate-limited CLIs can hold unacknowledged sends before any turn row; trust provider evidence.
@@ -247,7 +263,9 @@ describe('the idle sweep', () => {
await sweepTicks()
expect(rig.adapter.closeSession).not.toHaveBeenCalled()
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
await vi.waitFor(() =>
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
)
})
it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => {
@@ -54,7 +54,7 @@ describe('a send after the agent said it is not signed in', () => {
expect((await rig.host.send(CALLER, restTestSend('signed in now', fence()))).ok).toBe(true)
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(3))
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
expect((await rig.host.send(CALLER, restTestSend('and again', fence()))).ok).toBe(true)
@@ -184,7 +184,7 @@ describe('a chat permission mode', () => {
expect((await rig.host.send(CALLER, restTestSend('after the change', fence()))).ok).toBe(true)
await vi.waitFor(() => expect(rig.adapter.dispatch).toHaveBeenCalledTimes(2))
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(rig.adapter.acquire).toHaveBeenCalledTimes(2)
expect(rig.adapter.acquire).toHaveBeenLastCalledWith(
expect.objectContaining({ permissionMode: 'auto' })
@@ -774,6 +774,14 @@ describe('publication', () => {
})
it('an idle Stop that takes no effect pauses nothing', async () => {
const capabilities = host.deps.agents.capabilities('codex')
if (!capabilities) {
throw new Error('expected the queued test provider')
}
vi.spyOn(host.deps.agents, 'capabilities').mockReturnValue({
...capabilities,
threadGoal: false
})
const working = await workingSend()
const first = await send('to be refused', 'queue-if-active').result
const second = await send('waits behind the card', 'queue-if-active').result
@@ -8,6 +8,7 @@ import { StructuredAgentSessionHost } from './structured-agent-session-host'
import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon'
import { expect, it, vi } from 'vitest'
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn'
import {
CALLER,
attach,
@@ -233,15 +234,17 @@ it.each(['append', 'wait'] as const)(
})
}
const next = { ...change, objective: 'Later goal' }
const goalEnvelope = envelope('agentSession.threadGoal', { change: next })
const goal = host.changeThreadGoal(CALLER, {
envelope: envelope('agentSession.threadGoal', { change: next }),
envelope: goalEnvelope,
change: next
})
const result = goal.catch((error: unknown) => error)
await (phase === 'append'
? vi.waitFor(() => expect(append).toHaveBeenCalledOnce())
: host.collaboratorsForTests().serialize(SESSION, async () => {}))
const stopped = host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) })
const stopEnvelope = envelope('agentSession.cancel', {})
const stopped = host.cancel(CALLER, { envelope: stopEnvelope })
expect(changeThreadGoal).not.toHaveBeenCalled()
held.resolve()
if (phase === 'append') {
@@ -253,7 +256,17 @@ it.each(['append', 'wait'] as const)(
expect(await stopped).toMatchObject({ ok: true, value: { cancelled: true } })
expect(changeThreadGoal).not.toHaveBeenCalled()
if (phase === 'append') {
expect(append.mock.calls.some(([, body]) => body.kind === 'status')).toBe(false)
expect(withdraw).toHaveBeenCalledWith(
{ provider: 'orca', clientMessageId: `thread-goal:${goalEnvelope.clientOperationId}` },
expect.anything()
)
expect(append.mock.calls.filter(([, body]) => body.kind === 'status')).toEqual([
[
structuredAgentSessionStopNoteIdentity(stopEnvelope.clientOperationId),
{ kind: 'status', text: 'Cancellation requested.' },
expect.anything()
]
])
}
}
)
@@ -1,3 +1,4 @@
import type { AgentSessionStopOutcome } from './structured-agent-session-adapter-stop'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
@@ -17,6 +18,10 @@ import { structuredAgentSessionNamedTurnScope } from './structured-agent-session
/** Queued immediately behind Stop, so no later turn can intervene; wait only for an accepted interrupt. */
export type StructuredAgentSessionStopWindDown = {
outcome?: AgentSessionStopOutcome
operationId: string
feedbackId?: string
noteFence?: () => number
waitsForProvider: boolean
stoppedAt: number
stopNote: AgentJournalItemIdentity
@@ -36,21 +41,75 @@ export async function endStoppedStructuredAgentSession(
let failedOn: JournalStopFailedOn | undefined
try {
if (windDown.waitsForProvider) {
await ctx.adapter.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt)
await ctx.adapter
.awaitStoppedRequestEnd?.(ctx.sessionId, windDown.stoppedAt)
.catch((error: unknown) => {
if (windDown.outcome?.errors.length === 0) {
windDown.outcome.errors.push(error)
}
onError(error)
})
}
await stopChild()
await windDown.stopNoteWrite?.catch(onError)
} catch (error) {
onError(error)
if (windDown.outcome?.errors.length === 0) {
windDown.outcome.errors.push(error)
}
failedOn = structuredAgentSessionFailedStopMark(
contextStructuredAgentSessionCurrentWork(ctx),
ctx.journal
)
await windDown.stopNoteWrite?.catch(onError)
await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError)
if (!windDown.outcome) {
await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError)
}
} finally {
windDown.settled?.(failedOn)
}
const outcome = windDown.outcome
if (
outcome &&
(outcome.errors.length > 0 ||
outcome.pause === 'unconfirmed' ||
outcome.interrupt === 'unconfirmed' ||
outcome.root !== 'exited' ||
outcome.workload !== 'exited')
) {
// Thread-scoped operation feedback survives generic turn-end rekeying and turnless shutdown.
const identity = structuredAgentSessionStopNoteIdentity(
windDown.feedbackId ?? windDown.operationId
)
await ctx.journal.appendLifecycleBatch({
settlementId: `stop-outcome:${windDown.operationId}`,
fence: Math.max(ctx.fence, windDown.noteFence?.() ?? ctx.fence),
mutations: [
...(agentJournalItemKey(identity) === agentJournalItemKey(windDown.stopNote)
? []
: [{ kind: 'tombstone' as const, identity: windDown.stopNote }]),
{
kind: 'item',
identity,
turnScope: AGENT_JOURNAL_THREAD_SCOPE,
body: {
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), {
surface: 'row',
stopNotice:
outcome.pause === 'unconfirmed' &&
outcome.interrupt !== 'unconfirmed' &&
outcome.root === 'exited' &&
outcome.workload === 'exited'
? 'pauseUnconfirmed'
: 'stopUnconfirmed'
})
}
}
]
})
}
}
/** Revise only while work runs; ended work took the Stop regardless of child exit. */
@@ -220,7 +220,7 @@ describe('subscriber journal lifetime', () => {
rig.clock.now += IDLE_MS + 1
await sweepOnce(rig.host)
expect(rig.host.hasSession(SESSION)).toBe(false)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(subscriberCount()).toBe(1)
expect(reader.state.types).not.toContain('end')
vi.clearAllMocks()
@@ -100,7 +100,9 @@ describe('performThreadGoalChange', () => {
sessionId: 'session-1',
fence: 1,
change: { kind: 'set', objective: 'Ship the parser' },
replacesGoal: false
replacesGoal: false,
executionOptions: {},
signal: undefined
})
const expected = {
kind: 'message',
@@ -20,6 +20,7 @@ import type { StructuredAgentSessionAdapter } from './structured-agent-session-a
import type { StructuredAgentRegistry } from './structured-agent-registry'
import type { MutationPlan } from './structured-agent-session-mutation-plans'
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
import { captureStructuredExecutionOptions } from './structured-agent-session-execution-options'
import { throwIfSignalAborted } from '../../../shared/abort-signal-reason'
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
@@ -59,6 +60,20 @@ export async function performThreadGoalChange(
return goalsUnsupported()
}
const { change } = input
const captured =
change.kind === 'set' || (change.kind === 'status' && change.status === 'active')
? captureStructuredExecutionOptions({
...ctx,
persistedOptions: Object.fromEntries(
Object.entries(ctx.persistedOptions ?? {}).filter(
([key]) => key === 'model' || key === 'effort'
)
)
})
: { options: undefined }
if ('refusal' in captured) {
return { ok: false, refusal: captured.refusal }
}
const identity = objectiveIdentity(input.clientOperationId)
let replacesGoal = false
if (change.kind === 'set') {
@@ -96,6 +111,7 @@ export async function performThreadGoalChange(
fence: ctx.fence,
change,
replacesGoal,
executionOptions: captured.options,
signal: input.signal
})
if (!result.ok) {
@@ -6,6 +6,7 @@ import type {
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire'
import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation'
import type { AgentSessionStopOutcome } from './structured-agent-session-adapter-stop'
import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter'
import {
isStructuredAgentSessionCommandTurnId,
@@ -186,6 +187,7 @@ async function cancelAndNote(
? writeNote(note)
: undefined
// undefined means cancel threw and its effect is unknown.
let stopOutcome: AgentSessionStopOutcome | undefined
let taken: boolean | undefined
let interruptFailed = false
let refusal: AgentSessionCancelOutcome['refusal']
@@ -203,6 +205,7 @@ async function cancelAndNote(
...(dispatchStatus ? { dispatchStatus } : {}),
...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {})
})
stopOutcome = outcome.stop
taken = outcome.cancelled
cancelled = outcome.cancelled
refusal = outcome.refusal
@@ -232,13 +235,16 @@ async function cancelAndNote(
if (endsSession && (input.acceptedStop || !namesTurnNotLive || taken !== false)) {
// Accepted interrupts warrant wind-down even before their turn echo arrives.
binding.closedByWindDown = true
// Child exit confirms Stop, superseding interrupt uncertainty.
// Wind-down settles execution; provider control uncertainty survives in its outcome.
const ended: AgentJournalStatusItem | null =
note === null || (input.acceptedStop && !requestNoteWrite)
? null
: { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED }
const stopNoteWrite = ended !== null ? (requestNoteWrite ?? writeNote(ended)) : undefined
input.endSession?.({
outcome: stopOutcome,
noteFence: input.noteFence,
operationId: input.clientOperationId,
waitsForProvider: taken === true,
stoppedAt,
stopNote: noteIdentity,
@@ -249,7 +249,12 @@ it('Codex: distinct Stops during one running turn interrupt once and allow the n
const { items } = await current.host.journalSnapshot(SESSION)
expect(interrupts()).toMatchObject([{ params: { threadId: CODEX_THREAD, turnId: 'turn-1' } }])
expect(items.filter((item) => item.body.kind === 'status').map((item) => item.body)).toEqual([
{ kind: 'status', text: 'Cancellation requested.' }
{
kind: 'status',
tone: 'warning',
failure: { kind: 'cancelUnconfirmed' },
text: 'Codex could not confirm Stop. Check the chat before continuing.'
}
])
expect(items.filter((item) => item.body.kind === 'turn').map((item) => item.body)).toMatchObject([
{ kind: 'turn', turnId: 'turn-1', state: 'interrupted' },
@@ -331,7 +331,7 @@ it('stops an agent that is still starting', async () => {
expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] })
expect(acquire).toHaveBeenCalledTimes(2)
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(host['runtimeState'].startFlights.has(SESSION)).toBe(false)
expect(host['sessions'].get(SESSION)?.child ?? null).toBeNull()
})
@@ -365,7 +365,7 @@ it('sleeps only proven ended chats after idle eviction or restart', async () =>
await foundRestTestChat(rig)
rig.clock.now += IDLE_MS + 1
await vi.waitFor(() => {
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(rig.host.hasSession(SESSION)).toBe(false)
})
expect(rig.store.getRecord(SESSION)?.sleptAt).toBeUndefined()
@@ -54,7 +54,7 @@ it.each([10, 100])(
const server = await startOrcadWithLifecycle(
async (register) => {
register(async () => {
expect(close.mock.calls).toEqual(ids.map((id) => [id]))
expect(close.mock.calls).toEqual(ids.map((id) => [id, undefined]))
expect(record).toHaveBeenCalledTimes(1)
expect(record.mock.calls[0]?.[0]).toHaveLength(size)
expect(
@@ -93,7 +93,7 @@ it('releases an idle provider after the existing window, then lets the disconnec
rig.clock.now += 2
await sweepOnce(rig.host)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION, undefined)
expect(rig.host.hasLoadedProviders()).toBe(false)
expect(await monitor.check()).toBe(false)
rig.clock.now += SERVER_IDLE_MS
+8 -2
View File
@@ -2,7 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import { agentJournalTurnBody } from '../../shared/agent-session-turn-record'
import { AgentSessionAcquisitionRootExitObservedError } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
type StructuredAgentSessionAdapter,
AgentSessionAcquisitionRootExitObservedError
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { JsonlRpcResponseError } from '../jsonl-rpc/peer'
import {
closePiRpcAdapterFixtures,
@@ -95,7 +98,10 @@ describe('Pi RPC session ownership and delivery', () => {
it('lets a new acquisition proceed while the released child drains final stdout', async () => {
const h = await setup()
h.connection.deferExit = true
await h.adapter.closeSession(sessionId)
const adapter: StructuredAgentSessionAdapter = h.adapter
const observeClose = vi.fn()
await adapter.closeSession?.(sessionId, observeClose)
expect(observeClose).not.toHaveBeenCalled()
h.adapter.acknowledgeSessionRelease(sessionId)
await expect(h.adapter.acquire({ ...h.input, fence: 8 })).resolves.toMatchObject({
process: { pid: 4123 }
+5 -2
View File
@@ -230,7 +230,10 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter {
return session?.generation === generation && !session.connection.closed
}
async closeSession(id: string, requested = true): Promise<boolean> {
closeSession(id: string): Promise<boolean> {
return this.close(id, true)
}
private async close(id: string, requested: boolean): Promise<boolean> {
const session = this.sessions.get(id)
if (!session) {
return true
@@ -252,7 +255,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter {
}
forceCloseSession(id: string): Promise<boolean> {
this.sessions.get(id)?.fail(new Error('Pi event sink failed'))
return this.closeSession(id, false)
return this.close(id, false)
}
disposeSession(id: string): Promise<boolean> {
return this.closeSession(id)
@@ -0,0 +1,717 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PassThrough } from 'node:stream'
import { afterEach, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import { AGENT_SESSION_GOAL_CONTROL_COPY as COPY } from '../../shared/agent-session-goal-control-copy'
import type { AgentSessionThreadGoalChange } from '../../shared/agent-session-thread-goal-wire'
import { CodexAppServerRequestError } from '../codex/app-server/codex-app-server-request-error'
import {
CODEX_SCRIPTED_THREAD as THREAD,
codexScriptedChild
} from '../codex/codex-scripted-child.test-fixture'
import {
HOST_TEST_SESSION as SESSION,
hostTestAttachParams
} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import {
ensureStructuredAgentSessionHost,
stopStructuredAgentSessionRuntime
} from './structured-agent-session-runtime'
import {
closeProviderProcess,
rootOnlyProviderClosePolicy
} from '../provider-process/provider-process-close'
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
const CALLER = { callerKey: 'goal-stop-settings' }
let root: string | undefined
let operation = 0
const eventually = (check: () => void | Promise<void>) => vi.waitFor(check, { timeout: 10_000 })
afterEach(async () => {
vi.useRealTimers()
vi.restoreAllMocks()
await stopStructuredAgentSessionRuntime()
if (root) {
await rm(root, { recursive: true, force: true })
}
root = undefined
})
function envelope(
host: StructuredAgentSessionHost,
method: string,
fields: Record<string, unknown>
) {
return {
sessionId: SESSION,
clientOperationId: `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`,
expectedRuntimeFence: host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method,
sessionId: SESSION,
fields
})
}
}
async function openChat(child: ReturnType<typeof codexScriptedChild>) {
root = await mkdtemp(join(tmpdir(), 'orca-goal-stop-settings-'))
child.holdHandshakes = false
const host = await ensureStructuredAgentSessionHost({
logger: createStructuredAgentSessionLogger(),
stateDirectory: root,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => root ?? '',
resolveLaunchArgs: () => [],
resolveClaudeAuthPolicy: () => ({ account: 'managed' }),
...child.deps
})
const params = hostTestAttachParams(null, {
accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex') },
providerHandle: undefined
})
params.envelope.clientOperationId = `${Date.now()}-${(++operation).toString(16).padStart(32, '0')}`
expect(await host.attach(CALLER, params)).toMatchObject({ ok: true })
await eventually(() => expect(host.readStatusSummary(SESSION)?.hostExecutionPhase).toBe('ready'))
return host
}
const goal = (host: StructuredAgentSessionHost, change: AgentSessionThreadGoalChange) =>
host.changeThreadGoal(CALLER, {
envelope: envelope(host, 'agentSession.threadGoal', { change }),
change
})
const stop = (host: StructuredAgentSessionHost, turnId?: string) => {
const fields = turnId ? { turnId } : {}
return host.cancel(CALLER, { envelope: envelope(host, 'agentSession.cancel', fields), ...fields })
}
const setOption = (host: StructuredAgentSessionHost, key: string, value: string) =>
host.setOption(CALLER, {
envelope: envelope(host, 'agentSession.setOption', { key, value }),
key,
value
})
const settleStop = (host: StructuredAgentSessionHost) =>
host.collaboratorsForTests().serialize(SESSION, async () => {})
async function rows(host: StructuredAgentSessionHost) {
return (await host.journalSnapshot(SESSION)).items.flatMap((row) =>
row.body.kind === 'status' ? [row.body.text] : []
)
}
it('waits for applied saved model/effort before direct Set and Resume, and keeps settings when the goal fails', async () => {
const reply = Promise.withResolvers<unknown>()
let child: ReturnType<typeof codexScriptedChild>
let rejectGoal = false
child = codexScriptedChild({
'thread/settings/update': () => reply.promise,
'thread/goal/set': () => {
if (rejectGoal) {
throw new CodexAppServerRequestError('thread/goal/set', -32602, 'refused goal')
}
return {}
}
})
const host = await openChat(child)
expect(await setOption(host, 'model', 'scripted-alt-model')).toMatchObject({ ok: true })
expect(await setOption(host, 'effort', 'high')).toMatchObject({ ok: true })
const change = { kind: 'set' as const, objective: 'Use selected settings' }
const pending = goal(host, change)
await eventually(() =>
expect(child.requests().some((r) => r.method === 'thread/settings/update')).toBe(true)
)
reply.resolve({})
await new Promise<void>((resolve) => setImmediate(resolve))
expect(child.requests().filter((r) => r.method.startsWith('thread/goal/'))).toEqual([])
child.notify('thread/settings/updated', {
threadId: 'unrelated',
threadSettings: { model: 'scripted-alt-model', effort: 'high' }
})
await Promise.resolve()
expect(child.requests().filter((r) => r.method.startsWith('thread/goal/'))).toEqual([])
child.notify('thread/settings/updated', {
threadId: THREAD,
threadSettings: { model: 'scripted-alt-model', effort: 'high' }
})
expect(await pending).toMatchObject({ ok: true })
expect(child.requests().find((r) => r.method === 'thread/settings/update')?.params).toEqual({
threadId: THREAD,
model: 'scripted-alt-model',
effort: 'high'
})
rejectGoal = true
expect(await goal(host, { kind: 'status', status: 'active' })).toMatchObject({ ok: false })
rejectGoal = false
expect(await goal(host, { kind: 'status', status: 'active' })).toMatchObject({ ok: true })
expect(child.requests().filter((r) => r.method === 'thread/settings/update')).toHaveLength(1)
})
it.each(['rejected', 'unsupported'] as const)(
'keeps pre-goal %s effects definite through host settlement',
async (mode) => {
const child = codexScriptedChild({
'thread/settings/update': () => {
throw new CodexAppServerRequestError(
'thread/settings/update',
mode === 'unsupported' ? -32601 : -32602,
mode
)
}
})
const host = await openChat(child)
await setOption(host, 'model', 'scripted-alt-model')
const result = await goal(host, {
kind: 'set',
objective: 'Do not erase a goal for a settings failure'
})
expect(result.ok).toBe(mode === 'unsupported')
expect(child.requests().some((r) => r.method === 'thread/goal/set')).toBe(
mode === 'unsupported'
)
expect(child.requests().some((r) => r.method === 'thread/goal/clear')).toBe(false)
const snapshot = await host.journalSnapshot(SESSION)
expect(snapshot.items.some((r) => r.body.kind === 'message' && r.body.sentAs === 'goal')).toBe(
mode === 'unsupported'
)
}
)
it.each([null, 'complete'] as const)(
'repairs a model-created goal after the first %s read without warning on acknowledged shutdown',
async (initial) => {
let state: string | null = initial
let reads = 0
const child = codexScriptedChild({
'thread/goal/get': () => {
const observed = state
if (++reads === 1) {
state = 'active'
}
return { goal: observed === null ? null : { status: observed } }
},
'thread/goal/set': (params) => {
state = String(params?.status)
return { goal: { status: state } }
}
})
const host = await openChat(child)
const request = { envelope: envelope(host, 'agentSession.cancel', {}) }
expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true })
await settleStop(host)
expect(child.closes()).toBe(1)
expect(state).toBe('paused')
expect(
child
.requests()
.filter((r) => r.method.startsWith('thread/goal/'))
.map((r) => r.method)
).toEqual(['thread/goal/get', 'thread/goal/get', 'thread/goal/set'])
expect(await rows(host)).not.toContain(COPY.pauseUnconfirmed)
const calls = child.requests().length
expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true })
expect(child.requests()).toHaveLength(calls)
}
)
it.each(['disabled', 'unavailable'] as const)(
'keeps an initially %s goal read inapplicable',
async (mode) => {
const child = codexScriptedChild({
'thread/goal/get': () => {
throw new CodexAppServerRequestError(
'thread/goal/get',
mode === 'disabled' ? -32600 : -32601,
mode === 'disabled' ? 'goals feature is disabled' : 'Method not found'
)
}
})
const host = await openChat(child)
expect(await stop(host)).toMatchObject({ ok: true })
await settleStop(host)
expect(
child
.requests()
.filter((r) => r.method.startsWith('thread/goal/'))
.map((r) => r.method)
).toEqual(['thread/goal/get'])
expect(child.closes()).toBe(1)
expect(await rows(host)).not.toContain(COPY.pauseUnconfirmed)
expect(await rows(host)).not.toContain(COPY.stopUnconfirmed)
}
)
it.each(['no-observer', 'replacement'] as const)(
'keeps missing captured-close evidence unproven for %s',
async (mode) => {
const child = codexScriptedChild({ 'thread/goal/get': () => ({ goal: null }) })
const host = await openChat(child)
const session = host.collaboratorsForTests().sessions.get(SESSION)!
const captured = session.child!
const cancel = host.deps.adapter.cancelTurn.bind(host.deps.adapter)
let outcome: Awaited<ReturnType<typeof cancel>>['stop']
vi.spyOn(host.deps.adapter, 'cancelTurn').mockImplementation(async (input) => {
const result = await cancel(input)
outcome = result.stop
if (mode === 'replacement') {
session.child = { ...captured, generation: 'replacement', close: undefined }
}
return result
})
const dispose = vi.spyOn(host.deps.adapter, 'disposeSession').mockResolvedValue(true)
expect(await stop(host)).toMatchObject({ ok: true })
await settleStop(host)
expect(outcome).toMatchObject({
pause: 'acknowledged',
root: 'unproven',
workload: 'unproven',
errors: []
})
expect(await rows(host)).toContain(COPY.stopUnconfirmed)
if (mode === 'replacement') {
expect(dispose).not.toHaveBeenCalled()
expect(session.child).toMatchObject({ generation: 'replacement' })
expect(session.child?.close).toBeUndefined()
session.child = captured
} else {
expect(dispose).toHaveBeenCalledOnce()
}
}
)
it.each([
'pause',
'first-pause',
'unavailable-pause',
'disabled-pause',
'interrupt',
'disabled-interrupt',
'workload',
'root',
'named-root',
'note'
] as const)(
'retains %s failure after shutdown, including turnless work and strict note failure',
async (failure) => {
const rootFailure = failure === 'root' || failure === 'named-root'
const stopCurrent = (host: StructuredAgentSessionHost) =>
stop(host, failure === 'named-root' ? 'autonomous' : undefined)
let pauses = 0
const child = codexScriptedChild({
'thread/goal/get': () => {
if (failure === 'disabled-interrupt') {
throw new CodexAppServerRequestError(
'thread/goal/get',
-32600,
'goals feature is disabled'
)
}
return { goal: { status: 'active' } }
},
'thread/goal/set': () => {
if (failure === 'unavailable-pause' || failure === 'disabled-pause') {
throw new CodexAppServerRequestError(
'thread/goal/set',
failure === 'unavailable-pause' ? -32601 : -32600,
failure === 'disabled-pause' ? 'goals feature is disabled' : 'Method not found'
)
}
if (
failure === 'pause' ||
failure === 'note' ||
((failure === 'first-pause' || rootFailure) && ++pauses === 1)
) {
throw new CodexAppServerRequestError('thread/goal/set', -32602, 'refused')
}
return { goal: { status: 'paused' } }
},
...(['named-root', 'unavailable-pause', 'disabled-pause'].includes(failure)
? { 'turn/interrupt': () => ({}) }
: {}),
...(failure === 'interrupt' || failure === 'disabled-interrupt'
? {
'turn/interrupt': () => {
throw new Error('lost interrupt')
}
}
: {})
})
const host = await openChat(child)
if (
failure === 'interrupt' ||
failure === 'disabled-interrupt' ||
failure === 'named-root' ||
failure === 'unavailable-pause' ||
failure === 'disabled-pause'
) {
child.notify('turn/started', { threadId: THREAD, turn: { id: 'autonomous' } })
}
if (failure === 'workload') {
child.closeEvidence = { root: 'exited', tree: 'unverifiable' }
}
if (rootFailure) {
vi.spyOn(host.deps.adapter, 'disposeSession').mockResolvedValue(false)
}
if (failure === 'note') {
const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal
if (!journal) {
throw new Error('missing journal')
}
const releaseNote = Promise.withResolvers<void>()
const append = journal.appendLifecycleBatch.bind(journal)
vi.spyOn(journal, 'appendLifecycleBatch').mockImplementation((batch) => {
if (batch.settlementId.startsWith('stop-outcome:')) {
return releaseNote.promise.then(() => {
throw new Error('note store unavailable')
})
}
return append(batch)
})
const logged = vi.spyOn(host.deps.logger, 'warn')
const request = { envelope: envelope(host, 'agentSession.cancel', {}) }
expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true })
releaseNote.resolve() // Strict warning write fails only after the accepted reply.
await settleStop(host)
expect(logged).toHaveBeenCalledWith(
'ending the agent process on Stop failed',
expect.objectContaining({
sessionId: SESSION,
operationId: request.envelope.clientOperationId,
error: expect.objectContaining({ message: 'note store unavailable' })
})
)
expect(await rows(host)).not.toContain(COPY.pauseUnconfirmed)
} else {
expect(await stopCurrent(host)).toMatchObject({ ok: true })
await settleStop(host)
expect(await rows(host)).toContain(
failure === 'pause' ||
failure === 'first-pause' ||
failure === 'unavailable-pause' ||
failure === 'disabled-pause'
? COPY.pauseUnconfirmed
: COPY.stopUnconfirmed
)
if (failure === 'unavailable-pause' || failure === 'disabled-pause') {
expect(
child
.requests()
.filter((r) => r.method.startsWith('thread/goal/'))
.map((r) => r.method)
).toEqual(['thread/goal/get', 'thread/goal/set', 'thread/goal/get', 'thread/goal/set'])
expect(child.requests().filter((r) => r.method === 'turn/interrupt')).toHaveLength(1)
}
if (rootFailure) {
expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.close).toBeDefined()
const before = (await host.journalSnapshot(SESSION)).items.filter(
(r) => r.body.kind === 'status' && r.body.text === COPY.stopUnconfirmed
)
expect(before).toHaveLength(1)
const firstOutcome = host.collaboratorsForTests().sessions.get(SESSION)?.child
?.close?.stopOutcome
const controls = child.requests().length
const firstErrors = firstOutcome?.errors.slice()
const attempts = failure === 'root' ? 100 : 1000
const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal
const appended = vi.spyOn(journal, 'appendLifecycleBatch')
const logged = vi.spyOn(host.deps.logger, 'warn').mockImplementation(() => {})
const operations = new Set<string>()
for (let retry = 0; retry < attempts; retry++) {
const request = {
envelope: envelope(
host,
'agentSession.cancel',
failure === 'named-root' ? { turnId: 'autonomous' } : {}
),
...(failure === 'named-root' ? { turnId: 'autonomous' } : {})
}
operations.add(request.envelope.clientOperationId)
expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true, replayed: false })
await settleStop(host)
expect(
host.deps.store.readCommandReceipt(
{ kind: 'caller', callerKey: CALLER.callerKey },
request.envelope.clientOperationId
).verdict
).toBe('readable')
expect(firstOutcome?.errors).toEqual(firstErrors)
expect(firstOutcome?.errors[0]).toBe(firstErrors?.[0])
}
expect(operations.size).toBe(attempts)
const settlements = appended.mock.calls
.map(([batch]) => batch.settlementId)
.filter((id) => id.startsWith('stop-outcome:'))
expect(new Set(settlements).size).toBe(attempts)
expect(
logged.mock.calls.filter(
([message]) => message === "ending a stopped chat's provider session failed"
)
).toHaveLength(attempts)
expect(firstErrors).toHaveLength(1)
expect(firstErrors?.[0]).toMatchObject({ method: 'thread/goal/set', message: 'refused' })
expect((await rows(host)).filter((text) => text === COPY.stopUnconfirmed)).toHaveLength(1)
expect(host.collaboratorsForTests().sessions.get(SESSION)?.child?.close?.stopOutcome).toBe(
firstOutcome
)
vi.mocked(host.deps.adapter.disposeSession!).mockRestore()
expect(await stopCurrent(host)).toMatchObject({ ok: true })
await settleStop(host)
expect(child.requests()).toHaveLength(controls)
const after = (await host.journalSnapshot(SESSION)).items.filter(
(r) => r.body.kind === 'status' && r.body.failure?.kind === 'cancelUnconfirmed'
)
expect(after).toHaveLength(1)
expect(after[0].body).toMatchObject({ text: COPY.pauseUnconfirmed })
expect(after[0].itemId).toBe(before[0].itemId)
expect(firstOutcome).toMatchObject({
pause: 'unconfirmed',
root: 'exited',
workload: 'exited'
})
expect(firstOutcome?.errors).toEqual(firstErrors)
} else {
expect(child.closes()).toBe(1)
}
}
},
60_000
)
it('pauses before resolving an autonomous turn and interrupts before the final goal read', async () => {
const child = codexScriptedChild({
'thread/goal/get': () => ({ goal: { status: 'active' } }),
'thread/goal/set': () => {
child.notify('turn/started', { threadId: THREAD, turn: { id: 'newest' } })
return { goal: { status: 'paused' } }
}
})
const host = await openChat(child)
expect(await stop(host)).toMatchObject({ ok: true })
await settleStop(host)
const calls = child
.requests()
.filter((r) => r.method === 'turn/interrupt' || r.method.startsWith('thread/goal/'))
expect(calls.map((r) => r.method)).toEqual([
'thread/goal/get',
'thread/goal/set',
'turn/interrupt',
'thread/goal/get',
'thread/goal/set'
])
expect(calls[2].params?.turnId).toBe('newest')
expect(child.closes()).toBe(1)
})
it.each(['first-read', 'interrupt'] as const)(
'bounds %s by original Stop grace and still closes the child',
async (hung) => {
const reached = Promise.withResolvers<void>()
let reads = 0
const child = codexScriptedChild({
'thread/goal/get': () => {
reads++
if (hung === 'first-read') {
reached.resolve()
return new Promise(() => {})
}
return { goal: null }
},
'turn/interrupt': () => {
if (hung === 'interrupt') {
reached.resolve()
return new Promise(() => {})
}
return {}
}
})
const host = await openChat(child)
child.notify('turn/started', { threadId: THREAD, turn: { id: 'running' } })
await host.flushStreamedEvents(SESSION)
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const pending = stop(host)
await reached.promise
await vi.advanceTimersByTimeAsync(1_667)
expect(child.requests().some((r) => r.method === 'turn/interrupt')).toBe(true)
expect(child.closes()).toBe(0)
expect(reads).toBe(hung === 'first-read' ? 2 : 1)
await vi.advanceTimersByTimeAsync(3_333)
expect(await pending).toMatchObject({ ok: true })
await settleStop(host)
expect(child.closes()).toBe(1)
expect(reads).toBe(hung === 'first-read' ? 2 : 1)
expect(await rows(host)).toContain(
hung === 'first-read' ? COPY.pauseUnconfirmed : COPY.stopUnconfirmed
)
}
)
it('treats an exact goals-disabled declaration as inapplicable without skipping close', async () => {
const child = codexScriptedChild({
'thread/goal/get': () => {
throw new CodexAppServerRequestError('thread/goal/get', -32600, 'goals feature is disabled')
}
})
const host = await openChat(child)
expect(await stop(host)).toMatchObject({ ok: true })
await settleStop(host)
expect(child.closes()).toBe(1)
expect(child.requests().filter((r) => r.method.startsWith('thread/goal/'))).toHaveLength(1)
expect(await rows(host)).not.toContain(COPY.pauseUnconfirmed)
})
it('orders Stop behind active-Set handler effects and closes the old child before a later goal', async () => {
const reply = Promise.withResolvers<unknown>()
const effects = Promise.withResolvers<void>()
const reading = Promise.withResolvers<void>()
let state: string | null = null
let reads = 0
let activations = 0
const trace: string[] = []
const child = codexScriptedChild({
'thread/goal/set': (params) => {
const status = String(params?.status)
trace.push(status)
if (status === 'active' && ++activations === 1) {
return reply.promise
}
state = status
if (status === 'active') {
expect(child.closes()).toBe(1)
}
return { goal: { status } }
},
'thread/goal/get': async () => {
trace.push('get')
if (++reads === 1) {
reading.resolve()
// Both supported provider versions finish the earlier exclusive handler before this Get.
await effects.promise
state = 'active'
child.notify('turn/started', { threadId: THREAD, turn: { id: 'continued' } })
}
return { goal: state === null ? null : { status: state } }
},
'turn/interrupt': () => {
trace.push('interrupt')
child.notify('turn/completed', {
threadId: THREAD,
turn: { id: 'continued', status: 'interrupted' }
})
return {}
}
})
const host = await openChat(child)
const first = goal(host, { kind: 'set', objective: 'Earlier goal' })
await eventually(() => expect(activations).toBe(1))
const stopping = stop(host)
reply.resolve({ goal: { status: 'active' } })
expect(await first).toMatchObject({ ok: true })
await reading.promise
const later = goal(host, { kind: 'set', objective: 'Later goal' })
expect(trace).toEqual(['active', 'get'])
effects.resolve()
expect(await stopping).toMatchObject({ ok: true })
expect(await later).toMatchObject({ ok: true })
expect(trace).toEqual(['active', 'get', 'paused', 'interrupt', 'get', 'active'])
expect(state).toBe('active')
expect(child.closes()).toBe(1)
})
it('answers Stop before the shipped 15s phone budget while the owned 15,970ms close excludes later input', async () => {
let state = 'active'
const child = codexScriptedChild({
'thread/goal/get': () => ({ goal: { status: state } }),
'thread/goal/set': (params) => {
state = String(params?.status)
return { goal: { status: state } }
},
'turn/interrupt': () => ({})
})
const host = await openChat(child)
child.notify('turn/started', { threadId: THREAD, turn: { id: 'running' } })
await host.flushStreamedEvents(SESSION)
const dispose = host.deps.adapter.disposeSession?.bind(host.deps.adapter)
if (!dispose) {
throw new Error('missing disposal')
}
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
const started = Date.now()
const captured = Promise.withResolvers<void>()
const refreshed = Promise.withResolvers<void>()
const reaped = Promise.withResolvers<void>()
const capture = vi.fn(() => captured.promise)
const refresh = vi.fn(() => refreshed.promise)
const exit = Promise.withResolvers<void>()
let rootExited = false,
completedAt: number | undefined
vi.spyOn(host.deps.adapter, 'disposeSession').mockImplementation(async (id, observe) => {
await closeProviderProcess({
child: { pid: 12345, kill: () => true, stdin: new PassThrough() },
exitPromise: exit.promise,
rootVerdict: () => (rootExited ? 'exited' : 'live'),
supervised: true,
policy: rootOnlyProviderClosePolicy(true),
tree: {
capture,
refresh,
reap: async () => {
rootExited = true
exit.resolve()
await reaped.promise
return 'exited'
},
treeVerdict: 'exited'
},
terminateTree: async () => {
throw new Error('unexpected fallback')
}
})
const result = await dispose(id, observe)
completedAt = Date.now() - started
return result
})
const request = { envelope: envelope(host, 'agentSession.cancel', {}) }
expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true })
expect(Date.now() - started).toBeLessThan(15_000) // Unchanged shipped phone cancel budget.
expect(completedAt).toBeUndefined()
expect(
child
.requests()
.filter((r) => r.method.startsWith('thread/goal/') || r.method === 'turn/interrupt')
.map((r) => r.method)
).toEqual(['thread/goal/get', 'thread/goal/set', 'turn/interrupt', 'thread/goal/get'])
let laterFinished = false
const later = goal(host, { kind: 'set', objective: 'After Stop' }).then((value) => {
laterFinished = true
return value
})
for (const [ms, phase, reached] of [
[5000, undefined, capture],
[990, captured, capture],
[5500, undefined, refresh],
[990, refreshed, refresh]
] as const) {
await vi.advanceTimersByTimeAsync(ms)
expect(reached).toHaveBeenCalledOnce()
phase?.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(laterFinished).toBe(false)
expect(child.closes()).toBe(0)
}
expect(rootExited).toBe(true)
await vi.advanceTimersByTimeAsync(3490)
reaped.resolve()
expect(await later).toMatchObject({ ok: true })
expect(completedAt).toBe(15_970)
expect(child.closes()).toBe(1)
const calls = child.requests().length
expect(await host.cancel(CALLER, request)).toMatchObject({ ok: true })
await settleStop(host)
expect(child.requests()).toHaveLength(calls)
expect(child.closes()).toBe(1)
expect(await rows(host)).not.toContain(COPY.pauseUnconfirmed)
})
@@ -17,6 +17,7 @@ import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-it
import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope'
import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
import { AGENT_SESSION_GOAL_CONTROL_COPY } from '../../shared/agent-session-goal-control-copy'
import { NATIVE_CHAT_STOPPED_BEFORE_START_TEXT } from '../../shared/native-chat-stopped-before-start'
import { projectNativeChatTranscript } from '../../shared/native-chat-transcript-projection'
import { nativeChatRowsInDrawOrder } from '../../shared/native-chat-turn-grouping'
@@ -163,10 +164,19 @@ const openConnection: typeof openCodexAppServerConnection = async (
connectionHandlers = {}
) => {
handlers = connectionHandlers
let closed = false
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
get closed() {
return closed
},
get closeResult(): CodexAppServerConnection['closeResult'] {
return closed ? { root: 'exited', tree: 'exited' } : null
},
request: async (method, params) => {
if (method === 'thread/goal/get') {
return { goal: null }
}
if (method === 'thread/resume') {
resumes += 1
return { thread: { id: THREAD, ...(resumeTurns ? { turns: resumeTurns } : {}) } }
@@ -196,7 +206,10 @@ const openConnection: typeof openCodexAppServerConnection = async (
notify: () => {},
respond: () => {},
respondWithError: () => {},
close: async () => true
close: async () => {
closed = true
return true
}
}
return connection
}
@@ -294,7 +307,8 @@ describe("a send made while Codex held the first send's answer, then a Stop", ()
'first',
'streamed after the Stop',
'second',
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT,
AGENT_SESSION_GOAL_CONTROL_COPY.stopUnconfirmed
])
})
})
@@ -319,6 +333,7 @@ describe('a send a Stop took back, then a send made while it read Stopping', ()
expect(live.filter((text) => text !== 'Cancellation requested.')).toEqual([
'look around',
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT,
AGENT_SESSION_GOAL_CONTROL_COPY.stopUnconfirmed,
'sent while stopping'
])
@@ -411,20 +426,31 @@ describe('a send a Stop took back before Codex echoed it', () => {
}
expect(verdictOf(await submissions(), sent)).toBe('withdrawn')
// Every client draws the send where it was sent, then the one row saying it never started.
// The unopened send keeps its result; the unanswered interrupt also keeps its warning.
await host.flushStreamedEvents(SESSION)
const { items, submissions: settled } = await host.journalSnapshot(SESSION)
const messages = projectStructuredAgentSessionMessages(items, [], settled)
expect(messages.slice(-2).map((message) => [message.role, message.blocks])).toEqual([
expect(messages.slice(-3).map((message) => [message.role, message.blocks])).toEqual([
['user', [{ type: 'text', text: 'look around' }]],
['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]]
['system', [expect.objectContaining({ text: NATIVE_CHAT_STOPPED_BEFORE_START_TEXT })]],
[
'system',
[
expect.objectContaining({
text: AGENT_SESSION_GOAL_CONTROL_COPY.stopUnconfirmed,
tone: 'warning',
failure: { kind: 'cancelUnconfirmed' }
})
]
]
])
const rows = await drawn()
expect(rows.filter((text) => text === 'Cancellation requested.')).toHaveLength(1)
expect(rows.filter((text) => text === 'Cancellation requested.')).toHaveLength(0)
expect(rows.filter((text) => text !== 'Cancellation requested.')).toEqual([
'warm up',
'look around',
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT
NATIVE_CHAT_STOPPED_BEFORE_START_TEXT,
AGENT_SESSION_GOAL_CONTROL_COPY.stopUnconfirmed
])
})
})
@@ -19,6 +19,7 @@ import type { AgentJournalSubmission } from '../../shared/agent-session-journal-
import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection'
import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work'
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
import { AGENT_SESSION_GOAL_CONTROL_COPY } from '../../shared/agent-session-goal-control-copy'
import {
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
@@ -211,10 +212,19 @@ beforeEach(async () => {
) => {
handlers = connectionHandlers
connections += 1
let closed = false
const connection: CodexAppServerConnection = {
pid: 4321,
closed: false,
get closed() {
return closed
},
get closeResult(): CodexAppServerConnection['closeResult'] {
return closed ? { root: 'exited', tree: 'exited' } : null
},
request: async (method, params) => {
if (method === 'thread/goal/get') {
return { goal: null }
}
if (method === 'thread/start' || method === 'thread/resume') {
return { thread: { id: THREAD } }
}
@@ -258,6 +268,7 @@ beforeEach(async () => {
throw new Error('the kill timed out')
}
childCloses += 1
closed = true
return true
}
}
@@ -769,7 +780,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => {
await stopWhenItsTurnNeverOpens(release)
expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn')
expect(await statusRows()).toEqual(['Cancellation requested.'])
expect(await statusRows()).toEqual([AGENT_SESSION_GOAL_CONTROL_COPY.stopUnconfirmed])
expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({
state: 'completed'
})
@@ -821,7 +832,7 @@ describe("a Stop pressed while Codex's turn/start is in flight", () => {
expect(childCloses).toBe(1)
expect(verdictOf((await settled()).submissions, sent)).toBe('unknown')
expect(await statusRows()).toEqual(['Cancellation requested.'])
expect(await statusRows()).toEqual([AGENT_SESSION_GOAL_CONTROL_COPY.stopUnconfirmed])
})
// Handed over once the stopped turn read ended, it started its own turn, however late that
@@ -93,8 +93,14 @@ function fakeCodex(): CodexScript {
resumedThreadId: null,
pid: 4321,
closed: false,
get closeResult(): CodexAppServerConnection['closeResult'] {
return connection.closed ? { root: 'exited', tree: 'exited' } : null
},
request: async (method, params) => {
connection.calls.push({ method, params })
if (method === 'thread/goal/get') {
return { goal: null }
}
if (method === 'thread/start') {
return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } }
}
@@ -176,7 +182,7 @@ function envelope(method: string, fields: Record<string, unknown>, fence: number
function attachParams(fence: number | null) {
const params = {
location: {
executionHostId: 'local',
executionHostId: 'local' as const,
wslDistro: null,
workspaceId: WORKSPACE,
workspaceKind: 'git-worktree' as const
@@ -187,23 +193,13 @@ function attachParams(fence: number | null) {
runtimeKind: 'native' as const,
providerHandle: { kind: 'codex' as const, threadId: THREAD }
}
const envelope = {
const mutation = envelope('agentSession.attach', {}, fence)
mutation.payloadFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId: SESSION,
clientOperationId: operationId(),
expectedRuntimeFence: fence,
payloadFingerprint: ''
}
return {
...params,
envelope: {
...envelope,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId: SESSION,
fields: attachFingerprintFields({ ...params, envelope } as never)
})
}
}
fields: attachFingerprintFields({ ...params, envelope: mutation })
})
return { ...params, envelope: mutation }
}
function createIntentParams() {
@@ -624,10 +620,11 @@ describe('a structured codex session over agentSession.*', () => {
turnId: TURN
})
expect(cancelled).toEqual({ turnId: TURN, cancelled: true })
expect(codex.live().calls.at(-1)).toMatchObject({
method: 'turn/interrupt',
params: { threadId: THREAD, turnId: TURN }
})
expect(codex.live().calls.slice(-3)).toEqual([
{ method: 'thread/goal/get', params: { threadId: THREAD } },
{ method: 'turn/interrupt', params: { threadId: THREAD, turnId: TURN } },
{ method: 'thread/goal/get', params: { threadId: THREAD } }
])
// Disconnect reaps the subscription while retaining the session and child.
await call('agentSession.unsubscribe', { sessionId: SESSION })
@@ -650,6 +647,9 @@ describe('a structured codex session over agentSession.*', () => {
// Stop closes the old child; takeover advances the fence and resumes the proven thread.
const reaped = codex.live()
await vi.waitFor(() => expect(reaped.closed).toBe(true))
await getStructuredAgentSessionHost()
?.collaboratorsForTests()
.serialize(SESSION, async () => {})
const stoppedFence =
getStructuredAgentSessionHost()?.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? fence
const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>(
@@ -360,7 +360,7 @@ describe('a task dispatched into a worker whose own dispatch settled', () => {
await vi.waitFor(() =>
expect(observeStructuredWorker({ sessionId: REST_TEST_SESSION }).status).toBe('exited')
)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION)
expect(rig.adapter.closeSession).toHaveBeenCalledWith(REST_TEST_SESSION, undefined)
} finally {
hostRef.current = null
await rig.dispose()
@@ -647,3 +647,20 @@ it('opens Antigravity accounts from its signed-out notice, and offers no link fo
})
}
})
it('renders the localized unacknowledged goal pause through the existing Stop notice', async () => {
await i18n.changeLanguage('ja')
renderStatus({
kind: 'status',
tone: 'warning',
...agentSessionFailureWords(agentSessionFailureFact('cancelUnconfirmed'), {
surface: 'row',
stopNotice: 'pauseUnconfirmed'
})
})
expect(
screen.getByText(
'Codex の実行は停止しましたが、目標の一時停止は確認されていません。続行する前に目標を確認してください。'
)
).toBeInTheDocument()
})
@@ -1,3 +1,4 @@
import { agentSessionGoalControlWords } from './agent-session-goal-control-words'
import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
@@ -171,7 +172,7 @@ export function NativeChatNoticeRow({
{ agentName },
sayAgentSessionFailureTranslated
)
: block.text
: agentSessionGoalControlWords(block.text)
const detail =
failure?.detail?.audience === 'person' && failure.detail.text.trim() !== text.trim()
? failure.detail.text
@@ -0,0 +1,26 @@
import { translate } from '@/i18n/i18n'
import { AGENT_SESSION_GOAL_CONTROL_COPY as COPY } from '../../../../shared/agent-session-goal-control-copy'
const WORDS = new Map<string, () => string>([
[
COPY.settingsRejected,
() => translate('components.native-chat.goalControl.settingsRejected', COPY.settingsRejected)
],
[
COPY.settingsUnconfirmed,
() =>
translate('components.native-chat.goalControl.settingsUnconfirmed', COPY.settingsUnconfirmed)
],
[
COPY.pauseUnconfirmed,
() => translate('components.native-chat.goalControl.pauseUnconfirmed', COPY.pauseUnconfirmed)
],
[
COPY.stopUnconfirmed,
() => translate('components.native-chat.goalControl.stopUnconfirmed', COPY.stopUnconfirmed)
]
])
export function agentSessionGoalControlWords(text: string): string {
return WORDS.get(text)?.() ?? text
}
@@ -397,3 +397,29 @@ describe('a Stop pressed again, against a host that answers a repeat quietly', (
expect(new Set(host.ids).size).toBe(2)
})
})
it.each([
'Codex could not apply the selected model and effort. This goal change was not sent. Check the selection and try again.',
'Codex did not confirm the selected model and effort. This goal change was not sent. Check the chat and try again.'
])(
'preserves the pre-goal settings explanation on the existing control failure surface: %s',
async (message) => {
mocks.call.mockResolvedValue({
ok: false,
refusal: {
code: 'agent_session_operation_invalid',
message,
details: { reason: 'providerRejected' }
}
})
const { current } = render()
await act(async () => {
expect(
await current.mutate('agentSession.threadGoal', 'agentSession.threadGoal', {
change: { kind: 'set', objective: 'ship it' }
})
).toBeNull()
})
expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(message)
}
)
@@ -1,5 +1,7 @@
// Give each press its own operation ID; join pending targeted Stops only for older hosts. Command settlement survives provider changes; late pane presentation does not.
import { AGENT_SESSION_GOAL_CONTROL_COPY as GOAL_COPY } from '../../../../shared/agent-session-goal-control-copy'
import { agentSessionGoalControlWords } from './agent-session-goal-control-words'
import { useCallback, useLayoutEffect, useRef } from 'react'
import { toast } from 'sonner'
import type { AgentSessionMutationResult } from '../../../../shared/agent-session-wire'
@@ -65,7 +67,7 @@ function notDone(
fingerprintMethod: string,
fields: Record<string, unknown>,
agentName: string | undefined
): ExecutionResult<never> {
): Extract<ExecutionResult<never>, { kind: 'not-done' }> {
return {
kind: 'not-done',
notice: agentSessionWriteFailureText(
@@ -141,12 +143,20 @@ export function useStructuredAgentSessionMutate(args: {
)
}
if (!result.ok) {
return notDone(
const outcome = notDone(
agentSessionRefusalFailure(result.refusal),
fingerprintMethod,
fields,
agentName
)
const message = result.refusal.message
if (
fingerprintMethod === 'agentSession.threadGoal' &&
(message === GOAL_COPY.settingsRejected || message === GOAL_COPY.settingsUnconfirmed)
) {
outcome.notice = agentSessionGoalControlWords(message)
}
return outcome
}
return { kind: 'done', value: result.value }
},
+6
View File
@@ -3205,6 +3205,12 @@
"usageLimitWindowWeeklyModel": "weekly model",
"writeFailed": "Orca couldn't hand this message to the agent, so it was not sent."
},
"goalControl": {
"pauseUnconfirmed": "Codex stopped running, but its goal pause was not confirmed. Check the goal before continuing.",
"settingsRejected": "Codex could not apply the selected model and effort. This goal change was not sent. Check the selection and try again.",
"settingsUnconfirmed": "Codex did not confirm the selected model and effort. This goal change was not sent. Check the chat and try again.",
"stopUnconfirmed": "Codex could not confirm Stop. Check the chat before continuing."
},
"notices": {
"compaction": "Context compacted",
"contextCleared": "Context cleared",
+6
View File
@@ -19791,6 +19791,12 @@
"imagePreview": {
"fileActions": "File actions",
"linkActions": "Link actions"
},
"goalControl": {
"settingsRejected": "Codex could not apply the selected model and effort. This goal change was not sent. Check the selection and try again.",
"settingsUnconfirmed": "Codex did not confirm the selected model and effort. This goal change was not sent. Check the chat and try again.",
"pauseUnconfirmed": "Codex stopped running, but its goal pause was not confirmed. Check the goal before continuing.",
"stopUnconfirmed": "Codex could not confirm Stop. Check the chat before continuing."
}
},
"tab": {
+7 -1
View File
@@ -19401,7 +19401,13 @@
"explicitChatSsh": "El chat no está disponible en SSH. En su lugar, abra el agente en una terminal.",
"explicitChatWsl": "El chat no está disponible en WSL. En su lugar, abra el agente en una terminal.",
"structuredSessionTerminalNotVisible": "Orca abrió {{value0}} en una terminal, pero no pudo mostrarlo aquí cuando finalizó el lanzamiento. Se mantuvo el borrador de tu chat.",
"explicitChatUnavailable": "Este espacio de trabajo no puede ejecutar el chat. Verifique la instalación del agente o ábralo en una terminal."
"explicitChatUnavailable": "Este espacio de trabajo no puede ejecutar el chat. Verifique la instalación del agente o ábralo en una terminal.",
"goalControl": {
"settingsRejected": "Codex no pudo aplicar el modelo y el esfuerzo seleccionados. No se envió este cambio de objetivo. Revisa la selección e inténtalo de nuevo.",
"settingsUnconfirmed": "Codex no confirmó el modelo y el esfuerzo seleccionados. No se envió este cambio de objetivo. Revisa el chat e inténtalo de nuevo.",
"pauseUnconfirmed": "Codex dejó de ejecutarse, pero no se confirmó la pausa del objetivo. Revisa el objetivo antes de continuar.",
"stopUnconfirmed": "Codex no pudo confirmar la detención. Revisa el chat antes de continuar."
}
},
"tab": {
"bar": {
+7 -1
View File
@@ -19541,7 +19541,13 @@
"explicitChatSsh": "Le chat n’est pas disponible via SSH. Ouvrez plutôt l’agent dans un terminal.",
"explicitChatWsl": "Le chat n'est pas disponible dans WSL. Ouvrez plutôt l'agent dans un terminal.",
"structuredSessionTerminalNotVisible": "Orca a ouvert {{value0}} dans un terminal, mais n'a pas pu l'afficher ici une fois le lancement terminé. Votre brouillon de chat a été conservé.",
"explicitChatUnavailable": "Cet espace de travail ne peut pas exécuter le chat. Vérifiez l'installation de l'agent ou ouvrez-le dans un terminal."
"explicitChatUnavailable": "Cet espace de travail ne peut pas exécuter le chat. Vérifiez l'installation de l'agent ou ouvrez-le dans un terminal.",
"goalControl": {
"settingsRejected": "Codex n’a pas pu appliquer le modèle et l’effort sélectionnés. Ce changement d’objectif n’a pas été envoyé. Vérifiez la sélection et réessayez.",
"settingsUnconfirmed": "Codex n’a pas confirmé le modèle et l’effort sélectionnés. Ce changement d’objectif n’a pas été envoyé. Vérifiez le chat et réessayez.",
"pauseUnconfirmed": "Codex s’est arrêté, mais la mise en pause de son objectif n’a pas été confirmée. Vérifiez l’objectif avant de continuer.",
"stopUnconfirmed": "Codex n’a pas pu confirmer l’arrêt. Vérifiez le chat avant de continuer."
}
},
"tab": {
"bar": {
+7 -1
View File
@@ -19435,7 +19435,13 @@
"explicitChatWsl": "WSL ではチャットを利用できません。ターミナルで Agent を開いてください。",
"explicitChatSsh": "SSH 接続ではチャットを利用できません。ターミナルで Agent を開いてください。",
"structuredSessionTerminalNotVisible": "Orca はターミナルで {{value0}} を開きましたが、起動が終了したときにここに表示できませんでした。チャットの下書きが保存されました。",
"explicitChatUnavailable": "このワークスペースではチャットを実行できません。Agent のインストール状況を確認するか、ターミナルで開いてください。"
"explicitChatUnavailable": "このワークスペースではチャットを実行できません。Agent のインストール状況を確認するか、ターミナルで開いてください。",
"goalControl": {
"settingsRejected": "Codex は選択したモデルと推論量を適用できませんでした。目標の変更は送信されていません。選択内容を確認して、もう一度お試しください。",
"settingsUnconfirmed": "Codex は選択したモデルと推論量の適用を確認しませんでした。目標の変更は送信されていません。チャットを確認して、もう一度お試しください。",
"pauseUnconfirmed": "Codex の実行は停止しましたが、目標の一時停止は確認されていません。続行する前に目標を確認してください。",
"stopUnconfirmed": "Codex の停止を確認できませんでした。続行する前にチャットを確認してください。"
}
},
"tab": {
"bar": {
+7 -1
View File
@@ -19435,7 +19435,13 @@
"explicitChatWsl": "WSL에서는 채팅을 사용할 수 없습니다. 대신 터미널에서 에이전트를 여세요.",
"explicitChatSsh": "SSH 연결에서는 채팅을 사용할 수 없습니다. 대신 터미널에서 에이전트를 여세요.",
"structuredSessionTerminalNotVisible": "Orca가 터미널에서 {{value0}}을(를) 열었지만 실행이 완료된 후 여기에 표시하지 못했습니다. 채팅 초안은 보관되었습니다.",
"explicitChatUnavailable": "이 워크스페이스에서는 채팅을 실행할 수 없습니다. 에이전트 설치 상태를 확인하거나 터미널에서 여세요."
"explicitChatUnavailable": "이 워크스페이스에서는 채팅을 실행할 수 없습니다. 에이전트 설치 상태를 확인하거나 터미널에서 여세요.",
"goalControl": {
"settingsRejected": "Codex가 선택한 모델과 추론 수준을 적용하지 못했습니다. 이 목표 변경은 전송되지 않았습니다. 선택 항목을 확인하고 다시 시도하세요.",
"settingsUnconfirmed": "Codex가 선택한 모델과 추론 수준의 적용을 확인하지 않았습니다. 이 목표 변경은 전송되지 않았습니다. 채팅을 확인하고 다시 시도하세요.",
"pauseUnconfirmed": "Codex 실행은 중지되었지만 목표 일시 중지는 확인되지 않았습니다. 계속하기 전에 목표를 확인하세요.",
"stopUnconfirmed": "Codex 중지를 확인하지 못했습니다. 계속하기 전에 채팅을 확인하세요."
}
},
"tab": {
"bar": {
+6
View File
@@ -19962,6 +19962,12 @@
"imagePreview": {
"fileActions": "Ações do arquivo",
"linkActions": "Ações do link"
},
"goalControl": {
"settingsRejected": "O Codex não conseguiu aplicar o modelo e o esforço selecionados. Esta alteração de objetivo não foi enviada. Confira a seleção e tente novamente.",
"settingsUnconfirmed": "O Codex não confirmou o modelo e o esforço selecionados. Esta alteração de objetivo não foi enviada. Confira o chat e tente novamente.",
"pauseUnconfirmed": "O Codex parou de executar, mas a pausa do objetivo não foi confirmada. Confira o objetivo antes de continuar.",
"stopUnconfirmed": "O Codex não conseguiu confirmar a parada. Confira o chat antes de continuar."
}
},
"tab": {
+7 -1
View File
@@ -19400,7 +19400,13 @@
"explicitChatSsh": "无法通过 SSH 进行聊天。请改为在终端中打开智能体。",
"explicitChatWsl": "WSL 中无法使用聊天功能。请改为在终端中打开智能体。",
"explicitChatUnavailable": "此工作区无法运行聊天。检查智能体安装或在终端中打开它。",
"structuredSessionTerminalNotVisible": "Orca 已在终端中打开 {{value0}},但启动完成后无法在此处显示。您的聊天草稿已保留。"
"structuredSessionTerminalNotVisible": "Orca 已在终端中打开 {{value0}},但启动完成后无法在此处显示。您的聊天草稿已保留。",
"goalControl": {
"settingsRejected": "Codex 无法应用所选模型和推理强度。此目标更改尚未发送。请检查选择后重试。",
"settingsUnconfirmed": "Codex 未确认所选模型和推理强度。此目标更改尚未发送。请检查聊天后重试。",
"pauseUnconfirmed": "Codex 已停止运行,但尚未确认目标已暂停。请在继续前检查目标。",
"stopUnconfirmed": "Codex 无法确认停止。请在继续前检查聊天。"
}
},
"tab": {
"bar": {
@@ -7,7 +7,8 @@ import type { AgentSessionFailureWordsContext } from './agent-session-failure-wo
const BYTES_PER_MB = 1024 * 1024
function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) {
/** The explicit agent name or translated fallback used in failure sentence values. */
export function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) {
return { agent: agentName ?? say('theAgent') }
}
+5 -6
View File
@@ -17,6 +17,7 @@ import {
type AgentSessionFailureCopyValues,
type AgentSessionFailureSay
} from './agent-session-failure-copy'
import { AGENT_SESSION_GOAL_CONTROL_COPY } from './agent-session-goal-control-copy'
import { START_REFUSAL_RESUMABLE } from './agent-session-start-refusal-resumable'
import { providerRetryWords, withRetryCause } from './agent-session-provider-retry-words'
import {
@@ -24,7 +25,7 @@ import {
type AgentSessionUsageLimitResetWords
} from './agent-session-usage-limit-words'
import { commandRefusedByReason } from './agent-session-command-refusal-words'
import { attachmentProblemSentence } from './agent-session-attachment-failure-words'
import { agent, attachmentProblemSentence } from './agent-session-attachment-failure-words'
import { joinSentences } from './sentence-joining'
import { requestTimeoutSeconds } from './agent-session-request-timeout'
import {
@@ -56,6 +57,7 @@ export type AgentSessionFailureSurface = 'row' | 'rejection' | 'answer'
export type AgentSessionFailureWordsContext = {
/** The chat's agent, when the writer knows it. */
agentName?: string
stopNotice?: 'pauseUnconfirmed' | 'stopUnconfirmed'
/** Names the legacy queue-full marker; without it a full queue is worded as a sentence. */
provider?: 'claude' | 'codex'
/** Retry the failed start's conversation command instead of sending a message. */
@@ -78,10 +80,6 @@ type Sentence = (
say: AgentSessionFailureSay
) => string
function agent(say: AgentSessionFailureSay, { agentName }: AgentSessionFailureWordsContext) {
return { agent: agentName ?? say('theAgent') }
}
function namingAgent(id: AgentSessionFailureCopyId): Sentence {
return (context, _fact, _surface, say) => say(id, agent(say, context))
}
@@ -237,7 +235,8 @@ const FAILURE_SENTENCES = {
compactionFailed: (_context, fact, _surface, say) =>
quotingPersonDetail(say, 'compactionFailed', 'compactionFailedQuoted', fact.detail),
compactionUnconfirmed: (_context, _fact, _surface, say) => say('compactionUnconfirmed'),
cancelUnconfirmed: (_context, _fact, _surface, say) => say('cancelUnconfirmed'),
cancelUnconfirmed: ({ stopNotice }, _fact, _surface, say) =>
stopNotice ? AGENT_SESSION_GOAL_CONTROL_COPY[stopNotice] : say('cancelUnconfirmed'),
// The agent was reached and declined, so the sentence says that, not that the Stop was lost.
stopRefused: (context, fact, _surface, say) =>
fact.detail?.audience === 'person'
@@ -0,0 +1,9 @@
export const AGENT_SESSION_GOAL_CONTROL_COPY = {
settingsRejected:
'Codex could not apply the selected model and effort. This goal change was not sent. Check the selection and try again.',
settingsUnconfirmed:
'Codex did not confirm the selected model and effort. This goal change was not sent. Check the chat and try again.',
pauseUnconfirmed:
'Codex stopped running, but its goal pause was not confirmed. Check the goal before continuing.',
stopUnconfirmed: 'Codex could not confirm Stop. Check the chat before continuing.'
} as const
@@ -151,7 +151,7 @@ describe('a chat at rest keeps its worktree activatable', () => {
it('after the idle sweep stopped its agent and closed the conversation', async () => {
clock += STRUCTURED_AGENT_SESSION_IDLE_MS + 1
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledWith(SESSION)
expect(closeSession).toHaveBeenCalledWith(SESSION, undefined)
expect(host.handoffStatus(SESSION)).toMatchObject({ owner: 'native' })
expect(await activate()).toBe('structured')