mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-27 13:23:57 +02:00
feat: add rich run timeline backend
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+205
@@ -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`
|
||||||
@@ -182,6 +182,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
|||||||
public string ActivityType { get; init; } = string.Empty;
|
public string ActivityType { get; init; } = string.Empty;
|
||||||
public string? AgentId { get; init; }
|
public string? AgentId { get; init; }
|
||||||
public string? AgentName { get; init; }
|
public string? AgentName { get; init; }
|
||||||
|
public string? SourceAgentId { get; init; }
|
||||||
|
public string? SourceAgentName { get; init; }
|
||||||
public string? ToolName { get; init; }
|
public string? ToolName { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
string activityType,
|
string activityType,
|
||||||
AgentIdentity agent,
|
AgentIdentity agent,
|
||||||
|
AgentIdentity? sourceAgent = null,
|
||||||
string? toolName = null)
|
string? toolName = null)
|
||||||
{
|
{
|
||||||
return new AgentActivityEventDto
|
return new AgentActivityEventDto
|
||||||
@@ -183,6 +184,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
ActivityType = activityType,
|
ActivityType = activityType,
|
||||||
AgentId = agent.AgentId,
|
AgentId = agent.AgentId,
|
||||||
AgentName = agent.AgentName,
|
AgentName = agent.AgentName,
|
||||||
|
SourceAgentId = sourceAgent?.AgentId,
|
||||||
|
SourceAgentName = sourceAgent?.AgentName,
|
||||||
ToolName = toolName,
|
ToolName = toolName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -197,7 +200,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
return CreateActivityEvent(
|
return CreateActivityEvent(
|
||||||
command,
|
command,
|
||||||
activityType: "handoff",
|
activityType: "handoff",
|
||||||
agent: handoffAgent);
|
agent: handoffAgent,
|
||||||
|
sourceAgent: activeAgent);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeAgent.HasValue || !TryGetToolName(requestInfo, out string toolName))
|
if (!activeAgent.HasValue || !TryGetToolName(requestInfo, out string toolName))
|
||||||
|
|||||||
+152
-25
@@ -40,6 +40,15 @@ import {
|
|||||||
type ChatMessageRecord,
|
type ChatMessageRecord,
|
||||||
type SessionRecord,
|
type SessionRecord,
|
||||||
} from '@shared/domain/session';
|
} from '@shared/domain/session';
|
||||||
|
import {
|
||||||
|
appendRunActivityEvent,
|
||||||
|
completeSessionRunRecord,
|
||||||
|
createSessionRunRecord,
|
||||||
|
failSessionRunRecord,
|
||||||
|
upsertRunMessageEvent,
|
||||||
|
upsertSessionRunRecord,
|
||||||
|
type SessionRunRecord,
|
||||||
|
} from '@shared/domain/runTimeline';
|
||||||
import {
|
import {
|
||||||
createSessionToolingSelection,
|
createSessionToolingSelection,
|
||||||
type LspProfileDefinition,
|
type LspProfileDefinition,
|
||||||
@@ -318,6 +327,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
? createScratchpadSessionConfig(normalizedPattern)
|
? createScratchpadSessionConfig(normalizedPattern)
|
||||||
: undefined,
|
: undefined,
|
||||||
tooling: createSessionToolingSelection(),
|
tooling: createSessionToolingSelection(),
|
||||||
|
runs: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
workspace.sessions.unshift(session);
|
workspace.sessions.unshift(session);
|
||||||
@@ -376,28 +386,41 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = createId('turn');
|
||||||
|
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||||
|
const occurredAt = nowIso();
|
||||||
|
const userMessageId = createId('msg');
|
||||||
session.messages.push({
|
session.messages.push({
|
||||||
id: createId('msg'),
|
id: userMessageId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
authorName: 'You',
|
authorName: 'You',
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
createdAt: nowIso(),
|
createdAt: occurredAt,
|
||||||
});
|
});
|
||||||
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
||||||
session.status = 'running';
|
session.status = 'running';
|
||||||
session.lastError = undefined;
|
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);
|
await this.persistAndBroadcast(workspace);
|
||||||
this.emitSessionEvent({
|
this.emitSessionEvent({
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
occurredAt: nowIso(),
|
occurredAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestId = createId('turn');
|
|
||||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
|
||||||
try {
|
try {
|
||||||
const responseMessages = await this.sidecar.runTurn(
|
const responseMessages = await this.sidecar.runTurn(
|
||||||
{
|
{
|
||||||
@@ -411,26 +434,33 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
tooling: this.buildRunTurnToolingConfig(workspace, project, session),
|
tooling: this.buildRunTurnToolingConfig(workspace, project, session),
|
||||||
},
|
},
|
||||||
async (event) => {
|
async (event) => {
|
||||||
await this.applyTurnDelta(workspace, session.id, event);
|
await this.applyTurnDelta(workspace, session.id, requestId, event);
|
||||||
},
|
},
|
||||||
(event) => {
|
async (event) => {
|
||||||
this.emitAgentActivity(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);
|
await this.persistAndBroadcast(workspace);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const failedAt = nowIso();
|
||||||
session.status = 'error';
|
session.status = 'error';
|
||||||
session.lastError = error instanceof Error ? error.message : String(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({
|
this.emitSessionEvent({
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
occurredAt: nowIso(),
|
occurredAt: failedAt,
|
||||||
error: session.lastError,
|
error: session.lastError,
|
||||||
});
|
});
|
||||||
|
if (failedRun) {
|
||||||
|
this.emitRunUpdated(session.id, failedAt, failedRun);
|
||||||
|
}
|
||||||
|
|
||||||
await this.persistAndBroadcast(workspace);
|
await this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
@@ -607,18 +637,23 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
private async applyTurnDelta(
|
private async applyTurnDelta(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
requestId: string,
|
||||||
event: TurnDeltaEvent,
|
event: TurnDeltaEvent,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (event.content === undefined && event.contentDelta === undefined) {
|
if (event.content === undefined && event.contentDelta === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const occurredAt = nowIso();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const existing = session.messages.find((message) => message.id === event.messageId);
|
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) {
|
if (existing) {
|
||||||
existing.content =
|
existing.content = content;
|
||||||
event.content ?? mergeStreamingText(existing.content, event.contentDelta);
|
|
||||||
existing.pending = true;
|
existing.pending = true;
|
||||||
existing.authorName = event.authorName;
|
existing.authorName = event.authorName;
|
||||||
} else {
|
} else {
|
||||||
@@ -626,34 +661,75 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
id: event.messageId,
|
id: event.messageId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
authorName: event.authorName,
|
authorName: event.authorName,
|
||||||
content: event.content ?? event.contentDelta,
|
content,
|
||||||
createdAt: nowIso(),
|
createdAt: occurredAt,
|
||||||
pending: true,
|
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);
|
await this.workspaceRepository.save(workspace);
|
||||||
|
|
||||||
this.emitSessionEvent({
|
this.emitSessionEvent({
|
||||||
sessionId,
|
sessionId,
|
||||||
kind: 'message-delta',
|
kind: 'message-delta',
|
||||||
occurredAt: nowIso(),
|
occurredAt,
|
||||||
messageId: event.messageId,
|
messageId: event.messageId,
|
||||||
authorName: event.authorName,
|
authorName: event.authorName,
|
||||||
contentDelta: event.contentDelta,
|
contentDelta: event.contentDelta,
|
||||||
content: event.content,
|
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<void> {
|
||||||
|
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({
|
this.emitSessionEvent({
|
||||||
sessionId: event.sessionId,
|
sessionId,
|
||||||
kind: 'agent-activity',
|
kind: 'agent-activity',
|
||||||
occurredAt: nowIso(),
|
occurredAt,
|
||||||
activityType: event.activityType,
|
activityType: event.activityType,
|
||||||
agentId: event.agentId,
|
agentId: event.agentId,
|
||||||
agentName: event.agentName,
|
agentName: event.agentName,
|
||||||
|
sourceAgentId: event.sourceAgentId,
|
||||||
|
sourceAgentName: event.sourceAgentName,
|
||||||
toolName: event.toolName,
|
toolName: event.toolName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -683,12 +759,18 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 session = this.requireSession(workspace, sessionId);
|
||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const pattern = this.requirePattern(workspace, session.patternId);
|
||||||
const incomingIds = new Set(messages.map((message) => message.id));
|
const incomingIds = new Set(messages.map((message) => message.id));
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
|
const occurredAt = nowIso();
|
||||||
const existing = session.messages.find((current) => current.id === message.id);
|
const existing = session.messages.find((current) => current.id === message.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.authorName = message.authorName;
|
existing.authorName = message.authorName;
|
||||||
@@ -698,14 +780,25 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
session.messages.push({ ...message, pending: false });
|
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({
|
this.emitSessionEvent({
|
||||||
sessionId,
|
sessionId,
|
||||||
kind: 'message-complete',
|
kind: 'message-complete',
|
||||||
occurredAt: nowIso(),
|
occurredAt,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
authorName: message.authorName,
|
authorName: message.authorName,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
});
|
});
|
||||||
|
if (nextRun) {
|
||||||
|
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
||||||
|
}
|
||||||
|
|
||||||
this.emitCompletedActivity(sessionId, pattern, message);
|
this.emitCompletedActivity(sessionId, pattern, message);
|
||||||
}
|
}
|
||||||
@@ -716,15 +809,21 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const completedAt = nowIso();
|
||||||
session.status = 'idle';
|
session.status = 'idle';
|
||||||
session.lastError = undefined;
|
session.lastError = undefined;
|
||||||
session.updatedAt = nowIso();
|
session.updatedAt = completedAt;
|
||||||
|
const completedRun = this.updateSessionRun(session, requestId, (run) =>
|
||||||
|
completeSessionRunRecord(run, completedAt));
|
||||||
this.emitSessionEvent({
|
this.emitSessionEvent({
|
||||||
sessionId,
|
sessionId,
|
||||||
kind: 'status',
|
kind: 'status',
|
||||||
occurredAt: nowIso(),
|
occurredAt: completedAt,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
});
|
});
|
||||||
|
if (completedRun) {
|
||||||
|
this.emitRunUpdated(sessionId, completedAt, completedRun);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistAndBroadcast(workspace: WorkspaceState): Promise<WorkspaceState> {
|
private async persistAndBroadcast(workspace: WorkspaceState): Promise<WorkspaceState> {
|
||||||
@@ -835,6 +934,34 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private emitSessionEvent(event: SessionEventRecord): void {
|
||||||
this.emit('session-event', event);
|
this.emit('session-event', event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
|
|||||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||||
|
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||||
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
|
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
@@ -62,6 +63,7 @@ export class WorkspaceRepository {
|
|||||||
projects,
|
projects,
|
||||||
sessions: (stored.sessions ?? []).map((session) => ({
|
sessions: (stored.sessions ?? []).map((session) => ({
|
||||||
...session,
|
...session,
|
||||||
|
runs: normalizeSessionRunRecords(session.runs),
|
||||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||||
})),
|
})),
|
||||||
settings: normalizeWorkspaceSettings(stored.settings),
|
settings: normalizeWorkspaceSettings(stored.settings),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
import { upsertSessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { mergeStreamingText } from '@shared/utils/streamingText';
|
import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||||
@@ -40,6 +41,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S
|
|||||||
return applyMessageDeltaEvent(session, event);
|
return applyMessageDeltaEvent(session, event);
|
||||||
case 'message-complete':
|
case 'message-complete':
|
||||||
return applyMessageCompleteEvent(session, event);
|
return applyMessageCompleteEvent(session, event);
|
||||||
|
case 'run-updated':
|
||||||
|
return applyRunUpdatedEvent(session, event);
|
||||||
default:
|
default:
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -160,3 +163,23 @@ function applyMessageCompleteEvent(session: SessionRecord, event: SessionEventRe
|
|||||||
updatedAt: event.occurredAt,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export interface AgentActivityEvent {
|
|||||||
activityType: AgentActivityType;
|
activityType: AgentActivityType;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
sourceAgentId?: string;
|
||||||
|
sourceAgentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
|
|
||||||
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
|
||||||
|
|
||||||
export type SessionEventKind =
|
export type SessionEventKind =
|
||||||
@@ -5,6 +7,7 @@ export type SessionEventKind =
|
|||||||
| 'message-delta'
|
| 'message-delta'
|
||||||
| 'message-complete'
|
| 'message-complete'
|
||||||
| 'agent-activity'
|
| 'agent-activity'
|
||||||
|
| 'run-updated'
|
||||||
| 'error';
|
| 'error';
|
||||||
|
|
||||||
export interface SessionEventRecord {
|
export interface SessionEventRecord {
|
||||||
@@ -19,6 +22,9 @@ export interface SessionEventRecord {
|
|||||||
activityType?: SessionActivityType;
|
activityType?: SessionActivityType;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
|
sourceAgentId?: string;
|
||||||
|
sourceAgentName?: string;
|
||||||
toolName?: string;
|
toolName?: string;
|
||||||
|
run?: SessionRunRecord;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<ProjectRecord, 'id' | 'path'>;
|
||||||
|
workspaceKind: SessionRunWorkspaceKind;
|
||||||
|
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
||||||
|
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<RunTimelineEventRecord, 'agentId' | 'agentName'> {
|
||||||
|
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<RunTimelineEventRecord, 'id'> & { 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<RunTimelineEventStatus, 'completed' | 'error'>,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
normalizeSessionToolingSelection,
|
normalizeSessionToolingSelection,
|
||||||
type SessionToolingSelection,
|
type SessionToolingSelection,
|
||||||
} from '@shared/domain/tooling';
|
} from '@shared/domain/tooling';
|
||||||
|
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
|
|
||||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||||
@@ -38,6 +39,7 @@ export interface SessionRecord {
|
|||||||
lastError?: string;
|
lastError?: string;
|
||||||
scratchpadConfig?: ScratchpadSessionConfig;
|
scratchpadConfig?: ScratchpadSessionConfig;
|
||||||
tooling?: SessionToolingSelection;
|
tooling?: SessionToolingSelection;
|
||||||
|
runs: SessionRunRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSessionTitle(
|
export function resolveSessionTitle(
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ export function duplicateSessionRecord(
|
|||||||
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
|
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
runs: [],
|
||||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||||
...message,
|
...message,
|
||||||
pending: false,
|
pending: false,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function createSession(
|
|||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
status,
|
status,
|
||||||
messages,
|
messages,
|
||||||
|
runs: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
|
|
||||||
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
import { createWorkspaceSettings } from '@shared/domain/tooling';
|
import { createWorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ describe('session workspace helpers', () => {
|
|||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
messages: [],
|
messages: [],
|
||||||
|
runs: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
selectedSessionId: 'session-1',
|
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', () => {
|
test('ignores events for unknown sessions', () => {
|
||||||
const workspace = createWorkspace();
|
const workspace = createWorkspace();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
|||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
messages: [],
|
messages: [],
|
||||||
|
runs: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
|||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
runs: [],
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -148,6 +149,7 @@ describe('session library helpers', () => {
|
|||||||
updatedAt: '2026-03-23T00:07:00.000Z',
|
updatedAt: '2026-03-23T00:07:00.000Z',
|
||||||
});
|
});
|
||||||
expect(session.messages[0]?.pending).toBe(false);
|
expect(session.messages[0]?.pending).toBe(false);
|
||||||
|
expect(session.runs).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('searches across session title, messages, projects, and patterns', () => {
|
test('searches across session title, messages, projects, and patterns', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user