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