diff --git a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs index 710c35b..70c6c93 100644 --- a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs @@ -167,6 +167,7 @@ public sealed class TurnDeltaEventDto : SidecarEventDto public string MessageId { get; init; } = string.Empty; public string AuthorName { get; init; } = string.Empty; public string ContentDelta { get; init; } = string.Empty; + public string? Content { get; init; } } public sealed class TurnCompleteEventDto : SidecarEventDto diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs index db12f4e..bfcbe89 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -119,6 +119,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner string messageId = update.Update.MessageId ?? $"{command.RequestId}-delta-{fallbackMessageIndex++}"; StreamingSegment segment = GetOrCreateSegment(segments, messageId, authorName); segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), update.Update.Text)); + segment.SetAuthorName(authorName); await onDelta(new TurnDeltaEventDto { @@ -128,6 +129,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner MessageId = messageId, AuthorName = authorName, ContentDelta = update.Update.Text, + Content = segment.Content.ToString(), }).ConfigureAwait(false); } else if (evt is ExecutorCompletedEvent completed @@ -477,7 +479,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner public string MessageId { get; } - public string AuthorName { get; } + public string AuthorName { get; private set; } public StringBuilder Content { get; } = new(); @@ -486,6 +488,11 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner Content.Clear(); Content.Append(value); } + + public void SetAuthorName(string value) + { + AuthorName = value; + } } private sealed class AgentBundle : IAsyncDisposable diff --git a/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs index 1eefb1e..3c8f3eb 100644 --- a/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Eryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -128,6 +128,7 @@ public sealed class SidecarProtocolHostTests MessageId = "assistant-1", AuthorName = "Primary", ContentDelta = "Hello", + Content = "Hello", }); await onActivity(new AgentActivityEventDto @@ -201,6 +202,7 @@ public sealed class SidecarProtocolHostTests { Assert.Equal("turn-delta", deltaEvent.GetProperty("type").GetString()); Assert.Equal("Hello", deltaEvent.GetProperty("contentDelta").GetString()); + Assert.Equal("Hello", deltaEvent.GetProperty("content").GetString()); }, toolEvent => { diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index 76490b4..be85758 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -609,18 +609,24 @@ export class EryxAppService extends EventEmitter { sessionId: string, event: TurnDeltaEvent, ): Promise { + if (event.content === undefined && event.contentDelta === undefined) { + return; + } + const session = this.requireSession(workspace, sessionId); const existing = session.messages.find((message) => message.id === event.messageId); if (existing) { - existing.content = mergeStreamingText(existing.content, event.contentDelta); + existing.content = + event.content ?? mergeStreamingText(existing.content, event.contentDelta); existing.pending = true; + existing.authorName = event.authorName; } else { session.messages.push({ id: event.messageId, role: 'assistant', authorName: event.authorName, - content: event.contentDelta, + content: event.content ?? event.contentDelta, createdAt: nowIso(), pending: true, }); @@ -636,6 +642,7 @@ export class EryxAppService extends EventEmitter { messageId: event.messageId, authorName: event.authorName, contentDelta: event.contentDelta, + content: event.content, }); } @@ -697,6 +704,7 @@ export class EryxAppService extends EventEmitter { occurredAt: nowIso(), messageId: message.id, authorName: message.authorName, + content: message.content, }); this.emitCompletedActivity(sessionId, pattern, message); diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 53382c9..9f25fda 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -418,7 +418,13 @@ export function ChatPane({ : `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}` } > - + {!isUser && message.pending ? ( +
+ {message.content} +
+ ) : ( + + )} {message.pending && message.content && ( )} diff --git a/src/renderer/lib/sessionWorkspace.ts b/src/renderer/lib/sessionWorkspace.ts index 421bef5..6ef1189 100644 --- a/src/renderer/lib/sessionWorkspace.ts +++ b/src/renderer/lib/sessionWorkspace.ts @@ -77,17 +77,18 @@ function applyErrorEvent(session: SessionRecord, event: SessionEventRecord): Ses } function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord { - if (!event.messageId || event.contentDelta === undefined) { + if (!event.messageId || (event.content === undefined && event.contentDelta === undefined)) { return session; } + const resolvedContent = event.content ?? event.contentDelta ?? ''; const messageIndex = session.messages.findIndex((message) => message.id === event.messageId); if (messageIndex >= 0) { const existing = session.messages[messageIndex]; const nextMessage: ChatMessageRecord = { ...existing, authorName: event.authorName ?? existing.authorName, - content: mergeStreamingText(existing.content, event.contentDelta), + content: event.content ?? mergeStreamingText(existing.content, resolvedContent), pending: true, }; @@ -112,14 +113,14 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor ...session, messages: [ ...session.messages, - { - id: event.messageId, - role: 'assistant', - authorName: event.authorName ?? 'assistant', - content: event.contentDelta, - createdAt: event.occurredAt, - pending: true, - }, + { + id: event.messageId, + role: 'assistant', + authorName: event.authorName ?? 'assistant', + content: resolvedContent, + createdAt: event.occurredAt, + pending: true, + }, ], updatedAt: event.occurredAt, }; @@ -139,10 +140,15 @@ function applyMessageCompleteEvent(session: SessionRecord, event: SessionEventRe const nextMessage: ChatMessageRecord = { ...existing, authorName: event.authorName ?? existing.authorName, + content: event.content ?? existing.content, pending: false, }; - if (nextMessage.authorName === existing.authorName && !existing.pending) { + if ( + nextMessage.authorName === existing.authorName + && nextMessage.content === existing.content + && !existing.pending + ) { return session; } diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 499757a..d1fd854 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -133,6 +133,7 @@ export interface TurnDeltaEvent { messageId: string; authorName: string; contentDelta: string; + content?: string; } export interface TurnCompleteEvent { diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index 779827f..30df9ae 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -15,6 +15,7 @@ export interface SessionEventRecord { messageId?: string; authorName?: string; contentDelta?: string; + content?: string; activityType?: SessionActivityType; agentId?: string; agentName?: string; diff --git a/tests/renderer/sessionWorkspace.test.ts b/tests/renderer/sessionWorkspace.test.ts index 054868b..0de2553 100644 --- a/tests/renderer/sessionWorkspace.test.ts +++ b/tests/renderer/sessionWorkspace.test.ts @@ -36,6 +36,7 @@ describe('session workspace helpers', () => { messageId: 'assistant-1', authorName: 'Architect', contentDelta: 'Hello', + content: 'Hello', } satisfies SessionEventRecord); expect(created?.sessions[0].messages).toEqual([ @@ -56,6 +57,7 @@ describe('session workspace helpers', () => { messageId: 'assistant-1', authorName: 'Architect', contentDelta: ' world', + content: 'Hello world', } satisfies SessionEventRecord); expect(appended?.sessions[0].messages[0]).toMatchObject({ @@ -73,6 +75,7 @@ describe('session workspace helpers', () => { messageId: 'assistant-1', authorName: 'Implementer', contentDelta: 'Done', + content: 'Done', } satisfies SessionEventRecord); const completed = applySessionEventWorkspace(workspace, { @@ -81,6 +84,7 @@ describe('session workspace helpers', () => { occurredAt: '2026-03-23T00:00:02.000Z', messageId: 'assistant-1', authorName: 'Implementer', + content: 'Done', } satisfies SessionEventRecord); expect(completed?.sessions[0].messages[0]).toMatchObject({ @@ -98,6 +102,7 @@ describe('session workspace helpers', () => { messageId: 'assistant-1', authorName: 'Writer', contentDelta: 'How about', + content: 'How about', } satisfies SessionEventRecord); const second = applySessionEventWorkspace(first, { @@ -107,6 +112,7 @@ describe('session workspace helpers', () => { messageId: 'assistant-1', authorName: 'Writer', contentDelta: 'The **Ashen Crown** feels', + content: 'How about The **Ashen Crown** feels', } satisfies SessionEventRecord); const third = applySessionEventWorkspace(second, { @@ -116,6 +122,7 @@ describe('session workspace helpers', () => { messageId: 'assistant-1', authorName: 'Writer', contentDelta: 'classic and timeless.', + content: 'How about The **Ashen Crown** feels classic and timeless.', } satisfies SessionEventRecord); 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', () => { const running = applySessionEventWorkspace(createWorkspace(), { sessionId: 'session-1',