diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts new file mode 100644 index 000000000000..85247148f863 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts @@ -0,0 +1,263 @@ +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +// A Codex rewind left in doubt while its agent keeps running: no attach comes to settle it, so the +// next send asks the provider as an attach would, and a send refused meanwhile never poisons its id. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +const caller = { callerKey: 'desktop' } +const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } + +let directory: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let sink: StructuredAgentSessionEventSink +let acquires = 0 +const rewind = vi.fn>() +const recoverRewind = vi.fn>() +const rewindSupport = vi.fn>() +const dispatch = vi.fn() + +function adapter(): StructuredAgentSessionAdapter { + return { + supportsCreate: (_location, agent) => agent === 'codex', + supportsLocation: () => true, + acquire: async (input) => { + acquires += 1 + sink = input.events! + return { + process: { + hostId: 'local', + pid: 4000 + acquires, + processStartTimeMs: HOST_TEST_NOW, + spawnToken: input.spawnToken + }, + acquisitionGeneration: `generation-${acquires}`, + link: { + linkId: `link-${acquires}`, + mintedAtFence: input.fence, + observedAt: HOST_TEST_NOW, + origin: acquires === 1 ? 'created' : 'resumed', + handle: codexProviderHandle(THREAD) + } + } + }, + dispatch, + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => {}, + setOption: async () => {}, + rewindSupport, + rewind, + recoverRewind, + releaseAcquisition: async () => true, + closeSession: async () => true + } +} + +function openHost(): StructuredAgentSessionHost { + return new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + agents: claudeAndCodexDeclared(), + journalDatabase: openTestJournalHostDatabase(directory), + claimKeyId: 'key', + now: () => HOST_TEST_NOW, + probeOwner: async () => ({ outcome: 'exit-observed' }), + idleSweep: { intervalMs: 3_600_000 } + }) +} + +beforeEach(async () => { + resetHostTestOperationIds() + acquires = 0 + rewind.mockReset() + recoverRewind.mockReset().mockResolvedValue({ + ok: true, + items: [{ identity: KEPT, body: hostTestMessage('verified history') }] + }) + rewindSupport.mockReset().mockReturnValue({ supported: true }) + dispatch.mockReset().mockImplementation(async (input): Promise => ({ + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: THREAD, + turnId: input.clientMessageId, + ordinal: 1 + } + })) + directory = await mkdtemp(join(tmpdir(), 'orca-rewind-live-')) + store = await openTestAgentSessionRecordStore(directory) + host = openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(directory, { recursive: true, force: true }) +}) + +function fence(): number { + return store.getRecord(SESSION)!.lease.runtimeFence +} + +function rewindParams(itemId: string, expectedEpoch: string) { + return { + itemId, + expectedEpoch, + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: SESSION, + fields: { itemId, expectedEpoch } + }) + } + } +} + +function sendParams(text: string, clientOperationId = hostTestOperationId()) { + const body = hostTestMessage(text) + return { + body, + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + } + } +} + +/** A rewind whose provider call threw while the agent stays running. */ +async function rewindInDoubtWithLiveChild(): Promise { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const drop = { ...KEPT, turnId: 'drop' } + sink.appendItem(KEPT, hostTestMessage('verified history'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(drop, hostTestMessage('to be rewound'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + await host.flushStreamedEvents(SESSION) + rewind.mockRejectedValue(new Error('provider reply lost')) + const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch + await expect(host.rewind(caller, rewindParams(agentJournalItemKey(drop), epoch))).rejects.toThrow( + 'provider reply lost' + ) + expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) + expect(host.hasSession(SESSION)).toBe(true) +} + +function sentTexts(items: readonly AgentJournalRenderItem[]): string[] { + return items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) +} + +describe('a rewind in doubt while its agent keeps running', () => { + it('is settled by the next send, which is then delivered', async () => { + await rewindInDoubtWithLiveChild() + const before = acquires + + expect(await host.send(caller, sendParams('after the rewind'))).toMatchObject({ ok: true }) + expect(acquires).toBe(before) + expect(recoverRewind).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + expect(sentTexts((await host.journalSnapshot(SESSION)).items)).toEqual([ + 'verified history', + 'after the rewind' + ]) + }) + + it('lets the send through when the provider proves the rewind never happened', async () => { + await rewindInDoubtWithLiveChild() + recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) + + expect(await host.send(caller, sendParams('after the refused rewind'))).toMatchObject({ + ok: true + }) + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('refused') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + }) + + it('refuses the send while recovery stays unknown, and decides its Retry afresh once settled', async () => { + await rewindInDoubtWithLiveChild() + recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'outcome-unknown' }) + const id = hostTestOperationId() + + expect(await host.send(caller, sendParams('typed during the doubt', id))).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_unknown', + details: { reason: 'rewindUnconfirmed', rewindReason: 'outcome-unknown' } + } + }) + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') + expect(dispatch).not.toHaveBeenCalled() + + // The Retry keeps the id; the provider now answers, so the same id is sent. + expect(await host.send(caller, sendParams('typed during the doubt', id))).toMatchObject({ + ok: true, + replayed: false + }) + expect(store.getRecord(SESSION)?.rewind?.phase).toBe('completed') + await vi.waitFor(() => expect(dispatch).toHaveBeenCalledOnce()) + }) + + it('leaves /clear its own settled refusal: nothing ran, and it mints a fresh id per attempt', async () => { + await rewindInDoubtWithLiveChild() + recoverRewind.mockResolvedValue({ ok: false, reason: 'outcome-unknown' }) + const result = await host.conversationCommand(caller, { + command: 'clear', + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId: SESSION, + fields: { command: 'clear' } + }) + } + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 420a93b58320..1d2cfeba5898 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -37,6 +37,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support' const caller = { callerKey: 'desktop' } +let clock = HOST_TEST_NOW let directory: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -47,6 +48,7 @@ const rewind = vi.fn>() const recoverRewind = vi.fn>() beforeEach(async () => { + clock = HOST_TEST_NOW resetHostTestOperationIds() rewind.mockReset().mockResolvedValue({ ok: true }) recoverRewind.mockReset().mockResolvedValue({ @@ -104,7 +106,7 @@ beforeEach(async () => { adapter, journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', - now: () => HOST_TEST_NOW, + now: () => clock, probeOwner: async () => ({ outcome: 'exit-observed' }) }) }) @@ -269,6 +271,20 @@ describe('host rewind', () => { expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) }) + it('keeps when each kept message was first seen, not when the rewind re-read it', async () => { + const target = await seed() + const kept = (await host.journalSnapshot(HOST_TEST_SESSION)).items[0]! + const identity = { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId: 'kept' } + rewind.mockResolvedValueOnce({ + ok: true, + items: [{ identity: { ...identity, ordinal: 0 }, body: kept.body }] + }) + clock = HOST_TEST_NOW + 60_000 + expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) + const [after] = (await host.journalSnapshot(HOST_TEST_SESSION)).items + expect(after).toMatchObject({ itemId: kept.itemId, observedAt: kept.observedAt }) + expect(after!.observedAt).not.toBe(clock) + }) // The stream's failure is the stream's to recover from; its stale error is not the rewind's. it("rewinds after the chat's event sink failed, its error never failing the rewind", async () => { const target = await seed() @@ -314,7 +330,7 @@ describe('host rewind', () => { }) expect(rewind).not.toHaveBeenCalled() }) - it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => { + it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind while it stays unknown', async () => { const target = await seed() const request = await params(target) const before = await host.journalSnapshot(HOST_TEST_SESSION) @@ -325,6 +341,8 @@ describe('host rewind', () => { ok: false, refusal: { code: 'agent_session_operation_unknown' } }) + // The send asks the provider first; it still cannot say. + recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'outcome-unknown' }) const body = hostTestMessage('new prompt') const envelope = { ...(await params(target)).envelope, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 65bcd4c78399..181971ea42e5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -25,16 +25,22 @@ import { } from './structured-agent-session-mutation-admission' import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import { rewindRefusal } from './structured-rewind-refusal' +import { recoverStructuredRewind } from './structured-rewind-recovery' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { conversationCommandInFlight } from './structured-conversation-command-admission' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +function rewindInDoubt(record: AgentSessionRecord | null | undefined): boolean { + const phase = record?.rewind?.phase + return phase === 'prepared' || phase === 'provider-succeeded' +} + /** Why the record refuses any send right now, whoever owns it; null when a send may run. */ export function structuredAgentSessionSendBlock( record: AgentSessionRecord | null ): { ok: false; refusal: AgentSessionWireRefusal } | null { - const rewind = record?.rewind - if (rewind?.phase === 'prepared' || rewind?.phase === 'provider-succeeded') { + if (rewindInDoubt(record)) { return rewindRefusal('outcome-unknown') } const command = record?.conversationCommand @@ -135,15 +141,52 @@ export function openWithAgent( } } +/** The recovery an attach runs, for a child already running: no attach comes for it. A recovery + * that stays unknown leaves the record as it was. */ +async function recoverRewindOnLiveChild( + context: Pick, + sessionId: string +): Promise { + const journal = context.sessions.get(sessionId)?.journal + if (!journal) { + return + } + try { + await recoverStructuredRewind( + context.deps, + sessionId, + journal, + structuredAgentSessionConversationFence(context.deps.store, sessionId), + context.deps.adapter, + context.now + ) + } catch (error) { + context.deps.logger.warn('settling a rewind in doubt before a send failed', { + scope: 'rewind-recovery', + sessionId, + error + }) + return + } + context.publish(sessionId, journal) +} + /** A rewind still in doubt once the conversation is open is one only its provider can settle — - * the open settles every other — so a send starts the agent, whose attach recovers it. A resend - * of a recorded id needs only the conversation, its answer's source: it starts nothing, and an - * open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was running - * when this send arrived, which refuses only its first run. */ + * the open settles every other — so a send settles it first: an agent at rest is started, whose + * attach recovers it, and a running one is asked as that attach would. One still in doubt refuses + * the send here, before the ledger records it, so a Retry of the same id is decided afresh. A + * resend of a recorded id needs only the conversation, its answer's source: it starts nothing, + * and an open that fails leaves that answer unknown, never refused. `clearInFlight`: a /clear was + * running when this send arrived, which refuses only its first run. `refusesInRun`: the caller's + * own run refuses a rewind in doubt with a settled answer and mints a fresh id per attempt (/clear), + * so preparation leaves that refusal to it. */ export function sendPreparation( - context: Pick, + context: Pick< + StructuredAgentSessionMutationContext, + 'openConversation' | 'ensureAgent' | 'deps' | 'sessions' | 'publish' | 'now' + >, envelope: AgentSessionMutationEnvelope, - arrival: { clearInFlight?: boolean } = {} + arrival: { clearInFlight?: boolean; refusesInRun?: boolean } = {} ): (ledger: 'admit' | 'replay') => Promise { return async (ledger) => { if (ledger === 'admit' && arrival.clearInFlight) { @@ -159,10 +202,21 @@ export function sendPreparation( ? opened : { ok: false, refusal: agentSessionOperationOutcomeUnknown(envelope.clientOperationId) } } - const phase = context.deps.store.getRecord(envelope.sessionId)?.rewind?.phase - return opened.ok && (phase === 'prepared' || phase === 'provider-succeeded') - ? context.ensureAgent(envelope.sessionId) - : opened + const { sessionId } = envelope + if (!opened.ok || !rewindInDoubt(context.deps.store.getRecord(sessionId))) { + return opened + } + const running = Boolean(context.sessions.get(sessionId)?.child) + const ensured = await context.ensureAgent(sessionId) + if (!ensured.ok) { + return ensured + } + if (running) { + await recoverRewindOnLiveChild(context, sessionId) + } + return !arrival.refusesInRun && rewindInDoubt(context.deps.store.getRecord(sessionId)) + ? rewindRefusal('outcome-unknown') + : ensured } } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index bc43c67d2c4a..764b569d8ff5 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -111,7 +111,7 @@ export function runStructuredConversationCommand( callerKey: caller.callerKey, envelope, // Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing. - prepareSession: sendPreparation(context, envelope), + prepareSession: sendPreparation(context, envelope, { refusesInRun: true }), journal: () => context.sessions.get(sessionId)?.journal, publish: (journal) => context.publish(sessionId, journal), now: context.now, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts index 43a2beee24c1..3fb9495501e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -65,7 +65,8 @@ describe('rewind keeps each retained row attributed', () => { agentId: 'child-thread', parentAgentId: 'root', producerKind: 'agent', - observedAt: 2 + // When the row was first seen, not when the rewind re-read it. + observedAt: 1 }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index f3e725787097..b1cbbe22b74a 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,8 +1,8 @@ // Provider preflight returns provider items only. The host's lifecycle rows are its own record, so // a rewind that takes the provider list as the new epoch must splice those rows back beside the // provider item each one followed; so is an item of a kind a newer Orca wrote, which no provider -// holds. Provider items carry neither turn scope nor producer, so each keeps the ones its retained -// row held. +// holds. Provider items carry neither turn scope, producer nor the time the row was first seen, so +// each keeps the ones its retained row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -92,11 +92,13 @@ export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLi } } +/** A provider item the old epoch held keeps that row's scope, producer and first-seen time; only + * its body is the provider's. */ function withHeldAttribution(item: RetainedRow, held: RetainedRow | undefined): RetainedRow { if (!held) { return item } - const { itemId: _itemId, body: _body, observedAt: _observedAt, ...attribution } = held + const { itemId: _itemId, body: _body, ...attribution } = held return { ...item, ...attribution } } diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx new file mode 100644 index 000000000000..c25d4c294fd0 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.rewind-rows.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission, + AgentJournalTurnScope +} from '../../../../shared/agent-session-journal-types' +import { projectStructuredAgentSessionMessages } from '../../../../shared/structured-agent-session-message-projection' +import { TooltipProvider } from '@/components/ui/tooltip' +import { NativeChatRewindContext } from './native-chat-rewind-context' +import { NativeChatMessageList } from './NativeChatMessageList' +import { installNativeChatMessageListTestViewport } from './native-chat-message-list-test-viewport' + +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => vi.fn() +})) + +let restoreViewport = (): void => {} +beforeAll(() => { + restoreViewport = installNativeChatMessageListTestViewport() +}) +afterAll(() => restoreViewport()) +afterEach(cleanup) + +const THREAD: AgentJournalTurnScope = { kind: 'thread' } +const inTurn = (turnItemId: string): AgentJournalTurnScope => ({ kind: 'turn', turnItemId }) +let sequence = 0 +function item( + itemId: string, + body: AgentJournalItemBody, + turnScope: AgentJournalTurnScope = THREAD +): AgentJournalRenderItem { + sequence += 1 + return { itemId, revision: 0, sequence, observedAt: sequence, body, turnScope } +} +const say = ( + itemId: string, + role: 'user' | 'assistant', + text: string, + scope: AgentJournalTurnScope = THREAD, + extra: { command?: { name: string } } = {} +) => item(itemId, { kind: 'message', role, blocks: [{ type: 'text', text }], ...extra }, scope) +const settledTurn = (itemId: string, userItemId: string, start: number) => + item(itemId, { + kind: 'turn', + turnId: itemId, + state: 'completed', + outcome: 'success', + userItemId, + startedAt: start, + completedAt: start + 1_000 + }) + +const REWIND = { disabledReason: null, request: vi.fn() } + +describe('which transcript rows offer Rewind to here', () => { + it('offers it only on prompts that opened their own turn', () => { + const steer = agentJournalSubmissionKey('steer') + const compact = agentJournalItemKey({ provider: 'orca', clientMessageId: 'cmd-1' }) + const items = [ + say('u1', 'user', 'List three fruits'), + settledTurn('t1', 'u1', 1_000), + say(steer, 'user', 'Make it four', inTurn('t1')), + say('t1-answer', 'assistant', 'Apple, banana, cherry, date.', inTurn('t1')), + say(compact, 'user', '/compact', THREAD, { command: { name: 'compact' } }), + settledTurn('t2', compact, 5_000), + say('u3', 'user', 'Now vegetables'), + settledTurn('t3', 'u3', 9_000), + say('t3-answer', 'assistant', 'Carrot.', inTurn('t3')) + ] + const submissions: AgentJournalSubmission[] = [ + { + clientMessageId: 'steer', + fence: 1, + payloadFingerprint: 'steer', + dispatchState: 'accepted', + providerItemId: null, + reason: null, + submittedAt: 2_000, + resolvedAt: 2_100 + } + ] + render( + + + + + + ) + expect(screen.getByText('Make it four')).toBeInTheDocument() + expect(screen.getByText('/compact')).toBeInTheDocument() + const offered = screen.getAllByRole('button', { name: 'Rewind to here' }) + const after = (later: Element, earlier: Element) => + Boolean(earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING) + // The opener's and the last prompt's; the steer and the /compact row get none. + expect(offered).toHaveLength(2) + expect(after(offered[0]!, screen.getByText('List three fruits'))).toBe(true) + expect(after(screen.getByText('Make it four'), offered[0]!)).toBe(true) + expect(after(offered[1]!, screen.getByText('Now vegetables'))).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index f309593d898f..01c5b890b9c0 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -4,22 +4,36 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, describe, expect, it, vi } from 'vitest' import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { NativeChatRewindSurface } from './use-native-chat-rewind' + +const confirm = vi.hoisted(() => vi.fn()) +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => confirm +})) afterEach(cleanup) -function renderMessage(role: NativeChatMessage['role'], timestamp: number | null = 0) { +function renderMessage( + role: NativeChatMessage['role'], + timestamp: number | null = 0, + rewind?: NativeChatRewindSurface +) { return render( - + + + ) } @@ -50,6 +64,20 @@ describe('MessageRow control visibility', () => { }) }) + it('composes the edit-from-here action into the user hover/focus strip', () => { + const request = vi.fn() + renderMessage('user', 0, { disabledReason: null, request }) + const copy = screen.getByRole('button', { name: 'Copy message' }) + const time = screen.getByRole('time') + const edit = screen.getByRole('button', { name: 'Rewind to here' }) + expect(Array.from(copy.parentElement!.children)).toEqual([copy, time, edit]) + expect(copy.parentElement).toHaveClass('can-hover:opacity-0', 'group-hover:opacity-100') + edit.focus() + expect(edit).toHaveFocus() + fireEvent.click(edit) + expect(request).toHaveBeenCalledWith('message', confirm) + }) + it('copies the sent message text from a user bubble', async () => { const writeClipboardText = vi.fn().mockResolvedValue(undefined) Object.assign(window, { api: { ui: { writeClipboardText } } }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 529b0c024c8f..b6c241231176 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -1,4 +1,6 @@ import { memo, useCallback, useRef } from 'react' +import { NativeChatRewindAction } from './NativeChatRewindAction' +import type { NativeChatRewindSurface } from './use-native-chat-rewind' import { Goal, RotateCcw } from 'lucide-react' import { Button } from '@/components/ui/button' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' @@ -43,13 +45,15 @@ const USER_META_REVEAL = function UserMessageMeta({ markdown, timestamp, - sending + sending, + rewind }: { markdown: string timestamp: number | null sending: boolean + rewind?: { itemId: string; surface: NativeChatRewindSurface } }): React.JSX.Element | null { - if (!markdown && timestamp === null && !sending) { + if (!markdown && timestamp === null && !sending && !rewind) { return null } return ( @@ -64,6 +68,9 @@ function UserMessageMeta({ ) : ( )} + {rewind && !sending ? ( + + ) : null} ) } @@ -89,7 +96,8 @@ export const MessageRow = memo(function MessageRow({ subagentRoster, subagentDisclosure, inSubagentSection = false, - runtimeContext + runtimeContext, + rewind }: { message: NativeChatMessage previousTodoWrite?: NativeChatToolCallBlock @@ -112,6 +120,8 @@ export const MessageRow = memo(function MessageRow({ /** Inside a subagent's section, whose border has to reach past the row's controls. */ inSubagentSection?: boolean runtimeContext?: RuntimeFileOperationArgs | null + /** On a user row: discards it and everything after it. */ + rewind?: NativeChatRewindSurface }): React.JSX.Element | null { const rowRef = useRef(null) // One pass per block set, shared with the list that decides whether this row @@ -213,6 +223,7 @@ export const MessageRow = memo(function MessageRow({ markdown={markdown} timestamp={message.timestamp} sending={deliveryNotice?.sending === true} + {...(rewind ? { rewind: { itemId: message.id, surface: rewind } } : {})} /> {deliveryNotice?.text !== undefined ? (
diff --git a/src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx b/src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx new file mode 100644 index 000000000000..5fee5da21435 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatRewindAction.target.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor +} from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../../shared/agent-session-journal-item-key' +import { TooltipProvider } from '@/components/ui/tooltip' +import { MessageRow } from './NativeChatMessageRow' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), confirm: vi.fn() })) +let state: StructuredAgentSessionState + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('./use-structured-agent-session-hold', () => ({ + useStructuredAgentSessionHold: () => undefined +})) +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => mocks.confirm +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ state, loadingOlder: false, loadOlder: vi.fn() }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { AGENT_SESSION_REWIND_RECOVERY_CAPABILITY } from '../../../../shared/protocol-version' + +const target = { kind: 'local' } as const + +describe('rewind target after send acceptance', () => { + beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_REWIND_RECOVERY_CAPABILITY]) + vi.clearAllMocks() + localStorage.clear() + state = { + ...EMPTY_STRUCTURED_AGENT_SESSION, + epoch: 'epoch-1', + cursor: { epoch: 'epoch-1', sequence: 0 }, + fence: 3, + status: 'ready' + } + mocks.call.mockImplementation((_target, method, params) => { + if (method === 'agentSession.send') { + return new Promise(() => {}) + } + return Promise.resolve( + method === 'agentSession.rewind' + ? { ok: true, value: { itemId: params.itemId, epoch: 'epoch-2' } } + : { models: [], current: {}, rewind: { supported: true } } + ) + }) + }) + afterEach(() => { + cleanup() + setLocalRuntimeCapabilitiesForTests(null) + }) + + it.each([ + ['codex', 'orca'], + ['codex', 'provider'], + ['claude', 'orca'], + ['claude', 'provider'] + ] as const)('uses the accepted %s row with a %s journal key', async (agent, keySource) => { + const hook = renderHook(() => + useStructuredAgentSession({ sessionId: 'session', target, agent, isVisible: true }) + ) + await act(async () => { + expect(hook.result.current.send('Just sent', [])).toBe(true) + }) + await waitFor(() => expect(hook.result.current.messages).toHaveLength(1)) + const entry = hook.result.current.outbox[0]! + const optimisticId = agentJournalSubmissionKey(entry.clientMessageId) + const confirm = mocks.confirm.mockResolvedValue(true) + const row = () => ( + + + + ) + const view = render(row()) + fireEvent.click(screen.getByRole('button', { name: 'Rewind to here' })) + expect(confirm).not.toHaveBeenCalled() + + const providerItemId = agentJournalItemKey( + agent === 'codex' + ? { provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 } + : { provider: 'claude', sessionId: 'provider-session', uuid: 'message-uuid' } + ) + const itemId = keySource === 'orca' ? optimisticId : providerItemId + state = { + ...state, + cursor: { epoch: 'epoch-1', sequence: 1 }, + items: [{ itemId, revision: 1, sequence: 1, observedAt: 1, body: entry.body }], + submissions: [ + { + clientMessageId: entry.clientMessageId, + fence: 3, + payloadFingerprint: 'send-fingerprint', + dispatchState: 'accepted', + providerItemId, + reason: null, + submittedAt: 1, + resolvedAt: 2 + } + ] + } + hook.rerender() + await waitFor(() => expect(hook.result.current.rewind.disabledReason).toBeNull()) + expect(hook.result.current.outbox).toEqual([]) + expect(hook.result.current.messages.map((message) => message.id)).toEqual([itemId]) + if (keySource === 'provider') { + await act(() => hook.result.current.rewind.request(optimisticId, confirm)) + expect(confirm).not.toHaveBeenCalled() + } + + view.rerender(row()) + fireEvent.click(screen.getByRole('button', { name: 'Rewind to here' })) + await waitFor(() => + expect(mocks.call).toHaveBeenCalledWith( + target, + 'agentSession.rewind', + expect.objectContaining({ itemId, expectedEpoch: 'epoch-1' }) + ) + ) + expect(confirm).toHaveBeenCalledOnce() + expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ title: 'Rewind to here?' })) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx b/src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx new file mode 100644 index 000000000000..c0e4318c4e9b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatRewindAction.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MessageRow } from './NativeChatMessageRow' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +const confirm = vi.hoisted(() => vi.fn()) +vi.mock('@/components/confirmation-dialog-context', () => ({ + useConfirmationDialog: () => confirm +})) + +afterEach(cleanup) +function row( + role: NativeChatMessage['role'], + disabledReason: string | null = null, + enabled = true +) { + const request = vi.fn() + render( + + + + ) + return request +} +describe('user-row rewind affordance', () => { + it('is keyboard reachable and calls the selected user item from the hover strip', () => { + const request = row('user') + const button = screen.getByRole('button', { name: 'Rewind to here' }) + button.focus() + expect(button).toHaveFocus() + expect(button.parentElement).toHaveClass( + 'group-hover:opacity-100', + '[.group:has(:focus-visible)_&]:opacity-100' + ) + fireEvent.click(button) + expect(request).toHaveBeenCalledWith('user-1', confirm) + }) + it('exposes the disabled reason to keyboard users and cannot invoke rewind', async () => { + const request = row('user', 'Wait for the current turn to finish first.') + const button = screen.getByRole('button', { name: 'Rewind to here' }) + expect(button).toHaveAttribute('aria-disabled', 'true') + expect(button).toHaveAttribute('aria-description', 'Wait for the current turn to finish first.') + act(() => button.focus()) + expect(button).toHaveFocus() + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'Wait for the current turn to finish first.' + ) + fireEvent.click(button) + expect(request).not.toHaveBeenCalled() + }) + it.each(['assistant', 'reasoning', 'system'] as const)('omits rewind for %s', (role) => { + row(role) + expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull() + }) + it('renders nothing where the row is offered no rewind', () => { + row('user', null, false) + expect(screen.queryByRole('button', { name: 'Rewind to here' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatRewindAction.tsx b/src/renderer/src/components/native-chat/NativeChatRewindAction.tsx new file mode 100644 index 000000000000..19e3c79747d2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatRewindAction.tsx @@ -0,0 +1,40 @@ +import { Undo2 } from 'lucide-react' +import { useConfirmationDialog } from '@/components/confirmation-dialog-context' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { NativeChatRewindSurface } from './use-native-chat-rewind' + +export function NativeChatRewindAction({ + itemId, + rewind +}: { + itemId: string + rewind: NativeChatRewindSurface +}) { + const confirm = useConfirmationDialog() + const label = translate('components.native-chat.rewind.action', 'Rewind to here') + return ( + + + {/* Styled like the copy button beside it in the same hover strip. */} + + + + {rewind.disabledReason ?? label} + + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index ece6bca11114..8c7363c41151 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -230,6 +230,8 @@ export function createStructuredSessionMocks() { supportsStopAll: mocks.supportsBackgroundTaskStopAll }, turnId: mocks.turnId, + epoch: 'epoch-1', + rewind: { surface: undefined }, canStop: mocks.canStop ?? mocks.turnId !== null, stop: mocks.stop, queuedMessages: { diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index d6315f5eec18..8f43b043c81a 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -30,6 +30,8 @@ import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-a import { NativeChatLaunchRetry } from './NativeChatLaunchRetry' import { useNativeChatProvisionalLaunch } from './use-native-chat-provisional-launch' import { useStructuredAgentSessionHostExecutionPhase } from './StructuredAgentSessionStatusBridge' +import { useNativeChatRewindHost } from './use-native-chat-rewind-host' +import { NativeChatRewindContext } from './native-chat-rewind-context' import { NativeChatQueuedMessageList } from './NativeChatQueuedMessageList' import { useAppStore } from '../../store' import { structuredAgentLabel } from '@/lib/structured-agent-session-launch-label' @@ -55,12 +57,15 @@ export function NativeChatStructuredSession( ) // Chat-wide: absent means on; only an explicit off keeps mid-turn sends immediate. const queueFollowUps = useAppStore((store) => store.settings?.nativeChatQueueFollowUps !== false) + const composerRef = useRef(null) + const { rewindHost, focusComposer } = useNativeChatRewindHost(props, composerRef) const controller = useStructuredAgentSession({ ...props, // Why: Stop and a queued card's Edit give text back to the conversation's draft, as the composer keeps it. composerScopeKey: structuredAgentSessionDraftScopeKey(props.sessionId), queueFollowUps, providerStarting: startupPhase === 'starting', + rewind: rewindHost, transportEnabled: provisionalLaunch.transportEnabled, ...(provisionalLaunch.launch ? { launch: provisionalLaunch.launch } : {}) }) @@ -78,7 +83,6 @@ export function NativeChatStructuredSession( sequence: number } | null>(null) const rootRef = useRef(null) - const composerRef = useRef(null) const paneCommands = useStructuredNativeChatPaneCommands({ tabId: props.tabId, groupId: props.groupId, @@ -276,24 +280,28 @@ export function NativeChatStructuredSession( ) : viewState.kind === 'empty' ? ( ) : ( - + + + )}
{readFailedFinally ? null : ( @@ -307,9 +315,7 @@ export function NativeChatStructuredSession( {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} { - composerRef.current?.focus() - }} + focusComposer={focusComposer} /> { supportsBackgroundTaskStopAll: mocks.supportsBackgroundTaskStopAll, backgroundTasks: mocks.backgroundTasks, turnId: null, + epoch: 'epoch-1', + rewind: { surface: undefined }, cancel: vi.fn(), queuedMessages: { cards: [], diff --git a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx index 13f434a2b968..bcb3841cbb9c 100644 --- a/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTranscriptRow.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react' +import { memo, useContext } from 'react' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { cn } from '@/lib/utils' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' @@ -12,6 +12,8 @@ import type { NativeChatSubagentDisclosure } from './native-chat-subagent-sectio import type { NativeChatTaskListPredecessors } from './native-chat-task-list-history' import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots' import type { NativeChatDiffReveal, NativeChatDiffTarget } from './native-chat-turn-diffs' +import { NativeChatRewindContext } from './native-chat-rewind-context' +import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility' /** Everything a row needs that is the same for every row. Held as one memoized * object so a row's props change only when that row's own slot does. */ @@ -44,6 +46,8 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ slot: NativeChatTranscriptSlot context: NativeChatTranscriptRowContext }): React.JSX.Element { + // Offered only on the conversation's own turn-opening prompts (`nativeChatRowOffersRewind`). + const rewind = useContext(NativeChatRewindContext) // A subagent's section is set off from the conversation it sits in. const sectionClassName = cn( slot.depth > 0 && 'border-l-2 border-border/60 pl-3', @@ -73,6 +77,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ ) } const { message, turnKey, status, receipt, turnDiff } = slot + const deliveryNotice = context.deliveryNotices?.get(message.id) const predecessors = context.taskListPredecessors.get(message.id) const expanded = turnKey ? context.expandedTurnIds.has(turnKey) : undefined const statusRow = status ? ( @@ -106,12 +111,17 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({ onScrollMessageToTop={context.onScrollMessageToTop} onLinkClick={context.onLinkClick} allowFileUriLinks={context.allowFileUriLinks} - deliveryNotice={context.deliveryNotices?.get(message.id)} + deliveryNotice={deliveryNotice} folded={slot.folded} subagentRoster={slot.subagentRoster} subagentDisclosure={context.subagentDisclosure} inSubagentSection={slot.depth > 0} runtimeContext={context.runtimeContext} + rewind={ + nativeChatRowOffersRewind(message, slot, deliveryNotice !== undefined) + ? rewind + : undefined + } /> )} {slot.statusAbove ? null : statusRow} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 45b987c9585e..43e484c6f136 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -77,6 +77,20 @@ export function useStructuredAgentSessionHostExecutionPhase( ) } +/** Only the host's rewind recovery latch, so a chat re-renders when that changes, not on every status. */ +export function useStructuredAgentSessionRewindBlockedReason( + sessionId: string, + target: RuntimeClientTarget +): NonNullable | null { + const feed = useMemo(() => getStructuredAgentSessionStatusFeed(target), [target]) + useEffect(() => feed.activate(), [feed]) + return useSyncExternalStore( + feed.subscribe, + () => feed.getSnapshot().get(sessionId)?.rewindBlockedReason ?? null, + () => null + ) +} + /** The host's child records for the row, and the legacy roster readers of `subagents` keep. A host * that publishes views is copied verbatim; only an older host's task list is converted here. */ function childWorkFor(summary: AgentSessionStatusSummary): { diff --git a/src/renderer/src/components/native-chat/native-chat-rewind-context.ts b/src/renderer/src/components/native-chat/native-chat-rewind-context.ts new file mode 100644 index 000000000000..f5369196567d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-rewind-context.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react' +import type { NativeChatRewindSurface } from './use-native-chat-rewind' + +/** The rewind the pane's user rows offer; absent where the chat cannot rewind. */ +export const NativeChatRewindContext = createContext(undefined) diff --git a/src/renderer/src/components/native-chat/native-chat-rewind-copy.ts b/src/renderer/src/components/native-chat/native-chat-rewind-copy.ts new file mode 100644 index 000000000000..2599f84bf26e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-rewind-copy.ts @@ -0,0 +1,92 @@ +import { translate } from '@/i18n/i18n' +import { + AGENT_SESSION_REWIND_REASONS, + type AgentSessionRewindReason +} from '../../../../shared/agent-session-rewind' + +const reasonCopy = { + unsupported: () => + translate( + 'components.native-chat.rewind.unsupported', + "This chat can't go back to an earlier message." + ), + 'history-not-paginated': () => + translate( + 'components.native-chat.rewind.historyNotPaginated', + "This older Codex chat can't go back to an earlier message. Start a new chat to use Rewind to here." + ), + busy: () => + translate( + 'components.native-chat.rewind.busy', + 'Available once the agent is idle and nothing in this chat is waiting on you.' + ), + 'stale-epoch': () => + translate( + 'components.native-chat.rewind.staleEpoch', + 'The conversation changed. Review the latest messages and try again.' + ), + 'invalid-target': () => + translate( + 'components.native-chat.rewind.invalidTarget', + "The conversation can't go back to this message. Nothing was changed." + ), + 'history-limit': () => + translate( + 'components.native-chat.rewind.historyLimit', + 'This conversation is too long to go back in. Nothing was changed.' + ), + 'provider-refused': () => + translate( + 'components.native-chat.rewind.providerRefused', + "The agent couldn't go back to this message." + ), + 'proof-mismatch': () => + translate( + 'components.native-chat.rewind.proofMismatch', + "The agent's history didn't match this chat. Check the conversation before continuing." + ), + 'outcome-unknown': () => + translate( + 'components.native-chat.rewind.outcomeUnknown', + "Orca couldn't confirm whether the conversation went back to an earlier message. It checks before your next message is sent." + ) +} satisfies Record string> + +export function nativeChatRewindReasonCopy(reason: string | undefined): string { + const known = AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) + return known + ? reasonCopy[known]() + : translate( + 'components.native-chat.rewind.refused', + "Orca couldn't go back to this message. Check the conversation before continuing." + ) +} + +export function nativeChatRewindUnavailableCopy(): string { + return translate( + 'components.native-chat.rewind.unavailable', + 'Wait for the chat to finish connecting.' + ) +} + +/** The unknown outcome, said where the message has already gone back to the composer. */ +export function nativeChatRewindReturnedUnknownCopy(): string { + return translate( + 'components.native-chat.rewind.outcomeUnknownReturned', + "Orca couldn't confirm whether the conversation went back to this message. Your message is back in the composer, and Orca checks the conversation before your next message is sent." + ) +} + +export function nativeChatRewindTimeoutCopy(): string { + return translate( + 'components.native-chat.rewind.timeout', + "The chat didn't update after going back to this message. Reopen it to see the latest." + ) +} + +export function nativeChatRewindPendingCopy(): string { + return translate( + 'components.native-chat.rewind.pending', + 'Wait for the conversation to go back to the earlier message.' + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts b/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts new file mode 100644 index 000000000000..b9bf366fcd0e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-rewind-eligibility.ts @@ -0,0 +1,30 @@ +// Where "Rewind to here" is offered: which providers can rewind, and which transcript rows. + +import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +/** Whether the provider can rewind at all. An in-doubt read is still capable: the status feed's + * live latch, not this cached read, says when it resolves. */ +export function nativeChatRewindOffered(support: AgentSessionRewindSupport | undefined): boolean { + return support !== undefined && (support.supported || support.reason === 'outcome-unknown') +} + +/** A sent prompt that opened its own turn, outside any subagent's section. Codex rewinds whole + * turns, so a steer would also discard its turn's opener; unsent, queued, command and goal rows + * have no turn to go back to. An image with no local file could not go back to the composer. */ +export function nativeChatRowOffersRewind( + message: NativeChatMessage, + slot: { depth: number; turnKey: string | undefined }, + hasDeliveryNotice: boolean +): boolean { + return ( + message.role === 'user' && + slot.depth === 0 && + slot.turnKey === message.id && + !hasDeliveryNotice && + message.queued !== true && + message.command === undefined && + message.sentAs === undefined && + message.blocks.every((block) => block.type !== 'image-ref' || Boolean(block.path)) + ) +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 40e69565ef07..ee6a1ee824c2 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -1,11 +1,35 @@ import { useMemo } from 'react' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' import { dispatchWasWithdrawn } from '../../../../shared/structured-agent-session-dispatch-rejection' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { appendNativeChatDraftCache } from './native-chat-draft-cache' import { getStructuredAgentSessionOutbox } from './structured-agent-session-outbox-storage' import { appendNativeChatAttachmentCache } from './use-native-chat-composer-attachments' +/** Puts a message's text and images into a composer, after whatever is there. */ +export function returnMessageToComposer( + composerScopeKey: string, + /** Unique to this message, so its images never collide with ones already attached. */ + attachmentIdPrefix: string, + blocks: AgentJournalMessageItem['blocks'] +): void { + appendNativeChatDraftCache( + composerScopeKey, + blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') + ) + appendNativeChatAttachmentCache( + composerScopeKey, + blocks.flatMap((block, index) => + block.type === 'image-ref' && block.path + ? [{ id: `${attachmentIdPrefix}-${index}`, path: block.path }] + : [] + ) + ) +} + /** * Gives the sender back what a Stop withdrew: its text and images go into this pane's composer, * after whatever is there. Called before the entries leave storage, so a failure between the two @@ -28,18 +52,10 @@ function restoreWithdrawnMessages( if (!held.has(entry.clientMessageId)) { continue } - const blocks = entry.body.blocks - appendNativeChatDraftCache( - composerScopeKey, - blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') - ) - appendNativeChatAttachmentCache( + returnMessageToComposer( composerScopeKey, - blocks.flatMap((block, index) => - block.type === 'image-ref' && block.path - ? [{ id: `withdrawn-${entry.clientMessageId}-${index}`, path: block.path }] - : [] - ) + `withdrawn-${entry.clientMessageId}`, + entry.body.blocks ) } } diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind-host.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind-host.ts new file mode 100644 index 000000000000..9a1fe3bf0a62 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind-host.ts @@ -0,0 +1,24 @@ +import { useCallback, type RefObject } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { useStructuredAgentSessionRewindBlockedReason } from './StructuredAgentSessionStatusBridge' +import type { NativeChatComposerHandle } from './NativeChatComposer' +import type { NativeChatRewindHost } from './use-native-chat-rewind' + +/** What a pane hands its session's rewind: the host's in-doubt latch, its visibility, and where a + * returned message lands. `focusComposer` is the same focus the pane's other give-backs use. */ +export function useNativeChatRewindHost( + pane: { sessionId: string; target: RuntimeClientTarget; isVisible: boolean }, + composerRef: RefObject +): { rewindHost: NativeChatRewindHost; focusComposer: () => void } { + const hostBlockedReason = useStructuredAgentSessionRewindBlockedReason( + pane.sessionId, + pane.target + ) + const focusComposer = useCallback(() => { + composerRef.current?.focus() + }, [composerRef]) + return { + rewindHost: { hostBlockedReason, onMessageReturned: focusComposer, isVisible: pane.isVisible }, + focusComposer + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx new file mode 100644 index 000000000000..16a59098f778 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const toastError = vi.hoisted(() => vi.fn()) +vi.mock('sonner', () => ({ toast: { error: toastError } })) + +import { AGENT_SESSION_REWIND_REASONS } from '../../../../shared/agent-session-rewind' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../../shared/structured-agent-session-reducer' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS, useNativeChatRewind } from './use-native-chat-rewind' +import { nativeChatRowOffersRewind } from './native-chat-rewind-eligibility' +import { + nativeChatRewindPendingCopy, + nativeChatRewindReasonCopy, + nativeChatRewindReturnedUnknownCopy, + nativeChatRewindTimeoutCopy +} from './native-chat-rewind-copy' +import { + parseAgentSessionWriteFailure, + type AgentSessionWriteFailure +} from '../../../../shared/agent-session-write-failure' +import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache' + +type Input = Parameters[0] + +afterEach(cleanup) +beforeEach(() => { + toastError.mockReset() + writeNativeChatDraftCache('pane', '') +}) +const done = { kind: 'done' as const, value: { itemId: 'user', epoch: 'new' } } +const notDone = (failure: AgentSessionWriteFailure) => ({ + kind: 'not-done' as const, + notice: '', + failure +}) +const unknownRefusal: AgentSessionWriteFailure = { + kind: 'refused', + code: 'agent_session_operation_unknown', + details: { reason: 'rewindUnconfirmed', rewindReason: 'outcome-unknown' } +} +const item = ( + itemId: string, + sequence: number, + role: 'user' | 'assistant' = 'user' +): AgentJournalRenderItem => ({ + itemId, + sequence, + revision: 1, + observedAt: sequence, + body: { kind: 'message', role, blocks: [{ type: 'text', text: `text of ${itemId}` }] } +}) +function input(): Input & { send: ReturnType } { + return { + sessionId: 'session', + composerScopeKey: 'pane', + state: { + ...EMPTY_STRUCTURED_AGENT_SESSION, + epoch: 'old', + fence: 1, + status: 'ready' as const, + cursor: { epoch: 'old', sequence: 12 }, + items: [item('user', 10), item('reply', 11, 'assistant'), item('later', 12)] + }, + support: { supported: true as const }, + blocked: false, + send: vi.fn().mockResolvedValue(done) + } +} +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} +function render(props: Input) { + return renderHook((value: Input) => useNativeChatRewind(value), { initialProps: props }) +} + +describe('structured chat rewind', () => { + it('confirms without a count, sends, and returns the message', async () => { + const props = input() + const onMessageReturned = vi.fn() + const confirm = vi.fn().mockResolvedValue(true) + const view = render({ ...props, onMessageReturned }) + await act(() => view.result.current.request('user', confirm)) + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Rewind to here?', + confirmLabel: 'Rewind', + confirmVariant: 'destructive', + description: expect.not.stringMatching(/in total/) + }) + ) + expect(props.send).toHaveBeenCalledWith({ itemId: 'user', expectedEpoch: 'old' }) + expect(readNativeChatDraftCache('pane')).toContain('text of user') + expect(onMessageReturned).toHaveBeenCalledOnce() + expect(toastError).not.toHaveBeenCalled() + }) + + it('offers nothing for a row the journal does not hold as a user message', async () => { + const props = input() + const confirm = vi.fn() + const view = render(props) + await act(() => view.result.current.request('reply', confirm)) + await act(() => view.result.current.request('missing', confirm)) + expect(confirm).not.toHaveBeenCalled() + }) + + it('blocks nothing while the confirmation is open, and ignores a second click', async () => { + const props = input(), + confirmation = deferred() + const confirm = vi.fn(() => confirmation.promise) + const view = render(props) + let request!: Promise + act(() => { + request = view.result.current.request('user', confirm) + }) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + await act(() => view.result.current.request('user', confirm)) + expect(confirm).toHaveBeenCalledOnce() + await act(async () => { + confirmation.resolve(false) + await request + }) + expect(props.send).not.toHaveBeenCalled() + expect(view.result.current.pending).toBe(false) + }) + + it('blocks only while the confirmed request is in flight', async () => { + const props = input(), + response = deferred() + props.send.mockReturnValue(response.promise) + const view = render(props) + let request!: Promise + await act(async () => { + request = view.result.current.request('user', async () => true) + }) + expect(view.result.current.pending).toBe(true) + expect(view.result.current.blockedRef.current).toBe(true) + expect(view.result.current.disabledReason).toBe(nativeChatRewindPendingCopy()) + // The host's latch covers its own in-flight work; it does not relabel ours. + view.rerender({ ...props, hostBlockedReason: 'outcome-unknown' }) + expect(view.result.current.disabledReason).toBe(nativeChatRewindPendingCopy()) + await act(async () => { + view.rerender({ ...props, state: { ...props.state, epoch: 'new', items: [] } }) + response.resolve(done) + await request + }) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + }) + + it("lets the host's in-doubt latch disable only the action, never sending", async () => { + const props: Input = { ...input(), hostBlockedReason: 'outcome-unknown' } + const view = render(props) + expect(view.result.current.disabledReason).toBe(nativeChatRewindReasonCopy('outcome-unknown')) + expect(view.result.current.blockedRef.current).toBe(false) + expect(view.result.current.pending).toBe(false) + const run = vi.fn() + view.result.current.unlessBlocked(run)() + expect(run).toHaveBeenCalledOnce() + const confirm = vi.fn() + await act(() => view.result.current.request('user', confirm)) + expect(confirm).not.toHaveBeenCalled() + view.rerender({ ...props, hostBlockedReason: undefined }) + expect(view.result.current.disabledReason).toBeNull() + }) + + it('does not execute a confirmation after its pane unmounts', async () => { + const props = input(), + confirmation = deferred() + const view = render(props) + let request!: Promise + act(() => { + request = view.result.current.request('user', () => confirmation.promise) + }) + view.unmount() + await act(async () => { + confirmation.resolve(true) + await request + }) + expect(props.send).not.toHaveBeenCalled() + }) + + it.each(['busy', 'unwritable', 'loading'] as const)( + 'disables %s sessions with explanatory copy', + async (mode) => { + const props = input() + if (mode === 'busy') { + props.blocked = true + } + if (mode === 'unwritable') { + props.state.fence = null + } + if (mode === 'loading') { + props.state.status = 'loading' + } + const confirm = vi.fn() + const view = render(props) + expect(view.result.current.surface).toBeDefined() + expect(view.result.current.disabledReason).toBeTruthy() + await act(() => view.result.current.request('user', confirm)) + expect(confirm).not.toHaveBeenCalled() + expect(props.send).not.toHaveBeenCalled() + } + ) + + it.each([ + ['unresolved', undefined], + ['unsupported', { supported: false, reason: 'unsupported' }], + ['legacy', { supported: false, reason: 'history-not-paginated' }] + ] as const)('offers no action where support is %s', async (_mode, support) => { + const props: Input = { ...input(), support } + const confirm = vi.fn() + const view = render(props) + expect(view.result.current.surface).toBeUndefined() + await act(() => view.result.current.request('user', confirm)) + expect(confirm).not.toHaveBeenCalled() + }) + + it('still offers the action when the options read answered while the host was in doubt', () => { + const view = render({ ...input(), support: { supported: false, reason: 'outcome-unknown' } }) + expect(view.result.current.surface).toEqual({ + disabledReason: null, + request: view.result.current.request + }) + }) + + it.each(['epoch', 'messages', 'busy'] as const)( + 'rechecks %s after confirmation and says so once', + async (change) => { + const props = input(), + confirmation = deferred() + const view = render(props) + let request!: Promise + act(() => { + request = view.result.current.request('user', () => confirmation.promise) + }) + view.rerender({ + ...props, + blocked: change === 'busy', + state: { + ...props.state, + epoch: change === 'epoch' ? 'new' : 'old', + cursor: { epoch: 'old', sequence: change === 'messages' ? 13 : 12 } + } + }) + await act(async () => { + confirmation.resolve(true) + await request + }) + expect(props.send).not.toHaveBeenCalled() + expect(toastError).toHaveBeenCalledOnce() + expect(view.result.current.pending).toBe(false) + } + ) + + it.each(['before', 'after'] as const)( + 'settles when epoch reset arrives %s RPC success', + async (order) => { + const props = input(), + response = deferred() + props.send.mockReturnValue(response.promise) + const view = render(props) + let request!: Promise + await act(async () => { + request = view.result.current.request('user', async () => true) + }) + const reset = () => + view.rerender({ ...props, state: { ...props.state, epoch: 'new', items: [] } }) + if (order === 'before') { + reset() + } + await act(async () => { + response.resolve(done) + await request + }) + if (order === 'after') { + expect(view.result.current.pending).toBe(true) + expect(view.result.current.blockedRef.current).toBe(true) + reset() + } + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + } + ) + + it('lets go of a confirmed rewind whose new conversation never arrives, and says so once', async () => { + vi.useFakeTimers() + try { + const props = input() + const view = render(props) + await act(() => view.result.current.request('user', async () => true)) + expect(view.result.current.blockedRef.current).toBe(true) + await act(() => vi.advanceTimersByTimeAsync(NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS - 1)) + expect(view.result.current.pending).toBe(true) + await act(() => vi.advanceTimersByTimeAsync(1)) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + expect(toastError).toHaveBeenCalledExactlyOnceWith(nativeChatRewindTimeoutCopy()) + await act(() => vi.advanceTimersByTimeAsync(NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS)) + expect(toastError).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + + it.each(['hidden throughout', 'hidden part way'] as const)( + 'lets go silently when the pane was %s, since a hidden pane reads nothing', + async (mode) => { + vi.useFakeTimers() + try { + const props: Input = { ...input(), isVisible: mode !== 'hidden throughout' } + const view = render(props) + await act(() => view.result.current.request('user', async () => true)) + expect(view.result.current.blockedRef.current).toBe(true) + if (mode === 'hidden part way') { + await act(() => vi.advanceTimersByTimeAsync(1_000)) + view.rerender({ ...props, isVisible: false }) + view.rerender({ ...props, isVisible: true }) + } + await act(() => vi.advanceTimersByTimeAsync(NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS)) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + expect(toastError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + } + ) + + it.each([...AGENT_SESSION_REWIND_REASONS.filter((r) => r !== 'outcome-unknown'), 'future'])( + 'toasts refusal %s once, unblocks, and keeps the message where it is', + async (rewindReason) => { + const props = input() + props.send.mockResolvedValue( + notDone( + // Parsed as a reply is, so a reason this build does not know is dropped. + parseAgentSessionWriteFailure({ + kind: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'rewindRefused', rewindReason } + }) ?? { kind: 'failed' } + ) + ) + const onMessageReturned = vi.fn() + const view = render({ ...props, onMessageReturned }) + await act(() => view.result.current.request('user', async () => true)) + expect(toastError).toHaveBeenCalledExactlyOnceWith(nativeChatRewindReasonCopy(rewindReason)) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + expect(readNativeChatDraftCache('pane')).toBe('') + expect(onMessageReturned).not.toHaveBeenCalled() + } + ) + + it.each([ + ['unconfirmed', { kind: 'unconfirmed' } as const], + ['operation-unknown', unknownRefusal] + ])( + 'an unknown outcome (%s) unblocks at once and returns the message', + async (_label, failure) => { + const props = input() + props.send.mockResolvedValue(notDone(failure)) + const onMessageReturned = vi.fn() + const view = render({ ...props, onMessageReturned }) + await act(() => view.result.current.request('user', async () => true)) + expect(view.result.current.pending).toBe(false) + expect(view.result.current.blockedRef.current).toBe(false) + expect(view.result.current.disabledReason).toBeNull() + expect(readNativeChatDraftCache('pane')).toContain('text of user') + expect(onMessageReturned).toHaveBeenCalledOnce() + expect(toastError).toHaveBeenCalledExactlyOnceWith(nativeChatRewindReturnedUnknownCopy()) + expect(nativeChatRewindReturnedUnknownCopy()).not.toContain('blocked') + } + ) + + it('is not stranded when the host latches and then settles an unknown outcome as refused', async () => { + const props = input() + props.send.mockResolvedValue(notDone({ kind: 'unconfirmed' })) + const view = render(props) + await act(() => view.result.current.request('user', async () => true)) + view.rerender({ ...props, hostBlockedReason: 'outcome-unknown' }) + expect(view.result.current.blockedRef.current).toBe(false) + view.rerender({ ...props, hostBlockedReason: undefined }) + expect(view.result.current.blockedRef.current).toBe(false) + expect(view.result.current.disabledReason).toBeNull() + const confirm = vi.fn().mockResolvedValue(false) + await act(() => view.result.current.request('user', confirm)) + expect(confirm).toHaveBeenCalledOnce() + }) + + it('keeps the message when an unknown outcome turns out to have rewound', async () => { + const props = input() + props.send.mockResolvedValue(notDone({ kind: 'unconfirmed' })) + const view = render(props) + await act(() => view.result.current.request('user', async () => true)) + view.rerender({ ...props, state: { ...props.state, epoch: 'new', items: [] } }) + expect(readNativeChatDraftCache('pane')).toContain('text of user') + }) + + it('says nothing and returns nothing for a reply the pane stopped waiting on', async () => { + const props = input() + props.send.mockResolvedValue({ kind: 'dropped' }) + const view = render(props) + await act(() => view.result.current.request('user', async () => true)) + expect(toastError).not.toHaveBeenCalled() + expect(readNativeChatDraftCache('pane')).toBe('') + expect(view.result.current.blockedRef.current).toBe(false) + }) +}) + +describe('which rows offer rewind', () => { + const message = (fields: Partial = {}): NativeChatMessage => ({ + id: 'opener', + role: 'user', + blocks: [{ type: 'text', text: 'Prompt' }], + timestamp: 1, + source: 'transcript', + ...fields + }) + const opens = { depth: 0, turnKey: 'opener' } + + it('offers a sent prompt that opened its own turn', () => { + expect(nativeChatRowOffersRewind(message(), opens, false)).toBe(true) + }) + + it('offers a prompt whose image is a local file, which can go back to the composer', () => { + const withFile = message({ + blocks: [ + { type: 'text', text: 'Look' }, + { type: 'image-ref', path: '/tmp/shot.png' } + ] + }) + expect(nativeChatRowOffersRewind(withFile, opens, false)).toBe(true) + }) + + it.each([ + ['a steer into a running turn', message(), { depth: 0, turnKey: 'earlier-opener' }, false], + ['an unsent row', message({ unsent: true }), { depth: 0, turnKey: undefined }, false], + ['a row with a delivery notice', message(), opens, true], + ['a queued row', message({ queued: true }), opens, false], + ['a /compact row', message({ command: { name: 'compact' } }), opens, false], + ['a goal', message({ sentAs: 'goal' }), opens, false], + [ + 'a prompt with a URL image, which could not go back to the composer', + message({ blocks: [{ type: 'image-ref', url: 'https://example.com/shot.png' }] }), + opens, + false + ], + ['a subagent prompt', message(), { depth: 1, turnKey: 'opener' }, false], + ['an assistant row', message({ role: 'assistant' }), opens, false] + ] as const)('never offers %s', (_label, row, slot, notice) => { + expect(nativeChatRowOffersRewind(row, slot, notice)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-rewind.ts b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts new file mode 100644 index 000000000000..6a979be407b4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-rewind.ts @@ -0,0 +1,314 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import type { ConfirmationDialogContextValue } from '@/components/confirmation-dialog-context' +import { translate } from '@/i18n/i18n' +import type { + AgentSessionRewindReason, + AgentSessionRewindResult, + AgentSessionRewindSupport +} from '../../../../shared/agent-session-rewind' +import type { AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' +import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' +import { + nativeChatRewindPendingCopy, + nativeChatRewindReasonCopy, + nativeChatRewindReturnedUnknownCopy, + nativeChatRewindTimeoutCopy, + nativeChatRewindUnavailableCopy +} from './native-chat-rewind-copy' +import type { + StructuredAgentSessionWrite, + StructuredAgentSessionWriteOutcome +} from './use-structured-agent-session-mutate' +import { returnMessageToComposer } from './structured-agent-session-withdrawn-message-restore' +import { nativeChatRewindOffered } from './native-chat-rewind-eligibility' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' +import { useStructuredAgentSessionHostRecoversRewindOnSend } from '@/runtime/structured-agent-session-host-capability' + +export type NativeChatRewindSurface = { + disabledReason: string | null + request: (itemId: string, confirm: ConfirmationDialogContextValue) => Promise +} + +/** How long a confirmed rewind waits for its new conversation before letting go. */ +export const NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS = 120_000 + +type RewindInput = { + sessionId: string + /** The composer the discarded message returns to, after whatever is typed there. */ + composerScopeKey?: string + /** Called once the discarded message is back in the composer. */ + onMessageReturned?: () => void + /** Whether the pane is shown, and so reading; absent means shown. */ + isVisible?: boolean + state: StructuredAgentSessionState + /** Undefined until the host has answered for the current runtime. */ + support: AgentSessionRewindSupport | undefined + /** The host's in-doubt latch: disables the action, never sending. */ + hostBlockedReason?: AgentSessionRewindReason + blocked: boolean + send: (fields: { + itemId: string + expectedEpoch: string + }) => Promise> +} + +/** Which rewind check refused; `outcome-unknown` whenever nothing proves the rewind did not run. */ +function rewindFailureReason(failure: AgentSessionWriteFailure): string | undefined { + if (failure.kind === 'unconfirmed') { + return 'outcome-unknown' + } + if (failure.kind === 'failed') { + return undefined + } + if (failure.code === 'agent_session_operation_unknown') { + return 'outcome-unknown' + } + if (failure.code === 'structured_agent_session_unsupported') { + return 'unsupported' + } + return failure.details && 'rewindReason' in failure.details + ? failure.details.rewindReason + : undefined +} + +function isRewindTarget(state: StructuredAgentSessionState, itemId: string): boolean { + return state.items.some( + (item) => item.itemId === itemId && item.body.kind === 'message' && item.body.role === 'user' + ) +} + +function blockedReason(input: RewindInput): string | null { + if (input.hostBlockedReason) { + return nativeChatRewindReasonCopy(input.hostBlockedReason) + } + if (input.support?.supported === false && !nativeChatRewindOffered(input.support)) { + return nativeChatRewindReasonCopy(input.support.reason) + } + const { state } = input + if (!input.support || !state.epoch || state.fence === null || state.status !== 'ready') { + return nativeChatRewindUnavailableCopy() + } + return input.blocked ? nativeChatRewindReasonCopy('busy') : null +} + +/** Whether the message went back; read from the state the user confirmed against. */ +function returnTargetToComposer(input: RewindInput, itemId: string): boolean { + const target = input.state.items.find((item) => item.itemId === itemId) + if (!input.composerScopeKey || target?.body.kind !== 'message') { + return false + } + returnMessageToComposer(input.composerScopeKey, `rewound-${itemId}`, target.body.blocks) + return true +} + +export function useNativeChatRewind(input: RewindInput) { + const active = useRef(false) + useLayoutEffect(() => { + active.current = true + return () => { + active.current = false + } + }, []) + const latest = useRef(input) + useLayoutEffect(() => { + latest.current = input + }, [input]) + // Single-flight from the click on; only `sending` (after Confirm) blocks anything else. + const inFlight = useRef(false) + const [sending, setSending] = useState(false) + // A confirmed rewind whose new conversation has not arrived yet. + const [awaiting, setAwaiting] = useState<{ sessionId: string; epoch: string } | null>(null) + const awaitingReset = + awaiting?.sessionId === input.sessionId && awaiting.epoch === input.state.epoch + // A hidden pane stops reading, so its new conversation cannot arrive; that is no failure to report. + const hiddenWhileAwaiting = useRef(false) + const visible = input.isVisible !== false + useLayoutEffect(() => { + if (!visible) { + hiddenWhileAwaiting.current = true + } + }, [visible]) + useEffect(() => { + if (!awaitingReset) { + return + } + hiddenWhileAwaiting.current = latest.current.isVisible === false + const timer = setTimeout(() => { + setAwaiting(null) + if (!hiddenWhileAwaiting.current) { + toast.error(nativeChatRewindTimeoutCopy()) + } + }, NATIVE_CHAT_REWIND_RESET_TIMEOUT_MS) + return () => clearTimeout(timer) + }, [awaitingReset]) + const pending = sending || awaitingReset + const disabledReason = pending ? nativeChatRewindPendingCopy() : blockedReason(input) + const blockedRef = useRef(false) + useLayoutEffect(() => { + blockedRef.current = pending + }, [pending]) + + const request = useCallback(async (itemId: string, confirm: ConfirmationDialogContextValue) => { + const captured = latest.current + if ( + inFlight.current || + blockedRef.current || + blockedReason(captured) || + !isRewindTarget(captured.state, itemId) + ) { + return + } + const expectedEpoch = captured.state.epoch! + inFlight.current = true + let keepBlocked = false + try { + const confirmed = await confirm({ + title: translate('components.native-chat.rewind.title', 'Rewind to here?'), + description: translate( + 'components.native-chat.rewind.confirmation', + 'Discard this message and everything after it? The message returns to the composer so you can edit and resend it. File changes on disk are kept.' + ), + confirmLabel: translate('components.native-chat.rewind.confirm', 'Rewind'), + cancelLabel: translate('components.native-chat.rewind.cancel', 'Cancel'), + confirmVariant: 'destructive', + cancelVariant: 'ghost' + }) + const current = latest.current + if (!confirmed || !active.current || current.sessionId !== captured.sessionId) { + return + } + if ( + current.state.epoch !== expectedEpoch || + current.state.cursor?.sequence !== captured.state.cursor?.sequence + ) { + toast.error(nativeChatRewindReasonCopy('stale-epoch')) + return + } + const blocked = blockedReason(current) + if (blocked) { + toast.error(blocked) + return + } + blockedRef.current = true + setSending(true) + const outcome = await current.send({ itemId, expectedEpoch }) + if (!active.current || latest.current.sessionId !== captured.sessionId) { + return + } + const reset = latest.current.state.epoch !== expectedEpoch + const giveBack = () => { + if (returnTargetToComposer(captured, itemId)) { + latest.current.onMessageReturned?.() + } + } + if (outcome.kind === 'done') { + giveBack() + keepBlocked = !reset + setAwaiting(reset ? null : { sessionId: captured.sessionId, epoch: expectedEpoch }) + return + } + if (outcome.kind === 'dropped') { + return + } + const reason = rewindFailureReason(outcome.failure) + // A rewind that may have run must never lose the message; a duplicate draft is harmless. + if (reason === 'outcome-unknown') { + giveBack() + } + if (reset) { + return + } + toast.error( + reason === 'outcome-unknown' && captured.composerScopeKey + ? nativeChatRewindReturnedUnknownCopy() + : nativeChatRewindReasonCopy(reason) + ) + } finally { + inFlight.current = false + blockedRef.current = keepBlocked + setSending(false) + } + }, []) + /** Whether a send may go now; one refused during a confirmed rewind says why. */ + const admitsSend = useCallback((): boolean => { + if (blockedRef.current) { + toast.message(nativeChatRewindPendingCopy()) + } + return !blockedRef.current + }, []) + /** Runs an action only while no confirmed rewind is in flight. */ + const unlessBlocked = useCallback( + (run: (...input: A) => void) => + (...input: A): void => { + if (admitsSend()) { + run(...input) + } + }, + [admitsSend] + ) + const offered = nativeChatRewindOffered(input.support) + // Rows re-render only when this changes; none get the action where the provider can never rewind. + const surface = useMemo( + () => (offered ? { disabledReason, request } : undefined), + [offered, disabledReason, request] + ) + return { request, admitsSend, unlessBlocked, surface, disabledReason, pending, blockedRef } +} + +/** What the pane hosting a structured session tells its rewind. */ +export type NativeChatRewindHost = { + /** The host's in-doubt latch, from its status feed. */ + hostBlockedReason?: AgentSessionRewindReason | null + onMessageReturned?: () => void + isVisible?: boolean +} + +/** The rewind a structured session's user rows offer, sent through the session's own writes. */ +export function useStructuredAgentSessionRewind( + args: Omit & + NativeChatRewindHost & { target: RuntimeClientTarget; write: StructuredAgentSessionWrite } +) { + const { + blocked, + composerScopeKey, + hostBlockedReason, + isVisible, + onMessageReturned, + sessionId, + state, + target, + write + } = args + // An in-doubt rewind returns the prompt for the next send to settle; only a host that settles it + // on a send may offer one. Unknown hides it, as unresolved support does. + const support = useStructuredAgentSessionHostRecoversRewindOnSend(target) + ? args.support + : undefined + const input = useMemo( + () => ({ + sessionId, + composerScopeKey, + onMessageReturned, + isVisible, + hostBlockedReason: hostBlockedReason ?? undefined, + state, + support, + blocked, + send: (fields) => + write('agentSession.rewind', 'agentSession.rewind', fields) + }), + [ + blocked, + composerScopeKey, + hostBlockedReason, + isVisible, + onMessageReturned, + sessionId, + state, + support, + write + ] + ) + return useNativeChatRewind(input) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 1f5116718a50..d33ab9ef79c4 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -4,8 +4,8 @@ // from an earlier one. The host answers a second Stop of a named turn or task quietly; against an // older host that does not, this client joins one still on its way instead of stopping twice. // Every result is discarded unless the runtime fence it was issued against is still the current -// one, except the reply to the conversation command a pane still waits on, since that command may -// move the fence itself. A write that did not happen is reported once, in the person's words, by +// one, except the reply to the conversation command or rewind a pane still waits on, since either +// may move the fence itself. A write that did not happen is reported once, in the person's words, by // the caller that knows where to say it; nothing latches. import { useCallback, useEffect, useRef } from 'react' @@ -14,7 +14,8 @@ import type { AgentSessionMutationResult } from '../../../../shared/agent-sessio import { agentSessionRefusalFailure, agentSessionThrownFailure, - agentSessionWriteKindForMethod as writeKind + agentSessionWriteKindForMethod as writeKind, + type AgentSessionWriteFailure } from '../../../../shared/agent-session-write-failure' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' @@ -28,8 +29,8 @@ import { agentSessionWriteFailureText } from './agent-session-write-notice-text' export type StructuredAgentSessionWriteOutcome = | { kind: 'done'; value: T } - /** Refused or failed, with what to tell the person. */ - | { kind: 'not-done'; notice: string } + /** Refused or failed, with what to tell the person and the fact it was worded from. */ + | { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } /** Settled for an owner or session this pane no longer shows; there is nothing to say. */ | { kind: 'dropped' } @@ -53,6 +54,18 @@ function namesWhatItStops(fingerprintMethod: string, fields: Record +): { kind: 'not-done'; notice: string; failure: AgentSessionWriteFailure } { + return { + kind: 'not-done', + notice: agentSessionWriteFailureText(failure, writeKind(fingerprintMethod, fields)), + failure + } +} + export function useStructuredAgentSessionMutate(args: { sessionId: string target: RuntimeClientTarget @@ -69,8 +82,8 @@ export function useStructuredAgentSessionMutate(args: { new Map>>() ) const enabledRef = useRef(enabled) - // The conversation command this pane still waits on; only its reply may outlive a fence move. A - // newer command replaces it, and disabling the pane forgets it. + // The conversation command or rewind this pane still waits on; only its reply may outlive a fence + // move. A newer one replaces it, and disabling the pane forgets it. const commandInFlight = useRef(null) useEffect(() => { // Why: update the gate after commit so render stays free of ref mutations. @@ -91,7 +104,10 @@ export function useStructuredAgentSessionMutate(args: { } const targetFence = pressedFence const request = {} - if (fingerprintMethod === 'agentSession.conversationCommand') { + if ( + fingerprintMethod === 'agentSession.conversationCommand' || + fingerprintMethod === 'agentSession.rewind' + ) { commandInFlight.current = request } // Whether this call's answer is still for what the pane shows. Another fence's result is about @@ -122,27 +138,19 @@ export function useStructuredAgentSessionMutate(args: { }) } catch (error) { return settlesHere() - ? { - kind: 'not-done', - notice: agentSessionWriteFailureText( - agentSessionThrownFailure( - error, - error instanceof RuntimeRpcCallError ? error.code : undefined - ), - writeKind(fingerprintMethod, fields) - ) - } + ? notDone( + agentSessionThrownFailure( + error, + error instanceof RuntimeRpcCallError ? error.code : undefined + ), + fingerprintMethod, + fields + ) : { kind: 'dropped' } } if (!result.ok) { return settlesHere() - ? { - kind: 'not-done', - notice: agentSessionWriteFailureText( - agentSessionRefusalFailure(result.refusal), - writeKind(fingerprintMethod, fields) - ) - } + ? notDone(agentSessionRefusalFailure(result.refusal), fingerprintMethod, fields) : { kind: 'dropped' } } if (!settlesHere()) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts index 3f54875e4054..b6d76bd5a498 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-option-state.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { AgentSessionConversationCommand } from '../../../../shared/agent-session-conversation-command' import type { AgentSessionOptionsResult } from '../../../../shared/agent-session-wire' +import type { AgentSessionRewindSupport } from '../../../../shared/agent-session-rewind' import type { AgentType } from '../../../../shared/agent-status-types' import { getAgentSessionOptionCatalog, @@ -49,6 +50,9 @@ export function useStructuredAgentSessionOptionState(args: { commands: readonly AgentSessionConversationCommand[] threadGoal: AgentSessionOptionsResult['threadGoal'] contextUsage: AgentSessionOptionsResult['contextUsage'] + rewind: AgentSessionRewindSupport + /** The fence the read answered for; rewind support is only that runtime's. */ + fence: number | null } | null>(null) // A revision the loaded window dropped can move the host's whole-journal context facts. const contextRefresh = conversationSupport?.contextUsage ? (args.unloadedTurnRevisions ?? 0) : 0 @@ -110,7 +114,10 @@ export function useStructuredAgentSessionOptionState(args: { sessionId, commands: result.conversationCommands ?? [], threadGoal: result.threadGoal, - contextUsage: result.contextUsage + contextUsage: result.contextUsage, + // A host that predates rewind does not name it. + rewind: result.rewind ?? { supported: false, reason: 'unsupported' }, + fence }) updateOptionState((current) => current.record === activeOptionRecordRef.current diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts index b8838a2f4399..91b4a0a8084a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-options.ts @@ -283,6 +283,8 @@ export function useStructuredAgentSessionOptions(args: { threadGoal: support?.threadGoal, /** Absent from a host that predates it or a session that writes no context facts. */ contextUsage: support?.contextUsage, + /** Undefined until this fence's options read answers. */ + rewind: support?.fence === fence ? support.rewind : undefined, optionSnapshot, optionSurface, setStructuredOption diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rewind-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind-outbox.test.tsx new file mode 100644 index 000000000000..5ac9a1c695c5 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind-outbox.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { EMPTY_STRUCTURED_AGENT_SESSION } from '../../../../shared/structured-agent-session-reducer' +import { + enqueueStructuredAgentSessionLaunchPrompt, + readOutbox, + writeOutbox +} from './structured-agent-session-outbox-storage' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) +const state = { + ...EMPTY_STRUCTURED_AGENT_SESSION, + epoch: 'epoch-1', + fence: 3, + status: 'ready' as const +} + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) +vi.mock('./use-structured-agent-session-hold', () => ({ + useStructuredAgentSessionHold: () => undefined +})) +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ state, loadingOlder: false, loadOlder: vi.fn() }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +const args = { + sessionId: 'rewind-outbox', + target: { kind: 'local' as const }, + agent: 'codex' as const, + isVisible: true +} + +describe("the host's in-doubt rewind latch and the outbox", () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + localStorage.clear() + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.send' + ? new Promise(() => {}) + : Promise.resolve({ models: [], current: {}, rewind: { supported: true } }) + ) + }) + afterEach(() => vi.useRealTimers()) + + // The host recovers an in-doubt rewind on the next send, so holding sends here would deadlock it. + it.each(['queued', 'unconfirmed'] as const)( + 'keeps delivering a restored %s message and new sends while the latch is set', + async (entryState) => { + const entry = enqueueStructuredAgentSessionLaunchPrompt(args.sessionId, 'Pending prompt')! + writeOutbox(args.sessionId, [{ ...entry, state: entryState }]) + const view = renderHook(() => + useStructuredAgentSession({ ...args, rewind: { hostBlockedReason: 'outcome-unknown' } }) + ) + await act(async () => { + if (entryState === 'unconfirmed') { + view.result.current.retry(entry.clientMessageId) + } + await vi.advanceTimersByTimeAsync(60_000) + }) + const sends = () => + mocks.call.mock.calls.filter(([, method]) => method === 'agentSession.send') + expect(sends()).toHaveLength(1) + expect(view.result.current.send('New prompt', [])).toBe(true) + expect(view.result.current.error).toBeNull() + expect(readOutbox(args.sessionId).map((queued) => queued.clientMessageId)).toContain( + entry.clientMessageId + ) + view.unmount() + } + ) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx new file mode 100644 index 000000000000..34ae77bcb4ce --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.rewind.test.tsx @@ -0,0 +1,410 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' +import { AGENT_SESSION_REWIND_RECOVERY_CAPABILITY as RECOVERY } from '../../../../shared/protocol-version' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + operationId: vi.fn(), + toastError: vi.fn(), + toastMessage: vi.fn(), + outboxSend: vi.fn(), + outboxRetry: vi.fn(), + remoteCapabilities: new Set() +})) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: async (_environmentId: string, capability: string) => + mocks.remoteCapabilities.has(capability) +})) + +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: mocks.toastMessage } })) +let fence = 3 +let epoch = 'epoch-1' +let items: AgentJournalRenderItem[] = [] +let submissions: AgentJournalSubmission[] = [] +let queuedMessages: AgentSessionQueuedMessage[] | null = null + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + supportsStructuredAgentSessionQuietRepeatedStop: vi.fn(async () => false) +})) + +vi.mock('./native-chat-session-option-settings-write', () => ({ + enqueueSessionOptionSettingsWrite: vi.fn() +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence, + epoch, + cursor: { epoch, sequence: 2 }, + items, + submissions, + queuedMessages, + status: 'ready', + error: null, + hasOlder: false + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: mocks.operationId, + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + error: null, + send: mocks.outboxSend, + retry: mocks.outboxRetry + }) +})) + +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../../shared/agent-session-queued-message-wire' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' +import { useStructuredAgentSession } from './use-structured-agent-session' +import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-chat-draft-cache' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { + nativeChatRewindReasonCopy, + nativeChatRewindReturnedUnknownCopy +} from './native-chat-rewind-copy' + +const LOCAL_TARGET = { kind: 'local' } as const + +// This build's host settles an in-doubt rewind on the next send; an older one is tested below. +beforeEach(() => { + setLocalRuntimeCapabilitiesForTests([RECOVERY]) + mocks.remoteCapabilities = new Set([RECOVERY]) +}) +afterEach(() => setLocalRuntimeCapabilitiesForTests(null)) + +const OPTIONS = { + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [{ value: 'medium', label: 'Medium' }] + } + ], + current: { model: 'gpt-live', effort: 'medium' } +} + +describe('useStructuredAgentSession rewind RPC', () => { + beforeEach(() => { + vi.clearAllMocks() + fence = 3 + epoch = 'epoch-1' + submissions = [] + queuedMessages = null + items = [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Prompt' }] } + } + ] + mocks.operationId.mockReset().mockReturnValue('rewind-operation') + mocks.call.mockImplementation((_target, method) => + Promise.resolve( + method === 'agentSession.options' + ? { ...OPTIONS, rewind: { supported: true } } + : { ok: true, value: { itemId: 'user-1', epoch: 'epoch-2' } } + ) + ) + }) + + it('sends the agreed verb and fingerprint to the execution host and holds sends until reset', async () => { + const target = { kind: 'environment' as const, environmentId: 'ssh-host' } + const view = renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + await waitFor(() => expect(view.result.current.rewind.disabledReason).toBeNull()) + await act(() => view.result.current.rewind.request('user-1', async () => true)) + const { structuredAgentSessionPayloadFingerprint } = + await import('../../../../shared/structured-agent-session-mutation') + expect(mocks.call).toHaveBeenCalledWith(target, 'agentSession.rewind', { + envelope: { + sessionId: 'session-1', + clientOperationId: 'rewind-operation', + expectedRuntimeFence: 3, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.rewind', + sessionId: 'session-1', + fields: { itemId: 'user-1', expectedEpoch: 'epoch-1' } + }) + }, + itemId: 'user-1', + expectedEpoch: 'epoch-1' + }) + expect(view.result.current.send('stale composer text', [])).toBe(false) + expect(mocks.toastMessage).toHaveBeenCalledWith( + 'Wait for the conversation to go back to the earlier message.' + ) + epoch = 'epoch-2' + items = [] + view.rerender() + expect(view.result.current.messages).toEqual([]) + expect(view.result.current.rewind.pending).toBe(false) + }) + + it('renders reason-only host refusals without relying on host English', async () => { + mocks.call.mockImplementation((_target, method) => + Promise.resolve( + method === 'agentSession.options' + ? { ...OPTIONS, rewind: { supported: true } } + : { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: '', + details: { reason: 'rewindRefused', rewindReason: 'proof-mismatch' } + } + } + ) + ) + const view = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + await waitFor(() => expect(view.result.current.rewind.disabledReason).toBeNull()) + await act(() => view.result.current.rewind.request('user-1', async () => true)) + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + nativeChatRewindReasonCopy('proof-mismatch') + ) + expect(view.result.current.error).toBeNull() + }) + + it('explains an older host missing the RPC without claiming an uncertain rewind occurred', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.rewind' + ? Promise.reject( + new RuntimeRpcCallError({ + id: '1', + ok: false, + error: { code: 'method_not_found', message: 'Unknown method' }, + _meta: { runtimeId: 'runtime' } + }) + ) + : Promise.resolve({ ...OPTIONS, rewind: { supported: true } }) + ) + const view = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(view.result.current.rewind.disabledReason).toBeNull()) + await act(() => view.result.current.rewind.request('user-1', async () => true)) + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + nativeChatRewindReasonCopy('unsupported') + ) + expect(view.result.current.rewind.pending).toBe(false) + }) + + it('an unknown outcome leaves sending open and returns the message to the composer', async () => { + // A remote host without the capability throws before anything is sent: the outcome reads unknown. + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.rewind' + ? Promise.reject(new Error('Rewinding requires a newer Orca server.')) + : Promise.resolve({ ...OPTIONS, rewind: { supported: true } }) + ) + const onMessageReturned = vi.fn() + const view = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true, + composerScopeKey: 'unknown-scope', + rewind: { onMessageReturned } + }) + ) + await waitFor(() => expect(view.result.current.rewind.disabledReason).toBeNull()) + await act(() => view.result.current.rewind.request('user-1', async () => true)) + expect(readNativeChatDraftCache('unknown-scope')).toContain('Prompt') + expect(onMessageReturned).toHaveBeenCalledOnce() + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(nativeChatRewindReturnedUnknownCopy()) + expect(view.result.current.error).toBeNull() + view.result.current.send('Prompt', []) + view.result.current.retry('message-1') + expect(mocks.outboxSend).toHaveBeenCalledOnce() + expect(mocks.outboxRetry).toHaveBeenCalledOnce() + }) + + it("lets the host's in-doubt latch disable only the action", async () => { + mocks.call.mockResolvedValue({ ...OPTIONS, rewind: { supported: true } }) + const view = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true, + rewind: { hostBlockedReason: 'outcome-unknown' } + }) + ) + await waitFor(() => expect(view.result.current.rewind.surface).toBeDefined()) + expect(view.result.current.rewind.surface?.disabledReason).toBe( + nativeChatRewindReasonCopy('outcome-unknown') + ) + view.result.current.send('Next prompt', []) + expect(mocks.outboxSend).toHaveBeenCalledOnce() + expect(view.result.current.error).toBeNull() + }) + + it("holds the action behind the host's queued cards", async () => { + mocks.call.mockResolvedValue({ ...OPTIONS, rewind: { supported: true } }) + queuedMessages = [ + { + messageId: 'queued-1', + position: 0, + state: 'waiting', + paused: true, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Held' }] } + } + ] + const view = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(view.result.current.rewind.surface).toBeDefined()) + expect(view.result.current.rewind.disabledReason).toBe(nativeChatRewindReasonCopy('busy')) + }) +}) + +describe('useStructuredAgentSession rewind support and composer return', () => { + beforeEach(() => { + vi.clearAllMocks() + fence = 3 + epoch = 'epoch-1' + submissions = [] + queuedMessages = null + items = [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix the bug' }] } + } + ] + mocks.operationId.mockReset().mockReturnValue('rewind-operation') + }) + + it('offers no action on a host whose options name no rewind', async () => { + mocks.call.mockResolvedValue(OPTIONS) + const view = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => + expect(view.result.current.rewind.disabledReason).toBe( + nativeChatRewindReasonCopy('unsupported') + ) + ) + expect(view.result.current.rewind.surface).toBeUndefined() + }) + + it('returns the discarded message to the composer after the existing draft', async () => { + mocks.call.mockImplementation((_target, method) => + Promise.resolve( + method === 'agentSession.options' + ? { ...OPTIONS, rewind: { supported: true } } + : { ok: true, value: { itemId: 'user-1', epoch: 'epoch-2' } } + ) + ) + writeNativeChatDraftCache('rewind-scope', 'Half-typed') + const view = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true, + composerScopeKey: 'rewind-scope' + }) + ) + await waitFor(() => expect(view.result.current.rewind.disabledReason).toBeNull()) + await act(() => view.result.current.rewind.request('user-1', async () => true)) + expect(readNativeChatDraftCache('rewind-scope')).toContain('Half-typed') + expect(readNativeChatDraftCache('rewind-scope')).toContain('Fix the bug') + }) +}) + +describe('offering rewind only where the next send settles an in-doubt one', () => { + beforeEach(() => { + vi.clearAllMocks() + fence = 3 + epoch = 'epoch-1' + submissions = [] + queuedMessages = null + items = [ + { + itemId: 'user-1', + sequence: 1, + revision: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Prompt' }] } + } + ] + mocks.call.mockResolvedValue({ ...OPTIONS, rewind: { supported: true } }) + }) + + const remote = { kind: 'environment' as const, environmentId: 'ssh-host' } + const render = (target: typeof remote | typeof LOCAL_TARGET) => + renderHook(() => + useStructuredAgentSession({ sessionId: 'session-1', target, agent: 'codex', isVisible: true }) + ) + + it('offers it on a remote host that advertises the recovery', async () => { + const view = render(remote) + await waitFor(() => expect(view.result.current.rewind.surface).toBeDefined()) + }) + + it('offers none on a remote host that rewinds but predates the recovery', async () => { + mocks.remoteCapabilities = new Set() + const view = render(remote) + await waitFor(() => + expect(mocks.call).toHaveBeenCalledWith(remote, 'agentSession.options', expect.anything()) + ) + await act(async () => {}) + expect(view.result.current.rewind.surface).toBeUndefined() + }) + + it('offers it locally once the runtime answers, and none while it has not', async () => { + expect(render(LOCAL_TARGET).result.current.rewind.surface).toBeUndefined() + const view = render(LOCAL_TARGET) + await waitFor(() => expect(view.result.current.rewind.surface).toBeDefined()) + setLocalRuntimeCapabilitiesForTests(null) + const unanswered = render(LOCAL_TARGET) + await waitFor(() => expect(mocks.call).toHaveBeenCalled()) + await act(async () => {}) + expect(unanswered.result.current.rewind.surface).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts index 463e178cd202..3768a56a5b84 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -40,6 +40,8 @@ import { structuredAgentSessionStartFailureFacts } from './structured-agent-sess import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' +import { useStructuredAgentSessionRewind } from './use-native-chat-rewind' +import type { NativeChatRewindHost } from './use-native-chat-rewind' export type { StructuredPromptItem } from './structured-agent-session-message-projection' @@ -59,6 +61,8 @@ export function useStructuredAgentSession(args: { composerScopeKey?: string /** The chat-wide "queue follow-ups" setting; off keeps mid-turn sends immediate. */ queueFollowUps?: boolean + /** The host's rewind latch and what follows a message returned by a rewind. */ + rewind?: NativeChatRewindHost }) { const { agent, @@ -94,7 +98,8 @@ export function useStructuredAgentSession(args: { optionSurface, setStructuredOption, threadGoal: threadGoalSupport, - contextUsage: contextUsageSupport + contextUsage: contextUsageSupport, + rewind: rewindSupport } = useStructuredAgentSessionOptions({ agent, sessionId, @@ -128,6 +133,7 @@ export function useStructuredAgentSession(args: { const outboxController = useStructuredAgentSessionOutbox({ sessionId, target, + // Never held for an in-doubt rewind: the host recovers it on the next send. fence: transportState.fence, submissions: transportState.submissions, journalItems: transportState.journalItems, @@ -154,6 +160,24 @@ export function useStructuredAgentSession(args: { }) const { outbox } = outboxController + // What the host refuses a conversation command or a rewind behind. + const conversationBusy = Boolean( + transportState.turnId || + prompts.length || + transportState.backgroundTasks.isMonitoring || + outbox.length + ) + const rewind = useStructuredAgentSessionRewind({ + sessionId, + target, + composerScopeKey, + ...args.rewind, + state, + support: transportEnabled ? rewindSupport : undefined, + // The host also refuses a rewind behind its queued cards, paused ones included. + blocked: conversationBusy || commandPending.current || queuedMessageIds.length > 0, + write + }) // A host that takes a Stop naming no turn gets Stop from the send until the work settles; every // Stop before a turn opens needs that form. An older host can stop only a turn it has opened. const stopsConversation = @@ -193,18 +217,15 @@ export function useStructuredAgentSession(args: { mutate }) return { + epoch: state.epoch, + rewind, conversationCommands, runConversationCommand: (command: AgentSessionConversationCommand) => structuredConversationCommands.sendStructuredConversationCommand({ command, agentName: structuredAgentLabel(agent), pending: commandPending, - blocked: Boolean( - transportState.turnId || - prompts.length || - transportState.backgroundTasks.isMonitoring || - outbox.length - ), + blocked: conversationBusy || rewind.blockedRef.current, startFailures: () => structuredAgentSessionStartFailureFacts(stateRef.current.items), send: (command) => write( @@ -237,8 +258,9 @@ export function useStructuredAgentSession(args: { send: (...input: Parameters) => // Legacy: an older host refuses sends while a command runs; removable once those hosts age out. (!commandPending.current || hostStatesTurnScopes(transportState.journalItems)) && + rewind.admitsSend() && outboxController.send(...input), - retry: outboxController.retry, + retry: rewind.unlessBlocked(outboxController.retry), isWorking: transportState.isWorking, workingStartedAt: transportState.turnTiming.workingStartedAt, settledTurns: transportState.turnTiming.settledTurns, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1a91ef5da61e..44df977da4af 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18226,6 +18226,27 @@ "conversationCommand": { "pendingWork": "Wait for pending work and messages to finish before using this command." }, + "rewind": { + "unsupported": "This chat can't go back to an earlier message.", + "historyNotPaginated": "This older Codex chat can't go back to an earlier message. Start a new chat to use Rewind to here.", + "busy": "Available once the agent is idle and nothing in this chat is waiting on you.", + "staleEpoch": "The conversation changed. Review the latest messages and try again.", + "invalidTarget": "The conversation can't go back to this message. Nothing was changed.", + "historyLimit": "This conversation is too long to go back in. Nothing was changed.", + "providerRefused": "The agent couldn't go back to this message.", + "proofMismatch": "The agent's history didn't match this chat. Check the conversation before continuing.", + "outcomeUnknown": "Orca couldn't confirm whether the conversation went back to an earlier message. It checks before your next message is sent.", + "outcomeUnknownReturned": "Orca couldn't confirm whether the conversation went back to this message. Your message is back in the composer, and Orca checks the conversation before your next message is sent.", + "timeout": "The chat didn't update after going back to this message. Reopen it to see the latest.", + "refused": "Orca couldn't go back to this message. Check the conversation before continuing.", + "unavailable": "Wait for the chat to finish connecting.", + "pending": "Wait for the conversation to go back to the earlier message.", + "title": "Rewind to here?", + "confirmation": "Discard this message and everything after it? The message returns to the composer so you can edit and resend it. File changes on disk are kept.", + "confirm": "Rewind", + "cancel": "Cancel", + "action": "Rewind to here" + }, "drop": { "title": "Drop to attach to this chat", "subtitle": "Files are added to your message as paths the agent can read." diff --git a/src/renderer/src/runtime/structured-agent-session-client.test.ts b/src/renderer/src/runtime/structured-agent-session-client.test.ts index 19e89d83ecb7..2ecb463d1adf 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.test.ts @@ -101,7 +101,10 @@ describe('callStructuredAgentSession rewind capability', () => { 'env-1', AGENT_SESSION_REWIND_RUNTIME_CAPABILITY ) - expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.rewind', params) + // As long as a command's: the host may start an agent at rest first. + expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.rewind', params, { + timeoutMs: 195_000 + }) }) it('does not dispatch rewind when host capability cannot be verified', async () => { @@ -118,7 +121,9 @@ describe('callStructuredAgentSession rewind capability', () => { await callStructuredAgentSession(target, 'agentSession.send', params) expect(mocks.supportsCapability).not.toHaveBeenCalled() - expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.rewind', params) + expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.rewind', params, { + timeoutMs: 195_000 + }) expect(mocks.call).toHaveBeenCalledWith(target, 'agentSession.send', params) }) }) diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts index d0e2aca691c7..42320bdb7849 100644 --- a/src/renderer/src/runtime/structured-agent-session-client.ts +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -87,6 +87,8 @@ export async function readStructuredAgentSessionConversationOutline( const STRUCTURED_AGENT_SESSION_METHOD_TIMEOUT_MS: ReadonlyMap = new Map([ ['agentSession.conversationCommand', 195_000], + // The host may start an agent at rest before rewinding it, as it does for a command. + ['agentSession.rewind', 195_000], // A waiting catalog read lasts as long as the host's listing: Claude's is 60 s, after up to 15 s // for an account switch to settle and 5 s of login-shell environment. ['agentSession.modelCatalog', 90_000] diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx b/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx index 22ceb0b49793..7c8a0d4e3424 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.test.tsx @@ -5,7 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, - AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY + AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RECOVERY_CAPABILITY } from '../../../shared/protocol-version' const mocks = vi.hoisted(() => ({ @@ -27,6 +29,7 @@ vi.mock('./runtime-host-contact-regained', () => ({ import { setLocalRuntimeCapabilitiesForTests } from './local-runtime-capabilities' import { useStructuredAgentSessionHostQueuesMessagesState, + useStructuredAgentSessionHostRecoversRewindOnSend, useStructuredAgentSessionHostStopsConversation } from './structured-agent-session-host-capability' @@ -97,3 +100,32 @@ describe('the queued-messages capability, three-state', () => { expect(result.current).toBe('unknown') }) }) + +describe('whether a host settles an in-doubt rewind on the next send', () => { + it('reads the local runtime, which only rewinding is not enough for', () => { + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_REWIND_RUNTIME_CAPABILITY]) + expect( + renderHook(() => useStructuredAgentSessionHostRecoversRewindOnSend({ kind: 'local' })).result + .current + ).toBe(false) + setLocalRuntimeCapabilitiesForTests([AGENT_SESSION_REWIND_RECOVERY_CAPABILITY]) + expect( + renderHook(() => useStructuredAgentSessionHostRecoversRewindOnSend({ kind: 'local' })).result + .current + ).toBe(true) + }) + + it.each([true, false])('asks a remote host, which answers %s', async (supported) => { + mocks.supports.mockResolvedValueOnce(supported) + const { result } = renderHook(() => + useStructuredAgentSessionHostRecoversRewindOnSend({ + kind: 'environment', + environmentId: 'env-1' + }) + ) + await waitFor(() => + expect(mocks.supports).toHaveBeenCalledWith('env-1', AGENT_SESSION_REWIND_RECOVERY_CAPABILITY) + ) + await waitFor(() => expect(result.current).toBe(supported)) + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability.ts b/src/renderer/src/runtime/structured-agent-session-host-capability.ts index 2b1a76d2c5b4..e083eadb57a9 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability.ts +++ b/src/renderer/src/runtime/structured-agent-session-host-capability.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_QUEUED_MESSAGES_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, type RuntimeCapability } from '../../../shared/protocol-version' import type { RuntimeClientTarget } from './runtime-client-target' @@ -93,6 +94,14 @@ export function useStructuredAgentSessionHostStopsConversation( ) } +/** Whether the host settles a rewind left in doubt on the next send, agent running or not: only + * then may a client offer a rewind, which hands an in-doubt prompt back for that send. */ +export function useStructuredAgentSessionHostRecoversRewindOnSend( + target: RuntimeClientTarget +): boolean { + return useStructuredAgentSessionHostCapability(target, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY) +} + /** Whether the host holds mid-turn sends as drafts: only then may a client send `delivery` * or call the queuedMessage RPCs. */ export function useStructuredAgentSessionHostQueuesMessages(target: RuntimeClientTarget): boolean { diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts index 2aaaac158ff2..ee002c1c1000 100644 --- a/src/shared/agent-session-refusal-reason-words.ts +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -82,7 +82,7 @@ const REASON_WORDS = { promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), - // No chat surface sends a rewind; a replayed one says only that it did not happen. + // The rewind control words its own refusals; anywhere else says only that it did not happen. rewindRefused: codeWords('hostFinding'), rewindUnconfirmed: codeWords('hostFinding'), promptGone: causeWords('questionChanged', 'nothingLeft'), diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 4e997ef30062..e8315c7c8732 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -226,6 +226,8 @@ export type NativeChatMessage = AgentJournalProducerLinkage & { parentId?: string /** How a user message was delivered when it was not an ordinary prompt. */ sentAs?: AgentJournalMessageSendMode + /** On a conversation command the user sent, such as `/compact`: the command it names. */ + command?: { name: string } /** Accepted but not yet handed to the agent: drawn after everything the agent has done. */ queued?: true /** Shown as not sent: in no turn, so a newer turn's bar and clock never land on it. Drawn where diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index addba77593f9..2b34d58bb051 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -267,6 +267,8 @@ export const AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY = 'agent-session.conversation-outline.v1' as const // The RPC is registered unconditionally; per-session rewind support is a separate check. export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const +// Why: the rewind UI relies on the next send settling an in-doubt rewind, even with the agent up. +export const AGENT_SESSION_REWIND_RECOVERY_CAPABILITY = 'agent-session.rewind-recovery.v1' as const // Readers must understand a monitoring roster with no available stop control. // Why: a `turn` journal item replaced the status row that used to carry a turn's lifecycle. A // client that predates it would render the unknown kind as text, so the host publishes the legacy @@ -405,6 +407,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_COMPLETION_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, + AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index bb352d7549c9..0fbb057f66e3 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -165,6 +165,7 @@ export function projectStructuredItemToNativeChat( // Reducer updates replace journal items, so unchanged rows keep their render caches. const projected = itemBlocks(item) const sentAs = item.body.kind === 'message' ? item.body.sentAs : undefined + const command = item.body.kind === 'message' ? item.body.command : undefined const message: NativeChatMessage | null = projected ? { ...agentJournalItemRowOrigin(item), @@ -172,7 +173,8 @@ export function projectStructuredItemToNativeChat( role: projected.role, blocks: projected.blocks, // A send mode this build cannot name renders as an ordinary message. - ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}) + ...(sentAs !== undefined && isAgentJournalMessageSendMode(sentAs) ? { sentAs } : {}), + ...(command ? { command } : {}) } : null projectedItems.set(item, message)