feat(stage-ui): add swipe to reply for chat messages (#2489)
This commit is contained in:
@@ -190,6 +190,70 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
expect(providerUserMessage).not.toHaveProperty('tools')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The composer encoded a reply as localized Markdown inside the user text.
|
||||
// The stored message therefore lost the relation to the replied message.
|
||||
//
|
||||
// We fixed this by storing the reply message id and projecting its text only
|
||||
// for the provider request.
|
||||
it('stores a native reply relation without changing the user text', async () => {
|
||||
const harness = createHarness()
|
||||
harness.sessionMessages['session-1']?.push({
|
||||
role: 'assistant',
|
||||
content: 'Earlier answer',
|
||||
slices: [{ type: 'text', text: 'Earlier answer' }],
|
||||
tool_results: [],
|
||||
id: 'assistant-earlier',
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('My follow-up', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
replyToMessageId: 'assistant-earlier',
|
||||
})
|
||||
|
||||
const storedUserMessage = harness.sessionMessages['session-1']?.find(message => message.id === 'user-id')
|
||||
const providerMessages = harness.stream.mock.calls[0]?.[2]
|
||||
const providerUserMessage = providerMessages?.at(-1)
|
||||
|
||||
expect(storedUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: 'My follow-up',
|
||||
replyToMessageId: 'assistant-earlier',
|
||||
})
|
||||
expect(providerUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: '[2026-04-25 18:47] [Replying to: Earlier answer]\nMy follow-up',
|
||||
})
|
||||
expect(providerUserMessage).not.toHaveProperty('replyToMessageId')
|
||||
})
|
||||
|
||||
it('limits repeated reply text in the provider prompt', async () => {
|
||||
const harness = createHarness()
|
||||
harness.sessionMessages['session-1']?.push({
|
||||
role: 'assistant',
|
||||
content: 'a'.repeat(600),
|
||||
slices: [{ type: 'text', text: 'a'.repeat(600) }],
|
||||
tool_results: [],
|
||||
id: 'assistant-long-reply',
|
||||
})
|
||||
|
||||
await harness.runtime.ingest('My follow-up', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
replyToMessageId: 'assistant-long-reply',
|
||||
})
|
||||
|
||||
const providerMessages = harness.stream.mock.calls[0]?.[2]
|
||||
const providerUserMessage = providerMessages?.at(-1)
|
||||
|
||||
expect(providerUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: `[2026-04-25 18:47] [Replying to: ${'a'.repeat(479)}…]\nMy follow-up`,
|
||||
})
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// xsAI kept the assistant tool call and tool result in its private message copy.
|
||||
@@ -729,6 +793,92 @@ describe('createChatOrchestratorRuntime', () => {
|
||||
await firstSend
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2489#discussion_r3967818108
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// A queued send kept the reply target captured by the composer. Deleting that
|
||||
// target did not change the session generation, so the queued message stored a
|
||||
// dangling relation and projected the missing id into the provider prompt.
|
||||
//
|
||||
// The send must revalidate the relation against current session history after
|
||||
// asynchronous composition and immediately before append.
|
||||
it('drops a queued reply relation when its target is deleted before append', async () => {
|
||||
const harness = createHarness()
|
||||
let queuedSendContext: ChatHistoryItem | undefined
|
||||
let releaseQueuedComposition: (() => void) | undefined
|
||||
harness.runtime.hooks.onBeforeMessageComposed(async (message, context) => {
|
||||
if (message !== 'send without stale reply')
|
||||
return
|
||||
|
||||
queuedSendContext = context.message
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseQueuedComposition = resolve
|
||||
})
|
||||
})
|
||||
harness.sessionMessages['session-1']?.push({
|
||||
role: 'assistant',
|
||||
content: 'Reply target',
|
||||
slices: [{ type: 'text', text: 'Reply target' }],
|
||||
tool_results: [],
|
||||
id: 'deleted-reply-target',
|
||||
})
|
||||
let releaseFirstSend: (() => void) | undefined
|
||||
harness.stream.mockImplementationOnce(async (_model, _chatProvider, _messages, options) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFirstSend = resolve
|
||||
})
|
||||
await options?.onStreamEvent?.({ type: 'finish', finishReason: 'stop' })
|
||||
})
|
||||
|
||||
const firstSend = harness.runtime.ingest('hold queue', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
})
|
||||
const queuedReply = harness.runtime.ingest('send without stale reply', {
|
||||
model: 'gpt-test',
|
||||
chatProvider: provider,
|
||||
replyToMessageId: 'deleted-reply-target',
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.stream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(harness.runtime.getPendingQueuedSendCount()).toBe(1)
|
||||
})
|
||||
releaseFirstSend?.()
|
||||
await vi.waitFor(() => {
|
||||
expect(releaseQueuedComposition).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
const sessionMessages = harness.sessionMessages['session-1']
|
||||
if (!sessionMessages)
|
||||
throw new Error('Expected the active test session to exist')
|
||||
|
||||
harness.sessionMessages['session-1'] = sessionMessages
|
||||
.filter(message => message.id !== 'deleted-reply-target')
|
||||
releaseQueuedComposition?.()
|
||||
|
||||
await firstSend
|
||||
await queuedReply
|
||||
|
||||
const storedReply = harness.sessionMessages['session-1']
|
||||
?.find(message => message.role === 'user' && message.content === 'send without stale reply')
|
||||
const providerUserMessage = harness.stream.mock.calls[1]?.[2].at(-1)
|
||||
const syncedUserMessage = (harness.userAppended.at(-1) as { message?: ChatHistoryItem } | undefined)?.message
|
||||
|
||||
expect(storedReply).toBeDefined()
|
||||
expect(storedReply).not.toHaveProperty('replyToMessageId')
|
||||
expect(providerUserMessage).toMatchObject({
|
||||
role: 'user',
|
||||
content: '[2026-04-25 18:47] send without stale reply',
|
||||
})
|
||||
expect(syncedUserMessage).toBeDefined()
|
||||
expect(syncedUserMessage).not.toHaveProperty('replyToMessageId')
|
||||
expect(queuedSendContext).toBeDefined()
|
||||
expect(queuedSendContext).not.toHaveProperty('replyToMessageId')
|
||||
})
|
||||
|
||||
// https://github.com/moeru-ai/airi/pull/2086#discussion_r3714754876
|
||||
it('suppresses completion hooks when an active send session is deleted for Issue #2085', async () => {
|
||||
// ROOT CAUSE:
|
||||
|
||||
@@ -16,6 +16,12 @@ import { categorizeResponse, createStreamingCategorizer } from './response-categ
|
||||
|
||||
const REASONING_UI_FLUSH_CHUNK_SIZE = 24
|
||||
|
||||
/**
|
||||
* Caps repeated reply text in the model prompt. The referenced message remains
|
||||
* in history, so the prefix only needs enough text to identify it.
|
||||
*/
|
||||
const REPLY_PROMPT_REFERENCE_CHARACTER_LIMIT = 480
|
||||
|
||||
function prependTextToContent<T extends { content?: unknown }>(msg: T, text: string): T {
|
||||
const content = msg.content
|
||||
if (content === undefined)
|
||||
@@ -35,6 +41,54 @@ function prependTextToContent<T extends { content?: unknown }>(msg: T, text: str
|
||||
return msg
|
||||
}
|
||||
|
||||
function getMessageText(message: ChatHistoryItem): string {
|
||||
if (typeof message.content === 'string')
|
||||
return message.content
|
||||
|
||||
if (!Array.isArray(message.content))
|
||||
return ''
|
||||
|
||||
return message.content
|
||||
.filter(part => part.type === 'text')
|
||||
.map(part => part.text)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a model-only reference to the message selected by the user.
|
||||
*
|
||||
* @example
|
||||
* formatReplyPromptPrefix('message-1', new Map([
|
||||
* ['message-1', { id: 'message-1', role: 'user', content: 'Earlier turn' }],
|
||||
* ]))
|
||||
* // => '[Replying to: Earlier turn]\n'
|
||||
*/
|
||||
function formatReplyPromptPrefix(replyToMessageId: string | undefined, messagesById: Map<string, ChatHistoryItem>): string {
|
||||
if (!replyToMessageId)
|
||||
return ''
|
||||
|
||||
const target = messagesById.get(replyToMessageId)
|
||||
if (!target)
|
||||
return ''
|
||||
|
||||
const targetText = getMessageText(target).replace(/\s+/g, ' ').trim()
|
||||
const preview = targetText.length > REPLY_PROMPT_REFERENCE_CHARACTER_LIMIT
|
||||
? `${targetText.slice(0, REPLY_PROMPT_REFERENCE_CHARACTER_LIMIT - 1).trimEnd()}…`
|
||||
: targetText
|
||||
return preview
|
||||
? `[Replying to: ${preview}]\n`
|
||||
: `[Replying to message: ${replyToMessageId}]\n`
|
||||
}
|
||||
|
||||
function resolveReplyTargetId(replyToMessageId: string | undefined, messages: ChatHistoryItem[]): string | undefined {
|
||||
if (!replyToMessageId)
|
||||
return undefined
|
||||
|
||||
return messages.some(message => message.id === replyToMessageId)
|
||||
? replyToMessageId
|
||||
: undefined
|
||||
}
|
||||
|
||||
function cloneStreamingMessage(message: StreamingAssistantMessage): StreamingAssistantMessage {
|
||||
try {
|
||||
return structuredClone(message)
|
||||
@@ -62,6 +116,8 @@ export interface ChatOrchestratorSendOptions {
|
||||
toolReferences?: ChatToolReference[]
|
||||
/** Original transport input metadata used by bridge/devtools observers. */
|
||||
input?: ChatStreamEventContext['input']
|
||||
/** Message that the new user turn replies to in the target session. */
|
||||
replyToMessageId?: string
|
||||
/** Temperature for the LLM request. */
|
||||
temperature?: number
|
||||
/** Top_p for the LLM request. */
|
||||
@@ -431,13 +487,17 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
|
||||
function buildProviderMessages(sessionMessagesForSend: ChatHistoryItem[]): Array<Message | ErrorMessage> {
|
||||
const nowTs = now()
|
||||
const messagesById = new Map(
|
||||
sessionMessagesForSend.flatMap(message => message.id ? [[message.id, message] as const] : []),
|
||||
)
|
||||
|
||||
return sessionMessagesForSend.flatMap<Message | ErrorMessage>((msg) => {
|
||||
const { context: _context, id: _id, createdAt: _createdAt, tools: _tools, ...withoutContext } = msg
|
||||
const { context: _context, id: _id, createdAt: _createdAt, replyToMessageId, tools: _tools, ...withoutContext } = msg
|
||||
const rawMessage = unwrapMessage(withoutContext)
|
||||
|
||||
if (rawMessage.role === 'user') {
|
||||
return [prependTextToContent(rawMessage, formatTimePrefix(getStablePromptTimestamp(msg, nowTs)))]
|
||||
const prefix = `${formatTimePrefix(getStablePromptTimestamp(msg, nowTs))}${formatReplyPromptPrefix(replyToMessageId, messagesById)}`
|
||||
return [prependTextToContent(rawMessage, prefix)]
|
||||
}
|
||||
|
||||
if (rawMessage.role === 'assistant') {
|
||||
@@ -471,6 +531,7 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
deps.session.ensureSession(sessionId)
|
||||
|
||||
const existingSessionMessages = deps.session.getSessionMessages(sessionId)
|
||||
let replyToMessageId = resolveReplyTargetId(options.replyToMessageId, existingSessionMessages)
|
||||
const turnIndex = existingSessionMessages.filter(message => message.role === 'user').length + 1
|
||||
|
||||
// Activation measures whether a conversation reaches its first assistant
|
||||
@@ -494,7 +555,13 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
const roundId = createId()
|
||||
const streamingMessageContext: ChatStreamEventContext = {
|
||||
turnId: roundId,
|
||||
message: { role: 'user', content: sendingMessage, createdAt: sendingCreatedAt, id: streamContextMessageId },
|
||||
message: {
|
||||
role: 'user',
|
||||
content: sendingMessage,
|
||||
createdAt: sendingCreatedAt,
|
||||
id: streamContextMessageId,
|
||||
...(replyToMessageId ? { replyToMessageId } : {}),
|
||||
},
|
||||
contexts: deps.context.snapshot(),
|
||||
composedMessage: [],
|
||||
input: options.input,
|
||||
@@ -581,11 +648,21 @@ export function createChatOrchestratorRuntime(deps: ChatOrchestratorRuntimeDeps)
|
||||
if (shouldAbort())
|
||||
return
|
||||
|
||||
replyToMessageId = resolveReplyTargetId(
|
||||
options.replyToMessageId,
|
||||
deps.session.getSessionMessages(sessionId),
|
||||
)
|
||||
if (replyToMessageId)
|
||||
streamingMessageContext.message.replyToMessageId = replyToMessageId
|
||||
else
|
||||
delete streamingMessageContext.message.replyToMessageId
|
||||
|
||||
const userMessage = {
|
||||
role: 'user' as const,
|
||||
content: finalContent,
|
||||
createdAt: sendingCreatedAt,
|
||||
id: roundId,
|
||||
...(replyToMessageId ? { replyToMessageId } : {}),
|
||||
...(options.toolReferences?.length ? { tools: options.toolReferences } : {}),
|
||||
}
|
||||
deps.session.appendSessionMessage(sessionId, userMessage)
|
||||
|
||||
@@ -64,6 +64,8 @@ export type ChatHistoryItem = (ChatMessage | ErrorMessage) & {
|
||||
context?: ContextMessage
|
||||
createdAt?: number
|
||||
id?: string
|
||||
/** Message that this message replies to in the same chat session. */
|
||||
replyToMessageId?: string
|
||||
/** Tools selected for this message. The runtime rebuilds executors from these names. */
|
||||
tools?: ChatToolReference[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user