mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
fix(native-chat): stop glued rapid sends from pinning queued bubbles (#14663)
* fix(native-chat): stop glued rapid sends from pinning queued bubbles Trim the draft at the send boundary so the PTY body and the optimistic echo's match key agree, and bound the glue matcher to rows after the oldest open echo's send boundary. Fixes #14262 * fix(native-chat): preserve exact prompt payloads
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { countLeadingPendingTextsGluedToUserText } from './native-chat-pending-occurrence'
|
||||
import {
|
||||
appendPendingSendCache,
|
||||
clearPendingSendCacheForTests,
|
||||
@@ -64,3 +65,32 @@ describe('pending send occurrence reconciliation', () => {
|
||||
expect(prunePendingSends(afterFirstPrune, secondCompletedTurn)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countLeadingPendingTextsGluedToUserText', () => {
|
||||
it('consumes a leading run with or without one separator per boundary', () => {
|
||||
expect(countLeadingPendingTextsGluedToUserText(['joke', 'continue'], 'jokecontinue')).toBe(2)
|
||||
expect(countLeadingPendingTextsGluedToUserText(['joke', 'continue'], 'joke continue')).toBe(2)
|
||||
expect(countLeadingPendingTextsGluedToUserText(['a', 'b', 'c'], 'ab c')).toBe(3)
|
||||
})
|
||||
|
||||
it('stops at the first prompt that does not continue the row', () => {
|
||||
expect(countLeadingPendingTextsGluedToUserText(['hi'], 'history')).toBe(0)
|
||||
expect(countLeadingPendingTextsGluedToUserText(['hi', 'story'], 'hi story continued')).toBe(0)
|
||||
expect(countLeadingPendingTextsGluedToUserText(['hi', 'there friend'], 'hi there')).toBe(0)
|
||||
expect(countLeadingPendingTextsGluedToUserText(['hi', 'x'], 'hi x')).toBe(0)
|
||||
})
|
||||
|
||||
it('requires the first prompt to start the row', () => {
|
||||
expect(countLeadingPendingTextsGluedToUserText(['hi', 'there'], ' hi there')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns the run length, leaving later prompts to the next row', () => {
|
||||
expect(countLeadingPendingTextsGluedToUserText(['a', 'b', 'c'], 'a b')).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects empty inputs and empty prompts', () => {
|
||||
expect(countLeadingPendingTextsGluedToUserText([], 'anything')).toBe(0)
|
||||
expect(countLeadingPendingTextsGluedToUserText(['a'], '')).toBe(0)
|
||||
expect(countLeadingPendingTextsGluedToUserText(['a', '', 'b'], 'ab')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,9 +114,13 @@ export function matchingNativeChatUserTexts(
|
||||
}
|
||||
|
||||
/**
|
||||
* How many leading pending texts concatenate exactly to `userText`.
|
||||
* Covers rapid-send glue ("joke"+"continue" → "jokecontinue") without matching
|
||||
* unrelated prefixes ("hi" ↛ "history").
|
||||
* How many leading pending texts concatenate to exactly `userText`, allowing at
|
||||
* most one collapsed space at each send boundary. Covers rapid-send glue
|
||||
* ("joke"+"continue" → "joke continue") while still requiring the whole row to
|
||||
* be consumed, so unrelated prefixes never match ("hi" ↛ "history").
|
||||
*
|
||||
* Greedy is exact here: both sides are whitespace-normalized, so a piece never
|
||||
* starts with a space and at most one of the two boundary forms can apply.
|
||||
*/
|
||||
export function countLeadingPendingTextsGluedToUserText(
|
||||
pendingTexts: readonly string[],
|
||||
@@ -125,19 +129,22 @@ export function countLeadingPendingTextsGluedToUserText(
|
||||
if (pendingTexts.length === 0 || userText.length === 0) {
|
||||
return 0
|
||||
}
|
||||
let combined = ''
|
||||
let cursor = 0
|
||||
for (let index = 0; index < pendingTexts.length; index += 1) {
|
||||
const piece = pendingTexts[index]
|
||||
if (!piece) {
|
||||
return 0
|
||||
}
|
||||
combined += piece
|
||||
if (combined === userText) {
|
||||
return index + 1
|
||||
}
|
||||
if (!userText.startsWith(combined)) {
|
||||
if (userText.startsWith(piece, cursor)) {
|
||||
cursor += piece.length
|
||||
} else if (index > 0 && userText.startsWith(` ${piece}`, cursor)) {
|
||||
cursor += piece.length + 1
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
if (cursor === userText.length) {
|
||||
return index + 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -147,6 +154,9 @@ export function countLeadingPendingTextsGluedToUserText(
|
||||
* optimistic texts concatenated into one transcript user row). Exact single
|
||||
* matches stay in the content-key/occurrence path so repeated prompts and
|
||||
* send boundaries keep their existing semantics.
|
||||
*
|
||||
* `userTexts` must already be filtered to rows after the oldest entry's send
|
||||
* boundary — this matcher has no clock of its own.
|
||||
*/
|
||||
export function selectPendingIndicesRepresentedByUserTexts(
|
||||
pending: readonly NativeChatPendingOccurrence[],
|
||||
|
||||
@@ -22,22 +22,22 @@ import {
|
||||
} from './native-chat-pending'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
|
||||
function userMessage(id: string, text: string): NativeChatMessage {
|
||||
function userMessage(id: string, text: string, timestamp = 1): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 1,
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string): NativeChatMessage {
|
||||
function assistantMessage(id: string, text: string, timestamp = 2): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 2,
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
@@ -179,16 +179,6 @@ describe('prunePendingSends', () => {
|
||||
).toEqual([pendingOf('p2', 'repeat')])
|
||||
})
|
||||
|
||||
it('prunes consecutive optimistic sends that were glued into one transcript user turn', () => {
|
||||
const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')]
|
||||
expect(
|
||||
prunePendingSends(pending, [
|
||||
userMessage('u1', 'tell me a jokecontinue'),
|
||||
assistantMessage('a1', 'a joke')
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not treat an unrelated longer user turn as a glued match', () => {
|
||||
const pending = [pendingOf('p1', 'hi')]
|
||||
expect(
|
||||
@@ -200,6 +190,148 @@ describe('prunePendingSends', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// A glued row is written by the agent AFTER the sends that produced it, so every
|
||||
// fixture here puts the transcript row past `sentAt`. Matching an earlier row is
|
||||
// the failure mode these tests exist to pin down.
|
||||
const GLUE_BOUNDARY = assistantMessage('glue-boundary', 'ready', 1000)
|
||||
const GLUE_SENT_AT = 5000
|
||||
|
||||
function gluePending(id: string, text: string): NativeChatPendingSend {
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
sentAt: GLUE_SENT_AT,
|
||||
afterMessageId: GLUE_BOUNDARY.id,
|
||||
afterMessageTimestamp: GLUE_BOUNDARY.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible history, the send boundary, then the row the agent glued the queue into. */
|
||||
function glueTranscript(row: string): NativeChatMessage[] {
|
||||
return [
|
||||
userMessage('glue-history', 'hello', 900),
|
||||
GLUE_BOUNDARY,
|
||||
userMessage('glue-row', row, 6000)
|
||||
]
|
||||
}
|
||||
|
||||
function advancedGlueTranscript(row: string): NativeChatMessage[] {
|
||||
return [...glueTranscript(row), assistantMessage('glue-answer', 'done', 6100)]
|
||||
}
|
||||
|
||||
describe('glued rapid sends', () => {
|
||||
it('retires both echoes when a lost Enter glued the pair into one row', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('tell me a jokecontinue'))).toEqual([])
|
||||
})
|
||||
|
||||
it('hides both echoes as soon as the glued row lands, before the reply', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')]
|
||||
|
||||
expect(pendingSendsAsMessages(pending, glueTranscript('tell me a jokecontinue'))).toEqual([])
|
||||
})
|
||||
|
||||
it('retires a pair the agent glued with a separator of its own', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('tell me a joke continue'))).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('retires a pair whose glued row separates with tabs and newlines', () => {
|
||||
const pending = [gluePending('p1', 'tell me a joke'), gluePending('p2', 'continue')]
|
||||
|
||||
expect(
|
||||
prunePendingSends(pending, advancedGlueTranscript('tell me a joke \t\n continue'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('retires three prompts collapsed into one row with mixed boundaries', () => {
|
||||
const pending = [
|
||||
gluePending('p1', 'first'),
|
||||
gluePending('p2', 'second'),
|
||||
gluePending('p3', 'third')
|
||||
]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('firstsecond third'))).toEqual([])
|
||||
})
|
||||
|
||||
it('retires prompts that themselves contain the separator', () => {
|
||||
const pending = [gluePending('p1', 'fix the bug'), gluePending('p2', 'then run the tests')]
|
||||
|
||||
expect(
|
||||
prunePendingSends(pending, advancedGlueTranscript('fix the bug then run the tests'))
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('retires an identical prompt sent twice', () => {
|
||||
const pending = [gluePending('p1', 'repeat'), gluePending('p2', 'repeat')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('repeat repeat'))).toEqual([])
|
||||
})
|
||||
|
||||
it('retires an identical prompt sent three times', () => {
|
||||
const pending = [gluePending('p1', 'ha'), gluePending('p2', 'ha'), gluePending('p3', 'ha')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('haha ha'))).toEqual([])
|
||||
})
|
||||
|
||||
it('retires a pending send that is a prefix of the next one', () => {
|
||||
const pending = [gluePending('p1', 'hi'), gluePending('p2', 'hi there')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('hi hi there'))).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps sends when the glue only reaches a prefix of the row', () => {
|
||||
const pending = [gluePending('p1', 'hi'), gluePending('p2', 'story')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('hi story continued'))).toEqual(
|
||||
pending
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps sends when the row runs out mid-prompt', () => {
|
||||
const pending = [gluePending('p1', 'hi'), gluePending('p2', 'there friend')]
|
||||
|
||||
expect(prunePendingSends(pending, advancedGlueTranscript('hi there'))).toEqual(pending)
|
||||
})
|
||||
|
||||
it('keeps a queued pair when an earlier turn splits across it (#14406 regression)', () => {
|
||||
// The row predates both sends: "run the tests"+"again" only looks glued.
|
||||
const pending = [pendingOf('p1', 'run the tests'), pendingOf('p2', 'again')]
|
||||
|
||||
expect(
|
||||
prunePendingSends(pending, [
|
||||
userMessage('u1', 'run the tests again'),
|
||||
assistantMessage('a1', 'sure')
|
||||
])
|
||||
).toEqual(pending)
|
||||
})
|
||||
|
||||
it('keeps a re-sent pair that an older identical turn would match (#14406 regression)', () => {
|
||||
// Rapid-sending "fix the"+"bug" a second time must not bind to the first
|
||||
// "fix the bug" turn — that drops a real queued prompt with no bubble.
|
||||
const history = [userMessage('u1', 'fix the bug', 1000), assistantMessage('a1', 'fixed', 1100)]
|
||||
const pending = [gluePending('p3', 'fix the'), gluePending('p4', 'bug')]
|
||||
|
||||
expect(prunePendingSends(pending, history)).toEqual(pending)
|
||||
expect(pendingSendsAsMessages(pending, history)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps a re-sent pair even with no recorded message boundary', () => {
|
||||
const history = [userMessage('u1', 'fix the bug', 1000), assistantMessage('a1', 'fixed', 1100)]
|
||||
const pending = [
|
||||
{ id: 'p3', text: 'fix the', sentAt: GLUE_SENT_AT },
|
||||
{ id: 'p4', text: 'bug', sentAt: GLUE_SENT_AT }
|
||||
]
|
||||
|
||||
expect(prunePendingSends(pending, history)).toEqual(pending)
|
||||
expect(pendingSendsAsMessages(pending, history)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pendingSendsAsMessages', () => {
|
||||
it('returns the empty input without reading existing history', () => {
|
||||
const pending: NativeChatPendingSend[] = []
|
||||
@@ -248,13 +380,6 @@ describe('pendingSendsAsMessages', () => {
|
||||
expect(pendingSendsAsMessages(pending, [])).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides consecutive optimistic sends once a glued transcript user turn lands', () => {
|
||||
const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')]
|
||||
expect(pendingSendsAsMessages(pending, [userMessage('u1', 'tell me a jokecontinue')])).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a repeated prompt visible when its only match predates the send boundary', () => {
|
||||
const history = [userMessage('old-user', 'run tests'), assistantMessage('old-answer', 'passed')]
|
||||
const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: 'old-answer' }]
|
||||
|
||||
@@ -125,6 +125,26 @@ function messageIsAfterPendingTimestamp(
|
||||
: message.timestamp > boundary
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows a glue match may consume. Glue always starts at the oldest still-open
|
||||
* echo, so that echo's send boundary is the floor: unbounded, an older turn
|
||||
* whose text happens to split across the queue ("fix the bug" vs "fix the" +
|
||||
* "bug") would retire sends issued long after it. A missing message boundary
|
||||
* falls back to send time — a fuzzy match must never reach further back than
|
||||
* an exact one.
|
||||
*/
|
||||
function gluedCandidateMessages(
|
||||
messages: readonly NativeChatMessage[],
|
||||
open: readonly NativeChatPendingSend[]
|
||||
): readonly NativeChatMessage[] {
|
||||
const oldest = open[0]
|
||||
if (!oldest) {
|
||||
return []
|
||||
}
|
||||
const anchor = oldest.afterMessageId === undefined ? { ...oldest, afterMessageId: null } : oldest
|
||||
return messagesAfterPendingBoundary(messages, anchor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop any pending send only after the transcript has advanced beyond its real
|
||||
* user turn. Keeping the echo through the user-only transcript phase prevents a
|
||||
@@ -151,13 +171,13 @@ export function prunePendingSends(
|
||||
consumed.set(key, Math.max(used, occurrence))
|
||||
return occurrence > available
|
||||
})
|
||||
// Why: when rapid body writes glued two optimistic sends into one transcript
|
||||
// user row ("joke"+"continue"→"jokecontinue"), exact keys never match. Drop
|
||||
// those echoes once an assistant turn advances past the glued user text.
|
||||
// Why: when a lost Enter glued two optimistic sends onto one input line, the
|
||||
// transcript carries one row ("joke"+"continue"→"jokecontinue") that no exact
|
||||
// key matches. Drop those echoes once an assistant turn advances past it.
|
||||
const stillOpen = pending.filter((_, index) => exactKeep[index])
|
||||
const gluedRepresented = selectPendingIndicesRepresentedByUserTexts(
|
||||
stillOpen,
|
||||
advancedNativeChatUserTexts(messages)
|
||||
advancedNativeChatUserTexts(gluedCandidateMessages(messages, stillOpen))
|
||||
)
|
||||
const next = pending.filter((entry, index) => {
|
||||
if (!exactKeep[index]) {
|
||||
@@ -200,7 +220,7 @@ export function pendingSendsAsMessages(
|
||||
const stillVisible = pending.filter((_, index) => exactVisible[index])
|
||||
const gluedRepresented = selectPendingIndicesRepresentedByUserTexts(
|
||||
stillVisible,
|
||||
matchingNativeChatUserTexts(existingMessages)
|
||||
matchingNativeChatUserTexts(gluedCandidateMessages(existingMessages, stillVisible))
|
||||
)
|
||||
return pending
|
||||
.filter((entry, index) => {
|
||||
|
||||
Reference in New Issue
Block a user