From b74294155981ff3eaacf53466a80b2985b1d5441 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 00:04:47 +0100 Subject: [PATCH] feat: add rich run timeline backend Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- HANDOVER.md | 205 ++++++++ .../Contracts/ProtocolModels.cs | 2 + .../Services/CopilotWorkflowRunner.cs | 6 +- src/main/EryxAppService.ts | 177 ++++++- src/main/persistence/workspaceRepository.ts | 2 + src/renderer/lib/sessionWorkspace.ts | 23 + src/shared/contracts/sidecar.ts | 2 + src/shared/domain/event.ts | 6 + src/shared/domain/runTimeline.ts | 454 ++++++++++++++++++ src/shared/domain/session.ts | 2 + src/shared/domain/sessionLibrary.ts | 1 + tests/renderer/messagePhase.test.ts | 1 + tests/renderer/sessionWorkspace.test.ts | 43 ++ tests/shared/runTimeline.test.ts | 171 +++++++ tests/shared/session.test.ts | 1 + tests/shared/sessionLibrary.test.ts | 2 + 16 files changed, 1072 insertions(+), 26 deletions(-) create mode 100644 HANDOVER.md create mode 100644 src/shared/domain/runTimeline.ts create mode 100644 tests/shared/runTimeline.test.ts diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..9c7bea8 --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,205 @@ +# Rich run timeline handover + +This change implements the backend half of the `Rich run timeline` roadmap item and leaves the actual timeline UI/UX for the next agent. + +## What is done + +- Persisted structured run history on every `SessionRecord` +- Added a shared run/timeline domain model for durable per-run metadata and ordered timeline events +- Started a new run record whenever `sendSessionMessage(...)` kicks off a turn +- Grouped streamed assistant output into a single timeline message event keyed by `messageId` +- Persisted timeline updates during agent activity, message streaming, completion, and failure +- Added explicit handoff source metadata so the future UI can draw source/target handoff edges +- Added a live `run-updated` session event so the renderer can receive timeline changes incrementally without waiting for a full workspace refresh +- Kept duplicate-session behavior simple and safe: duplicated sessions copy transcript/tooling config but start with `runs: []` + +## Important files + +- `src/shared/domain/runTimeline.ts` + - New shared types and helpers: + - `SessionRunRecord` + - `RunTimelineEventRecord` + - `createSessionRunRecord(...)` + - `appendRunActivityEvent(...)` + - `upsertRunMessageEvent(...)` + - `completeSessionRunRecord(...)` + - `failSessionRunRecord(...)` +- `src/shared/domain/session.ts` + - `SessionRecord` now includes `runs: SessionRunRecord[]` +- `src/main/persistence/workspaceRepository.ts` + - Normalizes persisted `session.runs` +- `src/main/EryxAppService.ts` + - Creates and updates run records during turn execution + - Emits live `run-updated` session events +- `src/shared/domain/event.ts` + - Added session event kind `run-updated` + - Added optional `run` payload on `SessionEventRecord` +- `src/renderer/lib/sessionWorkspace.ts` + - Applies `run-updated` events by replacing/inserting the full run snapshot +- `src/shared/contracts/sidecar.ts` + - `AgentActivityEvent` now carries optional `sourceAgentId` / `sourceAgentName` +- `sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs` + - C# DTO mirror for the new handoff source fields +- `sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs` + - Handoff activity now includes the active/source agent when available + +## Backend contract + +### Session shape + +Every session now has: + +```ts +runs: SessionRunRecord[] +``` + +Runs are stored newest-first. + +### `SessionRunRecord` + +Key fields: + +- `id` +- `requestId` +- `projectId` +- `projectPath` +- `workspaceKind` +- `patternId` +- `patternName` +- `patternMode` +- `triggerMessageId` +- `startedAt` +- `completedAt?` +- `status` +- `agents` +- `events` + +`agents` is the per-run lane metadata the UI should use for lane headers / agent grouping. + +### `RunTimelineEventRecord` + +Event kinds: + +- `run-started` +- `thinking` +- `handoff` +- `tool-call` +- `message` +- `run-completed` +- `run-failed` + +Useful payload fields: + +- `occurredAt` +- `updatedAt?` +- `status` +- `agentId` / `agentName` +- `sourceAgentId` / `sourceAgentName` +- `targetAgentId` / `targetAgentName` +- `toolName` +- `messageId` +- `content` +- `error` + +### Live update event + +The renderer now receives: + +```ts +{ + sessionId: string; + kind: 'run-updated'; + occurredAt: string; + run: SessionRunRecord; +} +``` + +This is a full run snapshot, not a delta patch. The reducer already replaces/upserts the run for the selected session. + +## Current runtime behavior + +### Run start + +`sendSessionMessage(...)` now: + +1. appends the user message +2. creates a new `SessionRunRecord` +3. persists the workspace +4. starts the turn + +The initial run contains a `run-started` event pointing at `triggerMessageId`. + +### Thinking / handoff / tool activity + +Agent activity events now append timeline entries: + +- `thinking` +- `handoff` +- `tool-call` + +For handoffs, the backend now stores both source and target when the source agent is known. + +### Streamed assistant output + +Message streaming is grouped into one `message` event per `messageId`. + +During streaming: + +- the existing message event is updated in place +- `status` stays `running` +- `content` holds the latest snapshot +- `updatedAt` advances + +On completion: + +- the same event becomes `status: 'completed'` +- final content is persisted + +### Run completion / failure + +Successful runs append `run-completed`. + +Failed runs append `run-failed` and also mark any still-running message events as errored. + +## Notes for the UI/UX agent + +You do **not** need new IPC or backend endpoints for the first timeline UI pass. + +Use: + +- `session.runs` for initial render / reload +- live `run-updated` session events for in-flight changes + +Suggested UI plan: + +1. Replace or reframe the current agent-activity tiles around the run model instead of mixing unrelated tiles together. +2. Show runs newest-first in the side panel. +3. Inside a run, render a vertical timeline ordered by `events[]`. +4. Use `agents[]` to build per-agent lanes or grouped headers. +5. Render `message` events as the coherent answer step for streamed assistant output. +6. Use `messageId` and `triggerMessageId` for jump-to-message actions. +7. Use `agentId`, `sourceAgentId`, and `targetAgentId` for jump-to-agent / handoff visuals. + +## UX-specific suggestions + +- `run-started`: compact start card linked to the triggering user message +- `thinking`: lightweight state card or inline badge in the agent lane +- `handoff`: directional edge/card from source -> target +- `tool-call`: small tool chip/card with tool name and agent +- `message`: the main rich output card; this is the item that should expand/collapse cleanly +- `run-completed` / `run-failed`: footer-style terminal state card + +## Known limitations / open questions + +- There is no dedicated persisted `completed` activity event. Final assistant output plus `run-completed` is the canonical completion signal for now. +- Tool results are not persisted yet; only tool invocation metadata is stored. +- Pattern version metadata is not persisted yet; only `patternId`, `patternName`, and `patternMode` are captured. +- Duplicate sessions intentionally start with empty run history. If product wants copied traces later, that needs an explicit decision. +- Live `run-updated` events currently send the full run snapshot each time. That keeps the reducer simple; optimize later only if payload size becomes a problem. + +## Validation completed + +- `bun run typecheck` +- `bun test` +- `bun run sidecar:test` +- `bun run build` diff --git a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs index 70c6c93..32a57a3 100644 --- a/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Eryx.AgentHost/Contracts/ProtocolModels.cs @@ -182,6 +182,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto public string ActivityType { get; init; } = string.Empty; public string? AgentId { get; init; } public string? AgentName { get; init; } + public string? SourceAgentId { get; init; } + public string? SourceAgentName { get; init; } public string? ToolName { get; init; } } diff --git a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs index bfcbe89..7b36a25 100644 --- a/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Eryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -173,6 +173,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner RunTurnCommandDto command, string activityType, AgentIdentity agent, + AgentIdentity? sourceAgent = null, string? toolName = null) { return new AgentActivityEventDto @@ -183,6 +184,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner ActivityType = activityType, AgentId = agent.AgentId, AgentName = agent.AgentName, + SourceAgentId = sourceAgent?.AgentId, + SourceAgentName = sourceAgent?.AgentName, ToolName = toolName, }; } @@ -197,7 +200,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return CreateActivityEvent( command, activityType: "handoff", - agent: handoffAgent); + agent: handoffAgent, + sourceAgent: activeAgent); } if (!activeAgent.HasValue || !TryGetToolName(requestInfo, out string toolName)) diff --git a/src/main/EryxAppService.ts b/src/main/EryxAppService.ts index be85758..1d8d04f 100644 --- a/src/main/EryxAppService.ts +++ b/src/main/EryxAppService.ts @@ -40,6 +40,15 @@ import { type ChatMessageRecord, type SessionRecord, } from '@shared/domain/session'; +import { + appendRunActivityEvent, + completeSessionRunRecord, + createSessionRunRecord, + failSessionRunRecord, + upsertRunMessageEvent, + upsertSessionRunRecord, + type SessionRunRecord, +} from '@shared/domain/runTimeline'; import { createSessionToolingSelection, type LspProfileDefinition, @@ -318,6 +327,7 @@ export class EryxAppService extends EventEmitter { ? createScratchpadSessionConfig(normalizedPattern) : undefined, tooling: createSessionToolingSelection(), + runs: [], }; workspace.sessions.unshift(session); @@ -376,28 +386,41 @@ export class EryxAppService extends EventEmitter { return; } + const requestId = createId('turn'); + const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; + const occurredAt = nowIso(); + const userMessageId = createId('msg'); session.messages.push({ - id: createId('msg'), + id: userMessageId, role: 'user', authorName: 'You', content: trimmed, - createdAt: nowIso(), + createdAt: occurredAt, }); session.title = resolveSessionTitle(session, effectivePattern, session.messages); session.status = 'running'; session.lastError = undefined; - session.updatedAt = nowIso(); + session.updatedAt = occurredAt; + session.runs = [ + createSessionRunRecord({ + requestId, + project, + workspaceKind, + pattern: effectivePattern, + triggerMessageId: userMessageId, + startedAt: occurredAt, + }), + ...session.runs, + ]; await this.persistAndBroadcast(workspace); this.emitSessionEvent({ sessionId: session.id, kind: 'status', status: 'running', - occurredAt: nowIso(), + occurredAt, }); - const requestId = createId('turn'); - const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project'; try { const responseMessages = await this.sidecar.runTurn( { @@ -411,26 +434,33 @@ export class EryxAppService extends EventEmitter { tooling: this.buildRunTurnToolingConfig(workspace, project, session), }, async (event) => { - await this.applyTurnDelta(workspace, session.id, event); + await this.applyTurnDelta(workspace, session.id, requestId, event); }, - (event) => { - this.emitAgentActivity(event); + async (event) => { + await this.applyAgentActivity(workspace, session.id, requestId, event); }, ); - this.finalizeTurn(workspace, session.id, responseMessages); + this.finalizeTurn(workspace, session.id, requestId, responseMessages); await this.persistAndBroadcast(workspace); } catch (error) { + const failedAt = nowIso(); session.status = 'error'; session.lastError = error instanceof Error ? error.message : String(error); - session.updatedAt = nowIso(); + session.updatedAt = failedAt; + + const failedRun = this.updateSessionRun(session, requestId, (run) => + failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.')); this.emitSessionEvent({ sessionId: session.id, kind: 'error', - occurredAt: nowIso(), + occurredAt: failedAt, error: session.lastError, }); + if (failedRun) { + this.emitRunUpdated(session.id, failedAt, failedRun); + } await this.persistAndBroadcast(workspace); } @@ -607,18 +637,23 @@ export class EryxAppService extends EventEmitter { 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); if (existing) { - existing.content = - event.content ?? mergeStreamingText(existing.content, event.contentDelta); + existing.content = content; existing.pending = true; existing.authorName = event.authorName; } else { @@ -626,34 +661,75 @@ export class EryxAppService extends EventEmitter { id: event.messageId, role: 'assistant', authorName: event.authorName, - content: event.content ?? event.contentDelta, - createdAt: nowIso(), + content, + createdAt: occurredAt, pending: true, }); } - session.updatedAt = nowIso(); + 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); this.emitSessionEvent({ sessionId, kind: 'message-delta', - occurredAt: nowIso(), + occurredAt, messageId: event.messageId, authorName: event.authorName, contentDelta: event.contentDelta, content: event.content, }); + if (nextRun) { + this.emitRunUpdated(sessionId, occurredAt, nextRun); + } } - private emitAgentActivity(event: AgentActivityEvent): void { + 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 !== 'completed') { + 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, + })); + } + if (nextRun) { + session.updatedAt = occurredAt; + await this.workspaceRepository.save(workspace); + this.emitRunUpdated(sessionId, occurredAt, nextRun); + } + this.emitSessionEvent({ - sessionId: event.sessionId, + sessionId, kind: 'agent-activity', - occurredAt: nowIso(), + occurredAt, activityType: event.activityType, agentId: event.agentId, agentName: event.agentName, + sourceAgentId: event.sourceAgentId, + sourceAgentName: event.sourceAgentName, toolName: event.toolName, }); } @@ -683,12 +759,18 @@ export class EryxAppService extends EventEmitter { }); } - private finalizeTurn(workspace: WorkspaceState, sessionId: string, messages: ChatMessageRecord[]): void { + private finalizeTurn( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + messages: ChatMessageRecord[], + ): void { const session = this.requireSession(workspace, sessionId); const pattern = this.requirePattern(workspace, session.patternId); const incomingIds = new Set(messages.map((message) => message.id)); for (const message of messages) { + const occurredAt = nowIso(); const existing = session.messages.find((current) => current.id === message.id); if (existing) { existing.authorName = message.authorName; @@ -698,14 +780,25 @@ export class EryxAppService extends EventEmitter { session.messages.push({ ...message, pending: false }); } + const nextRun = this.updateSessionRun(session, requestId, (run) => + upsertRunMessageEvent(run, { + messageId: message.id, + occurredAt, + authorName: message.authorName, + content: message.content, + status: 'completed', + })); this.emitSessionEvent({ sessionId, kind: 'message-complete', - occurredAt: nowIso(), + occurredAt, messageId: message.id, authorName: message.authorName, content: message.content, }); + if (nextRun) { + this.emitRunUpdated(sessionId, occurredAt, nextRun); + } this.emitCompletedActivity(sessionId, pattern, message); } @@ -716,15 +809,21 @@ export class EryxAppService extends EventEmitter { } } + const completedAt = nowIso(); session.status = 'idle'; session.lastError = undefined; - session.updatedAt = nowIso(); + session.updatedAt = completedAt; + const completedRun = this.updateSessionRun(session, requestId, (run) => + completeSessionRunRecord(run, completedAt)); this.emitSessionEvent({ sessionId, kind: 'status', - occurredAt: nowIso(), + occurredAt: completedAt, status: 'idle', }); + if (completedRun) { + this.emitRunUpdated(sessionId, completedAt, completedRun); + } } private async persistAndBroadcast(workspace: WorkspaceState): Promise { @@ -835,6 +934,34 @@ export class EryxAppService extends EventEmitter { }; } + private updateSessionRun( + session: SessionRecord, + requestId: string, + updater: (run: SessionRunRecord) => SessionRunRecord, + ): SessionRunRecord | undefined { + const run = session.runs.find((candidate) => candidate.requestId === requestId); + if (!run) { + return undefined; + } + + const nextRun = updater(run); + if (nextRun === run) { + return undefined; + } + + session.runs = upsertSessionRunRecord(session.runs, nextRun); + return nextRun; + } + + private emitRunUpdated(sessionId: string, occurredAt: string, run: SessionRunRecord): void { + this.emitSessionEvent({ + sessionId, + kind: 'run-updated', + occurredAt, + run, + }); + } + private emitSessionEvent(event: SessionEventRecord): void { this.emit('session-event', event); } diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index 8c0cac4..56aeb76 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises'; import { createBuiltinPatterns } from '@shared/domain/pattern'; import type { PatternDefinition } from '@shared/domain/pattern'; import { mergeScratchpadProject } from '@shared/domain/project'; +import { normalizeSessionRunRecords } from '@shared/domain/runTimeline'; import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling'; import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; import { nowIso } from '@shared/utils/ids'; @@ -62,6 +63,7 @@ export class WorkspaceRepository { projects, sessions: (stored.sessions ?? []).map((session) => ({ ...session, + runs: normalizeSessionRunRecords(session.runs), tooling: normalizeSessionToolingSelection(session.tooling), })), settings: normalizeWorkspaceSettings(stored.settings), diff --git a/src/renderer/lib/sessionWorkspace.ts b/src/renderer/lib/sessionWorkspace.ts index 6ef1189..9f1a10f 100644 --- a/src/renderer/lib/sessionWorkspace.ts +++ b/src/renderer/lib/sessionWorkspace.ts @@ -1,4 +1,5 @@ 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'; @@ -40,6 +41,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S return applyMessageDeltaEvent(session, event); case 'message-complete': return applyMessageCompleteEvent(session, event); + case 'run-updated': + return applyRunUpdatedEvent(session, event); default: return session; } @@ -160,3 +163,23 @@ function applyMessageCompleteEvent(session: SessionRecord, event: SessionEventRe updatedAt: event.occurredAt, }; } + +function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord { + if (!event.run) { + return session; + } + + const nextRuns = upsertSessionRunRecord(session.runs, event.run); + if ( + nextRuns.length === session.runs.length + && nextRuns.every((run, index) => run === session.runs[index]) + ) { + return session; + } + + return { + ...session, + runs: nextRuns, + updatedAt: event.occurredAt, + }; +} diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index d1fd854..b9028b8 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -152,6 +152,8 @@ export interface AgentActivityEvent { activityType: AgentActivityType; agentId?: string; agentName?: string; + sourceAgentId?: string; + sourceAgentName?: string; toolName?: string; } diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index 30df9ae..9e871a3 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -1,3 +1,5 @@ +import type { SessionRunRecord } from '@shared/domain/runTimeline'; + export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; export type SessionEventKind = @@ -5,6 +7,7 @@ export type SessionEventKind = | 'message-delta' | 'message-complete' | 'agent-activity' + | 'run-updated' | 'error'; export interface SessionEventRecord { @@ -19,6 +22,9 @@ export interface SessionEventRecord { activityType?: SessionActivityType; agentId?: string; agentName?: string; + sourceAgentId?: string; + sourceAgentName?: string; toolName?: string; + run?: SessionRunRecord; error?: string; } diff --git a/src/shared/domain/runTimeline.ts b/src/shared/domain/runTimeline.ts new file mode 100644 index 0000000..7860062 --- /dev/null +++ b/src/shared/domain/runTimeline.ts @@ -0,0 +1,454 @@ +import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; +import type { ProjectRecord } from '@shared/domain/project'; +import { createId } from '@shared/utils/ids'; + +export type SessionRunStatus = 'running' | 'completed' | 'error'; +export type SessionRunWorkspaceKind = 'project' | 'scratchpad'; +export type RunTimelineEventKind = + | 'run-started' + | 'thinking' + | 'handoff' + | 'tool-call' + | 'message' + | 'run-completed' + | 'run-failed'; +export type RunTimelineEventStatus = 'running' | 'completed' | 'error'; + +export interface RunTimelineAgentRecord { + agentId: string; + agentName: string; + model: string; + reasoningEffort?: ReasoningEffort; +} + +export interface RunTimelineEventRecord { + id: string; + kind: RunTimelineEventKind; + occurredAt: string; + updatedAt?: string; + status: RunTimelineEventStatus; + agentId?: string; + agentName?: string; + sourceAgentId?: string; + sourceAgentName?: string; + targetAgentId?: string; + targetAgentName?: string; + toolName?: string; + messageId?: string; + content?: string; + error?: string; +} + +export interface SessionRunRecord { + id: string; + requestId: string; + projectId: string; + projectPath: string; + workspaceKind: SessionRunWorkspaceKind; + patternId: string; + patternName: string; + patternMode: PatternDefinition['mode']; + triggerMessageId: string; + startedAt: string; + completedAt?: string; + status: SessionRunStatus; + agents: RunTimelineAgentRecord[]; + events: RunTimelineEventRecord[]; +} + +export interface CreateSessionRunRecordInput { + requestId: string; + project: Pick; + workspaceKind: SessionRunWorkspaceKind; + pattern: Pick; + triggerMessageId: string; + startedAt: string; +} + +export interface AppendRunActivityEventInput { + activityType: 'thinking' | 'tool-calling' | 'handoff'; + occurredAt: string; + agentId?: string; + agentName?: string; + sourceAgentId?: string; + sourceAgentName?: string; + toolName?: string; +} + +export interface UpsertRunMessageEventInput { + messageId: string; + occurredAt: string; + authorName?: string; + content?: string; + status: RunTimelineEventStatus; + error?: string; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeRunTimelineAgent( + agent: RunTimelineAgentRecord, +): RunTimelineAgentRecord | undefined { + const agentId = normalizeOptionalString(agent.agentId); + const agentName = normalizeOptionalString(agent.agentName); + const model = normalizeOptionalString(agent.model); + if (!agentId || !agentName || !model) { + return undefined; + } + + return { + agentId, + agentName, + model, + reasoningEffort: agent.reasoningEffort, + }; +} + +function normalizeRunTimelineEvent( + event: RunTimelineEventRecord, +): RunTimelineEventRecord | undefined { + const id = normalizeOptionalString(event.id); + const occurredAt = normalizeOptionalString(event.occurredAt); + if (!id || !occurredAt) { + return undefined; + } + + return { + id, + kind: event.kind, + occurredAt, + updatedAt: normalizeOptionalString(event.updatedAt), + status: event.status, + agentId: normalizeOptionalString(event.agentId), + agentName: normalizeOptionalString(event.agentName), + sourceAgentId: normalizeOptionalString(event.sourceAgentId), + sourceAgentName: normalizeOptionalString(event.sourceAgentName), + targetAgentId: normalizeOptionalString(event.targetAgentId), + targetAgentName: normalizeOptionalString(event.targetAgentName), + toolName: normalizeOptionalString(event.toolName), + messageId: normalizeOptionalString(event.messageId), + content: event.content, + error: normalizeOptionalString(event.error), + }; +} + +function resolveRunTimelineAgent( + run: SessionRunRecord, + agentId?: string, + agentName?: string, +): Pick { + const normalizedAgentId = normalizeOptionalString(agentId); + const normalizedAgentName = normalizeOptionalString(agentName); + + if (normalizedAgentId) { + const matchedAgent = run.agents.find((agent) => agent.agentId === normalizedAgentId); + if (matchedAgent) { + return { + agentId: matchedAgent.agentId, + agentName: matchedAgent.agentName, + }; + } + } + + if (normalizedAgentName) { + const matchedAgent = run.agents.find((agent) => agent.agentName === normalizedAgentName); + if (matchedAgent) { + return { + agentId: matchedAgent.agentId, + agentName: matchedAgent.agentName, + }; + } + } + + return { + agentId: normalizedAgentId, + agentName: normalizedAgentName, + }; +} + +function appendRunTimelineEvent( + run: SessionRunRecord, + event: Omit & { id?: string }, +): SessionRunRecord { + const nextEvent = normalizeRunTimelineEvent({ + id: event.id ?? createId('run-event'), + ...event, + }); + if (!nextEvent) { + return run; + } + + return { + ...run, + events: [...run.events, nextEvent], + }; +} + +function settleOpenMessageEvents( + run: SessionRunRecord, + status: Extract, + occurredAt: string, + error?: string, +): SessionRunRecord { + let changed = false; + const normalizedError = normalizeOptionalString(error); + const nextEvents = run.events.map((event) => { + if (event.kind !== 'message' || event.status !== 'running') { + return event; + } + + changed = true; + return { + ...event, + status, + updatedAt: occurredAt, + error: normalizedError, + }; + }); + + if (!changed) { + return run; + } + + return { + ...run, + events: nextEvents, + }; +} + +export function createSessionRunRecord(input: CreateSessionRunRecordInput): SessionRunRecord { + return { + id: createId('run'), + requestId: input.requestId, + projectId: input.project.id, + projectPath: input.project.path, + workspaceKind: input.workspaceKind, + patternId: input.pattern.id, + patternName: input.pattern.name, + patternMode: input.pattern.mode, + triggerMessageId: input.triggerMessageId, + startedAt: input.startedAt, + status: 'running', + completedAt: undefined, + agents: input.pattern.agents + .map((agent): RunTimelineAgentRecord => ({ + agentId: agent.id, + agentName: agent.name, + model: agent.model, + reasoningEffort: agent.reasoningEffort, + })) + .flatMap((agent) => { + const normalized = normalizeRunTimelineAgent(agent); + return normalized ? [normalized] : []; + }), + events: [ + { + id: createId('run-event'), + kind: 'run-started', + occurredAt: input.startedAt, + status: 'completed', + messageId: input.triggerMessageId, + }, + ], + }; +} + +export function normalizeSessionRunRecords( + runs: readonly SessionRunRecord[] | undefined, +): SessionRunRecord[] { + if (!runs || runs.length === 0) { + return []; + } + + return runs.flatMap((run) => { + const id = normalizeOptionalString(run.id); + const requestId = normalizeOptionalString(run.requestId); + const projectId = normalizeOptionalString(run.projectId); + const projectPath = normalizeOptionalString(run.projectPath); + const patternId = normalizeOptionalString(run.patternId); + const patternName = normalizeOptionalString(run.patternName); + const triggerMessageId = normalizeOptionalString(run.triggerMessageId); + const startedAt = normalizeOptionalString(run.startedAt); + if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) { + return []; + } + + return [ + { + id, + requestId, + projectId, + projectPath, + workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project', + patternId, + patternName, + patternMode: run.patternMode, + triggerMessageId, + startedAt, + completedAt: normalizeOptionalString(run.completedAt), + status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : 'completed', + agents: run.agents.flatMap((agent) => { + const normalized = normalizeRunTimelineAgent(agent); + return normalized ? [normalized] : []; + }), + events: run.events.flatMap((event) => { + const normalized = normalizeRunTimelineEvent(event); + return normalized ? [normalized] : []; + }), + }, + ]; + }); +} + +export function upsertSessionRunRecord( + runs: readonly SessionRunRecord[], + nextRun: SessionRunRecord, +): SessionRunRecord[] { + const runIndex = runs.findIndex((run) => run.id === nextRun.id); + if (runIndex < 0) { + return [nextRun, ...runs]; + } + + const nextRuns = runs.slice(); + nextRuns[runIndex] = nextRun; + return nextRuns; +} + +export function appendRunActivityEvent( + run: SessionRunRecord, + input: AppendRunActivityEventInput, +): SessionRunRecord { + switch (input.activityType) { + case 'thinking': { + const agent = resolveRunTimelineAgent(run, input.agentId, input.agentName); + return appendRunTimelineEvent(run, { + kind: 'thinking', + occurredAt: input.occurredAt, + status: 'completed', + ...agent, + }); + } + case 'tool-calling': { + const agent = resolveRunTimelineAgent(run, input.agentId, input.agentName); + return appendRunTimelineEvent(run, { + kind: 'tool-call', + occurredAt: input.occurredAt, + status: 'completed', + ...agent, + toolName: normalizeOptionalString(input.toolName), + }); + } + case 'handoff': { + const sourceAgent = resolveRunTimelineAgent(run, input.sourceAgentId, input.sourceAgentName); + const targetAgent = resolveRunTimelineAgent(run, input.agentId, input.agentName); + return appendRunTimelineEvent(run, { + kind: 'handoff', + occurredAt: input.occurredAt, + status: 'completed', + agentId: sourceAgent.agentId, + agentName: sourceAgent.agentName, + sourceAgentId: sourceAgent.agentId, + sourceAgentName: sourceAgent.agentName, + targetAgentId: targetAgent.agentId, + targetAgentName: targetAgent.agentName, + }); + } + } +} + +export function upsertRunMessageEvent( + run: SessionRunRecord, + input: UpsertRunMessageEventInput, +): SessionRunRecord { + const messageId = normalizeOptionalString(input.messageId); + if (!messageId) { + return run; + } + + const author = resolveRunTimelineAgent(run, undefined, input.authorName); + const existingIndex = run.events.findIndex((event) => event.kind === 'message' && event.messageId === messageId); + const normalizedError = normalizeOptionalString(input.error); + + if (existingIndex < 0) { + return appendRunTimelineEvent(run, { + kind: 'message', + occurredAt: input.occurredAt, + updatedAt: input.occurredAt, + status: input.status, + messageId, + content: input.content, + error: normalizedError, + ...author, + }); + } + + const existingEvent = run.events[existingIndex]; + const nextEvent: RunTimelineEventRecord = { + ...existingEvent, + agentId: existingEvent.agentId ?? author.agentId, + agentName: existingEvent.agentName ?? author.agentName, + updatedAt: input.occurredAt, + status: input.status, + content: input.content ?? existingEvent.content, + error: normalizedError, + }; + + if ( + nextEvent.agentId === existingEvent.agentId + && nextEvent.agentName === existingEvent.agentName + && nextEvent.updatedAt === existingEvent.updatedAt + && nextEvent.status === existingEvent.status + && nextEvent.content === existingEvent.content + && nextEvent.error === existingEvent.error + ) { + return run; + } + + const nextEvents = run.events.slice(); + nextEvents[existingIndex] = nextEvent; + return { + ...run, + events: nextEvents, + }; +} + +export function completeSessionRunRecord( + run: SessionRunRecord, + completedAt: string, +): SessionRunRecord { + const settledRun = settleOpenMessageEvents(run, 'completed', completedAt); + const completedRun: SessionRunRecord = { + ...settledRun, + status: 'completed', + completedAt, + }; + + return appendRunTimelineEvent(completedRun, { + kind: 'run-completed', + occurredAt: completedAt, + status: 'completed', + }); +} + +export function failSessionRunRecord( + run: SessionRunRecord, + failedAt: string, + error: string, +): SessionRunRecord { + const settledRun = settleOpenMessageEvents(run, 'error', failedAt, error); + const failedRun: SessionRunRecord = { + ...settledRun, + status: 'error', + completedAt: failedAt, + }; + + return appendRunTimelineEvent(failedRun, { + kind: 'run-failed', + occurredAt: failedAt, + status: 'error', + error, + }); +} diff --git a/src/shared/domain/session.ts b/src/shared/domain/session.ts index 70635a0..a44294f 100644 --- a/src/shared/domain/session.ts +++ b/src/shared/domain/session.ts @@ -4,6 +4,7 @@ import { normalizeSessionToolingSelection, type SessionToolingSelection, } from '@shared/domain/tooling'; +import type { SessionRunRecord } from '@shared/domain/runTimeline'; export type ChatRole = 'system' | 'user' | 'assistant'; export type SessionStatus = 'idle' | 'running' | 'error'; @@ -38,6 +39,7 @@ export interface SessionRecord { lastError?: string; scratchpadConfig?: ScratchpadSessionConfig; tooling?: SessionToolingSelection; + runs: SessionRunRecord[]; } export function resolveSessionTitle( diff --git a/src/shared/domain/sessionLibrary.ts b/src/shared/domain/sessionLibrary.ts index 1ca2f92..681eeb9 100644 --- a/src/shared/domain/sessionLibrary.ts +++ b/src/shared/domain/sessionLibrary.ts @@ -181,6 +181,7 @@ export function duplicateSessionRecord( enabledLspProfileIds: [...session.tooling.enabledLspProfileIds], } : undefined, + runs: [], messages: session.messages.map((message): ChatMessageRecord => ({ ...message, pending: false, diff --git a/tests/renderer/messagePhase.test.ts b/tests/renderer/messagePhase.test.ts index 90cdaae..535dd66 100644 --- a/tests/renderer/messagePhase.test.ts +++ b/tests/renderer/messagePhase.test.ts @@ -16,6 +16,7 @@ function createSession( updatedAt: '2026-03-23T00:00:00.000Z', status, messages, + runs: [], }; } diff --git a/tests/renderer/sessionWorkspace.test.ts b/tests/renderer/sessionWorkspace.test.ts index 0de2553..be47175 100644 --- a/tests/renderer/sessionWorkspace.test.ts +++ b/tests/renderer/sessionWorkspace.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import type { SessionEventRecord } from '@shared/domain/event'; +import type { SessionRunRecord } from '@shared/domain/runTimeline'; import { createWorkspaceSettings } from '@shared/domain/tooling'; import type { WorkspaceState } from '@shared/domain/workspace'; @@ -21,6 +22,7 @@ describe('session workspace helpers', () => { updatedAt: '2026-03-23T00:00:00.000Z', status: 'idle', messages: [], + runs: [], }, ], selectedSessionId: 'session-1', @@ -185,6 +187,47 @@ describe('session workspace helpers', () => { }); }); + test('applies live run snapshots without waiting for a workspace refresh', () => { + const run: SessionRunRecord = { + id: 'run-1', + requestId: 'turn-1', + projectId: 'project-1', + projectPath: 'C:\\workspace\\alpha', + workspaceKind: 'project', + patternId: 'pattern-1', + patternName: 'Sequential Review', + patternMode: 'sequential', + triggerMessageId: 'msg-user-1', + startedAt: '2026-03-23T00:00:01.000Z', + status: 'running', + agents: [ + { + agentId: 'agent-1', + agentName: 'Writer', + model: 'gpt-5.4', + }, + ], + events: [ + { + id: 'run-event-1', + kind: 'run-started', + occurredAt: '2026-03-23T00:00:01.000Z', + status: 'completed', + messageId: 'msg-user-1', + }, + ], + }; + + const updated = applySessionEventWorkspace(createWorkspace(), { + sessionId: 'session-1', + kind: 'run-updated', + occurredAt: '2026-03-23T00:00:01.000Z', + run, + } satisfies SessionEventRecord); + + expect(updated?.sessions[0].runs).toEqual([run]); + }); + test('ignores events for unknown sessions', () => { const workspace = createWorkspace(); expect( diff --git a/tests/shared/runTimeline.test.ts b/tests/shared/runTimeline.test.ts new file mode 100644 index 0000000..e1801ba --- /dev/null +++ b/tests/shared/runTimeline.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from 'bun:test'; + +import type { PatternDefinition } from '@shared/domain/pattern'; +import { + appendRunActivityEvent, + completeSessionRunRecord, + createSessionRunRecord, + normalizeSessionRunRecords, + upsertRunMessageEvent, +} from '@shared/domain/runTimeline'; +import type { ProjectRecord } from '@shared/domain/project'; + +function createPattern(): PatternDefinition { + return { + id: 'pattern-sequential', + name: 'Sequential Trio Review', + description: 'Sequential handoff review flow.', + mode: 'sequential', + availability: 'available', + maxIterations: 1, + agents: [ + { + id: 'agent-writer', + name: 'Writer', + description: 'Writes the draft.', + instructions: 'Write.', + model: 'gpt-5.4', + }, + { + id: 'agent-reviewer', + name: 'Reviewer', + description: 'Reviews the draft.', + instructions: 'Review.', + model: 'claude-sonnet-4.5', + }, + ], + createdAt: '2026-03-23T00:00:00.000Z', + updatedAt: '2026-03-23T00:00:00.000Z', + }; +} + +function createProject(): ProjectRecord { + return { + id: 'project-1', + name: 'alpha', + path: 'C:\\workspace\\alpha', + addedAt: '2026-03-23T00:00:00.000Z', + }; +} + +describe('run timeline helpers', () => { + test('creates a persistent run record with agent lanes and a trigger event', () => { + const run = createSessionRunRecord({ + requestId: 'turn-1', + project: createProject(), + workspaceKind: 'project', + pattern: createPattern(), + triggerMessageId: 'msg-user-1', + startedAt: '2026-03-23T00:00:01.000Z', + }); + + expect(run).toMatchObject({ + requestId: 'turn-1', + projectId: 'project-1', + projectPath: 'C:\\workspace\\alpha', + patternId: 'pattern-sequential', + patternName: 'Sequential Trio Review', + patternMode: 'sequential', + triggerMessageId: 'msg-user-1', + status: 'running', + }); + expect(run.agents).toEqual([ + { + agentId: 'agent-writer', + agentName: 'Writer', + model: 'gpt-5.4', + reasoningEffort: undefined, + }, + { + agentId: 'agent-reviewer', + agentName: 'Reviewer', + model: 'claude-sonnet-4.5', + reasoningEffort: undefined, + }, + ]); + expect(run.events[0]).toMatchObject({ + kind: 'run-started', + status: 'completed', + messageId: 'msg-user-1', + }); + }); + + test('records grouped message steps and settles the run on completion', () => { + const baseRun = createSessionRunRecord({ + requestId: 'turn-1', + project: createProject(), + workspaceKind: 'project', + pattern: createPattern(), + triggerMessageId: 'msg-user-1', + startedAt: '2026-03-23T00:00:01.000Z', + }); + + const streamingRun = upsertRunMessageEvent(baseRun, { + messageId: 'msg-assistant-1', + authorName: 'Writer', + content: 'Draft', + occurredAt: '2026-03-23T00:00:02.000Z', + status: 'running', + }); + + const completedRun = completeSessionRunRecord( + upsertRunMessageEvent(streamingRun, { + messageId: 'msg-assistant-1', + authorName: 'Writer', + content: 'Draft polished into the final answer.', + occurredAt: '2026-03-23T00:00:03.000Z', + status: 'completed', + }), + '2026-03-23T00:00:04.000Z', + ); + + const messageEvent = completedRun.events.find((event) => event.kind === 'message'); + expect(messageEvent).toMatchObject({ + messageId: 'msg-assistant-1', + agentId: 'agent-writer', + agentName: 'Writer', + content: 'Draft polished into the final answer.', + status: 'completed', + updatedAt: '2026-03-23T00:00:03.000Z', + }); + expect(completedRun.status).toBe('completed'); + expect(completedRun.completedAt).toBe('2026-03-23T00:00:04.000Z'); + expect(completedRun.events.at(-1)).toMatchObject({ + kind: 'run-completed', + status: 'completed', + }); + }); + + test('captures handoffs with explicit source and target agents', () => { + const run = appendRunActivityEvent( + createSessionRunRecord({ + requestId: 'turn-1', + project: createProject(), + workspaceKind: 'project', + pattern: createPattern(), + triggerMessageId: 'msg-user-1', + startedAt: '2026-03-23T00:00:01.000Z', + }), + { + activityType: 'handoff', + occurredAt: '2026-03-23T00:00:02.000Z', + sourceAgentId: 'agent-writer', + agentId: 'agent-reviewer', + }, + ); + + expect(run.events.at(-1)).toMatchObject({ + kind: 'handoff', + agentId: 'agent-writer', + agentName: 'Writer', + sourceAgentId: 'agent-writer', + sourceAgentName: 'Writer', + targetAgentId: 'agent-reviewer', + targetAgentName: 'Reviewer', + }); + }); + + test('normalizes missing run collections to an empty array', () => { + expect(normalizeSessionRunRecords(undefined)).toEqual([]); + }); +}); diff --git a/tests/shared/session.test.ts b/tests/shared/session.test.ts index de28758..d6b7d86 100644 --- a/tests/shared/session.test.ts +++ b/tests/shared/session.test.ts @@ -51,6 +51,7 @@ function createSession(overrides?: Partial): SessionRecord { updatedAt: '2026-03-23T00:00:00.000Z', status: 'idle', messages: [], + runs: [], ...overrides, }; } diff --git a/tests/shared/sessionLibrary.test.ts b/tests/shared/sessionLibrary.test.ts index de7811a..d03f0a8 100644 --- a/tests/shared/sessionLibrary.test.ts +++ b/tests/shared/sessionLibrary.test.ts @@ -59,6 +59,7 @@ function createSession(overrides?: Partial): SessionRecord { createdAt: '2026-03-23T00:00:00.000Z', }, ], + runs: [], ...overrides, }; } @@ -148,6 +149,7 @@ describe('session library helpers', () => { updatedAt: '2026-03-23T00:07:00.000Z', }); expect(session.messages[0]?.pending).toBe(false); + expect(session.runs).toEqual([]); }); test('searches across session title, messages, projects, and patterns', () => {