Files
aryx/tests/renderer/sessionWorkspace.test.ts
T
David KayaandCopilot 19a764d297 feat(workflows): Phase 1 backend — WorkflowDefinition model, persistence, IPC, sidecar execution
Introduce WorkflowDefinition as a first-class entity alongside PatternDefinition.

Shared domain:
- src/shared/domain/workflow.ts: new WorkflowDefinition, WorkflowGraph,
  WorkflowNode/Edge/Config types, normalization, TS-side validation, and
  buildWorkflowExecutionPattern (synthetic pattern bridge for session plumbing)
- workspace.ts: add workflows[] and selectedWorkflowId
- session.ts: add optional workflowId to SessionRecord
- runTimeline.ts: add optional workflowId/workflowName to run records
- sessionLibrary.ts: workflow-aware session search

IPC / main process:
- contracts/channels.ts, contracts/ipc.ts: workflow CRUD channels
- preload/index.ts: expose saveWorkflow, deleteWorkflow, createWorkflowSession
- ipc/registerIpcHandlers.ts: register workflow handlers
- persistence/workspaceRepository.ts: load/save/normalize workflows
- AryxAppService.ts: saveWorkflow, deleteWorkflow, createWorkflowSession,
  resolveSessionExecutionDefinition, workflow-aware turn/run plumbing

Sidecar protocol:
- contracts/sidecar.ts: ValidateWorkflowCommand, WorkflowValidationEvent,
  optional workflow on RunTurnCommand
- sidecarProcess.ts: validate-workflow command dispatch and event handling
- Contracts/ProtocolModels.cs: workflow DTOs and validate-workflow DTOs
- Services/SidecarProtocolHost.cs: validate-workflow command handler
- Services/CopilotWorkflowRunner.cs: workflow-aware build and checkpoint path
- Services/WorkflowValidator.cs: graph validation (connectivity, start/end,
  fan-out/in arity, path reachability)
- Services/WorkflowRunner.cs: WorkflowDefinitionDto -> Agent Framework Workflow
  builder (direct, fan-out, fan-in edges; agent executor binding)

Project: bump both csproj from net9.0 to net10.0

Tests:
- tests/shared/workflow.test.ts: TS workflow validation and pattern synthesis
- tests/main/appServiceWorkflow.test.ts: saveWorkflow / createWorkflowSession
- tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs: ValidateWorkflowCommand
- Fix two stale workspace fixtures missing workflows field

Validation: tsc clean, bun test 367/367 pass, dotnet test 244/244 pass, bun run build succeeds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-05 17:29:03 +02:00

355 lines
11 KiB
TypeScript

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';
describe('session workspace helpers', () => {
function createWorkspace(): WorkspaceState {
return {
projects: [],
patterns: [],
workflows: [],
settings: createWorkspaceSettings(),
sessions: [
{
id: 'session-1',
projectId: 'project-1',
patternId: 'pattern-1',
title: 'Session',
createdAt: '2026-03-23T00:00:00.000Z',
updatedAt: '2026-03-23T00:00:00.000Z',
status: 'idle',
messages: [],
runs: [],
},
],
selectedSessionId: 'session-1',
lastUpdatedAt: '2026-03-23T00:00:00.000Z',
};
}
test('applies message deltas by creating and appending assistant messages', () => {
const created = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Architect',
contentDelta: 'Hello',
content: 'Hello',
} satisfies SessionEventRecord);
expect(created?.sessions[0].messages).toEqual([
{
id: 'assistant-1',
role: 'assistant',
authorName: 'Architect',
content: 'Hello',
createdAt: '2026-03-23T00:00:01.000Z',
pending: true,
},
]);
const appended = applySessionEventWorkspace(created, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Architect',
contentDelta: ' world',
content: 'Hello world',
} satisfies SessionEventRecord);
expect(appended?.sessions[0].messages[0]).toMatchObject({
authorName: 'Architect',
content: 'Hello world',
pending: true,
});
});
test('marks streamed messages complete when the session event arrives', () => {
const workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Implementer',
contentDelta: 'Done',
content: 'Done',
} satisfies SessionEventRecord);
const completed = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-complete',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Implementer',
content: 'Done',
} satisfies SessionEventRecord);
expect(completed?.sessions[0].messages[0]).toMatchObject({
authorName: 'Implementer',
content: 'Done',
pending: false,
});
});
test('keeps snapshot-like streamed updates readable while a message is pending', () => {
const first = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Writer',
contentDelta: 'How about',
content: 'How about',
} satisfies SessionEventRecord);
const second = applySessionEventWorkspace(first, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Writer',
contentDelta: 'The **Ashen Crown** feels',
content: 'How about The **Ashen Crown** feels',
} satisfies SessionEventRecord);
const third = applySessionEventWorkspace(second, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:03.000Z',
messageId: 'assistant-1',
authorName: 'Writer',
contentDelta: 'classic and timeless.',
content: 'How about The **Ashen Crown** feels classic and timeless.',
} satisfies SessionEventRecord);
expect(third?.sessions[0].messages[0]).toMatchObject({
authorName: 'Writer',
content: 'How about The **Ashen Crown** feels classic and timeless.',
pending: true,
});
});
test('applies final content from completion events without waiting for a workspace refresh', () => {
const workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Reviewer',
contentDelta: 'Draft',
content: 'Draft',
} satisfies SessionEventRecord);
const completed = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-complete',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Reviewer',
content: 'Draft polished into the final review.',
} satisfies SessionEventRecord);
expect(completed?.sessions[0].messages[0]).toMatchObject({
authorName: 'Reviewer',
content: 'Draft polished into the final review.',
pending: false,
});
});
test('updates session status and error state from session events', () => {
const running = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:01.000Z',
status: 'running',
} satisfies SessionEventRecord);
expect(running?.sessions[0]).toMatchObject({
status: 'running',
lastError: undefined,
});
const failed = applySessionEventWorkspace(running, {
sessionId: 'session-1',
kind: 'error',
occurredAt: '2026-03-23T00:00:02.000Z',
error: 'Boom',
} satisfies SessionEventRecord);
expect(failed?.sessions[0]).toMatchObject({
status: 'error',
lastError: 'Boom',
});
});
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('auto-completes previous pending assistant messages when a new message starts', () => {
const first = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Exploring the codebase...',
content: 'Exploring the codebase...',
} satisfies SessionEventRecord);
expect(first?.sessions[0].messages).toHaveLength(1);
expect(first?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
pending: true,
});
const second = applySessionEventWorkspace(first, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-2',
authorName: 'Primary Agent',
contentDelta: 'Still exploring...',
content: 'Still exploring...',
} satisfies SessionEventRecord);
expect(second?.sessions[0].messages).toHaveLength(2);
expect(second?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
content: 'Exploring the codebase...',
pending: false,
});
expect(second?.sessions[0].messages[1]).toMatchObject({
id: 'assistant-2',
content: 'Still exploring...',
pending: true,
});
});
test('ignores events for unknown sessions', () => {
const workspace = createWorkspace();
expect(
applySessionEventWorkspace(workspace, {
sessionId: 'missing',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Architect',
contentDelta: 'Hello',
} satisfies SessionEventRecord),
).toBe(workspace);
});
test('reclassifies a message as thinking when message-reclassified event arrives', () => {
const workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Let me search...',
content: 'Let me search...',
} satisfies SessionEventRecord);
const reclassified = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
expect(reclassified?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
messageKind: 'thinking',
});
});
test('ignores message-reclassified for an already reclassified message', () => {
let workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Primary Agent',
contentDelta: 'Let me search...',
content: 'Let me search...',
} satisfies SessionEventRecord);
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
const duplicate = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:03.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
// Should return the same reference (no change)
expect(duplicate).toBe(workspace);
});
test('ignores message-reclassified for unknown message ids', () => {
const workspace = createWorkspace();
const result = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'nonexistent',
messageKind: 'thinking',
} satisfies SessionEventRecord);
expect(result).toBe(workspace);
});
});