refactor: consolidate streaming text ownership and forward intent events

- Main process now always emits resolved content in message-delta events,
  making it the single authoritative text assembly point
- Renderer no longer imports or runs mergeStreamingText; uses main-process
  resolved content directly with simple append as defensive fallback
- Forward assistant-intent events through the session event pipeline; track
  currentIntent on SessionRecord, cleared on turn completion/cancellation
- Forward reasoning-delta events through the session event pipeline so
  renderers can consume incremental reasoning text
- Add SessionEventKind entries: assistant-intent, reasoning-delta
- Add SessionEventRecord fields: intent, reasoningId, reasoningDelta
- Remove dead applyTurnDelta, applyMessageReclassified, applyAgentActivity
  methods from AryxAppService (superseded by SessionTurnExecutor)
- Remove stale mergeStreamingText and appendRunActivityEvent imports from
  AryxAppService
- Add 5 regression tests: resolved content path, simple append fallback,
  intent set/clear lifecycle, duplicate suppression, intent preservation
  during status transitions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-13 16:12:41 +02:00
co-authored by Copilot
parent 1fdb1c27d5
commit 08886f9078
6 changed files with 175 additions and 158 deletions
+25 -154
View File
@@ -117,7 +117,6 @@ import {
} from '@shared/domain/session';
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
import {
appendRunActivityEvent,
cancelSessionRunRecord,
completeSessionRunRecord,
createSessionRunRecord,
@@ -152,7 +151,6 @@ import {
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
import { getScratchpadSessionPath } from '@main/persistence/appPaths';
@@ -1924,158 +1922,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.cwd = scratchpadDirectory;
}
private async applyTurnDelta(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: TurnDeltaEvent,
): Promise<void> {
if (event.content === undefined && event.contentDelta === undefined) {
return;
}
const occurredAt = nowIso();
const session = this.requireSession(workspace, sessionId);
const existing = session.messages.find((message) => message.id === event.messageId);
const content =
existing && event.content === undefined
? 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',
authorName: event.authorName,
content,
createdAt: occurredAt,
pending: true,
});
}
const nextRun = this.updateSessionRun(session, requestId, (run) =>
upsertRunMessageEvent(run, {
messageId: event.messageId,
occurredAt,
authorName: event.authorName,
content,
status: 'running',
}));
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',
occurredAt,
messageId: event.messageId,
authorName: event.authorName,
contentDelta: event.contentDelta,
content: event.content,
});
if (nextRun) {
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
}
private async applyMessageReclassified(
workspace: WorkspaceState,
sessionId: string,
event: MessageReclassifiedEvent,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const message = session.messages.find((m) => m.id === event.messageId);
if (!message || message.messageKind === 'thinking') {
return;
}
message.messageKind = 'thinking';
const occurredAt = nowIso();
session.updatedAt = occurredAt;
await this.workspaceRepository.save(workspace);
this.emitSessionEvent({
sessionId,
kind: 'message-reclassified',
occurredAt,
messageId: event.messageId,
messageKind: 'thinking',
});
}
private async applyAgentActivity(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: AgentActivityEvent,
): Promise<void> {
const occurredAt = nowIso();
const session = this.requireSession(workspace, sessionId);
const activityType = event.activityType;
let nextRun: SessionRunRecord | undefined;
if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') {
nextRun = this.updateSessionRun(session, requestId, (run) =>
appendRunActivityEvent(run, {
activityType,
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
}));
}
if (nextRun) {
session.updatedAt = occurredAt;
await this.workspaceRepository.save(workspace);
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
this.emitSessionEvent({
sessionId,
kind: 'agent-activity',
occurredAt,
activityType: event.activityType,
agentId: event.agentId,
agentName: event.agentName,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
});
}
private emitCompletedActivity(
sessionId: string,
workflow: WorkflowDefinition,
@@ -2401,6 +2247,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
usageQuotaSnapshots: event.quotaSnapshots,
});
return;
case 'assistant-intent': {
const session = this.requireSession(workspace, sessionId);
session.currentIntent = event.intent;
session.updatedAt = occurredAt;
this.emitSessionEvent({
sessionId,
kind: 'assistant-intent',
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
intent: event.intent,
});
return;
}
case 'reasoning-delta':
this.emitSessionEvent({
sessionId,
kind: 'reasoning-delta',
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
reasoningId: event.reasoningId,
reasoningDelta: event.contentDelta,
});
return;
}
}
+3 -1
View File
@@ -633,7 +633,7 @@ export class SessionTurnExecutor {
messageId: event.messageId,
authorName: event.authorName,
contentDelta: event.contentDelta,
content: event.content,
content,
});
if (nextRun) {
this.emitRunUpdated(sessionId, occurredAt, nextRun);
@@ -822,6 +822,7 @@ export class SessionTurnExecutor {
const completedAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
@@ -854,6 +855,7 @@ export class SessionTurnExecutor {
const cancelledAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
+24 -3
View File
@@ -2,7 +2,6 @@ import type { SessionEventRecord } from '@shared/domain/event';
import { upsertSessionRunRecord } from '@shared/domain/runTimeline';
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { mergeStreamingText } from '@shared/utils/streamingText';
export function applySessionEventWorkspace(
current: WorkspaceState | undefined,
@@ -45,6 +44,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S
return applyMessageReclassifiedEvent(session, event);
case 'run-updated':
return applyRunUpdatedEvent(session, event);
case 'assistant-intent':
return applyAssistantIntentEvent(session, event);
default:
return session;
}
@@ -63,6 +64,7 @@ function applyStatusEvent(session: SessionRecord, event: SessionEventRecord): Se
...session,
status: event.status,
lastError: event.status === 'error' ? session.lastError : undefined,
currentIntent: event.status === 'idle' ? undefined : session.currentIntent,
updatedAt: event.occurredAt,
};
}
@@ -86,14 +88,19 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
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];
// The main process resolves content authoritatively; prefer event.content
// (full assembled text) and fall back to simple append for backward compat.
const nextContent =
event.content !== undefined
? event.content
: existing.content + (event.contentDelta ?? '');
const nextMessage: ChatMessageRecord = {
...existing,
authorName: event.authorName ?? existing.authorName,
content: event.content ?? mergeStreamingText(existing.content, resolvedContent),
content: nextContent,
pending: true,
};
@@ -114,6 +121,8 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
};
}
const resolvedContent = event.content ?? event.contentDelta ?? '';
// Auto-complete any previously pending assistant messages so only
// the new message shows the "Thinking" indicator.
const completedMessages = session.messages.map((message) =>
@@ -217,3 +226,15 @@ function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord)
updatedAt: event.occurredAt,
};
}
function applyAssistantIntentEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
if (!event.intent || session.currentIntent === event.intent) {
return session;
}
return {
...session,
currentIntent: event.intent,
updatedAt: event.occurredAt,
};
}
+9
View File
@@ -31,6 +31,8 @@ export type SessionEventKind =
| 'session-compaction'
| 'pending-messages-modified'
| 'assistant-usage'
| 'assistant-intent'
| 'reasoning-delta'
| 'workflow-diagnostic';
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
@@ -102,6 +104,13 @@ export interface SessionEventRecord {
usageTotalNanoAiu?: number;
usageQuotaSnapshots?: Record<string, QuotaSnapshot>;
// Assistant intent fields
intent?: string;
// Reasoning delta fields
reasoningId?: string;
reasoningDelta?: string;
// Workflow diagnostic fields
diagnosticSeverity?: WorkflowDiagnosticSeverity;
diagnosticKind?: WorkflowDiagnosticKind;
+1
View File
@@ -85,6 +85,7 @@ export interface SessionRecord {
pendingPlanReview?: PendingPlanReviewRecord;
pendingMcpAuth?: PendingMcpAuthRecord;
runs: SessionRunRecord[];
currentIntent?: string;
}
function normalizeOptionalString(value?: string): string | undefined {
+113
View File
@@ -351,4 +351,117 @@ describe('session workspace helpers', () => {
expect(result).toBe(workspace);
});
test('uses resolved content from main process and falls back to simple append', () => {
// When content is present (main process resolved it), use it directly
const first = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: 'Hello',
content: 'Hello',
} satisfies SessionEventRecord);
// Main process sends full resolved content on every delta
const second = applySessionEventWorkspace(first, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: ' world',
content: 'Hello world',
} satisfies SessionEventRecord);
expect(second?.sessions[0].messages[0].content).toBe('Hello world');
// Backward compat: if content is missing, simple append of contentDelta
const fallback = applySessionEventWorkspace(second, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:03.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: '!',
} satisfies SessionEventRecord);
expect(fallback?.sessions[0].messages[0].content).toBe('Hello world!');
});
test('sets currentIntent from assistant-intent events', () => {
const workspace = createWorkspace();
const updated = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:01.000Z',
intent: 'Exploring codebase',
} satisfies SessionEventRecord);
expect(updated?.sessions[0].currentIntent).toBe('Exploring codebase');
});
test('clears currentIntent when session transitions to idle', () => {
let workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:01.000Z',
status: 'running',
} satisfies SessionEventRecord);
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:02.000Z',
intent: 'Writing tests',
} satisfies SessionEventRecord);
expect(workspace?.sessions[0].currentIntent).toBe('Writing tests');
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:03.000Z',
status: 'idle',
} satisfies SessionEventRecord);
expect(workspace?.sessions[0].currentIntent).toBeUndefined();
});
test('ignores duplicate assistant-intent events', () => {
const workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:01.000Z',
intent: 'Exploring codebase',
} satisfies SessionEventRecord);
const duplicate = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:02.000Z',
intent: 'Exploring codebase',
} satisfies SessionEventRecord);
expect(duplicate).toBe(workspace);
});
test('preserves currentIntent when status changes to running', () => {
let workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:01.000Z',
intent: 'Analyzing code',
} satisfies SessionEventRecord);
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:02.000Z',
status: 'running',
} satisfies SessionEventRecord);
expect(workspace?.sessions[0].currentIntent).toBe('Analyzing code');
});
});