Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,40 @@ describe('runRewindSubmit durable-address discipline (#87059)', () => {
const submit = calls.find(call => call.method === 'prompt.submit')

expect(submit?.params?.truncate_before_row_id).toBe(13)
expect(submit?.params?.truncate_before_user_ordinal).toBe(1)
expect(submit?.params?.truncate_before_user_ordinal).toBeUndefined()
expect(submit?.params?.truncate_before_message_id).toBeUndefined()
expect(submit?.params?.confirm_truncate).toBe(true)
})

it('sends ONLY the durable rowId when bound, dropping a divergent ordinal (#4030 false-refusal)', async () => {
// The renderer's visible-user ordinal can diverge from the gateway's durable
// ordinal space (here ordinal 5 vs the durable target's real ordinal) — the
// #87059 class that used to make the gateway refuse a legitimate rewind with
// 4030 because the row_id and ordinal disagreed. A bound row_id is
// authoritative and the cut is aimed by it alone, so it must be sent without
// the wasteful, mismatch-triggering ordinal / message-id.
const calls: Call[] = []

await runRewindSubmit(makeGateway(calls), 'sid', 'fixed prompt', 5, undefined, false, undefined, 13, 'typo prompt')

const submit = calls.find(call => call.method === 'prompt.submit')

expect(submit?.params?.truncate_before_row_id).toBe(13)
expect(submit?.params?.truncate_before_user_ordinal).toBeUndefined()
expect(submit?.params?.truncate_before_message_id).toBeUndefined()
expect(submit?.params?.confirm_truncate).toBe(true)
})

it('keeps confirm_empty_truncate for an ordinal-0 restore even with a bound rowId', async () => {
const calls: Call[] = []

await runRewindSubmit(makeGateway(calls), 'sid', 'restore to empty', 0, undefined, false, undefined, 13, 'typo prompt')

const submit = calls.find(call => call.method === 'prompt.submit')

expect(submit?.params?.truncate_before_row_id).toBe(13)
expect(submit?.params?.truncate_before_user_ordinal).toBeUndefined()
expect(submit?.params?.confirm_empty_truncate).toBe(true)
})
})

Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/app/session/hooks/use-prompt-actions/rewind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ export async function runRewindSubmit(
const hasDurableAddress =
typeof truncateRowId === 'number' ||
(typeof truncateMessageId === 'string' && truncateMessageId.length > 0 && !isSyntheticRendererId(truncateMessageId))
// A bound INTEGER row_id is the gateway's authoritative address — the cut is
// ALWAYS aimed by the resolved durable target, never the client ordinal
// (gateway `_reconcile_client_ordinal`: "The cut itself is always aimed by the
// resolved durable target, never by the client ordinal"). But the renderer's
// visible-user ordinal can diverge from the gateway's durable ordinal space by
// an arbitrary amount (#87059; observed 75 vs 102), and the gateway REFUSES a
// mismatch with 4030 even though the row_id resolved fine — turning a
// legitimate rewind/edit/regenerate into a false refusal. With a row_id in
// hand, send it alone and drop the redundant ordinal + message-id; the gateway
// already prefers row_id over both, and stale-row_id safety is unchanged (an
// unknown/dead id still fails closed with 4018).
const hasRowId = typeof truncateRowId === 'number' && Number.isInteger(truncateRowId)

if (wantsTruncation && !hasDurableAddress) {
resolvedRowId =
Expand All @@ -262,6 +274,14 @@ export async function runRewindSubmit(
resolvedMessageId = undefined
}

if (hasRowId) {
// Durable row_id in hand: drop the ordinal and message-id so the gateway
// aims purely by the row_id (see the note above) instead of tripping its
// 4030 cross-check on a divergent renderer ordinal.
resolvedOrdinal = undefined
resolvedMessageId = undefined
}

const interrupt = async () => {
try {
await requestGateway('session.interrupt', { session_id: liveSessionId })
Expand Down
Loading