diff --git a/src/main/claude/claude-structured-dispatch.test.ts b/src/main/claude/claude-structured-dispatch.test.ts index 86ab6a45627..1054561f657 100644 --- a/src/main/claude/claude-structured-dispatch.test.ts +++ b/src/main/claude/claude-structured-dispatch.test.ts @@ -15,6 +15,7 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession acquisitionGeneration: 'generation-1', prompts: {} as ClaudeSession['prompts'], dispatchWaiters: [], + dispatchSequence: 0, options: new Map(), reportedOptions: {}, capabilities: [], diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 045c18fa6aa..0941f654d64 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -169,6 +169,9 @@ export async function dispatchClaudeTurn( } catch (error) { return { state: 'rejected', reason: (error as Error).message } } + // Advance before writing so an unknown/late replay still fences cancellation + // of the previously acknowledged turn on this session-scoped connection. + const dispatchSequence = ++session.dispatchSequence const acceptsResult = input.body.blocks.some( (block) => block.type === 'text' && block.text.trimStart().startsWith('/') ) @@ -193,6 +196,7 @@ export async function dispatchClaudeTurn( // Claude's interrupt API is session-scoped, so retain the provider turn // identity and let cancellation reject a stale request for an older turn. session.activeTurnId = uuid + session.activeTurnSequence = dispatchSequence } return uuid ? { diff --git a/src/main/claude/claude-structured-session-adapter.test.ts b/src/main/claude/claude-structured-session-adapter.test.ts index 4649b457137..d8cb1632946 100644 --- a/src/main/claude/claude-structured-session-adapter.test.ts +++ b/src/main/claude/claude-structured-session-adapter.test.ts @@ -358,6 +358,39 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => { ) }) + it('does not cancel an acknowledged turn after a later dispatch returns unknown', async () => { + const claude = fakeClaude({ replayUuids: ['turn-T', null] }) + const adapter = await acquired(claude) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-T', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { uuid: 'turn-T' } + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-U', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ state: 'unknown' }) + expect(claude.connections[0].sent).toHaveLength(2) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength( + 0 + ) + }) + it('classifies provider-declined options without treating timeouts as settled', async () => { const claude = fakeClaude({ routes: { diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e91b1e0e28f..c456288468e 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -155,7 +155,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda this.sessions.get(input.sessionId) === session && session.fence === input.fence && session.acquisitionGeneration === acquisitionGeneration && - (session.activeTurnId === undefined || session.activeTurnId === input.turnId) + (session.activeTurnId === undefined + ? session.dispatchSequence === 0 + : session.activeTurnId === input.turnId && + session.activeTurnSequence === session.dispatchSequence) ) }) } diff --git a/src/main/claude/claude-structured-session-publication.ts b/src/main/claude/claude-structured-session-publication.ts index 853118f6fa2..a5b8bcfbf56 100644 --- a/src/main/claude/claude-structured-session-publication.ts +++ b/src/main/claude/claude-structured-session-publication.ts @@ -44,6 +44,7 @@ export function createClaudeSessionPublication(input: { acquisitionGeneration: input.acquisitionGeneration, prompts: input.prompts, dispatchWaiters: [], + dispatchSequence: 0, options: new Map(input.options), capabilities: input.capabilities, reportedOptions: { diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b165ba166a6..68558bacb1f 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -95,6 +95,10 @@ export type ClaudeSession = { capabilities: readonly string[] /** Provider uuid of the most recently admitted turn, if one is active. */ activeTurnId?: string + /** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */ + dispatchSequence: number + /** Dispatch sequence that admitted activeTurnId. */ + activeTurnSequence?: number translator: ClaudeJournalTranslator | null events: StructuredAgentSessionEventSink | undefined } diff --git a/src/main/claude/claude-structured-session-test-support.ts b/src/main/claude/claude-structured-session-test-support.ts index 8d21e157778..2cb8e15d882 100644 --- a/src/main/claude/claude-structured-session-test-support.ts +++ b/src/main/claude/claude-structured-session-test-support.ts @@ -56,7 +56,7 @@ export function fakeClaude( exitBeforeInit?: string settings?: unknown replayUuid?: string | null - replayUuids?: string[] + replayUuids?: (string | null)[] capabilities?: string[] unprovenCloseVerdict?: ClaudeStreamJsonConnection['exitVerdict'] routes?: Record @@ -149,13 +149,17 @@ export function fakeClaude( send: async (message) => { connection.sent.push(message) if (message.type === 'user' && options.replayUuid !== null) { - const replayUuid = options.replayUuids - ? (options.replayUuids[replayIndex++] ?? `user-uuid-${replayIndex}`) - : (options.replayUuid ?? 'user-uuid') - handlers.onMessage?.({ - ...message, - uuid: replayUuid - }) + const configuredReplayUuid = options.replayUuids + ? options.replayUuids[replayIndex++] + : options.replayUuid + const replayUuid = + configuredReplayUuid === undefined ? `user-uuid-${replayIndex}` : configuredReplayUuid + if (replayUuid !== null) { + handlers.onMessage?.({ + ...message, + uuid: replayUuid + }) + } } }, exitVerdict: options.unprovenCloseVerdict ?? { root: 'live', tree: 'unverifiable' },