mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 12:18:44 +02:00
fix: stabilize streamed message rendering
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -167,6 +167,7 @@ public sealed class TurnDeltaEventDto : SidecarEventDto
|
|||||||
public string MessageId { get; init; } = string.Empty;
|
public string MessageId { get; init; } = string.Empty;
|
||||||
public string AuthorName { get; init; } = string.Empty;
|
public string AuthorName { get; init; } = string.Empty;
|
||||||
public string ContentDelta { get; init; } = string.Empty;
|
public string ContentDelta { get; init; } = string.Empty;
|
||||||
|
public string? Content { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TurnCompleteEventDto : SidecarEventDto
|
public sealed class TurnCompleteEventDto : SidecarEventDto
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}";
|
string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}";
|
||||||
StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName);
|
StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName);
|
||||||
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), update.Update.Text));
|
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), update.Update.Text));
|
||||||
|
segment.SetAuthorName(authorName);
|
||||||
|
|
||||||
await onDelta(new TurnDeltaEventDto
|
await onDelta(new TurnDeltaEventDto
|
||||||
{
|
{
|
||||||
@@ -128,6 +129,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
MessageId = messageId,
|
MessageId = messageId,
|
||||||
AuthorName = authorName,
|
AuthorName = authorName,
|
||||||
ContentDelta = update.Update.Text,
|
ContentDelta = update.Update.Text,
|
||||||
|
Content = segment.Content.ToString(),
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else if (evt is ExecutorCompletedEvent completed
|
else if (evt is ExecutorCompletedEvent completed
|
||||||
@@ -477,7 +479,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
|
|
||||||
public string MessageId { get; }
|
public string MessageId { get; }
|
||||||
|
|
||||||
public string AuthorName { get; }
|
public string AuthorName { get; private set; }
|
||||||
|
|
||||||
public StringBuilder Content { get; } = new();
|
public StringBuilder Content { get; } = new();
|
||||||
|
|
||||||
@@ -486,6 +488,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
Content.Clear();
|
Content.Clear();
|
||||||
Content.Append(value);
|
Content.Append(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetAuthorName(string value)
|
||||||
|
{
|
||||||
|
AuthorName = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class AgentBundle : IAsyncDisposable
|
private sealed class AgentBundle : IAsyncDisposable
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
MessageId = "assistant-1",
|
MessageId = "assistant-1",
|
||||||
AuthorName = "Primary",
|
AuthorName = "Primary",
|
||||||
ContentDelta = "Hello",
|
ContentDelta = "Hello",
|
||||||
|
Content = "Hello",
|
||||||
});
|
});
|
||||||
|
|
||||||
await onActivity(new AgentActivityEventDto
|
await onActivity(new AgentActivityEventDto
|
||||||
@@ -201,6 +202,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
{
|
{
|
||||||
Assert.Equal("turn-delta", deltaEvent.GetProperty("type").GetString());
|
Assert.Equal("turn-delta", deltaEvent.GetProperty("type").GetString());
|
||||||
Assert.Equal("Hello", deltaEvent.GetProperty("contentDelta").GetString());
|
Assert.Equal("Hello", deltaEvent.GetProperty("contentDelta").GetString());
|
||||||
|
Assert.Equal("Hello", deltaEvent.GetProperty("content").GetString());
|
||||||
},
|
},
|
||||||
toolEvent =>
|
toolEvent =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -609,18 +609,24 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
event: TurnDeltaEvent,
|
event: TurnDeltaEvent,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (event.content === undefined && event.contentDelta === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const existing = session.messages.find((message) => message.id === event.messageId);
|
const existing = session.messages.find((message) => message.id === event.messageId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.content = mergeStreamingText(existing.content, event.contentDelta);
|
existing.content =
|
||||||
|
event.content ?? mergeStreamingText(existing.content, event.contentDelta);
|
||||||
existing.pending = true;
|
existing.pending = true;
|
||||||
|
existing.authorName = event.authorName;
|
||||||
} else {
|
} else {
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
id: event.messageId,
|
id: event.messageId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
authorName: event.authorName,
|
authorName: event.authorName,
|
||||||
content: event.contentDelta,
|
content: event.content ?? event.contentDelta,
|
||||||
createdAt: nowIso(),
|
createdAt: nowIso(),
|
||||||
pending: true,
|
pending: true,
|
||||||
});
|
});
|
||||||
@@ -636,6 +642,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
messageId: event.messageId,
|
messageId: event.messageId,
|
||||||
authorName: event.authorName,
|
authorName: event.authorName,
|
||||||
contentDelta: event.contentDelta,
|
contentDelta: event.contentDelta,
|
||||||
|
content: event.content,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,6 +704,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
occurredAt: nowIso(),
|
occurredAt: nowIso(),
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
authorName: message.authorName,
|
authorName: message.authorName,
|
||||||
|
content: message.content,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.emitCompletedActivity(sessionId, pattern, message);
|
this.emitCompletedActivity(sessionId, pattern, message);
|
||||||
|
|||||||
@@ -418,7 +418,13 @@ export function ChatPane({
|
|||||||
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}`
|
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<MarkdownContent content={message.content} />
|
{!isUser && message.pending ? (
|
||||||
|
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-zinc-200">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MarkdownContent content={message.content} />
|
||||||
|
)}
|
||||||
{message.pending && message.content && (
|
{message.pending && message.content && (
|
||||||
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-zinc-400" />
|
<span className="mt-1 inline-block h-4 w-[2px] animate-pulse rounded-sm bg-zinc-400" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -77,17 +77,18 @@ function applyErrorEvent(session: SessionRecord, event: SessionEventRecord): Ses
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
|
function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
|
||||||
if (!event.messageId || event.contentDelta === undefined) {
|
if (!event.messageId || (event.content === undefined && event.contentDelta === undefined)) {
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolvedContent = event.content ?? event.contentDelta ?? '';
|
||||||
const messageIndex = session.messages.findIndex((message) => message.id === event.messageId);
|
const messageIndex = session.messages.findIndex((message) => message.id === event.messageId);
|
||||||
if (messageIndex >= 0) {
|
if (messageIndex >= 0) {
|
||||||
const existing = session.messages[messageIndex];
|
const existing = session.messages[messageIndex];
|
||||||
const nextMessage: ChatMessageRecord = {
|
const nextMessage: ChatMessageRecord = {
|
||||||
...existing,
|
...existing,
|
||||||
authorName: event.authorName ?? existing.authorName,
|
authorName: event.authorName ?? existing.authorName,
|
||||||
content: mergeStreamingText(existing.content, event.contentDelta),
|
content: event.content ?? mergeStreamingText(existing.content, resolvedContent),
|
||||||
pending: true,
|
pending: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,14 +113,14 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
|
|||||||
...session,
|
...session,
|
||||||
messages: [
|
messages: [
|
||||||
...session.messages,
|
...session.messages,
|
||||||
{
|
{
|
||||||
id: event.messageId,
|
id: event.messageId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
authorName: event.authorName ?? 'assistant',
|
authorName: event.authorName ?? 'assistant',
|
||||||
content: event.contentDelta,
|
content: resolvedContent,
|
||||||
createdAt: event.occurredAt,
|
createdAt: event.occurredAt,
|
||||||
pending: true,
|
pending: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
updatedAt: event.occurredAt,
|
updatedAt: event.occurredAt,
|
||||||
};
|
};
|
||||||
@@ -139,10 +140,15 @@ function applyMessageCompleteEvent(session: SessionRecord, event: SessionEventRe
|
|||||||
const nextMessage: ChatMessageRecord = {
|
const nextMessage: ChatMessageRecord = {
|
||||||
...existing,
|
...existing,
|
||||||
authorName: event.authorName ?? existing.authorName,
|
authorName: event.authorName ?? existing.authorName,
|
||||||
|
content: event.content ?? existing.content,
|
||||||
pending: false,
|
pending: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (nextMessage.authorName === existing.authorName && !existing.pending) {
|
if (
|
||||||
|
nextMessage.authorName === existing.authorName
|
||||||
|
&& nextMessage.content === existing.content
|
||||||
|
&& !existing.pending
|
||||||
|
) {
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export interface TurnDeltaEvent {
|
|||||||
messageId: string;
|
messageId: string;
|
||||||
authorName: string;
|
authorName: string;
|
||||||
contentDelta: string;
|
contentDelta: string;
|
||||||
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnCompleteEvent {
|
export interface TurnCompleteEvent {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface SessionEventRecord {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
contentDelta?: string;
|
contentDelta?: string;
|
||||||
|
content?: string;
|
||||||
activityType?: SessionActivityType;
|
activityType?: SessionActivityType;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('session workspace helpers', () => {
|
|||||||
messageId: 'assistant-1',
|
messageId: 'assistant-1',
|
||||||
authorName: 'Architect',
|
authorName: 'Architect',
|
||||||
contentDelta: 'Hello',
|
contentDelta: 'Hello',
|
||||||
|
content: 'Hello',
|
||||||
} satisfies SessionEventRecord);
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
expect(created?.sessions[0].messages).toEqual([
|
expect(created?.sessions[0].messages).toEqual([
|
||||||
@@ -56,6 +57,7 @@ describe('session workspace helpers', () => {
|
|||||||
messageId: 'assistant-1',
|
messageId: 'assistant-1',
|
||||||
authorName: 'Architect',
|
authorName: 'Architect',
|
||||||
contentDelta: ' world',
|
contentDelta: ' world',
|
||||||
|
content: 'Hello world',
|
||||||
} satisfies SessionEventRecord);
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
expect(appended?.sessions[0].messages[0]).toMatchObject({
|
expect(appended?.sessions[0].messages[0]).toMatchObject({
|
||||||
@@ -73,6 +75,7 @@ describe('session workspace helpers', () => {
|
|||||||
messageId: 'assistant-1',
|
messageId: 'assistant-1',
|
||||||
authorName: 'Implementer',
|
authorName: 'Implementer',
|
||||||
contentDelta: 'Done',
|
contentDelta: 'Done',
|
||||||
|
content: 'Done',
|
||||||
} satisfies SessionEventRecord);
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
const completed = applySessionEventWorkspace(workspace, {
|
const completed = applySessionEventWorkspace(workspace, {
|
||||||
@@ -81,6 +84,7 @@ describe('session workspace helpers', () => {
|
|||||||
occurredAt: '2026-03-23T00:00:02.000Z',
|
occurredAt: '2026-03-23T00:00:02.000Z',
|
||||||
messageId: 'assistant-1',
|
messageId: 'assistant-1',
|
||||||
authorName: 'Implementer',
|
authorName: 'Implementer',
|
||||||
|
content: 'Done',
|
||||||
} satisfies SessionEventRecord);
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
expect(completed?.sessions[0].messages[0]).toMatchObject({
|
expect(completed?.sessions[0].messages[0]).toMatchObject({
|
||||||
@@ -98,6 +102,7 @@ describe('session workspace helpers', () => {
|
|||||||
messageId: 'assistant-1',
|
messageId: 'assistant-1',
|
||||||
authorName: 'Writer',
|
authorName: 'Writer',
|
||||||
contentDelta: 'How about',
|
contentDelta: 'How about',
|
||||||
|
content: 'How about',
|
||||||
} satisfies SessionEventRecord);
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
const second = applySessionEventWorkspace(first, {
|
const second = applySessionEventWorkspace(first, {
|
||||||
@@ -107,6 +112,7 @@ describe('session workspace helpers', () => {
|
|||||||
messageId: 'assistant-1',
|
messageId: 'assistant-1',
|
||||||
authorName: 'Writer',
|
authorName: 'Writer',
|
||||||
contentDelta: 'The **Ashen Crown** feels',
|
contentDelta: 'The **Ashen Crown** feels',
|
||||||
|
content: 'How about The **Ashen Crown** feels',
|
||||||
} satisfies SessionEventRecord);
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
const third = applySessionEventWorkspace(second, {
|
const third = applySessionEventWorkspace(second, {
|
||||||
@@ -116,6 +122,7 @@ describe('session workspace helpers', () => {
|
|||||||
messageId: 'assistant-1',
|
messageId: 'assistant-1',
|
||||||
authorName: 'Writer',
|
authorName: 'Writer',
|
||||||
contentDelta: 'classic and timeless.',
|
contentDelta: 'classic and timeless.',
|
||||||
|
content: 'How about The **Ashen Crown** feels classic and timeless.',
|
||||||
} satisfies SessionEventRecord);
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
expect(third?.sessions[0].messages[0]).toMatchObject({
|
expect(third?.sessions[0].messages[0]).toMatchObject({
|
||||||
@@ -125,6 +132,33 @@ describe('session workspace helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applies final content from completion events without waiting for a workspace refresh', () => {
|
||||||
|
const workspace = applySessionEventWorkspace(createWorkspace(), {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-delta',
|
||||||
|
occurredAt: '2026-03-23T00:00:01.000Z',
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
authorName: 'Reviewer',
|
||||||
|
contentDelta: 'Draft',
|
||||||
|
content: 'Draft',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
const completed = applySessionEventWorkspace(workspace, {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
kind: 'message-complete',
|
||||||
|
occurredAt: '2026-03-23T00:00:02.000Z',
|
||||||
|
messageId: 'assistant-1',
|
||||||
|
authorName: 'Reviewer',
|
||||||
|
content: 'Draft polished into the final review.',
|
||||||
|
} satisfies SessionEventRecord);
|
||||||
|
|
||||||
|
expect(completed?.sessions[0].messages[0]).toMatchObject({
|
||||||
|
authorName: 'Reviewer',
|
||||||
|
content: 'Draft polished into the final review.',
|
||||||
|
pending: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('updates session status and error state from session events', () => {
|
test('updates session status and error state from session events', () => {
|
||||||
const running = applySessionEventWorkspace(createWorkspace(), {
|
const running = applySessionEventWorkspace(createWorkspace(), {
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
|
|||||||
Reference in New Issue
Block a user