The guard `fromKey === toKey || fromKey !== NO_SESSION` only ever admitted null -> real; write that directly. Bot Chat kickoffs are visible user rows (prompt.submit without display_kind), so the comment names only widget intents as the hidden-row producer. No behaviour change: 98 tests green, real->real leak test still fails when the guard is loosened.
164 lines
4.3 KiB
TypeScript
164 lines
4.3 KiB
TypeScript
/**
|
|
* Spoken-reply identity for Desktop auto-speak / Read Aloud.
|
|
*
|
|
* The live assistant row id (`assistant-stream-*`, `inflight-assistant-*`) is
|
|
* not stable: hydrate rewrites that row under its durable backend id. Keying
|
|
* "already spoken" on id alone then re-reads the same turn at the playback-idle
|
|
* edge. A content fingerprint would swallow a later distinct turn that happens
|
|
* to say the same thing ("Done.").
|
|
*
|
|
* Anchor on the assistant-role ordinal (nth visible assistant bubble). The
|
|
* rewrite keeps that slot; a new turn appends and the ordinal moves.
|
|
*/
|
|
|
|
export interface SpokenReplyAnchor {
|
|
id: string
|
|
ordinal: number
|
|
}
|
|
|
|
export interface SpokenReplyMessage {
|
|
hidden?: boolean
|
|
id: string
|
|
role: string
|
|
}
|
|
|
|
const NO_SESSION = '\0'
|
|
|
|
const lastSpokenBySession = new Map<string, SpokenReplyAnchor>()
|
|
|
|
export function isLiveTailReplyId(id: string): boolean {
|
|
return id.startsWith('assistant-stream-') || id.startsWith('inflight-assistant-')
|
|
}
|
|
|
|
function sessionKey(sessionId: string | null | undefined): string {
|
|
return sessionId ?? NO_SESSION
|
|
}
|
|
|
|
export function assistantReplyOrdinal(messages: readonly SpokenReplyMessage[], id: string): number {
|
|
let ordinal = -1
|
|
|
|
for (const message of messages) {
|
|
if (message.role !== 'assistant' || message.hidden) {
|
|
continue
|
|
}
|
|
|
|
ordinal += 1
|
|
|
|
if (message.id === id) {
|
|
return ordinal
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
function lastVisibleAssistant(messages: readonly SpokenReplyMessage[]): SpokenReplyMessage | undefined {
|
|
return messages.findLast(message => message.role === 'assistant' && !message.hidden)
|
|
}
|
|
|
|
/** If a spoken live-tail row vanished and the same assistant slot now has a
|
|
* durable id, migrate the anchor. Leave durable ids and later turns alone. */
|
|
export function absorbSpokenReplyRewrite(
|
|
spoken: SpokenReplyAnchor | null,
|
|
messages: readonly SpokenReplyMessage[]
|
|
): SpokenReplyAnchor | null {
|
|
if (!spoken) {
|
|
return null
|
|
}
|
|
|
|
if (assistantReplyOrdinal(messages, spoken.id) >= 0) {
|
|
return spoken
|
|
}
|
|
|
|
if (!isLiveTailReplyId(spoken.id)) {
|
|
return spoken
|
|
}
|
|
|
|
const last = lastVisibleAssistant(messages)
|
|
|
|
if (!last) {
|
|
return spoken
|
|
}
|
|
|
|
const ordinal = assistantReplyOrdinal(messages, last.id)
|
|
|
|
if (ordinal !== spoken.ordinal) {
|
|
return spoken
|
|
}
|
|
|
|
return { id: last.id, ordinal }
|
|
}
|
|
|
|
export function spokenReplyOf(sessionId: string | null | undefined): SpokenReplyAnchor | null {
|
|
return lastSpokenBySession.get(sessionKey(sessionId)) ?? null
|
|
}
|
|
|
|
function markSpokenReply(sessionId: string | null | undefined, anchor: SpokenReplyAnchor): void {
|
|
lastSpokenBySession.set(sessionKey(sessionId), anchor)
|
|
}
|
|
|
|
export function markAssistantIdSpoken(
|
|
sessionId: string | null | undefined,
|
|
messages: readonly SpokenReplyMessage[],
|
|
id: string
|
|
): void {
|
|
const ordinal = assistantReplyOrdinal(messages, id)
|
|
|
|
if (ordinal < 0) {
|
|
return
|
|
}
|
|
|
|
markSpokenReply(sessionId, { id, ordinal })
|
|
}
|
|
|
|
/**
|
|
* Carry the spoken anchor when a chat gets a real session id (null → created)
|
|
* mid voice-conversation. Do not copy across two real sessions — that would
|
|
* leak "already spoken" into a different transcript. The null-session entry is
|
|
* moved, not copied: left behind, it would mark the NEXT new chat's first reply
|
|
* as already spoken.
|
|
*/
|
|
export function adoptSpokenReplySession(
|
|
fromSessionId: string | null | undefined,
|
|
toSessionId: string | null | undefined
|
|
): void {
|
|
const fromKey = sessionKey(fromSessionId)
|
|
const toKey = sessionKey(toSessionId)
|
|
|
|
if (fromKey !== NO_SESSION || toKey === NO_SESSION) {
|
|
return
|
|
}
|
|
|
|
const from = lastSpokenBySession.get(fromKey)
|
|
|
|
if (!from) {
|
|
return
|
|
}
|
|
|
|
// Dropped even when not adopted below: the anchor belongs to this chat.
|
|
lastSpokenBySession.delete(fromKey)
|
|
|
|
if (!lastSpokenBySession.has(toKey)) {
|
|
lastSpokenBySession.set(toKey, from)
|
|
}
|
|
}
|
|
|
|
/** Current spoken anchor, migrated in place when the live row was rewritten. */
|
|
export function resolveSpokenReply(
|
|
sessionId: string | null | undefined,
|
|
messages: readonly SpokenReplyMessage[]
|
|
): SpokenReplyAnchor | null {
|
|
const current = spokenReplyOf(sessionId)
|
|
const next = absorbSpokenReplyRewrite(current, messages)
|
|
|
|
if (next && next.id !== current?.id) {
|
|
markSpokenReply(sessionId, next)
|
|
}
|
|
|
|
return next
|
|
}
|
|
|
|
export function clearSpokenRepliesForTests(): void {
|
|
lastSpokenBySession.clear()
|
|
}
|