Merge pull request #72339 from NousResearch/bb/redirect-user-row

Preserve the original prompt when a mid-turn redirect corrects a turn
This commit is contained in:
brooklyn! 2026-07-26 20:09:46 -05:00 committed by GitHub
commit 6cea77303b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 279 additions and 14 deletions

View file

@ -435,6 +435,39 @@ describe('preserveLocalPendingTurnMessages', () => {
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
})
// A mid-turn redirect inserts its correction as a SECOND optimistic user row
// for the same turn. Keeping only the newest dropped the prompt that started
// it, so a resume repainted the thread with the user's message missing.
it('keeps every optimistic user row in the live run after a mid-turn redirect', () => {
const previous = [
msg('user-1000', 'user', 'remove the session counts'),
msg('user-2000', 'user', 'hurry up'),
msg('assistant-stream-1', 'assistant', 'Moving.', { pending: true })
]
expect(preserveLocalPendingTurnMessages([], previous).map(message => message.id)).toEqual([
'user-1000',
'user-2000',
'assistant-stream-1'
])
})
it('still drops optimistic rows separated from the live run by an assistant reply', () => {
const previous = [
msg('user-stale', 'user', 'compressed-away prompt'),
msg('assistant-stale', 'assistant', 'compressed-away reply'),
msg('user-1000', 'user', 'the live prompt'),
msg('user-2000', 'user', 'the correction'),
msg('assistant-stream-1', 'assistant', 'Moving.', { pending: true })
]
expect(preserveLocalPendingTurnMessages([], previous).map(message => message.id)).toEqual([
'user-1000',
'user-2000',
'assistant-stream-1'
])
})
// #67603: the gateway persists model-switch / personality notices as role=user
// ([System: …], tui_gateway/server.py). A single trailing marker is already
// handled by the latestAuthoritativeUser guard above, but TWO switches around
@ -538,6 +571,46 @@ describe('preserveLocalPendingTurnMessages', () => {
})
describe('appendLiveSessionProjection', () => {
// Corrections typed while a turn ran are their own user bubbles on the same
// turn. Resume must rebuild the prompt AND every correction, in order.
it('projects mid-turn redirect corrections after the prompt that started the turn', () => {
const restored = appendLiveSessionProjection([], {
session_id: 'runtime-1',
inflight: {
user: 'remove the session counts',
corrections: ['hurry up', 'and the worktree ones'],
assistant: 'Moving.',
streaming: true
}
})
expect(restored.map(message => message.parts.map(part => ('text' in part ? part.text : '')).join(''))).toEqual([
'remove the session counts',
'hurry up',
'and the worktree ones',
'Moving.'
])
})
it('does not re-project a correction the transcript already persisted', () => {
const stored = [msg('stored-user', 'user', 'remove the session counts'), msg('stored-fix', 'user', 'hurry up')]
const restored = appendLiveSessionProjection(stored, {
session_id: 'runtime-1',
inflight: {
user: 'remove the session counts',
corrections: ['hurry up'],
assistant: 'Moving.',
streaming: true
}
})
expect(restored.filter(message => message.role === 'user').map(message => message.id)).toEqual([
'stored-user',
'stored-fix'
])
})
it('does not duplicate the inflight user when the persisted turn carries @image refs', () => {
// By the time a stored transcript reaches appendLiveSessionProjection it
// has already been run through toChatMessages, so the @image directive has

View file

@ -326,6 +326,26 @@ export function preserveLocalPendingTurnMessages(
.reverse()
.find(message => message.role === 'user' && message.id.startsWith('user-'))
// A mid-turn redirect inserts its correction as a second optimistic user row
// directly before the live reply, so one turn can own a contiguous RUN of
// them. Preserving only the newest keeps the correction and drops the prompt
// that started the turn. Widen to the run — but only the contiguous one: any
// `user-*` row separated by an assistant reply is stale post-compression
// history, which is what the newest-only rule exists to discard.
const liveOptimisticUsers = new Set<ChatMessage>()
if (newestOptimisticUser) {
for (let index = previousMessages.indexOf(newestOptimisticUser); index >= 0; index -= 1) {
const candidate = previousMessages[index]
if (candidate.role !== 'user' || !candidate.id.startsWith('user-')) {
break
}
liveOptimisticUsers.add(candidate)
}
}
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
const preserved: ChatMessage[] = []
@ -346,7 +366,7 @@ export function preserveLocalPendingTurnMessages(
continue
}
if (isOptimisticUser && message !== newestOptimisticUser) {
if (isOptimisticUser && !liveOptimisticUsers.has(message)) {
continue
}
@ -392,13 +412,27 @@ export function appendLiveSessionProjection(
const inflightUser = projection.inflight?.user?.trim() ?? ''
const inflightAssistant = projection.inflight?.assistant ?? ''
const inflightStreaming = Boolean(projection.inflight?.streaming)
// Mid-turn redirect corrections. They are additional user bubbles belonging
// to this same turn, ordered after the prompt that started it.
const inflightCorrections = (projection.inflight?.corrections ?? [])
.map(correction => correction?.trim() ?? '')
.filter(Boolean)
// A retained failed turn (the gateway keeps error snapshots replayable when
// the terminal frame may have been lost to a disconnect) — surface the
// failure on the projected row instead of rendering the partial as healthy.
const inflightError = projection.inflight?.error?.trim() ?? ''
const queuedUser = projection.queued?.user?.trim() ?? ''
if (!inflightUser && !inflightAssistant && !inflightStreaming && !inflightError && !queuedUser) {
if (
!inflightUser &&
!inflightAssistant &&
!inflightStreaming &&
!inflightError &&
!queuedUser &&
!inflightCorrections.length
) {
return messages
}
@ -409,10 +443,20 @@ export function appendLiveSessionProjection(
// both makes a backgrounded prompt appear twice when its session is reopened.
// Only suppress the projection when the latest authoritative user row is the
// same turn — older identical prompts must not hide a newly accepted repeat.
const latestUser = [...messages].reverse().find(message => message.role === 'user')
// A mid-turn redirect gives that turn a RUN of user rows (prompt +
// corrections), so match the contiguous run ending at the latest user row
// rather than the single last one.
const latestUserIndex = messages.map(message => message.role).lastIndexOf('user')
const latestUserRun: ChatMessage[] = []
const inflightUserAlreadyPersisted =
latestUser && textWithoutImageRefs(chatMessageText(latestUser)) === textWithoutImageRefs(inflightUser)
for (let index = latestUserIndex; index >= 0 && messages[index].role === 'user'; index -= 1) {
latestUserRun.unshift(messages[index])
}
const persistedInLatestRun = (text: string): boolean =>
latestUserRun.some(message => textWithoutImageRefs(chatMessageText(message)) === textWithoutImageRefs(text))
const inflightUserAlreadyPersisted = Boolean(inflightUser) && persistedInLatestRun(inflightUser)
if (inflightUser && !inflightUserAlreadyPersisted) {
projected.push({
@ -422,6 +466,22 @@ export function appendLiveSessionProjection(
})
}
// Corrections typed while the turn ran. Each is its own bubble, placed after
// the original prompt and before the reply they redirected — the same order
// the live transcript showed. Skip any the transcript already holds so a
// resume doesn't double them.
for (const [index, correction] of inflightCorrections.entries()) {
if (persistedInLatestRun(correction)) {
continue
}
projected.push({
id: `user-inflight-correction-${index}-${sessionId}`,
role: 'user',
parts: [textPart(correction)]
})
}
// Keep a pending assistant boundary even before the first delta when a
// queued user turn follows it. This preserves the two distinct turns.
if (inflightAssistant || inflightStreaming || inflightError || (inflightUser && queuedUser)) {

View file

@ -238,3 +238,63 @@ describe('mergeInFlightMessages', () => {
expect(result.caughtUp).toBe(false)
})
})
describe('mid-turn redirect corrections', () => {
beforeEach(() => {
window.localStorage.clear()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// A redirect inserts its correction as a second user row directly before the
// live reply, so the turn opens with a RUN of user rows. Journaling only back
// to the nearest one lost the prompt that actually started the turn — the
// vanishing user bubble.
it('journals the whole user run, not just the correction', () => {
persistInFlightTurnState({
awaitingResponse: false,
busy: true,
messages: [
user('user-1', 'remove the session counts'),
user('user-2', 'hurry up'),
assistant('assistant-stream-1', 'Moving.', { pending: true })
],
storedSessionId: 'stored-redirect',
streamId: 'assistant-stream-1',
turnStartedAt: Date.now()
})
vi.advanceTimersByTime(400)
const journaled = readInFlightTurnJournal('stored-redirect')?.messages ?? []
expect(journaled.map(message => message.parts.map(part => (part as { text: string }).text).join(''))).toEqual([
'remove the session counts',
'hurry up',
'Moving.'
])
})
it('still stops at an assistant boundary so prior turns are not journaled', () => {
persistInFlightTurnState({
awaitingResponse: false,
busy: true,
messages: [
user('user-old', 'an earlier turn'),
assistant('assistant-old', 'an earlier answer'),
user('user-1', 'the live prompt'),
assistant('assistant-stream-1', 'Moving.', { pending: true })
],
storedSessionId: 'stored-boundary',
streamId: 'assistant-stream-1',
turnStartedAt: Date.now()
})
vi.advanceTimersByTime(400)
const journaled = readInFlightTurnJournal('stored-boundary')?.messages ?? []
expect(journaled.map(message => message.id)).toEqual(['user-1', 'assistant-stream-1'])
})
})

View file

@ -216,6 +216,14 @@ function recoverableTail(messages: ChatMessage[], streamId: null | string): Chat
if (visible[index].role === 'user') {
start = index
// A mid-turn redirect inserts its correction as another user row right
// before the live reply, so the turn can open with a RUN of user rows.
// Keep walking back over them: stopping at the nearest one journals the
// correction alone and loses the prompt that actually started the turn.
while (start > 0 && visible[start - 1].role === 'user') {
start -= 1
}
break
}
}

View file

@ -523,6 +523,9 @@ export interface SessionResumeResponse {
}
inflight?: null | {
assistant?: string
/** Mid-turn redirect corrections, oldest first. The turn's original prompt
* stays in `user`; these are the follow-ups typed while it ran. */
corrections?: string[]
/** Retained failed turn: the error the terminal frame carried (the frame
* itself may have been lost to a disconnect). */
error?: string

View file

@ -66,7 +66,9 @@ def test_busy_interrupt_mode_redirects_active_turn(monkeypatch):
assert resp["result"]["status"] == "redirected"
assert seen == ["redirect"]
assert session["inflight_turn"]["user"] == "redirect"
# Appended, not overwritten: the original prompt must stay recoverable.
assert session["inflight_turn"]["user"] == "original request"
assert session["inflight_turn"]["corrections"] == ["redirect"]
assert session.get("queued_prompt") is None

View file

@ -7531,11 +7531,56 @@ def test_session_redirect_calls_capable_core_agent(monkeypatch):
"text": "use Postgres",
}
assert calls == ["use Postgres"]
assert session["inflight_turn"]["user"] == "use Postgres"
# The correction is recorded alongside the prompt that started the turn,
# never over it — resume must be able to rebuild both bubbles.
assert session["inflight_turn"]["user"] == "original request"
assert session["inflight_turn"]["corrections"] == ["use Postgres"]
assert session.get("last_active") is not None
assert before is None or session["last_active"] >= before
def test_session_redirect_records_correction_without_erasing_prompt():
"""A redirect must not overwrite the turn's original user text.
The inflight snapshot is the only thing session.resume can replay, so
overwriting ``user`` erased the prompt that started the turn and the
client repainted the thread with the user's message missing.
"""
session = {}
server._start_inflight_turn(session, "remove the session counts")
server._append_inflight_delta(session, "Moving.")
server._record_inflight_correction(session, "hurry up")
server._record_inflight_correction(session, "and the worktree ones")
snapshot = server._inflight_snapshot(session)
assert snapshot is not None
assert snapshot["user"] == "remove the session counts"
assert snapshot["corrections"] == ["hurry up", "and the worktree ones"]
def test_inflight_snapshot_omits_corrections_when_none_recorded():
session = {}
server._start_inflight_turn(session, "just the prompt")
snapshot = server._inflight_snapshot(session)
assert snapshot is not None
assert "corrections" not in snapshot
def test_new_turn_does_not_inherit_prior_turn_corrections():
session = {}
server._start_inflight_turn(session, "first prompt")
server._record_inflight_correction(session, "first correction")
server._start_inflight_turn(session, "second prompt")
snapshot = server._inflight_snapshot(session)
assert snapshot is not None
assert snapshot["user"] == "second prompt"
assert "corrections" not in snapshot
def test_session_redirect_queues_during_agent_build_window(monkeypatch):
# A fresh turn flips running=True and builds the agent asynchronously, so
# session["agent"] is briefly None. A correction landing here must queue

View file

@ -6387,16 +6387,25 @@ def _append_inflight_delta(session: dict, delta: Any) -> None:
session["inflight_turn"] = turn
def _replace_inflight_user(session: dict, text: Any) -> None:
"""Reflect an accepted correction as the live turn's current user text."""
user = _inflight_text(text)
if not user:
def _record_inflight_correction(session: dict, text: Any) -> None:
"""Record an accepted mid-turn correction on the live turn.
The correction is appended, never written over ``user``: a resuming client
must be able to rebuild BOTH bubbles. Overwriting the slot erased the
prompt that started the turn from the only snapshot resume can read, so a
reconnect (or a dev hot-reload that wipes the renderer cache) repainted the
thread with the user's original message missing.
"""
correction = _inflight_text(text)
if not correction:
return
turn = session.get("inflight_turn")
if not isinstance(turn, dict):
return
turn = dict(turn)
turn["user"] = user
corrections = list(turn.get("corrections") or [])
corrections.append(correction)
turn["corrections"] = corrections
turn["updated_at"] = time.time()
session["inflight_turn"] = turn
@ -6689,7 +6698,7 @@ def _handle_busy_submit(
try:
if agent.redirect(plain_text):
with session["history_lock"]:
_replace_inflight_user(session, plain_text)
_record_inflight_correction(session, plain_text)
session["last_active"] = time.time()
return _ok(rid, {"status": "redirected"})
except Exception:
@ -6760,6 +6769,11 @@ def _inflight_snapshot(session: dict) -> dict | None:
"streaming": streaming,
"user": user,
}
corrections = [c for c in (turn.get("corrections") or []) if str(c).strip()]
if corrections:
# Mid-turn redirects. Carried alongside the original prompt (not over
# it) so resume can rebuild every user bubble the turn produced.
snapshot["corrections"] = [str(c) for c in corrections]
if error:
# Retained failed turn (see _fail_inflight_turn): carry the error
# semantics so a resuming client can rebuild the failed-turn bubble
@ -10753,7 +10767,7 @@ def _(rid, params: dict) -> dict:
return _err(rid, 5000, f"redirect failed: {exc}")
if accepted:
with session["history_lock"]:
_replace_inflight_user(session, text)
_record_inflight_correction(session, text)
session["last_active"] = time.time()
return _ok(
rid,