fix(codex): retain exact cancellable prompt turn ids

This commit is contained in:
Brennan Benson
2026-09-14 14:37:52 -07:00
parent 196a97e788
commit 01b7108703
6 changed files with 60 additions and 12 deletions
@@ -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) }
}
+5 -1
View File
@@ -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,
@@ -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': () => {
@@ -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', () => {
+2
View File
@@ -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
@@ -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