fix: auto-complete previous pending messages when a new assistant message starts

When the agent produces multiple intermediate responses during a turn,
each gets a unique messageId but all stay pending until finalizeTurn().
This caused multiple stacked 'Thinking' bubbles in the chat transcript.

Now, when a new messageId arrives in applyTurnDelta, any previously
pending assistant messages are marked complete and message-complete
events are emitted before the new message-delta. The renderer reducer
mirrors this logic for defensive consistency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 23:59:06 +01:00
co-authored by Copilot
parent 6d12cce836
commit 7921b6648f
3 changed files with 68 additions and 1 deletions
+19
View File
@@ -1471,11 +1471,20 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
? mergeStreamingText(existing.content, event.contentDelta)
: (event.content ?? event.contentDelta);
// When a new assistant message begins, auto-complete any previously pending
// assistant messages so only the latest one shows the "Thinking" indicator.
const completedMessages: ChatMessageRecord[] = [];
if (existing) {
existing.content = content;
existing.pending = true;
existing.authorName = event.authorName;
} else {
for (const message of session.messages) {
if (message.pending && message.role === 'assistant') {
message.pending = false;
completedMessages.push(message);
}
}
session.messages.push({
id: event.messageId,
role: 'assistant',
@@ -1498,6 +1507,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.updatedAt = occurredAt;
await this.workspaceRepository.save(workspace);
for (const completed of completedMessages) {
this.emitSessionEvent({
sessionId,
kind: 'message-complete',
occurredAt,
messageId: completed.id,
authorName: completed.authorName,
content: completed.content,
});
}
this.emitSessionEvent({
sessionId,
kind: 'message-delta',
+9 -1
View File
@@ -112,10 +112,18 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
};
}
// Auto-complete any previously pending assistant messages so only
// the new message shows the "Thinking" indicator.
const completedMessages = session.messages.map((message) =>
message.pending && message.role === 'assistant'
? { ...message, pending: false }
: message,
);
return {
...session,
messages: [
...session.messages,
...completedMessages,
{
id: event.messageId,
role: 'assistant',
+40
View File
@@ -228,6 +228,46 @@ describe('session workspace helpers', () => {
expect(updated?.sessions[0].runs).toEqual([run]);
});
test('auto-completes previous pending assistant messages when a new message starts', () => {
const first = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Exploring the codebase...',
content: 'Exploring the codebase...',
} satisfies SessionEventRecord);
expect(first?.sessions[0].messages).toHaveLength(1);
expect(first?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
pending: true,
});
const second = applySessionEventWorkspace(first, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-2',
authorName: 'Primary Agent',
contentDelta: 'Still exploring...',
content: 'Still exploring...',
} satisfies SessionEventRecord);
expect(second?.sessions[0].messages).toHaveLength(2);
expect(second?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
content: 'Exploring the codebase...',
pending: false,
});
expect(second?.sessions[0].messages[1]).toMatchObject({
id: 'assistant-2',
content: 'Still exploring...',
pending: true,
});
});
test('ignores events for unknown sessions', () => {
const workspace = createWorkspace();
expect(