mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-26 14:38:45 +02:00
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:
+25
-154
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface SessionRecord {
|
||||
pendingPlanReview?: PendingPlanReviewRecord;
|
||||
pendingMcpAuth?: PendingMcpAuthRecord;
|
||||
runs: SessionRunRecord[];
|
||||
currentIntent?: string;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user