From 01b71087034e04fbb4b2bca0e5dfc4e1e886c6cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:37:52 -0700 Subject: [PATCH] fix(codex): retain exact cancellable prompt turn ids --- .../codex/codex-prompt-registry-bounds.ts | 5 +-- src/main/codex/codex-prompt-registry.ts | 6 +++- .../codex-structured-prompt-ownership.test.ts | 25 +++++++++++++++ .../codex-structured-prompt-replies.test.ts | 31 ++++++++++++++----- src/shared/agent-session-wire.ts | 2 ++ .../structured-agent-session-params.ts | 3 +- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/main/codex/codex-prompt-registry-bounds.ts b/src/main/codex/codex-prompt-registry-bounds.ts index f57fbb1e8d1..9c97bf63cd9 100644 --- a/src/main/codex/codex-prompt-registry-bounds.ts +++ b/src/main/codex/codex-prompt-registry-bounds.ts @@ -2,6 +2,7 @@ import { boundPayload, digestPayload } from '../native-chat/agent-session-journal/journal-payload-bounds' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' export const CODEX_JOURNAL_PROMPT_ID_COMPONENT_MAX_BYTES = 256 export const CODEX_JOURNAL_PROMPT_OPTION_ID_MAX_BYTES = 1024 @@ -13,7 +14,7 @@ export const CODEX_PROMPT_MAX_ANSWER_BYTES = 64 * 1024 export const MAX_CODEX_PROMPT_REGISTRY_ENTRIES = 128 export const MAX_CODEX_PROMPT_JOURNAL_BINDINGS = 256 export const MAX_CODEX_PROMPT_REGISTRY_BYTES = 4 * 1024 * 1024 -const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = 512 +const CODEX_PROMPT_TURN_ID_RESERVED_BYTES = AGENT_SESSION_ID_MAX_LENGTH * 3 type CodexPromptRegistryEntryBounds = { threadId: string @@ -49,7 +50,7 @@ export function codexPromptTurnIdentity(turnId: string): { turnId: string | null turnIdDigest?: string } { - return Buffer.byteLength(turnId, 'utf8') <= CODEX_PROMPT_TURN_ID_RESERVED_BYTES + return turnId.length <= AGENT_SESSION_ID_MAX_LENGTH ? { turnId } : { turnId: null, turnIdDigest: digestPayload(turnId) } } diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts index 70a57255943..c6d0d7f4bef 100644 --- a/src/main/codex/codex-prompt-registry.ts +++ b/src/main/codex/codex-prompt-registry.ts @@ -89,11 +89,15 @@ export class CodexPromptRegistry { return null } const turnId = readString(request.params, 'turnId') + const turnIdentity = turnId ? codexPromptTurnIdentity(turnId) : { turnId: null } + if (turnId && turnIdentity.turnId === null) { + return null + } const prompt: CodexPendingPrompt = { requestId: request.id, method: request.method, threadId, - ...(turnId ? codexPromptTurnIdentity(turnId) : { turnId: null }), + ...turnIdentity, codexItemId, promptKey: readString(request.params, 'approvalId') ?? codexItemId, questionIds, diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index 6475c439e05..cfb77e628a7 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' import type { AgentJournalItemBody, AgentJournalItemIdentity @@ -301,6 +302,30 @@ describe('Codex live prompt ownership', () => { expect(codex.connections[0]?.closed).toBe(false) }) + it('keeps a wire-valid multibyte prompt turn id as the exact interrupt target', async () => { + const promptTurnId = '界'.repeat(171) + expect(promptTurnId.length).toBeLessThanOrEqual(AGENT_SESSION_ID_MAX_LENGTH) + expect(Buffer.byteLength(promptTurnId, 'utf8')).toBeGreaterThan(AGENT_SESSION_ID_MAX_LENGTH) + const codex = fakeCodex({ + 'turn/interrupt': () => completeTurn(codex, 'thread-child', promptTurnId) + }) + const adapter = await acquired(codex) + registerPrompt(adapter, codex, 'child-prompt', 'thread-child', promptTurnId) + + await expect( + adapter.cancelTurn({ + sessionId: 'session-1', + turnId: 'root-turn', + fence: 7, + prompt: { itemId: 'child-prompt' } + }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0]?.calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: 'thread-child', turnId: promptTurnId } + }) + }) + it('settles a grouped prompt and its running turn before reporting cancellation', async () => { const codex = fakeCodex({ 'turn/interrupt': () => { diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts index efb04de0675..e575f27632b 100644 --- a/src/main/codex/codex-structured-prompt-replies.test.ts +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_ID_MAX_LENGTH } from '../../shared/agent-session-wire' import { applyCodexPromptAnswer, CodexPromptRegistry, @@ -122,7 +123,7 @@ describe('CodexPromptRegistry', () => { expect(registry.find('other-thread-item')?.requestId).toBe(3) }) - it('bounds an oversized backfilled turn id and still clears its prompt', () => { + it('retains a bounded cleanup identity for an unaddressable backfilled turn id', () => { const registry = new CodexPromptRegistry() const turnId = 'turn-'.padEnd(MAX_CODEX_PROMPT_REGISTRY_BYTES + 1, 'x') registry.register({ @@ -138,20 +139,34 @@ describe('CodexPromptRegistry', () => { expect(registry.find('journal-root')).toBeNull() }) - it('bounds an oversized request turn id and still clears its prompt', () => { + it('reserves enough bytes for a wire-valid multibyte backfilled turn id', () => { const registry = new CodexPromptRegistry() - const turnId = 'turn-'.padEnd(MAX_CODEX_PROMPT_REGISTRY_BYTES + 1, 'x') + registry.register({ + id: 1, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'root-item', threadId: 'thread-1' } + }) + const reservedBytes = registry.bytes + const turnId = '界'.repeat(AGENT_SESSION_ID_MAX_LENGTH) + + registry.bindJournalItemId('journal-root', 'thread-1', 'root-item', turnId) + + expect(registry.find('journal-root')?.turnId).toBe(turnId) + expect(registry.bytes).toBe(reservedBytes) + expect(registry.bytes).toBeLessThanOrEqual(MAX_CODEX_PROMPT_REGISTRY_BYTES) + }) + + it('rejects a request turn id beyond the wire identity bound', () => { + const registry = new CodexPromptRegistry() + const turnId = 'x'.repeat(AGENT_SESSION_ID_MAX_LENGTH + 1) const prompt = registry.register({ id: 1, method: 'item/commandExecution/requestApproval', params: { itemId: 'root-item', threadId: 'thread-1', turnId } }) - expect(prompt).not.toBeNull() - expect(registry.bytes).toBeLessThanOrEqual(MAX_CODEX_PROMPT_REGISTRY_BYTES) - registry.bindJournalItemId('journal-root', 'thread-1', 'root-item') - registry.clearTurn('thread-1', turnId) - expect(registry.find('journal-root')).toBeNull() + expect(prompt).toBeNull() + expect(registry.bytes).toBe(0) }) it('addresses a prompt by its journal item id once bound, and forgets both', () => { diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index fb495f7cb8c..b1ad68f0fa3 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -75,6 +75,8 @@ export type AgentSessionTurnActivity = { text: string } +export const AGENT_SESSION_ID_MAX_LENGTH = 512 + /** Backward paging is the client's normal read; 40 matches the page size the * mobile list renders without a visible fill-in. */ export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index 6c669f14049..edbda12d6d9 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -2,11 +2,12 @@ import { z } from 'zod' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' import { + AGENT_SESSION_ID_MAX_LENGTH, AGENT_SESSION_HISTORY_DIRECTIONS, AGENT_SESSION_HISTORY_MAX_LIMIT } from '../agent-session-wire' -export const MAX_ID_LENGTH = 512 +export const MAX_ID_LENGTH = AGENT_SESSION_ID_MAX_LENGTH // Four Claude questions with all four generated choices occupy 610 chars when fully percent-encoded. export const MAX_RESPONSE_OPTION_ID_LENGTH = 1024