diff --git a/src/main/codex/__fixtures__/codex-app-server-goal-lifecycle.jsonl b/src/main/codex/__fixtures__/codex-app-server-goal-lifecycle.jsonl new file mode 100644 index 00000000000..7d2ceb61acb --- /dev/null +++ b/src/main/codex/__fixtures__/codex-app-server-goal-lifecycle.jsonl @@ -0,0 +1,6 @@ +{"receivedAt":"2026-10-24T06:35:28.038Z","direction":"response","frame":{"method":"thread/goal/updated","params":{"threadId":"","turnId":null,"goal":{"threadId":"","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":"","turnId":null,"goal":{"threadId":"","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":"","turnId":null,"goal":{"threadId":"","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":""},"emittedAtMs":1792823753377}} +{"receivedAt":"2026-10-24T06:35:53.382Z","direction":"response","frame":{"method":"thread/goal/updated","params":{"threadId":"","turnId":null,"goal":{"threadId":"","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":""},"emittedAtMs":1792823753760}} diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index afe6c333c9e..771935cd76d 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -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 = '' + 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[] = [] + 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 }) diff --git a/src/main/codex/codex-structured-thread-goal.test.ts b/src/main/codex/codex-structured-thread-goal.test.ts index 9397c08839f..08c7ee54d34 100644 --- a/src/main/codex/codex-structured-thread-goal.test.ts +++ b/src/main/codex/codex-structured-thread-goal.test.ts @@ -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') { diff --git a/src/main/codex/codex-structured-thread-goal.ts b/src/main/codex/codex-structured-thread-goal.ts index a21f2df8444..4d51e50b989 100644 --- a/src/main/codex/codex-structured-thread-goal.ts +++ b/src/main/codex/codex-structured-thread-goal.ts @@ -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 } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts index de4a01b4648..8459c3f68a6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-ready-operation-order.test.ts @@ -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) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 5fa234fdb94..7dec3590d1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -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) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 88e3ad74ce7..82183d5adaa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -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> 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) } } diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx index 7881d74c5f6..c64d0d15103 100644 --- a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.test.tsx @@ -30,7 +30,8 @@ function goal(overrides: Partial = {}): 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() diff --git a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx index 3e94ac4f368..2d0f7645f2d 100644 --- a/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx +++ b/src/renderer/src/components/native-chat/NativeChatThreadGoalBanner.tsx @@ -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: { onChange({ kind: 'clear' })} > @@ -102,7 +99,7 @@ export function NativeChatThreadGoalBanner(props: { {statusChange === 'paused' ? ( onChange({ kind: 'status', status: 'paused' })} > @@ -110,7 +107,7 @@ export function NativeChatThreadGoalBanner(props: { ) : statusChange === 'active' ? ( onChange({ kind: 'status', status: 'active' })} > diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts index e5e2c9f7a33..602e5b7b578 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.test.ts @@ -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( diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index c30ba8fa589..006b81e4d35 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -37,7 +37,11 @@ const SENTENCES: Record 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: () => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx index 10068b3c50c..35e9611a9f7 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.test.tsx @@ -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 = Promise.resolve(false) - let second: Promise = Promise.resolve(false) + it('admits Pause and Clear while Set is unsettled, keeping pending until all settle', async () => { + const set = Promise.withResolvers() + const pause = Promise.withResolvers() + const clear = Promise.withResolvers() + const answers = [set, pause, clear] + const { mutate, calls } = mutateWith(() => answers.shift()!.promise) + const { result } = harness({ support: { current: GOAL }, mutate }) + let changes: Promise[] = [] 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) }) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts index 9def6ab3be4..e1d992c0a7c 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-thread-goal.ts @@ -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( '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 } } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 89bcb6acf4d..f3366f42b17 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 18109ec6578..bc16640bd1f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8839ab9b575..7a41bbdfe25 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 9a7ff7d191b..f3b4a70f3b9 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4d907dc014c..d2524c04625 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18758,7 +18758,6 @@ "notDoneAnswer": "回答は送信されませんでした。", "notDoneOption": "設定は変更されませんでした。", "notDoneCommand": "コマンドは実行されませんでした。", - "notDoneGoal": "目標は変更されませんでした。", "notDoneQueuedEdit": "編集内容は保存されていません。", "restartFailed": "Agent を再起動できませんでした。", "capacity": "このチャットを実行しているコンピューターの Orca がリクエストの上限に達しました。そのコンピューターで Orca を更新してから、もう一度お試しください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 05423f9d0b0..3caeb07a979 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18758,7 +18758,6 @@ "notDoneAnswer": "답변이 전송되지 않았습니다.", "notDoneOption": "설정이 변경되지 않았습니다.", "notDoneCommand": "명령이 실행되지 않았습니다.", - "notDoneGoal": "목표가 변경되지 않았습니다.", "notDoneQueuedEdit": "편집한 내용은 저장되지 않았습니다.", "restartFailed": "에이전트를 다시 시작하지 못했습니다.", "capacity": "이 채팅을 실행하는 컴퓨터의 Orca가 요청 한도에 도달했습니다. 해당 컴퓨터에서 Orca를 업데이트한 다음 다시 시도하세요.", diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index fd27fe41c4c..51ff735797f 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -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.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d17c30f447e..e74636f3062 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18723,7 +18723,6 @@ "notDoneAnswer": "您的回答未发送。", "notDoneOption": "设置未更改。", "notDoneCommand": "命令未运行。", - "notDoneGoal": "目标未更改。", "notDoneQueuedEdit": "你的编辑未保存。", "restartFailed": "智能体无法重新启动。", "capacity": "运行此聊天的计算机上的 Orca 已达到请求上限。请在那台计算机上更新 Orca,然后重试。", diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 85f4a2c1f39..de742ea4b6d 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -94,7 +94,7 @@ const NOT_DONE: Record = command: 'notDoneCommand', clear: 'notDoneCommand', compact: 'notDoneCommand', - goal: 'notDoneGoal', + goal: 'notDoneGoalChangeIncomplete', 'queued-edit': 'notDoneQueuedEdit' } diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 0fbc6bff18e..ea20d4f2d96 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -41,7 +41,7 @@ const NOT_DONE: Record = 'queued-edit': 'notDoneQueuedEdit', clear: 'notDoneCommand', compact: 'notDoneCommand', - goal: 'notDoneGoal' + goal: 'notDoneGoalChangeIncomplete' } /** Share actionable wording for /clear and /compact blocked by running work. */ diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index ef2f7145372..4ec1d82bc19 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -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: