Preserve uncertain goal text and accept distinct controls (#33022)

* fix: preserve goal control cancellation and uncertain outcomes

* fix(i18n): retire obsolete goal failure translations

* fix: keep goal controls independent of pending actions

* test: cover unknown goal recovery and later edits

* test: replay recorded Codex goal notifications through journal

* test: type recorded goal projections from reader return
This commit is contained in:
Brennan Benson
2026-10-25 14:04:59 -07:00
committed by GitHub
parent 25262fc8e9
commit e26e4185bc
24 changed files with 324 additions and 209 deletions
@@ -0,0 +1,6 @@
{"receivedAt":"2026-10-24T06:35:28.038Z","direction":"response","frame":{"method":"thread/goal/updated","params":{"threadId":"<threadId-1>","turnId":null,"goal":{"threadId":"<threadId-1>","objective":"Inspect each of samples/sample-01.txt through sample-12.txt in order using a separate read for each, then compare inventory.txt and README.md and report a 12-row summary. No file edits or external network.","status":"active","tokenBudget":null,"tokensUsed":0,"timeUsedSeconds":0,"createdAt":1792823727,"updatedAt":1792823727}},"emittedAtMs":1792823728031}}
{"receivedAt":"2026-10-24T06:35:28.872Z","direction":"response","frame":{"method":"thread/goal/updated","params":{"threadId":"<threadId-1>","turnId":null,"goal":{"threadId":"<threadId-1>","objective":"Inspect each of samples/sample-01.txt through sample-12.txt in order using a separate read for each, then compare inventory.txt and README.md and report a 12-row summary. No file edits or external network.","status":"paused","tokenBudget":null,"tokensUsed":0,"timeUsedSeconds":0,"createdAt":1792823727,"updatedAt":1792823728}},"emittedAtMs":1792823728871}}
{"receivedAt":"2026-10-24T06:35:37.636Z","direction":"response","frame":{"method":"thread/goal/updated","params":{"threadId":"<threadId-1>","turnId":null,"goal":{"threadId":"<threadId-1>","objective":"Inspect each of samples/sample-01.txt through sample-12.txt in order using a separate read for each, then compare inventory.txt and README.md and report a 12-row summary. No file edits or external network.","status":"active","tokenBudget":null,"tokensUsed":0,"timeUsedSeconds":0,"createdAt":1792823727,"updatedAt":1792823737}},"emittedAtMs":1792823737636}}
{"receivedAt":"2026-10-24T06:35:53.377Z","direction":"response","frame":{"method":"thread/goal/cleared","params":{"threadId":"<threadId-1>"},"emittedAtMs":1792823753377}}
{"receivedAt":"2026-10-24T06:35:53.382Z","direction":"response","frame":{"method":"thread/goal/updated","params":{"threadId":"<threadId-1>","turnId":null,"goal":{"threadId":"<threadId-1>","objective":"Inspect samples/sample-12.txt through samples/sample-01.txt in reverse order using a separate read for each, compare their fruit with inventory.txt, and report a reverse-order 12-row summary. No edits or external network.","status":"active","tokenBudget":null,"tokensUsed":0,"timeUsedSeconds":0,"createdAt":1792823753,"updatedAt":1792823753}},"emittedAtMs":1792823753381}}
{"receivedAt":"2026-10-24T06:35:53.761Z","direction":"response","frame":{"method":"thread/goal/cleared","params":{"threadId":"<threadId-1>"},"emittedAtMs":1792823753760}}
@@ -1,4 +1,5 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import { readFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -14,6 +15,7 @@ import { createTrackedJournalOpener } from '../native-chat/agent-session-journal
import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page'
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { CodexJournalGoals } from './codex-structured-journal-goals'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
@@ -87,6 +89,72 @@ function journalSink(journal: AgentSessionJournal) {
}
describe('codex goal accounting revisions', () => {
it('projects recorded Codex 0.163.0 goal controls through the journal to the banner state', async () => {
// Actual UI Set/Pause/Resume/Edit/Clear notifications; only provider IDs are redacted.
const notifications: {
receivedAt: string
frame: { method: string; params: { threadId: string }; emittedAtMs: number }
}[] = readFileSync(
join(__dirname, '__fixtures__', 'codex-app-server-goal-lifecycle.jsonl'),
'utf8'
)
.trim()
.split('\n')
.map((line) => JSON.parse(line))
const threadId = '<threadId-1>'
root = await mkdtemp(join(tmpdir(), 'orca-goal-recorded-'))
const journal = await journals.open({
identity: { ...IDENTITY, providerHandle: codexProviderHandle(threadId) },
stateDirectory: root
})
const { sink, drained } = journalSink(journal)
const translator = createCodexJournalTranslator({
sink,
sessionId: IDENTITY.sessionId,
primaryThreadId: () => threadId
})
const projected: ReturnType<typeof currentAgentSessionThreadGoal>[] = []
for (const { receivedAt, frame } of notifications) {
expect(
translator.handle({
type: 'notification',
sessionId: IDENTITY.sessionId,
threadId: frame.params.threadId,
method: frame.method,
params: frame.params,
observedAt: Date.parse(receivedAt)
})
).toEqual({ accepted: true })
await drained()
projected.push(currentAgentSessionThreadGoal(journal.snapshot().items))
}
const original = {
objective:
'Inspect each of samples/sample-01.txt through sample-12.txt in order using a separate read for each, then compare inventory.txt and README.md and report a 12-row summary. No file edits or external network.',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1792823727_000,
updatedAt: 1792823727_000
}
expect(projected).toEqual([
original,
{ ...original, status: 'paused', updatedAt: 1792823728_000 },
{ ...original, updatedAt: 1792823737_000 },
null,
{
...original,
objective:
'Inspect samples/sample-12.txt through samples/sample-01.txt in reverse order using a separate read for each, compare their fruit with inventory.txt, and report a reverse-order 12-row summary. No edits or external network.',
createdAt: 1792823753_000,
updatedAt: 1792823753_000
},
null
])
translator.dispose()
})
it('revises the goal row in place: one visible row, pinned sequence, fresh counters', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-goal-revision-'))
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
@@ -18,33 +18,33 @@ describe('codex thread goal requests', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'set', objective: 'Ship it' }, false)).toEqual([
{
method: 'thread/goal/set',
params: { threadId: THREAD, objective: 'Ship it', status: 'active' }
params: { threadId: THREAD, origin: 'user', objective: 'Ship it', status: 'active' }
}
])
})
it('clears the existing goal before setting a new objective, so the new goal starts fresh', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'set', objective: 'Ship it' }, true)).toEqual([
{ method: 'thread/goal/clear', params: { threadId: THREAD } },
{ method: 'thread/goal/clear', params: { threadId: THREAD, origin: 'user' } },
{
method: 'thread/goal/set',
params: { threadId: THREAD, objective: 'Ship it', status: 'active' }
params: { threadId: THREAD, origin: 'user', objective: 'Ship it', status: 'active' }
}
])
})
it('pauses and resumes by status alone, keeping the objective', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'status', status: 'paused' }, true)).toEqual([
{ method: 'thread/goal/set', params: { threadId: THREAD, status: 'paused' } }
{ method: 'thread/goal/set', params: { threadId: THREAD, origin: 'user', status: 'paused' } }
])
expect(codexThreadGoalRequests(THREAD, { kind: 'status', status: 'active' }, true)).toEqual([
{ method: 'thread/goal/set', params: { threadId: THREAD, status: 'active' } }
{ method: 'thread/goal/set', params: { threadId: THREAD, origin: 'user', status: 'active' } }
])
})
it('clears with only the thread id', () => {
it('clears with user provenance', () => {
expect(codexThreadGoalRequests(THREAD, { kind: 'clear' }, true)).toEqual([
{ method: 'thread/goal/clear', params: { threadId: THREAD } }
{ method: 'thread/goal/clear', params: { threadId: THREAD, origin: 'user' } }
])
})
@@ -54,10 +54,10 @@ describe('codex thread goal requests', () => {
changeCodexThreadGoal(session(request), { kind: 'set', objective: 'Ship it' }, true, 5_000)
).resolves.toEqual({ ok: true })
expect(request.mock.calls).toEqual([
['thread/goal/clear', { threadId: THREAD }, { timeoutMs: 5_000 }],
['thread/goal/clear', { threadId: THREAD, origin: 'user' }, { timeoutMs: 5_000 }],
[
'thread/goal/set',
{ threadId: THREAD, objective: 'Ship it', status: 'active' },
{ threadId: THREAD, origin: 'user', objective: 'Ship it', status: 'active' },
{ timeoutMs: 5_000 }
]
])
@@ -84,6 +84,27 @@ describe('codex thread goal requests', () => {
expect(request).toHaveBeenCalledOnce()
})
it('reports a cleared previous goal when replacement is refused', async () => {
const request = vi
.fn()
.mockResolvedValueOnce({ cleared: true })
.mockRejectedValueOnce(
new CodexAppServerRequestError('thread/goal/set', -32600, 'goals disabled')
)
await expect(
changeCodexThreadGoal(
session(request),
{ kind: 'set', objective: 'Ship it' },
true,
undefined
)
).resolves.toEqual({
ok: false,
rejected:
'Codex cleared the previous goal but could not set the new goal. Set the goal again to continue.'
})
})
it('stops at a refused clear, so a refused replacement leaves the old goal alone', async () => {
const request = vi.fn(async (method: unknown) => {
if (method === 'thread/goal/clear') {
+17 -5
View File
@@ -13,19 +13,24 @@ export function codexThreadGoalRequests(
change: AgentSessionThreadGoalChange,
replacesGoal: boolean
): CodexThreadGoalRequest[] {
const clear: CodexThreadGoalRequest = { method: 'thread/goal/clear', params: { threadId } }
const clear: CodexThreadGoalRequest = {
method: 'thread/goal/clear',
params: { threadId, origin: 'user' }
}
if (change.kind === 'clear') {
return [clear]
}
if (change.kind === 'status') {
return [{ method: 'thread/goal/set', params: { threadId, status: change.status } }]
return [
{ method: 'thread/goal/set', params: { threadId, origin: 'user', status: change.status } }
]
}
// Clear replacements to reset identity/usage; activating a goal starts work without a turn.
return [
...(replacesGoal ? [clear] : []),
{
method: 'thread/goal/set',
params: { threadId, objective: change.objective, status: 'active' }
params: { threadId, origin: 'user', objective: change.objective, status: 'active' }
}
]
}
@@ -37,16 +42,23 @@ export async function changeCodexThreadGoal(
timeoutMs: number | undefined,
signal?: AbortSignal
): Promise<{ ok: true } | { ok: false; rejected: string }> {
let cleared = false
try {
for (const request of codexThreadGoalRequests(session.threadId, change, replacesGoal)) {
throwIfSignalAborted(signal)
await session.connection.request(request.method, request.params, { timeoutMs })
cleared ||= request.method === 'thread/goal/clear'
}
return { ok: true }
} catch (error) {
if (isCodexAppServerRequestError(error)) {
return { ok: false, rejected: error.message }
return {
ok: false,
rejected: cleared
? 'Codex cleared the previous goal but could not set the new goal. Set the goal again to continue.'
: error.message
}
}
throw error
}
return { ok: true }
}
@@ -1,10 +1,20 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import {
closeTestJournalHostDatabase,
openTestJournalHostDatabase
} from '../agent-session-journal/journal-host-database-test-support'
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 {
CALLER,
attach,
envelope,
hostTestState
hostTestState,
hostTestRecoveryCapsuleSettled,
replaceHostTestState
} from './structured-agent-session-host-test-harness'
import {
HOST_TEST_SESSION as SESSION,
@@ -57,44 +67,106 @@ it.each(['send', 'compact'] as const)(
}
)
it('rechecks a goal retry settled during startup, and replays it later without a child', async () => {
const { host, store, acquire } = hostTestState()
it('persists unknown Set objective and receipt across reopen, without replay overriding later actions', async () => {
const { root, acquire } = hostTestState()
let { host, store } = hostTestState()
const changeThreadGoal = vi.fn(async () => ({ ok: true as const }))
host.deps.adapter.changeThreadGoal = changeThreadGoal
await attach()
const change = { kind: 'set' as const, objective: 'Ship the parser' }
let params = { envelope: envelope('agentSession.threadGoal', { change }), change }
changeThreadGoal.mockRejectedValueOnce(new Error('not applied'))
await expect(host.changeThreadGoal(CALLER, params)).rejects.toThrow('not applied')
expect(await host.changeThreadGoal(CALLER, params)).toMatchObject({ ok: true, replayed: false })
params = { envelope: envelope('agentSession.threadGoal', { change }), change }
changeThreadGoal.mockRejectedValueOnce(new Error('not applied'))
await expect(host.changeThreadGoal(CALLER, params)).rejects.toThrow('not applied')
const params = { envelope: envelope('agentSession.threadGoal', { change }), change }
changeThreadGoal.mockRejectedValueOnce(new Error('reply lost'))
await expect(host.changeThreadGoal(CALLER, params)).rejects.toThrow('reply lost')
expect(
store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome
).toEqual({ status: 'unknown' })
const attempted = {
kind: 'message',
role: 'user',
sentAs: 'goal',
blocks: [{ type: 'text', text: change.objective }]
}
const originalJournal = host.collaboratorsForTests().sessions.get(SESSION)!.journal
await host.close(SESSION, 'evict')
let ready!: () => void
const open = vi.fn(
() =>
new Promise<{ reportedOptions: { model: string } }>((resolve) => {
ready = () => resolve({ reportedOptions: { model: 'default' } })
})
)
host.deps.adapter.open = open
const retry = host.changeThreadGoal(CALLER, params)
await vi.waitFor(() => expect(open).toHaveBeenCalledOnce())
expect(changeThreadGoal).toHaveBeenCalledTimes(3)
await store.recordOperationOutcome({
callerKey: CALLER.callerKey,
operationId: params.envelope.clientOperationId,
outcome: { status: 'succeeded', sessionId: SESSION }
await abandonStructuredAgentSessionHost(host)
await hostTestRecoveryCapsuleSettled()
const database = host.deps.journalDatabase
closeTestJournalHostDatabase(root)
expect(database.isClosed).toBe(true)
store = await openTestAgentSessionRecordStore(root)
host = new StructuredAgentSessionHost({
...host.deps,
store,
journalDatabase: openTestJournalHostDatabase(root)
})
ready()
expect(await retry).toMatchObject({ ok: true, replayed: true })
expect(changeThreadGoal).toHaveBeenCalledTimes(3)
expect(acquire).toHaveBeenCalledTimes(2)
replaceHostTestState({ host, store })
expect((await host.journalSnapshot(SESSION)).items.map((item) => item.body)).toContainEqual(
attempted
)
expect(host.collaboratorsForTests().sessions.get(SESSION)!.journal).not.toBe(originalJournal)
expect(
store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome
).toEqual({ status: 'unknown' })
expect(await host.changeThreadGoal(CALLER, params)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_unknown' }
})
expect(changeThreadGoal).toHaveBeenCalledOnce()
const latestGoal = {
objective: 'Ship the newer goal',
status: 'active',
tokenBudget: null,
tokensUsed: 0,
timeUsedSeconds: 0,
createdAt: 1,
updatedAt: 2
} as const
const laterActions = [
{ kind: 'status', status: 'paused' },
{ kind: 'clear' },
{ kind: 'set', objective: latestGoal.objective }
] as const
const acceptedActions = laterActions.map((later) => ({
envelope: envelope('agentSession.threadGoal', { change: later }),
change: later
}))
for (const accepted of acceptedActions) {
expect(await host.changeThreadGoal(CALLER, accepted)).toMatchObject({ ok: true })
if (accepted.change.kind === 'set') {
acquire.mock.calls
.at(-1)![0]
.events!.appendItem(
{ provider: 'orca', clientMessageId: 'reported-newer-goal' },
{ kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: latestGoal } },
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await host.flushStreamedEvents(SESSION)
}
const beforeReplay = await host.journalSnapshot(SESSION)
expect(await host.changeThreadGoal(CALLER, params)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_unknown' }
})
expect(await host.journalSnapshot(SESSION)).toEqual(beforeReplay)
}
expect(host.collaboratorsForTests().sessions.get(SESSION)!.journal.threadGoal()).toEqual(
latestGoal
)
expect(changeThreadGoal).toHaveBeenCalledTimes(4)
await host.close(SESSION, 'evict')
expect(await host.changeThreadGoal(CALLER, params)).toMatchObject({ ok: true, replayed: true })
expect(await host.changeThreadGoal(CALLER, acceptedActions[1]!)).toMatchObject({
ok: true,
replayed: true
})
expect(await host.changeThreadGoal(CALLER, params)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_unknown' }
})
expect(acquire).toHaveBeenCalledTimes(2)
expect(changeThreadGoal).toHaveBeenCalledTimes(3)
expect(changeThreadGoal).toHaveBeenCalledTimes(4)
expect(host.collaboratorsForTests().sessions.get(SESSION)!.journal.threadGoal()).toEqual(
latestGoal
)
})
it.each([false, true])('keeps a goal behind active dispatch (ready child: %s)', async (ready) => {
@@ -10,11 +10,7 @@ import type {
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import {
journalRecordsThreadGoalChange,
performThreadGoalChange,
threadGoalPlan
} from './structured-agent-session-thread-goal'
import { performThreadGoalChange, threadGoalPlan } from './structured-agent-session-thread-goal'
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
import { claudeAndCodexAgents } from './structured-agent-session-adapter-router-test-support'
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
@@ -141,7 +137,7 @@ describe('performThreadGoalChange', () => {
expect(journal.snapshot().items).toEqual([])
})
it('removes the objective when the provider request fails outright', async () => {
it('retains the objective when a lost reply leaves application unknown', async () => {
const journal = await openJournal()
const ctx = context(journal, {
changeThreadGoal: async () => {
@@ -155,39 +151,8 @@ describe('performThreadGoalChange', () => {
change: { kind: 'set', objective: 'Ship the parser' }
})
).rejects.toThrow('connection closed')
expect(journal.snapshot().items).toEqual([])
})
it('puts the objective back, once, when a withdrawn set runs again', async () => {
const journal = await openJournal()
const attempts: (() => Promise<{ ok: true }>)[] = [
async () => {
throw new Error('request timed out')
},
async () => ({ ok: true as const })
]
const ctx = context(journal, {
changeThreadGoal: () => attempts.shift()!()
})
const input = {
clientOperationId: 'op-9',
change: { kind: 'set' as const, objective: 'Ship the parser' }
}
await expect(performThreadGoalChange(ctx, input)).rejects.toThrow('request timed out')
expect(journal.snapshot().items).toEqual([])
await expect(performThreadGoalChange(ctx, input)).resolves.toEqual({
ok: true,
value: { change: 'set' }
})
expect(journal.snapshot().items.map((item) => item.body)).toEqual([
{
kind: 'message',
role: 'user',
blocks: [{ type: 'text', text: 'Ship the parser' }],
sentAs: 'goal'
}
expect.objectContaining({ sentAs: 'goal' })
])
})
@@ -292,51 +257,18 @@ describe('threadGoalPlan replay', () => {
payloadFingerprint: 'fp'
}
it('answers a lost response from the goal the journal records, and runs again otherwise', async () => {
it('replays only recorded success, never rerunning an unknown operation over a later decision', async () => {
const journal = await openJournal()
const ctx = context(journal, {})
const paused = threadGoalPlan({ envelope, change: { kind: 'status', status: 'paused' } })
const set = threadGoalPlan({ envelope, change: { kind: 'set', objective: 'Ship the parser' } })
const clear = threadGoalPlan({ envelope, change: { kind: 'clear' } })
const unknown = { status: 'unknown' as const }
expect(paused.recoverUnknownFromDurableState).toBe(true)
expect(paused.rerunWhenReplayMissing?.(ctx)).toBe(true)
expect(paused.replay(ctx, unknown)).toBeNull()
expect(set.replay(ctx, unknown)).toBeNull()
expect(clear.replay(ctx, unknown)).toEqual({ change: 'clear' })
const plan = threadGoalPlan({ envelope, change: { kind: 'set', objective: GOAL.objective } })
await appendGoalRow(journal, {})
expect(plan.replay(ctx, { status: 'unknown' })).toBeNull()
await appendGoalRow(journal, { status: 'paused' })
expect(paused.replay(ctx, unknown)).toEqual({ change: 'status' })
expect(set.replay(ctx, unknown)).toBeNull()
expect(clear.replay(ctx, unknown)).toBeNull()
expect(set.replay(ctx, { status: 'succeeded', sessionId: 'session-1' })).toEqual({
expect(plan.replay(ctx, { status: 'unknown' })).toBeNull()
expect(plan.rerunWhenReplayMissing?.(ctx)).not.toBe(true)
expect(plan.recoverUnknownFromDurableState).not.toBe(true)
expect(plan.replay(ctx, { status: 'succeeded', sessionId: 'session-1' })).toEqual({
change: 'set'
})
expect(
set.replay(ctx, { status: 'failed', code: 'agent_session_operation_invalid' })
).toBeNull()
})
it('reads a set as applied only when the recorded goal is that objective, active', () => {
const change = { kind: 'set' as const, objective: 'Ship the parser' }
expect(journalRecordsThreadGoalChange(GOAL, change)).toBe(true)
expect(journalRecordsThreadGoalChange({ ...GOAL, status: 'paused' }, change)).toBe(false)
expect(journalRecordsThreadGoalChange({ ...GOAL, objective: 'Ship it' }, change)).toBe(false)
expect(journalRecordsThreadGoalChange(null, change)).toBe(false)
})
it('reads a status change as applied only when the recorded goal is in that status', () => {
const pause = { kind: 'status' as const, status: 'paused' as const }
expect(journalRecordsThreadGoalChange({ ...GOAL, status: 'paused' }, pause)).toBe(true)
expect(journalRecordsThreadGoalChange(GOAL, pause)).toBe(false)
expect(
journalRecordsThreadGoalChange(
{ ...GOAL, status: 'paused' },
{ kind: 'status', status: 'active' }
)
).toBe(false)
expect(journalRecordsThreadGoalChange(null, pause)).toBe(false)
})
})
@@ -48,21 +48,6 @@ function objectiveIdentity(clientOperationId: string): AgentJournalItemIdentity
return { provider: 'orca', clientMessageId: `thread-goal:${clientOperationId}` }
}
/** Provider-journaled transitions recover operations whose response was lost. */
export function journalRecordsThreadGoalChange(
goal: AgentJournalThreadGoal | null,
change: AgentSessionThreadGoalChange
): boolean {
switch (change.kind) {
case 'clear':
return goal === null
case 'status':
return goal !== null && goal.status === change.status
case 'set':
return goal !== null && goal.status === 'active' && goal.objective === change.objective
}
}
export async function performThreadGoalChange(
ctx: AgentSessionTurnContext,
input: { clientOperationId: string; change: AgentSessionThreadGoalChange; signal?: AbortSignal }
@@ -99,20 +84,20 @@ export async function performThreadGoalChange(
await ctx.journal.appendTombstone(identity, { fence: ctx.fence })
}
}
let result: Awaited<ReturnType<typeof ctx.adapter.changeThreadGoal>>
try {
throwIfSignalAborted(input.signal)
result = await ctx.adapter.changeThreadGoal({
sessionId: ctx.sessionId,
fence: ctx.fence,
change,
replacesGoal,
signal: input.signal
})
} catch (error) {
await withdrawObjective()
throw error
}
// A lost response cannot prove non-application; retain the objective for provider reconciliation.
const result = await ctx.adapter.changeThreadGoal({
sessionId: ctx.sessionId,
fence: ctx.fence,
change,
replacesGoal,
signal: input.signal
})
if (!result.ok) {
await withdrawObjective()
return refused('providerRejected', result.rejected)
@@ -135,15 +120,8 @@ export function threadGoalPlan(params: {
change: params.change,
signal: params.signal
}),
// Recover lost responses from provider-journaled state; otherwise every change is safe to repeat.
recoverUnknownFromDurableState: true,
replay: (ctx, outcome) =>
outcome.status === 'succeeded' ||
(outcome.status === 'unknown' &&
journalRecordsThreadGoalChange(ctx.journal.threadGoal(), params.change))
? value
: null,
rerunWhenReplayMissing: () => true
// Current state cannot identify an old operation after a later user action changed the goal.
replay: (_ctx, outcome) => (outcome.status === 'succeeded' ? value : null)
}
}
@@ -30,7 +30,8 @@ function goal(overrides: Partial<AgentJournalThreadGoal> = {}): AgentJournalThre
function renderBanner(
value: AgentJournalThreadGoal,
pending = false,
runningTurn: { startedAt: number | null } | null = { startedAt: null }
runningTurn: { startedAt: number | null } | null = { startedAt: null },
startsHeld = false
) {
const onChange = vi.fn()
const view = render(
@@ -39,6 +40,7 @@ function renderBanner(
goal={value}
pending={pending}
hostChanges
startsHeld={startsHeld}
sendUnavailable={false}
isVisible
runningTurn={runningTurn}
@@ -100,15 +102,27 @@ describe('NativeChatThreadGoalBanner', () => {
expect(screen.queryByRole('button', { name: 'Resume goal' })).toBeNull()
})
it.each(['paused', 'blocked'] as const)(
'admits pending %s Resume but honors a permission hold',
(status) => {
const { onChange, view } = renderBanner(goal({ status }), true)
fireEvent.click(screen.getByRole('button', { name: 'Resume goal' }))
expect(onChange).toHaveBeenCalledWith({ kind: 'status', status: 'active' })
view.unmount()
renderBanner(goal({ status }), true, null, true)
expect(screen.getByRole('button', { name: 'Resume goal' })).toBeDisabled()
}
)
it('renders nothing for a completed goal', () => {
const { view } = renderBanner(goal({ status: 'complete' }))
expect(view.container).toBeEmptyDOMElement()
})
it('disables the goal commands while one is in flight, but not the expand toggle', () => {
it('keeps Pause and Clear available while another goal change is in flight', () => {
renderBanner(goal(), true)
expect(screen.getByRole('button', { name: 'Clear goal' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Pause goal' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Clear goal' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Pause goal' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: 'Show full goal' }))
expect(screen.getByRole('button', { name: 'Hide full goal' })).toBeEnabled()
@@ -57,7 +57,7 @@ export function NativeChatThreadGoalBanner(props: {
runningTurn: { startedAt: number | null } | null
onChange: (change: AgentSessionThreadGoalChange) => void
}): React.JSX.Element | null {
const { goal, pending, onChange } = props
const { goal, onChange } = props
const [expanded, setExpanded] = useState(false)
const { runningTurn } = props
const now = useNow(1_000, props.isVisible && goal.status === 'active' && runningTurn !== null)
@@ -91,10 +91,7 @@ export function NativeChatThreadGoalBanner(props: {
<GoalAction
label={translate('components.native-chat.goal.clear', 'Clear goal')}
// Claude's `/goal clear` must enter a fresh turn, like a composer send.
disabled={
pending ||
(!props.hostChanges && (props.sendUnavailable || props.startsHeld === true))
}
disabled={!props.hostChanges && (props.sendUnavailable || props.startsHeld === true)}
onClick={() => onChange({ kind: 'clear' })}
>
<Trash2 className="size-3.5" />
@@ -102,7 +99,7 @@ export function NativeChatThreadGoalBanner(props: {
{statusChange === 'paused' ? (
<GoalAction
label={translate('components.native-chat.goal.pause', 'Pause goal')}
disabled={pending}
disabled={false}
onClick={() => onChange({ kind: 'status', status: 'paused' })}
>
<Pause className="size-3.5" />
@@ -110,7 +107,7 @@ export function NativeChatThreadGoalBanner(props: {
) : statusChange === 'active' ? (
<GoalAction
label={translate('components.native-chat.goal.resume', 'Resume goal')}
disabled={pending || props.startsHeld === true}
disabled={props.startsHeld === true}
onClick={() => onChange({ kind: 'status', status: 'active' })}
>
<Play className="size-3.5" />
@@ -12,7 +12,10 @@ import {
type AgentSessionWriteNoticePart,
type AgentSessionWriteNoticeSentence
} from '../../../../shared/agent-session-write-notice-copy'
import { agentSessionWriteNoticeText } from './agent-session-write-notice-text'
import {
agentSessionWriteFailureText,
agentSessionWriteNoticeText
} from './agent-session-write-notice-text'
const SENTENCES = Object.keys(AGENT_SESSION_WRITE_NOTICE_COPY).filter(
(key): key is AgentSessionWriteNoticeSentence => key in AGENT_SESSION_WRITE_NOTICE_COPY
@@ -36,6 +39,22 @@ describe('desktop words for a write that did not happen', () => {
expect(en.components['native-chat'].writeNotice).toEqual(AGENT_SESSION_WRITE_NOTICE_COPY)
})
it('does not promise the old goal survived a refused replacement, including older translations', async () => {
for (const language of ['en', 'fr']) {
await i18n.changeLanguage(language)
expect(
agentSessionWriteFailureText(
{
kind: 'refused',
code: 'agent_session_operation_invalid',
details: { reason: 'providerRejected' }
},
'goal'
)
).toContain("Couldn't finish changing the goal. Check the chat.")
}
})
it('translates each sentence whole and shows a provider reason as written', async () => {
await i18n.changeLanguage('fr')
expect(agentSessionWriteNoticeText(['restartFailed', 'notDoneSend'])).toBe(
@@ -37,7 +37,11 @@ const SENTENCES: Record<AgentSessionWriteNoticeSentence, () => string> = {
translate('components.native-chat.writeNotice.notDoneOption', COPY.notDoneOption),
notDoneCommand: () =>
translate('components.native-chat.writeNotice.notDoneCommand', COPY.notDoneCommand),
notDoneGoal: () => translate('components.native-chat.writeNotice.notDoneGoal', COPY.notDoneGoal),
notDoneGoalChangeIncomplete: () =>
translate(
'components.native-chat.writeNotice.notDoneGoalChangeIncomplete',
COPY.notDoneGoalChangeIncomplete
),
notDoneQueuedEdit: () =>
translate('components.native-chat.writeNotice.notDoneQueuedEdit', COPY.notDoneQueuedEdit),
restartFailed: () =>
@@ -104,28 +104,32 @@ describe('useStructuredAgentSessionThreadGoal', () => {
expect(cleared.result.current?.goal).toBeNull()
})
it('serializes changes: a second submit while one is unsettled is answered false, not sent', async () => {
let settle: (value: { change: 'set' } | null) => void = () => undefined
const { mutate, calls } = mutateWith(
() => new Promise<{ change: 'set' } | null>((resolve) => (settle = resolve))
)
const { result } = harness({ support: { current: null }, mutate })
let first: Promise<boolean> = Promise.resolve(false)
let second: Promise<boolean> = Promise.resolve(false)
it('admits Pause and Clear while Set is unsettled, keeping pending until all settle', async () => {
const set = Promise.withResolvers<unknown>()
const pause = Promise.withResolvers<unknown>()
const clear = Promise.withResolvers<unknown>()
const answers = [set, pause, clear]
const { mutate, calls } = mutateWith(() => answers.shift()!.promise)
const { result } = harness({ support: { current: GOAL }, mutate })
let changes: Promise<boolean>[] = []
act(() => {
first = result.current!.change({ kind: 'set', objective: 'Ship the parser' })
second = result.current!.change({ kind: 'set', objective: 'Ship the parser' })
changes = [
result.current!.change({ kind: 'set', objective: 'New objective' }),
result.current!.change({ kind: 'status', status: 'paused' }),
result.current!.change({ kind: 'clear' })
]
})
await expect(second).resolves.toBe(false)
expect(calls).toHaveBeenCalledTimes(1)
expect(calls).toHaveBeenCalledWith('agentSession.threadGoal', 'agentSession.threadGoal', {
change: { kind: 'set', objective: 'Ship the parser' }
expect(calls).toHaveBeenCalledTimes(3)
expect(result.current?.pending).toBe(true)
await act(async () => {
clear.resolve({ change: 'clear' })
pause.resolve({ change: 'status' })
})
expect(result.current?.pending).toBe(true)
await act(async () => settle({ change: 'set' }))
await expect(first).resolves.toBe(true)
await act(async () => {
set.resolve({ change: 'set' })
await expect(Promise.all(changes)).resolves.toEqual([true, true, true])
})
expect(result.current?.pending).toBe(false)
})
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import type {
AgentJournalRenderItem,
AgentJournalThreadGoal
@@ -33,8 +33,7 @@ export function useStructuredAgentSessionThreadGoal(args: {
send: (text: string) => boolean | 'queued'
}): StructuredAgentSessionThreadGoal | null {
const { journalItems, mutate, support, reported, journalEpoch, send } = args
const [pending, setPending] = useState(false)
const pendingRef = useRef(false)
const [pendingCount, setPendingCount] = useState(0)
const report = support ?? reported
// Loaded rows reach the live head and outrank older whole-journal goal evidence.
const floor =
@@ -52,11 +51,7 @@ export function useStructuredAgentSessionThreadGoal(args: {
if (!hostChanges) {
return next.kind === 'clear' && send(CLEAR_GOAL_COMMAND) !== false
}
if (pendingRef.current) {
return false
}
pendingRef.current = true
setPending(true)
setPendingCount((count) => count + 1)
try {
const result = await mutate<AgentSessionThreadGoalResult>(
'agentSession.threadGoal',
@@ -65,8 +60,7 @@ export function useStructuredAgentSessionThreadGoal(args: {
)
return result !== null
} finally {
pendingRef.current = false
setPending(false)
setPendingCount((count) => count - 1)
}
},
[hostChanges, mutate, send]
@@ -74,5 +68,5 @@ export function useStructuredAgentSessionThreadGoal(args: {
if (report === undefined || (!hostChanges && goal === null)) {
return null
}
return { goal, pending, hostChanges, change }
return { goal, pending: pendingCount > 0, hostChanges, change }
}
+1 -1
View File
@@ -3314,7 +3314,7 @@
"notAvailable": "This isn't available in this chat.",
"notDoneAnswer": "Your answer was not sent.",
"notDoneCommand": "The command didn't run.",
"notDoneGoal": "The goal wasn't changed.",
"notDoneGoalChangeIncomplete": "Couldn't finish changing the goal. Check the chat.",
"notDoneOption": "The setting wasn't changed.",
"notDoneQueuedEdit": "Your edit wasn't saved.",
"notDoneReadHistory": "This chat's history couldn't be loaded.",
+1 -1
View File
@@ -19059,7 +19059,7 @@
"notDoneAnswer": "Your answer was not sent.",
"notDoneOption": "The setting wasn't changed.",
"notDoneCommand": "The command didn't run.",
"notDoneGoal": "The goal wasn't changed.",
"notDoneGoalChangeIncomplete": "Couldn't finish changing the goal. Check the chat.",
"notDoneQueuedEdit": "Your edit wasn't saved.",
"restartFailed": "The agent couldn't restart.",
"capacity": "Orca on the computer running this chat has hit a request limit. Update Orca there, then try again.",
-1
View File
@@ -18728,7 +18728,6 @@
"notDoneAnswer": "Su respuesta no se envió.",
"notDoneOption": "La configuración no se cambió.",
"notDoneCommand": "El comando no se ejecutó.",
"notDoneGoal": "El objetivo no se cambió.",
"notDoneQueuedEdit": "Tu edición no se guardó.",
"restartFailed": "El agente no pudo reiniciarse.",
"capacity": "Orca en el equipo que ejecuta este chat alcanzó un límite de solicitudes. Actualice Orca en ese equipo y vuelva a intentarlo.",
-1
View File
@@ -18868,7 +18868,6 @@
"notDoneAnswer": "Votre réponse n'a pas été envoyée.",
"notDoneOption": "Le paramètre n'a pas été modifié.",
"notDoneCommand": "La commande n'a pas été exécutée.",
"notDoneGoal": "L'objectif n'a pas été modifié.",
"notDoneQueuedEdit": "Votre modification n'a pas été enregistrée.",
"restartFailed": "L'agent n'a pas pu redémarrer.",
"capacity": "Orca sur l'ordinateur qui exécute ce chat a atteint une limite de requêtes. Mettez à jour Orca sur cet ordinateur, puis réessayez.",
-1
View File
@@ -18758,7 +18758,6 @@
"notDoneAnswer": "回答は送信されませんでした。",
"notDoneOption": "設定は変更されませんでした。",
"notDoneCommand": "コマンドは実行されませんでした。",
"notDoneGoal": "目標は変更されませんでした。",
"notDoneQueuedEdit": "編集内容は保存されていません。",
"restartFailed": "Agent を再起動できませんでした。",
"capacity": "このチャットを実行しているコンピューターの Orca がリクエストの上限に達しました。そのコンピューターで Orca を更新してから、もう一度お試しください。",
-1
View File
@@ -18758,7 +18758,6 @@
"notDoneAnswer": "답변이 전송되지 않았습니다.",
"notDoneOption": "설정이 변경되지 않았습니다.",
"notDoneCommand": "명령이 실행되지 않았습니다.",
"notDoneGoal": "목표가 변경되지 않았습니다.",
"notDoneQueuedEdit": "편집한 내용은 저장되지 않았습니다.",
"restartFailed": "에이전트를 다시 시작하지 못했습니다.",
"capacity": "이 채팅을 실행하는 컴퓨터의 Orca가 요청 한도에 도달했습니다. 해당 컴퓨터에서 Orca를 업데이트한 다음 다시 시도하세요.",
-1
View File
@@ -19703,7 +19703,6 @@
"notDoneAnswer": "Sua resposta não foi enviada.",
"notDoneOption": "A configuração não foi alterada.",
"notDoneCommand": "O comando não foi executado.",
"notDoneGoal": "O objetivo não foi alterado.",
"notDoneQueuedEdit": "Sua edição não foi salva.",
"restartFailed": "Não foi possível reiniciar o agente.",
"capacity": "O Orca no computador que executa este chat atingiu um limite de requisições. Atualize o Orca lá e tente novamente.",
-1
View File
@@ -18723,7 +18723,6 @@
"notDoneAnswer": "您的回答未发送。",
"notDoneOption": "设置未更改。",
"notDoneCommand": "命令未运行。",
"notDoneGoal": "目标未更改。",
"notDoneQueuedEdit": "你的编辑未保存。",
"restartFailed": "智能体无法重新启动。",
"capacity": "运行此聊天的计算机上的 Orca 已达到请求上限。请在那台计算机上更新 Orca,然后重试。",
@@ -94,7 +94,7 @@ const NOT_DONE: Record<AgentSessionWriteKind, AgentSessionWriteNoticeSentence> =
command: 'notDoneCommand',
clear: 'notDoneCommand',
compact: 'notDoneCommand',
goal: 'notDoneGoal',
goal: 'notDoneGoalChangeIncomplete',
'queued-edit': 'notDoneQueuedEdit'
}
+1 -1
View File
@@ -41,7 +41,7 @@ const NOT_DONE: Record<AgentSessionWriteKind, AgentSessionWriteNoticeSentence> =
'queued-edit': 'notDoneQueuedEdit',
clear: 'notDoneCommand',
compact: 'notDoneCommand',
goal: 'notDoneGoal'
goal: 'notDoneGoalChangeIncomplete'
}
/** Share actionable wording for /clear and /compact blocked by running work. */
@@ -28,7 +28,7 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = {
notDoneAnswer: 'Your answer was not sent.',
notDoneOption: "The setting wasn't changed.",
notDoneCommand: "The command didn't run.",
notDoneGoal: "The goal wasn't changed.",
notDoneGoalChangeIncomplete: "Couldn't finish changing the goal. Check the chat.",
notDoneQueuedEdit: "Your edit wasn't saved.",
restartFailed: "The agent couldn't restart.",
capacity: