feat(stage-ui): add swipe to reply for chat messages (#2489)

This commit is contained in:
Neko
2026-09-10 00:01:53 +08:00
committed by GitHub
parent 3e94ea816c
commit 0d7b5e9a60
57 changed files with 3649 additions and 320 deletions
@@ -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)
+2
View File
@@ -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[]
}