diff --git a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx index 002bb5841dd..0cc5fc120ef 100644 --- a/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx +++ b/mobile/src/session/mobile-native-chat-stopped-before-start.test.tsx @@ -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([ diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 1776389b67c..b9ff6229dcb 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -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) }) }) diff --git a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts index d39099c1d46..bd5855e2faa 100644 --- a/src/main/codex/codex-app-server-connection-tree-unproven.test.ts +++ b/src/main/codex/codex-app-server-connection-tree-unproven.test.ts @@ -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, diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index ad841e3397c..6c1eca81814 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -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) }) }) diff --git a/src/main/codex/codex-request-end-wait.ts b/src/main/codex/codex-request-end-wait.ts index b51cd5e7e58..6fc901cc4fd 100644 --- a/src/main/codex/codex-request-end-wait.ts +++ b/src/main/codex/codex-request-end-wait.ts @@ -14,7 +14,7 @@ export function codexStoppedRequestEndWait( ): (sessionId: string, stoppedAt: number) => Promise { 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 diff --git a/src/main/codex/codex-scripted-child.test-fixture.ts b/src/main/codex/codex-scripted-child.test-fixture.ts index 6d729daa2f2..4d5e14ed0a3 100644 --- a/src/main/codex/codex-scripted-child.test-fixture.ts +++ b/src/main/codex/codex-scripted-child.test-fixture.ts @@ -12,6 +12,8 @@ const THREAD = CODEX_SCRIPTED_THREAD type ScriptedCodexRoute = (params: Record | undefined) => unknown export type ScriptedCodexChild = ScriptedAgentChild & { + notify(method: string, params: unknown): void + closeEvidence: NonNullable requests(): readonly { method: string; params?: Record }[] } @@ -44,6 +46,8 @@ function heldAnswer( export const codexScriptedChild = ( routes: Readonly> = {} ): 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 }[] = [] let newest: ReturnType> | undefined const open: typeof openCodexAppServerConnection = async (_launch, handlers = {}) => { + notify = (method, params) => handlers.onNotification?.(method, params) spawned += 1 const gate = Promise.withResolvers() 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, diff --git a/src/main/codex/codex-structured-acquisition-notification.ts b/src/main/codex/codex-structured-acquisition-notification.ts index 88ed3655ff1..d71ef386cd0 100644 --- a/src/main/codex/codex-structured-acquisition-notification.ts +++ b/src/main/codex/codex-structured-acquisition-notification.ts @@ -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, diff --git a/src/main/codex/codex-structured-acquisition-window.ts b/src/main/codex/codex-structured-acquisition-window.ts index 40e53712a16..c1a2ff1aa62 100644 --- a/src/main/codex/codex-structured-acquisition-window.ts +++ b/src/main/codex/codex-structured-acquisition-window.ts @@ -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 diff --git a/src/main/codex/codex-structured-applied-settings.ts b/src/main/codex/codex-structured-applied-settings.ts new file mode 100644 index 00000000000..53b0188df3a --- /dev/null +++ b/src/main/codex/codex-structured-applied-settings.ts @@ -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() + 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() + 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() + 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')) + } + } +} diff --git a/src/main/codex/codex-structured-goal-stop.ts b/src/main/codex/codex-structured-goal-stop.ts new file mode 100644 index 00000000000..d1ed06f8429 --- /dev/null +++ b/src/main/codex/codex-structured-goal-stop.ts @@ -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 +}): Promise { + 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) => { + 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 } +} diff --git a/src/main/codex/codex-structured-message-options.ts b/src/main/codex/codex-structured-message-options.ts index e115e24aede..23b52e79e19 100644 --- a/src/main/codex/codex-structured-message-options.ts +++ b/src/main/codex/codex-structured-message-options.ts @@ -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')) } diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index 6c0841717c2..0e525d93d10 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -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[0] @@ -89,6 +90,20 @@ export async function cancelCodexStructuredTurn(input: { }): Promise { 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) { diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 07d97094820..27ec0b12f56 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -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 diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index e242229bafb..6c7841ce2c6 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -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 = (input) => - changeCodexThreadGoal( - this.session(input.sessionId), - input.change, - input.replacesGoal, + changeThreadGoal: NonNullable = (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 = (sessionId) => - this.teardown.close(sessionId) + closeSession: NonNullable = (...args) => + this.teardown.close(...args) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) - disposeSession: NonNullable = (sessionId) => - this.teardown.close(sessionId) + disposeSession: NonNullable = (...args) => + this.teardown.close(...args) closeAll = (): Promise => this.teardown.closeAll() releaseAcquisition = (input: { sessionId: string }): Promise => this.teardown.close(input.sessionId) diff --git a/src/main/codex/codex-structured-session-start.ts b/src/main/codex/codex-structured-session-start.ts index e7b269b6c97..74163e7a672 100644 --- a/src/main/codex/codex-structured-session-start.ts +++ b/src/main/codex/codex-structured-session-start.ts @@ -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, diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index 3e8deea7e6c..d662f369a9c 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -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> + appliedSettings?: CodexAppliedSettings /** Optional for adapters without a host catalog store. */ catalogAccess?: CodexSessionCatalogAccess dispatchEchoes: CodexDispatchEchoes diff --git a/src/main/codex/codex-structured-session-teardown.ts b/src/main/codex/codex-structured-session-teardown.ts index 1a8ead5b076..66919b35fd6 100644 --- a/src/main/codex/codex-structured-session-teardown.ts +++ b/src/main/codex/codex-structured-session-teardown.ts @@ -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 => { + close = async (sessionId: string, observeClose?: AgentSessionCloseObserver): Promise => { 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 => { diff --git a/src/main/codex/codex-structured-thread-goal.test.ts b/src/main/codex/codex-structured-thread-goal.test.ts index 08c7ee54d34..8f866a6b688 100644 --- a/src/main/codex/codex-structured-thread-goal.test.ts +++ b/src/main/codex/codex-structured-thread-goal.test.ts @@ -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 + listeners: Set + readonly snapshot: Record +} + +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) { // 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() + 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() + } +}) diff --git a/src/main/codex/codex-structured-thread-goal.ts b/src/main/codex/codex-structured-thread-goal.ts index 4d51e50b989..6bac56d1294 100644 --- a/src/main/codex/codex-structured-thread-goal.ts +++ b/src/main/codex/codex-structured-thread-goal.ts @@ -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>[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) +} diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 2cb43aa976c..6df57a59456 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -13,7 +13,7 @@ export type CodexOpenedThread = { thread?: Record 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 } : {}) } } diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts index 9792a5f4e70..4ff2fbd5d03 100644 --- a/src/main/codex/codex-structured-turn-start.ts +++ b/src/main/codex/codex-structured-turn-start.ts @@ -46,6 +46,7 @@ export type CodexTurnHost = { threadId: string options: Map appliedTurnOptions?: Readonly> + 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)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index 6837149bc78..252cc914333 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -176,17 +176,22 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi return adapter.copyConversation(input) } - closeSession = (sessionId: string): Promise => - this.stopSession(sessionId, (adapter) => adapter.closeSession?.(sessionId)) + closeSession: NonNullable = ( + sessionId, + observeClose + ) => this.stopSession(sessionId, (adapter) => adapter.closeSession?.(sessionId, observeClose)) forceCloseSession = (sessionId: string): Promise => this.stopSession(sessionId, (adapter) => (adapter.forceCloseSession ?? adapter.closeSession)?.call(adapter, sessionId) ) - disposeSession = (sessionId: string): Promise => + disposeSession: NonNullable = ( + sessionId, + observeClose + ) => this.stopSession(sessionId, (adapter) => - (adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId) + (adapter.disposeSession ?? adapter.closeSession)?.call(adapter, sessionId, observeClose) ) private async stopSession( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index e05bbb2ed71..c2764372d78 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -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 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index ca8b07ff650..fabca1ddb8b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -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 /** True proves child exit; root/processless verdicts throw only after finalization (stopAgentSessionProviderRoot). */ - closeSession?(sessionId: string): Promise + closeSession?(sessionId: string, observeClose?: AgentSessionCloseObserver): Promise /** Sink-failure shutdown leaves exit recovery unexpected. */ forceCloseSession?(sessionId: string): Promise /** Teardown without requiring a future-resume cursor. */ - disposeSession?(sessionId: string): Promise + disposeSession?(sessionId: string, observeClose?: AgentSessionCloseObserver): Promise /** Acknowledge only after dead-child, lease and journal ownership release. */ acknowledgeSessionRelease?( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts index 336c586c47e..d1d6c6a254a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop-target.ts @@ -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. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 76b579fec6f..c53b4a83dce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -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 @@ -70,6 +73,7 @@ export function mutateWithChatStop( 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( ): Promise => { // 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( // 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( 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( ) } }) + void shutdown.catch(logChildEnd) return result } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts index 0ec7cc40e5d..2e96a1ed95e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-child-close.ts @@ -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 { // 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 { 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) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index 44b983a4416..08a6bcb960e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 399b97af90f..09f246498fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -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 reason?: string diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 8b60918d3eb..14ad4c3ff14 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -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 /** Order child exit at the Stop request; repeated requests advance this boundary. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index c6c70f6ae56..0d9ed7f5048 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -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 () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts index ea51d69eb64..29445164e89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-outgrown-child.test.ts @@ -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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts index db1ed42476d..a1ca93faecd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-permission-mode.test.ts @@ -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' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index c1182f9c068..5e3a4672bd0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index 8459c3f68a6..21246d11180 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -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() + ] + ]) } } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts index 1003e2a7b2f..18b9407d150 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-wind-down.ts @@ -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. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts index 8760e378525..500557bc3c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-journal-retention.test.ts @@ -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() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 7dec3590d1d..bf1d336a9b7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -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', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 82183d5adaa..6c097e09370 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -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) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 7e9bbae575d..6486e928d15 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts index 8eb26ecf9ca..4a0755b3274 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unnamed-stop-races.test.ts @@ -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' }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 0490d1efe2e..5ee3450471b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -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() diff --git a/src/main/orcad/orcad-chat-restart-offer.test.ts b/src/main/orcad/orcad-chat-restart-offer.test.ts index 5148c9eda21..37cee0fec44 100644 --- a/src/main/orcad/orcad-chat-restart-offer.test.ts +++ b/src/main/orcad/orcad-chat-restart-offer.test.ts @@ -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( diff --git a/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts index 84b149f8480..30e072de3d0 100644 --- a/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts +++ b/src/main/orcad/orcad-managed-idle-exit-chat-provider.test.ts @@ -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 diff --git a/src/main/pi/rpc-session-adapter.test.ts b/src/main/pi/rpc-session-adapter.test.ts index b308ed6ecef..52dccb27692 100644 --- a/src/main/pi/rpc-session-adapter.test.ts +++ b/src/main/pi/rpc-session-adapter.test.ts @@ -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 } diff --git a/src/main/pi/rpc-session-adapter.ts b/src/main/pi/rpc-session-adapter.ts index 7ed6bf4a4b6..e556c124236 100644 --- a/src/main/pi/rpc-session-adapter.ts +++ b/src/main/pi/rpc-session-adapter.ts @@ -230,7 +230,10 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { return session?.generation === generation && !session.connection.closed } - async closeSession(id: string, requested = true): Promise { + closeSession(id: string): Promise { + return this.close(id, true) + } + private async close(id: string, requested: boolean): Promise { const session = this.sessions.get(id) if (!session) { return true @@ -252,7 +255,7 @@ export class PiRpcSessionAdapter implements StructuredAgentSessionAdapter { } forceCloseSession(id: string): Promise { 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 { return this.closeSession(id) diff --git a/src/main/runtime/structured-agent-session-codex-goal-stop-settings.test.ts b/src/main/runtime/structured-agent-session-codex-goal-stop-settings.test.ts new file mode 100644 index 00000000000..794b097fa62 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-goal-stop-settings.test.ts @@ -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) => 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 +) { + 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) { + 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() + let child: ReturnType + 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((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>['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() + 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() + 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() + 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() + const effects = Promise.withResolvers() + const reading = Promise.withResolvers() + 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() + const refreshed = Promise.withResolvers() + const reaped = Promise.withResolvers() + const capture = vi.fn(() => captured.promise) + const refresh = vi.fn(() => refreshed.promise) + const exit = Promise.withResolvers() + 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) +}) diff --git a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts index 5ef1257b4c1..a4f06b28afb 100644 --- a/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts +++ b/src/main/runtime/structured-agent-session-codex-stopped-send-order.test.ts @@ -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 ]) }) }) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index ba42952a329..7d5dbb188f8 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -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 diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 2ac6d9cdbf0..d30a357d657 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -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, 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[] } }>( diff --git a/src/main/runtime/structured-worker-at-rest.test.ts b/src/main/runtime/structured-worker-at-rest.test.ts index ae4cd0063e6..290d6b6bd75 100644 --- a/src/main/runtime/structured-worker-at-rest.test.ts +++ b/src/main/runtime/structured-worker-at-rest.test.ts @@ -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() diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx index 90ba6d77610..2391af31227 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.test.tsx @@ -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() +}) diff --git a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx index e7f3267ec17..3b301943966 100644 --- a/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatNoticeRow.tsx @@ -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 diff --git a/src/renderer/src/components/native-chat/agent-session-goal-control-words.ts b/src/renderer/src/components/native-chat/agent-session-goal-control-words.ts new file mode 100644 index 00000000000..7ea1f018df2 --- /dev/null +++ b/src/renderer/src/components/native-chat/agent-session-goal-control-words.ts @@ -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>([ + [ + 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 +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx index 93380496b12..77541c54f4e 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.test.tsx @@ -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) + } +) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 1842079828b..4c1ee7b55a3 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -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, agentName: string | undefined -): ExecutionResult { +): Extract, { 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 } }, diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index d4ad218bd4f..c0a4538ce72 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7b5ccbc12ff..b701791fc9b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d17b6e3ed14..4edee42a079 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d36eda4b9fd..9533dbb4d98 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c4aa3c0efaa..d194bd19baa 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6ea5e6769f4..ff7377501c5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -19435,7 +19435,13 @@ "explicitChatWsl": "WSL에서는 채팅을 사용할 수 없습니다. 대신 터미널에서 에이전트를 여세요.", "explicitChatSsh": "SSH 연결에서는 채팅을 사용할 수 없습니다. 대신 터미널에서 에이전트를 여세요.", "structuredSessionTerminalNotVisible": "Orca가 터미널에서 {{value0}}을(를) 열었지만 실행이 완료된 후 여기에 표시하지 못했습니다. 채팅 초안은 보관되었습니다.", - "explicitChatUnavailable": "이 워크스페이스에서는 채팅을 실행할 수 없습니다. 에이전트 설치 상태를 확인하거나 터미널에서 여세요." + "explicitChatUnavailable": "이 워크스페이스에서는 채팅을 실행할 수 없습니다. 에이전트 설치 상태를 확인하거나 터미널에서 여세요.", + "goalControl": { + "settingsRejected": "Codex가 선택한 모델과 추론 수준을 적용하지 못했습니다. 이 목표 변경은 전송되지 않았습니다. 선택 항목을 확인하고 다시 시도하세요.", + "settingsUnconfirmed": "Codex가 선택한 모델과 추론 수준의 적용을 확인하지 않았습니다. 이 목표 변경은 전송되지 않았습니다. 채팅을 확인하고 다시 시도하세요.", + "pauseUnconfirmed": "Codex 실행은 중지되었지만 목표 일시 중지는 확인되지 않았습니다. 계속하기 전에 목표를 확인하세요.", + "stopUnconfirmed": "Codex 중지를 확인하지 못했습니다. 계속하기 전에 채팅을 확인하세요." + } }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 19657d77396..1c4907d53ce 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1c9a7e91f18..174abdd5ea7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/shared/agent-session-attachment-failure-words.ts b/src/shared/agent-session-attachment-failure-words.ts index 9e126d314d9..db9eca45d01 100644 --- a/src/shared/agent-session-attachment-failure-words.ts +++ b/src/shared/agent-session-attachment-failure-words.ts @@ -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') } } diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index da689fcefe3..eac5459a035 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -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' diff --git a/src/shared/agent-session-goal-control-copy.ts b/src/shared/agent-session-goal-control-copy.ts new file mode 100644 index 00000000000..b5deda77260 --- /dev/null +++ b/src/shared/agent-session-goal-control-copy.ts @@ -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 diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 3f5413557c3..8e07fbbc604 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -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')