mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): stop dropping the prompt a mid-turn redirect corrected
redirectPrompt inserts its correction as a second user row just before the live reply, so one turn can own a contiguous run of user rows. Three recovery paths each assumed a turn has exactly one, and all three kept the correction and discarded the prompt that started the turn: - recoverableTail walked back to the nearest user row, so the crash journal never stored the original. - preserveLocalPendingTurnMessages kept only the newest optimistic user row. Widened to the contiguous run — rows separated by an assistant reply are still dropped, which is the stale-post-compression case that rule exists for. - appendLiveSessionProjection had no way to render corrections; it now projects them after the prompt, deduped against the transcript's latest user run. Losing a row also shifted every later role:ordinal pairing in the reconcile, which is why the thread looked like it compacted rather than just missing one bubble. Reproducible on a reconnect and on a dev hot update, which remounts the session cache while the gateway socket survives.
This commit is contained in:
parent
2dd4cbbe61
commit
2109a1875e
5 changed files with 209 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue