diff --git a/src/main/codex/codex-structured-fork-eligibility.test.ts b/src/main/codex/codex-structured-fork-eligibility.test.ts new file mode 100644 index 00000000000..e3d216af222 --- /dev/null +++ b/src/main/codex/codex-structured-fork-eligibility.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalRenderItem +} from '../../shared/agent-session-journal-types' +import { + selectAgentSessionPrefix, + structuredForkEligibleItems +} from '../../shared/agent-session-prefix' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' +const HANDLE = { provider: 'codex', threadId: THREAD_ID } as const + +/** Reduces the sink's append/tombstone stream the way the journal store does, so the predicate + * under test sees what the real producer actually leaves behind. */ +function reducingSink(): { + sink: StructuredAgentSessionEventSink + items: () => AgentJournalRenderItem[] +} { + const rows = new Map() + return { + sink: { + appendItem: (identity: AgentJournalItemIdentity, body: AgentJournalItemBody) => { + rows.set(agentJournalItemKey(identity), body) + }, + appendTombstone: (identity: AgentJournalItemIdentity) => { + rows.delete(agentJournalItemKey(identity)) + }, + publish: () => {} + }, + items: () => + [...rows].map(([itemId, body], index) => ({ + itemId, + body, + sequence: index, + observedAt: 1 + })) as AgentJournalRenderItem[] + } +} + +function notification(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params } +} + +/** Drives the real translator through one turn. Stopping before `turn/completed` leaves the + * journal in the shape a live turn really has. */ +function journalAfterTurn(settled: boolean, turnId: string = TURN_ID): AgentJournalRenderItem[] { + const tap = reducingSink() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + // Live user bubbles come from the submission, not the provider echo. + tap.sink.appendItem( + { provider: 'orca', clientMessageId: `prompt-${turnId}` }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Ask' }] } + ) + translator.handle(notification('turn/started', { turn: { id: turnId } })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: `answer-${turnId}`, text: 'Answered' } + }) + ) + if (settled) { + translator.handle(notification('turn/completed', { turn: { id: turnId } })) + } + return tap.items() +} + +function assistantId(items: readonly AgentJournalRenderItem[]): string { + const answer = items.find( + (item) => item.body.kind === 'message' && item.body.role === 'assistant' + ) + expect(answer).toBeDefined() + return answer!.itemId +} + +describe('forking a turn the real Codex producer settled', () => { + it('leaves no lifecycle row behind once the turn completes', () => { + const items = journalAfterTurn(true) + // The producer contract this feature must read: settlement TOMBSTONES the row. Nothing in the + // product ever writes `turnLifecycle.state: 'completed'`, so a fixture that does is not a + // model of any journal Orca can produce. + expect(items.some((item) => item.body.kind === 'status' && item.body.turnLifecycle)).toBe(false) + expect(items.map((item) => item.body.kind)).toEqual(['message', 'message']) + }) + + it('offers the settled turn as forkable and retains it inclusively', () => { + const items = journalAfterTurn(true) + const itemId = assistantId(items) + expect(structuredForkEligibleItems(items).has(itemId)).toBe(true) + const selected = selectAgentSessionPrefix({ + items, + itemId, + handle: HANDLE, + boundary: 'through' + }) + expect(selected).toMatchObject({ ok: true, throughId: TURN_ID }) + expect(selected.ok && selected.retained.map((item) => item.itemId)).toEqual( + items.map((item) => item.itemId) + ) + }) + + it('refuses the same turn while it is still running', () => { + const items = journalAfterTurn(false) + const itemId = assistantId(items) + expect( + items.some( + (item) => item.body.kind === 'status' && item.body.turnLifecycle?.state === 'running' + ) + ).toBe(true) + expect(structuredForkEligibleItems(items).has(itemId)).toBe(false) + expect( + selectAgentSessionPrefix({ items, itemId, handle: HANDLE, boundary: 'through' }) + ).toMatchObject({ ok: false, reason: 'busy' }) + }) + + it('keeps a settled earlier turn forkable while a later turn runs', () => { + const settled = journalAfterTurn(true) + const items = [...settled, ...journalAfterTurn(false, 'turn-2')] + expect(structuredForkEligibleItems(items)).toEqual(new Set([assistantId(settled)])) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts index 57f6f2d92df..a29c01c3e5f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition.ts @@ -1,6 +1,7 @@ import { beginStructuredForkAttempt, - proveStructuredForkAcquisition + proveStructuredForkAcquisition, + refuseStructuredForkAttempt } from './structured-agent-session-fork-lifecycle' import { isDeepStrictEqual } from 'node:util' import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof' @@ -80,8 +81,16 @@ export async function acquireOwner( } } catch (error) { if (isAgentSessionPreSpawnError(error)) { + // The launch never resolved, so no provider session can exist: settle the attempt rather + // than strand it. A failure past this point stays ambiguous and keeps the guard. + await refuseStructuredForkAttempt(input.store, record, describePreSpawnRefusal(error)) throw error } return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) } } + +function describePreSpawnRefusal(error: unknown): string { + const cause = error instanceof Error ? (error.cause ?? error) : error + return cause instanceof Error && cause.message ? cause.message : 'agent_session_pre_spawn' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.test.ts index fba8bff63ec..697d7959837 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionForkRecord } from '../../../shared/agent-session-fork' @@ -9,7 +9,9 @@ import { isAgentSessionRecord } from '../../../shared/agent-session-record' import { beginStructuredForkAttempt, proveStructuredForkAcquisition, - publishStructuredForkJournal + publishStructuredForkJournal, + refuseStructuredForkAttempt, + restartRefusedStructuredFork } from './structured-agent-session-fork-lifecycle' const NOW = 1_800_000_000_000 @@ -198,4 +200,109 @@ describe('durable fork lifecycle', () => { }) ).toThrow('agent_session_provider_handle_invalid') }) + + it('settles an attempt that never reached the provider and drops its stranded prefix', async () => { + const { store, record, fork } = await prepare() + await beginStructuredForkAttempt(store, record) + await refuseStructuredForkAttempt(store, record, 'managed account is switching') + // The store re-serializes in full on every lease renewal, so the dead prefix must not survive. + expect(store.getRecord(record.sessionId)?.fork).toMatchObject({ + phase: 'refused', + reason: 'managed account is switching', + retained: [] + }) + expect(isAgentSessionRecord(store.getRecord(record.sessionId))).toBe(true) + // A refused record is terminal: it is re-armed deliberately, never resumed in place. + await expect( + beginStructuredForkAttempt(store, store.getRecord(record.sessionId)!) + ).rejects.toThrow('agent_session_operation_unknown') + await restartRefusedStructuredFork(store, record.sessionId, fork) + expect( + await beginStructuredForkAttempt(store, store.getRecord(record.sessionId)!) + ).toMatchObject({ throughId: 'turn' }) + }) + + it('leaves an ambiguous outcome under the guard rather than settling it', async () => { + const { store, record } = await prepare() + await beginStructuredForkAttempt(store, record) + await store.commitProcessIdentity({ + sessionId: record.sessionId, + fence: 1, + process: { hostId: 'local', pid: 123, processStartTimeMs: NOW, spawnToken: 'child-token' }, + now: NOW + }) + const proved = await store.proveOwner({ + sessionId: record.sessionId, + fence: 1, + now: NOW, + link: proveStructuredForkAcquisition(record, { + linkId: 'fork-link', + handle: { provider: 'codex', threadId: 'child' }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW + }) + }) + expect(proved.fork?.phase).toBe('provider-succeeded') + // A provider session may already exist here; settling would license a second one. + await refuseStructuredForkAttempt(store, proved, 'too late') + expect(store.getRecord(record.sessionId)?.fork?.phase).toBe('provider-succeeded') + }) + + it('settles its existing epoch after a crash instead of replacing the child journal twice', async () => { + const { root, store, record } = await prepare() + await beginStructuredForkAttempt(store, record) + await store.commitProcessIdentity({ + sessionId: record.sessionId, + fence: 1, + process: { hostId: 'local', pid: 123, processStartTimeMs: NOW, spawnToken: 'child-token' }, + now: NOW + }) + const proved = await store.proveOwner({ + sessionId: record.sessionId, + fence: 1, + now: NOW, + link: proveStructuredForkAcquisition(record, { + linkId: 'fork-link', + handle: { provider: 'codex', threadId: 'child' }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW + }) + }) + const openJournal = async () => { + const journal = new AgentSessionJournal({ + journalDir: join(root, 'journal'), + identity: { + sessionId: record.sessionId, + workspaceId: 'workspace', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'child' } + } + }) + journals.push(journal) + await journal.open() + return journal + } + // Crash in the window between the journal transaction and the completion transition. + const transition = vi + .spyOn(store, 'transitionHandoff') + .mockRejectedValueOnce(new Error('host exited')) + await expect(publishStructuredForkJournal(store, proved, await openJournal())).rejects.toThrow( + 'host exited' + ) + transition.mockRestore() + expect(store.getRecord(record.sessionId)?.fork?.phase).toBe('provider-succeeded') + + const reopened = await openJournal() + const epoch = reopened.cursor().epoch + await publishStructuredForkJournal(store, store.getRecord(record.sessionId)!, reopened) + expect(reopened.cursor().epoch).toBe(epoch) + expect(reopened.snapshot().items.map((item) => item.itemId)).toEqual(['codex:child:turn:0']) + expect(store.getRecord(record.sessionId)).toMatchObject({ + schemaVersion: 2, + fork: { phase: 'completed', retained: [] } + }) + }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.ts index 9e6edc5cb49..dd23f0f7577 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-fork-lifecycle.ts @@ -1,5 +1,10 @@ +import { isDeepStrictEqual } from 'node:util' import { agentSessionForkAnchor } from '../../../shared/agent-session-fork' -import type { AgentSessionForkTarget } from '../../../shared/agent-session-fork' +import type { + AgentSessionForkRecord, + AgentSessionForkTarget +} from '../../../shared/agent-session-fork' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' import { agentSessionProviderHandleKey, @@ -39,6 +44,56 @@ export async function beginStructuredForkAttempt( } } +/** + * Settle an attempt that provably never reached the provider. + * + * Without a terminal for this case the record stays `attempted` forever: every later attach throws + * `agent_session_operation_unknown` and replay deliberately skips the phase, so the child is + * wedged. Clearing `retained` matters just as much — the store re-serializes in full on every + * lease renewal, so a stranded prefix is rewritten every few seconds for the life of the record. + * + * Only a proven pre-provider failure may settle here. An ambiguous one must keep `attempted` and + * refuse a second attempt rather than risk a second provider session. + */ +export async function refuseStructuredForkAttempt( + store: AgentSessionRecordStore, + record: AgentSessionRecord, + reason: string +): Promise { + const fork = store.getRecord(record.sessionId)?.fork + if (fork?.phase !== 'attempted') { + return + } + await store.transitionHandoff(record.sessionId, (current) => { + if ( + current.lease.runtimeFence !== record.lease.runtimeFence || + current.fork?.phase !== 'attempted' + ) { + throw new Error('agent_session_checkpoint_stale') + } + return { + ...current, + fork: { ...current.fork, phase: 'refused', reason: reason.slice(0, 512), retained: [] } + } + }) +} + +/** Re-arm a refused fork. The retry re-derives the prefix from the parent, because settling the + * refusal dropped the stranded copy. */ +export function restartRefusedStructuredFork( + store: AgentSessionRecordStore, + sessionId: string, + fork: AgentSessionForkRecord +): Promise { + return store.transitionHandoff(sessionId, (current) => { + if (current.fork?.phase !== 'refused') { + throw new Error('agent_session_checkpoint_stale') + } + // Merged, not replaced: the replay that preceded this stamped the recovery operation ids. + return { ...current, fork: { ...current.fork, ...fork, phase: 'prepared' } } + }) +} + export function proveStructuredForkAcquisition( record: AgentSessionRecord, acquired: AgentSessionProviderHandleLink @@ -83,11 +138,23 @@ export async function publishStructuredForkJournal( } return { ...item, body: item.body } }) - await journal.replaceEpochItems( - 'handle_forked', - record.lease.runtimeFence, - forkJournalSeed(items, fork.source, head.handle) - ) + const seed = forkJournalSeed(items, fork.source, head.handle) + // A crash after the journal transaction must settle its existing epoch, not replace it twice. + // The child journal is opened and seeded before its event sink is bound, so on re-entry it + // holds exactly the seed; anything else means the child moved on and must not be overwritten. + const landed = journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })) + if (landed.length > 0) { + if ( + !isDeepStrictEqual( + landed, + seed.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body })) + ) + ) { + throw new Error('agent_session_operation_invalid') + } + } else { + await journal.replaceEpochItems('handle_forked', record.lease.runtimeFence, seed) + } await store.transitionHandoff(record.sessionId, (current) => { if ( current.lease.runtimeFence !== record.lease.runtimeFence || diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-fork.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-fork.test.ts index 0f9c476336b..0ad56f4348c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-fork.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-fork.test.ts @@ -9,9 +9,10 @@ import type { AgentSessionForkSource } from '../../../shared/agent-session-fork' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { - StructuredAgentSessionAdapter, - StructuredAgentSessionAcquireInput +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -61,6 +62,8 @@ async function setup(provider: 'claude' | 'codex' = 'codex') { sessionId: isChild ? 'child-thread' : 'parent-thread', leafUuid: forked ? input.fork!.throughId : 'turn-2-1' } as const) + // Settlement TOMBSTONES a turn's lifecycle row, so a finished turn leaves none behind. A + // fixture that appends `turnLifecycle.state: 'completed'` models no journal Orca can produce. if (!isChild) { for (const turn of ['turn-1', 'turn-2']) { input.events?.appendItem(identity(turn, 0), { @@ -73,10 +76,6 @@ async function setup(provider: 'claude' | 'codex' = 'codex') { role: 'assistant', blocks: [{ type: 'text', text: `${turn} answer` }] }) - input.events?.appendItem( - { provider: 'orca', clientMessageId: `${turn}-done` }, - { kind: 'status', text: 'Completed', turnLifecycle: { turnId: turn, state: 'completed' } } - ) } } return { @@ -104,10 +103,6 @@ async function setup(provider: 'claude' | 'codex' = 'codex') { role: 'assistant', blocks: [{ type: 'text', text: 'Third answer' }] }) - input.events?.appendItem( - { provider: 'orca', clientMessageId: 'turn-3-done' }, - { kind: 'status', text: 'Completed', turnLifecycle: { turnId: 'turn-3', state: 'completed' } } - ) return { state: 'accepted', providerIdentity: identity('turn-3', 0) } }) const adapter: StructuredAgentSessionAdapter = { @@ -256,10 +251,28 @@ describe('fork from a structured turn', () => { expect(store.getRecord('child-session')?.fork?.phase).toBe('attempted') expect(store.getVisibleSessionTabIndex().sessionIds).not.toContain('child-session') expect(host.hasSession('child-session')).toBe(false) - await host.fork(caller, child, source) + // The ambiguity guard: the provider may hold a child, so the retry must never make a second. + expect(await host.fork(caller, child, source)).toMatchObject({ ok: false }) expect(acquire).toHaveBeenCalledTimes(2) }) + it('recovers a fork whose launch failed before any provider session existed', async () => { + const { host, store, child, source, acquire } = await setup() + acquire.mockImplementationOnce(async () => { + throw new AgentSessionPreSpawnError(new Error('managed account is switching')) + }) + await expect(host.fork(caller, child, source)).rejects.toThrow('managed account is switching') + // Settled, not stranded — and the dead prefix is dropped rather than rewritten on every + // lease renewal for the life of the record. + expect(store.getRecord('child-session')?.fork).toMatchObject({ + phase: 'refused', + retained: [] + }) + expect(await host.fork(caller, child, source)).toMatchObject({ ok: true }) + expect(host.journalSnapshot('child-session').items).not.toHaveLength(0) + expect(acquire.mock.calls.filter(([input]) => input.fork)).toHaveLength(2) + }) + it('resumes the proved child when journal publication fails instead of forking again', async () => { const { host, child, source, acquire, store } = await setup() const replace = vi diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-fork.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-fork.ts index 0721f99764d..f9210ee53a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-fork.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-fork.ts @@ -2,6 +2,7 @@ import { forkJournalSeed } from './structured-fork-journal-seed' import { restoreRewindJournalBody } from './structured-rewind-journal-body' import type { AgentSessionRewindReason } from '../../../shared/agent-session-rewind' import { prepareStructuredForkReplay } from './structured-agent-session-fork-replay' +import { restartRefusedStructuredFork } from './structured-agent-session-fork-lifecycle' import type { AgentSessionForkSource } from '../../../shared/agent-session-fork' import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' @@ -65,7 +66,9 @@ export function forkStructuredAgentSession( ) { return refuse('invalid-target') } - let fork = prior + // A refused attempt proved no provider session exists, so the retry re-derives the prefix + // instead of replaying a record whose retained copy was dropped when it settled. + let fork = prior?.phase === 'refused' ? undefined : prior if (!fork) { const support = context.deps.adapter.forkSupport?.(source.sessionId) if (!support?.supported) { @@ -140,10 +143,15 @@ export function forkStructuredAgentSession( fields: attachFingerprintFields(attach) }) } + // Ordered: replay reads the settled `refused` record to mint the recovery envelope this + // existing child record requires, and only then is the record re-armed for a fresh attempt. const replay = await prepareStructuredForkReplay(context, caller, attach) if (replay.result) { return replay.result } + if (prior?.phase === 'refused') { + await restartRefusedStructuredFork(store, params.envelope.sessionId, fork) + } return attachStructuredAgentSession(attachContext, caller.callerKey, replay.params) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.test.ts b/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.test.ts index 297d2d15c5b..cc3dc490278 100644 --- a/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.test.ts @@ -85,13 +85,62 @@ describe('fork journal identities', () => { expect(() => forkJournalIdentity({ provider: 'claude', sessionId: 'parent', uuid: 'id' }, source, target) ).toThrow('agent_session_identity_required') - expect(() => - forkJournalIdentity( - { provider: 'legacy', agent: 'codex', sessionId: 'parent', recordId: '1' }, - source, - target - ) - ).toThrow('agent_session_identity_required') + }) + + it('carries an adopted transcript into the child and continues it on the new thread', () => { + // Adoption imports the whole prior conversation under `legacy:` keys, so a turn taken after + // adopting is a mix of legacy history and Codex echoes. Both belong in the child. + const imported = ['msg_1', 'msg_2'].map((recordId) => ({ + itemId: agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'parent', + recordId + }), + body, + observedAt: 1 + })) + const seed = forkJournalSeed( + [ + ...imported, + { itemId: 'codex:parent:turn-a:0', body, observedAt: 2 }, + { itemId: 'codex:parent:turn-a:1', body, observedAt: 3 } + ], + source, + target + ) + expect(seed.map((item) => agentJournalItemKey(item.identity))).toEqual([ + 'legacy:codex:parent:msg_1', + 'legacy:codex:parent:msg_2', + 'codex:child:turn-a:0', + 'codex:child:turn-a:1' + ]) + }) + + it('leaves the parent turn-lifecycle row behind rather than seeding a phantom running turn', () => { + const lifecycle: AgentJournalItemBody = { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId: 'turn-a', state: 'running' } + } + const seed = forkJournalSeed( + [ + { + itemId: agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'parent', + recordId: 'turn-lifecycle:turn-a' + }), + body: lifecycle, + observedAt: 1 + }, + { itemId: 'codex:parent:turn-a:1', body, observedAt: 2 } + ], + source, + target + ) + expect(seed.map((item) => agentJournalItemKey(item.identity))).toEqual(['codex:child:turn-a:1']) }) it('does not retain source submission identities or prompt authority', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.ts b/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.ts index 54b13b69f6f..f305008da28 100644 --- a/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.ts +++ b/src/main/native-chat/agent-session-wire/structured-fork-journal-seed.ts @@ -32,6 +32,12 @@ export function forkJournalIdentity( ordinal: identity.ordinal } } + // Import-scoped bridge-era rows: no provider echo can ever reconcile against one, and the + // session id already names the provider conversation the child forks from, so it carries over + // verbatim rather than being minted into a namespace the child cannot reproduce. + if (identity.provider === 'legacy') { + return identity + } if (identity.provider === 'claude' && target.provider === 'claude') { // Forked files rewrite sessionId; retained UUIDs still belong to the parent namespace. return { @@ -57,8 +63,9 @@ export function forkJournalSeed( if (!identity) { throw new Error('agent_session_identity_required') } - // Host status and submission identities belong to the parent's operation lifecycle. - if (identity.provider === 'orca') { + // Host status and submission identities belong to the parent's operation lifecycle. So does a + // turn-lifecycle row, whichever namespace it was keyed in — the child opens its own. + if (identity.provider === 'orca' || (item.body.kind === 'status' && item.body.turnLifecycle)) { return [] } const rekeyed = forkJournalIdentity(identity, source, target) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index e6970905052..0a61cdff321 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -244,7 +244,6 @@ export function NativeChatMessageList({ onScroll={handleScroll} className="scrollbar-sleek h-full overflow-y-auto [scrollbar-gutter:stable_both-edges] px-3 pt-10 pb-4 sm:px-4" > -
)} {forkAction?.eligibleIds.has(message.id) ? ( - @@ -355,7 +360,6 @@ export function NativeChatMessageList({ ) : null} {!showTurnStatus && showTypingIndicator ? : null}
- {showJump ? (