diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 5f90f74..8e49aec 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { session.cwd = scratchpadDirectory; } - private async applyTurnDelta( - workspace: WorkspaceState, - sessionId: string, - requestId: string, - event: TurnDeltaEvent, - ): Promise { - 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 { - 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 { - 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 { 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; } } diff --git a/src/main/services/sessionTurnExecutor.ts b/src/main/services/sessionTurnExecutor.ts index e1256ec..29be8ff 100644 --- a/src/main/services/sessionTurnExecutor.ts +++ b/src/main/services/sessionTurnExecutor.ts @@ -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; diff --git a/src/renderer/lib/sessionWorkspace.ts b/src/renderer/lib/sessionWorkspace.ts index 80a313a..bb5db3d 100644 --- a/src/renderer/lib/sessionWorkspace.ts +++ b/src/renderer/lib/sessionWorkspace.ts @@ -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, + }; +} diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index df8719e..ebd545b 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -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; + // Assistant intent fields + intent?: string; + + // Reasoning delta fields + reasoningId?: string; + reasoningDelta?: string; + // Workflow diagnostic fields diagnosticSeverity?: WorkflowDiagnosticSeverity; diagnosticKind?: WorkflowDiagnosticKind; diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index dde0f51..d0f015b 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -85,6 +85,7 @@ export interface SessionRecord { pendingPlanReview?: PendingPlanReviewRecord; pendingMcpAuth?: PendingMcpAuthRecord; runs: SessionRunRecord[]; + currentIntent?: string; } function normalizeOptionalString(value?: string): string | undefined { diff --git a/tests/renderer/sessionWorkspace.test.ts b/tests/renderer/sessionWorkspace.test.ts index 769363c..1fa61f6 100644 --- a/tests/renderer/sessionWorkspace.test.ts +++ b/tests/renderer/sessionWorkspace.test.ts @@ -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'); + }); });