diff --git a/.gitignore b/.gitignore index 29b8fb3..b500baf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules/ dist/ dist-electron/ +dist-sidecar/ +release/ sidecar/**/bin/ sidecar/**/obj/ diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 040abd3..d1303ea 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -31,7 +31,7 @@ export default defineConfig({ renderer: { root: 'src/renderer', build: { - outDir: '../../dist/renderer', + outDir: 'dist/renderer', }, plugins: [react(), tailwindcss()], resolve: { diff --git a/package.json b/package.json index c6eb19b..3f0a36f 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,16 @@ "main": "dist-electron/main/index.js", "scripts": { "dev": "electron-vite dev", - "build": "electron-vite build && bun run sidecar:build", + "build:electron": "electron-vite build", + "build": "bun run build:electron && bun run sidecar:build", + "package": "bun run build:electron && bun run sidecar:publish && node \".\\scripts\\package-electron.mjs\"", "preview": "electron-vite preview", "lsp:typescript": "typescript-language-server --stdio", "typecheck": "tsc --noEmit -p tsconfig.json", "test": "bun run typecheck && bun test", "sidecar:restore": "powershell -NoProfile -Command \"dotnet restore 'sidecar\\\\Kopaya.AgentHost.slnx'\"", "sidecar:build": "powershell -NoProfile -Command \"dotnet build 'sidecar\\\\Kopaya.AgentHost.slnx'\"", + "sidecar:publish": "powershell -NoProfile -Command \"if (Test-Path 'dist-sidecar\\\\win-x64') { Remove-Item 'dist-sidecar\\\\win-x64' -Recurse -Force }; dotnet publish 'sidecar\\\\src\\\\Kopaya.AgentHost\\\\Kopaya.AgentHost.csproj' -c Release -r win-x64 --self-contained true -p:DebugType=None -p:DebugSymbols=false -o 'dist-sidecar\\\\win-x64'\"", "sidecar:test": "powershell -NoProfile -Command \"dotnet test 'sidecar\\\\Kopaya.AgentHost.slnx'\"" }, "repository": { diff --git a/scripts/package-electron.mjs b/scripts/package-electron.mjs new file mode 100644 index 0000000..e362cea --- /dev/null +++ b/scripts/package-electron.mjs @@ -0,0 +1,117 @@ +import { access, cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const productName = 'Kopaya'; +const sidecarRuntime = 'win-x64'; +const outputDirectoryName = 'win-unpacked'; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDirectory, '..'); +const electronDistributionDirectory = join(repositoryRoot, 'node_modules', 'electron', 'dist'); +const rendererBuildDirectory = join(repositoryRoot, 'dist'); +const electronBuildDirectory = join(repositoryRoot, 'dist-electron'); +const publishedSidecarDirectory = join(repositoryRoot, 'dist-sidecar', sidecarRuntime); +const outputDirectory = join(repositoryRoot, 'release', outputDirectoryName); +const outputResourcesDirectory = join(outputDirectory, 'resources'); +const packagedAppDirectory = join(outputResourcesDirectory, 'app'); + +async function ensurePathExists(path, label) { + try { + await access(path, constants.F_OK); + } catch { + throw new Error(`${label} was not found at ${path}.`); + } +} + +async function readJson(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +async function collectRuntimeDependencies() { + const rootPackageJson = await readJson(join(repositoryRoot, 'package.json')); + const dependencies = new Set(Object.keys(rootPackageJson.dependencies ?? {})); + const queue = [...dependencies]; + + while (queue.length > 0) { + const dependencyName = queue.shift(); + const dependencyPackageJson = await readJson( + join(repositoryRoot, 'node_modules', ...dependencyName.split('/'), 'package.json'), + ); + + for (const transitiveDependency of Object.keys({ + ...(dependencyPackageJson.dependencies ?? {}), + ...(dependencyPackageJson.optionalDependencies ?? {}), + })) { + if (!dependencies.has(transitiveDependency)) { + dependencies.add(transitiveDependency); + queue.push(transitiveDependency); + } + } + } + + return [...dependencies].sort(); +} + +async function copyRuntimeDependencies(dependencyNames) { + const packagedNodeModulesDirectory = join(packagedAppDirectory, 'node_modules'); + await mkdir(packagedNodeModulesDirectory, { recursive: true }); + + for (const dependencyName of dependencyNames) { + const dependencyPathParts = dependencyName.split('/'); + const sourceDirectory = join(repositoryRoot, 'node_modules', ...dependencyPathParts); + const targetDirectory = join(packagedNodeModulesDirectory, ...dependencyPathParts); + await mkdir(dirname(targetDirectory), { recursive: true }); + await cp(sourceDirectory, targetDirectory, { recursive: true }); + } +} + +async function writePackagedManifest() { + const sourcePackageJson = await readJson(join(repositoryRoot, 'package.json')); + const packagedManifest = { + name: sourcePackageJson.name, + productName, + version: sourcePackageJson.version, + description: sourcePackageJson.description, + main: sourcePackageJson.main, + author: sourcePackageJson.author, + license: sourcePackageJson.license, + }; + + await writeFile(join(packagedAppDirectory, 'package.json'), `${JSON.stringify(packagedManifest, null, 2)}\n`); +} + +async function main() { + if (process.platform !== 'win32') { + throw new Error('This packaging script currently targets Windows only.'); + } + + await Promise.all([ + ensurePathExists(electronDistributionDirectory, 'Electron runtime'), + ensurePathExists(rendererBuildDirectory, 'Renderer build output'), + ensurePathExists(electronBuildDirectory, 'Electron build output'), + ensurePathExists(publishedSidecarDirectory, 'Published sidecar output'), + ]); + + const runtimeDependencies = await collectRuntimeDependencies(); + + await rm(outputDirectory, { recursive: true, force: true }); + await mkdir(outputDirectory, { recursive: true }); + await cp(electronDistributionDirectory, outputDirectory, { recursive: true }); + await rename(join(outputDirectory, 'electron.exe'), join(outputDirectory, `${productName}.exe`)); + + await mkdir(packagedAppDirectory, { recursive: true }); + await Promise.all([ + writePackagedManifest(), + cp(rendererBuildDirectory, join(packagedAppDirectory, 'dist'), { recursive: true }), + cp(electronBuildDirectory, join(packagedAppDirectory, 'dist-electron'), { recursive: true }), + cp(publishedSidecarDirectory, join(outputResourcesDirectory, 'sidecar'), { recursive: true }), + ]); + await copyRuntimeDependencies(runtimeDependencies); + + console.log(`Packaged ${productName} to ${outputDirectory}`); + console.log(`Bundled ${runtimeDependencies.length} runtime dependencies and the self-contained .NET sidecar.`); +} + +await main(); diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs new file mode 100644 index 0000000..a0430b2 --- /dev/null +++ b/sidecar/tests/Kopaya.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using Kopaya.AgentHost.Contracts; +using Kopaya.AgentHost.Services; + +namespace Kopaya.AgentHost.Tests; + +public sealed class SidecarProtocolHostTests +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true, + }; + + [Fact] + public async Task DescribeCapabilitiesCommand_ReturnsCapabilitiesAndCompletion() + { + IReadOnlyList events = await RunHostAsync(new DescribeCapabilitiesCommandDto + { + Type = "describe-capabilities", + RequestId = "cap-1", + }); + + Assert.Collection( + events, + capabilitiesEvent => + { + Assert.Equal("capabilities", capabilitiesEvent.GetProperty("type").GetString()); + Assert.Equal("cap-1", capabilitiesEvent.GetProperty("requestId").GetString()); + + JsonElement capabilities = capabilitiesEvent.GetProperty("capabilities"); + Assert.Equal("dotnet-maf", capabilities.GetProperty("runtime").GetString()); + + JsonElement modes = capabilities.GetProperty("modes"); + Assert.True(modes.GetProperty("single").GetProperty("available").GetBoolean()); + Assert.False(modes.GetProperty("magentic").GetProperty("available").GetBoolean()); + + string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty; + Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase); + }, + completionEvent => + { + Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); + Assert.Equal("cap-1", completionEvent.GetProperty("requestId").GetString()); + }); + } + + [Fact] + public async Task ValidatePatternCommand_ReturnsIssuesAndCompletion() + { + IReadOnlyList events = await RunHostAsync(new ValidatePatternCommandDto + { + Type = "validate-pattern", + RequestId = "validate-1", + Pattern = new PatternDefinitionDto + { + Id = "single-pattern", + Name = "", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent(), + CreateAgent(id: "agent-2", name: "Reviewer", model: ""), + ], + }, + }); + + Assert.Collection( + events, + validationEvent => + { + Assert.Equal("pattern-validation", validationEvent.GetProperty("type").GetString()); + Assert.Equal("validate-1", validationEvent.GetProperty("requestId").GetString()); + + JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray(); + Assert.Contains(issues, issue => + issue.GetProperty("field").GetString() == "name" + && issue.GetProperty("message").GetString() == "Pattern name is required."); + Assert.Contains(issues, issue => + issue.GetProperty("field").GetString() == "agents" + && issue.GetProperty("message").GetString() == "Single-agent chat requires exactly one agent."); + Assert.Contains(issues, issue => + issue.GetProperty("field").GetString() == "agents.model" + && issue.GetProperty("message").GetString() == "Agent \"Reviewer\" requires a model identifier."); + }, + completionEvent => + { + Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); + Assert.Equal("validate-1", completionEvent.GetProperty("requestId").GetString()); + }); + } + + private static async Task> RunHostAsync(TCommand command) + { + string input = JsonSerializer.Serialize(command, JsonOptions) + Environment.NewLine; + + using StringReader reader = new(input); + using StringWriter writer = new(); + + SidecarProtocolHost host = new(); + await host.RunAsync(reader, writer, CancellationToken.None); + + return ParseEvents(writer.ToString()); + } + + private static IReadOnlyList ParseEvents(string output) + { + List events = []; + using StringReader reader = new(output); + + string? line; + while ((line = reader.ReadLine()) is not null) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + using JsonDocument document = JsonDocument.Parse(line); + events.Add(document.RootElement.Clone()); + } + + return events; + } + + private static PatternAgentDefinitionDto CreateAgent( + string id = "agent-1", + string name = "Primary", + string model = "gpt-5.4", + string instructions = "Help with the user's request.") + { + return new PatternAgentDefinitionDto + { + Id = id, + Name = name, + Model = model, + Instructions = instructions, + }; + } +} diff --git a/sidecar/tests/Kopaya.AgentHost.Tests/UnitTest1.cs b/sidecar/tests/Kopaya.AgentHost.Tests/UnitTest1.cs index 376008b..96de877 100644 --- a/sidecar/tests/Kopaya.AgentHost.Tests/UnitTest1.cs +++ b/sidecar/tests/Kopaya.AgentHost.Tests/UnitTest1.cs @@ -10,60 +10,99 @@ public sealed class PatternValidatorTests [Fact] public void SingleAgentPattern_WithExactlyOneAgent_IsValid() { - PatternDefinitionDto pattern = new() - { - Id = "single", - Name = "Single", - Mode = "single", - Availability = "available", - Agents = - [ - new PatternAgentDefinitionDto - { - Id = "agent-1", - Name = "Primary", - Model = "gpt-5.4", - Instructions = "Help with the user's request.", - }, - ], - }; - - IReadOnlyList issues = _validator.Validate(pattern); + IReadOnlyList issues = _validator.Validate( + CreatePattern( + "single", + [CreateAgent()])); Assert.Empty(issues); } + [Fact] + public void HandoffPattern_WithSingleAgent_IsReportedAsInvalid() + { + IReadOnlyList issues = _validator.Validate( + CreatePattern( + "handoff", + [CreateAgent()])); + + Assert.Contains(issues, issue => + issue.Field == "agents" + && issue.Message == "Handoff orchestration requires at least two agents."); + } + + [Fact] + public void AgentWithoutModel_IsReportedAsInvalid() + { + IReadOnlyList issues = _validator.Validate( + CreatePattern( + "sequential", + [ + CreateAgent(model: ""), + CreateAgent(id: "agent-2", name: "Reviewer"), + ])); + + Assert.Contains(issues, issue => + issue.Field == "agents.model" + && issue.Message == "Agent \"Primary\" requires a model identifier."); + } + [Fact] public void MagenticPattern_IsReportedAsUnavailable() { - PatternDefinitionDto pattern = new() + IReadOnlyList issues = _validator.Validate( + CreatePattern( + "magentic", + [ + CreateAgent(id: "agent-1", name: "Planner", instructions: "Plan the task."), + CreateAgent( + id: "agent-2", + name: "Specialist", + model: "claude-opus-4.5", + instructions: "Complete the task."), + ], + availability: "unavailable", + unavailabilityReason: "Unsupported in C#.", + name: "Magentic")); + + Assert.Contains(issues, issue => + issue.Field == "availability" + && issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(issues, issue => + issue.Field == "mode" + && issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase)); + } + + private static PatternDefinitionDto CreatePattern( + string mode, + IReadOnlyList agents, + string availability = "available", + string? unavailabilityReason = null, + string name = "Pattern") + { + return new PatternDefinitionDto { - Id = "magentic", - Name = "Magentic", - Mode = "magentic", - Availability = "unavailable", - UnavailabilityReason = "Unsupported in C#.", - Agents = - [ - new PatternAgentDefinitionDto - { - Id = "agent-1", - Name = "Planner", - Model = "gpt-5.4", - Instructions = "Plan the task.", - }, - new PatternAgentDefinitionDto - { - Id = "agent-2", - Name = "Specialist", - Model = "claude-opus-4.5", - Instructions = "Complete the task.", - }, - ], + Id = $"{mode}-pattern", + Name = name, + Mode = mode, + Availability = availability, + UnavailabilityReason = unavailabilityReason, + Agents = agents, }; + } - IReadOnlyList issues = _validator.Validate(pattern); - - Assert.Contains(issues, issue => issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase)); + private static PatternAgentDefinitionDto CreateAgent( + string id = "agent-1", + string name = "Primary", + string model = "gpt-5.4", + string instructions = "Help with the user's request.") + { + return new PatternAgentDefinitionDto + { + Id = id, + Name = name, + Model = model, + Instructions = instructions, + }; } } diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 8e725de..fe26b90 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -1,6 +1,5 @@ import { app } from 'electron'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { join } from 'node:path'; import type { SidecarCapabilities, @@ -11,6 +10,7 @@ import type { RunTurnCommand, } from '@shared/contracts/sidecar'; import type { ChatMessageRecord } from '@shared/domain/session'; +import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime'; type PendingCommand = | { @@ -30,30 +30,6 @@ type PendingCommand = onDelta: (event: TurnDeltaEvent) => void; }; -function getProjectRoot(): string { - return app.getAppPath(); -} - -function resolveSidecarProcess(): { command: string; args: string[] } { - if (app.isPackaged) { - return { - command: join(process.resourcesPath, 'sidecar', 'Kopaya.AgentHost.exe'), - args: ['--stdio'], - }; - } - - return { - command: 'dotnet', - args: [ - 'run', - '--project', - join(getProjectRoot(), 'sidecar', 'src', 'Kopaya.AgentHost', 'Kopaya.AgentHost.csproj'), - '--', - '--stdio', - ], - }; -} - export class SidecarClient { private process?: ChildProcessWithoutNullStreams; private stdoutBuffer = ''; @@ -94,9 +70,14 @@ export class SidecarClient { return this.process; } - const sidecar = resolveSidecarProcess(); + const sidecar = resolveSidecarProcess({ + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + platform: process.platform, + }); this.process = spawn(sidecar.command, sidecar.args, { - cwd: getProjectRoot(), + cwd: sidecar.cwd, stdio: 'pipe', windowsHide: true, }); diff --git a/src/main/sidecar/sidecarRuntime.ts b/src/main/sidecar/sidecarRuntime.ts new file mode 100644 index 0000000..7ff02a2 --- /dev/null +++ b/src/main/sidecar/sidecarRuntime.ts @@ -0,0 +1,48 @@ +import { posix, win32 } from 'node:path'; + +export interface SidecarRuntimeContext { + readonly isPackaged: boolean; + readonly appPath: string; + readonly resourcesPath: string; + readonly platform: NodeJS.Platform; +} + +export interface ResolvedSidecarProcess { + readonly command: string; + readonly args: string[]; + readonly cwd: string; +} + +function getPathModule(platform: NodeJS.Platform) { + return platform === 'win32' ? win32 : posix; +} + +function getBundledSidecarExecutableName(platform: NodeJS.Platform): string { + return platform === 'win32' ? 'Kopaya.AgentHost.exe' : 'Kopaya.AgentHost'; +} + +export function resolveSidecarProcess(context: SidecarRuntimeContext): ResolvedSidecarProcess { + const pathModule = getPathModule(context.platform); + + if (context.isPackaged) { + const sidecarDirectory = pathModule.join(context.resourcesPath, 'sidecar'); + + return { + command: pathModule.join(sidecarDirectory, getBundledSidecarExecutableName(context.platform)), + args: ['--stdio'], + cwd: sidecarDirectory, + }; + } + + return { + command: 'dotnet', + args: [ + 'run', + '--project', + pathModule.join(context.appPath, 'sidecar', 'src', 'Kopaya.AgentHost', 'Kopaya.AgentHost.csproj'), + '--', + '--stdio', + ], + cwd: context.appPath, + }; +} diff --git a/tests/main/sidecarRuntime.test.ts b/tests/main/sidecarRuntime.test.ts new file mode 100644 index 0000000..3aedf96 --- /dev/null +++ b/tests/main/sidecarRuntime.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from 'bun:test'; + +import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime'; + +describe('resolveSidecarProcess', () => { + test('runs the sidecar project with dotnet from the repository root during development', () => { + expect( + resolveSidecarProcess({ + isPackaged: false, + appPath: 'C:\\workspace\\personal\\repositories\\kopaya', + resourcesPath: 'C:\\workspace\\personal\\repositories\\kopaya\\resources', + platform: 'win32', + }), + ).toEqual({ + command: 'dotnet', + args: [ + 'run', + '--project', + 'C:\\workspace\\personal\\repositories\\kopaya\\sidecar\\src\\Kopaya.AgentHost\\Kopaya.AgentHost.csproj', + '--', + '--stdio', + ], + cwd: 'C:\\workspace\\personal\\repositories\\kopaya', + }); + }); + + test('runs the bundled Windows sidecar executable from the resources directory when packaged', () => { + expect( + resolveSidecarProcess({ + isPackaged: true, + appPath: 'C:\\workspace\\personal\\repositories\\kopaya\\release\\win-unpacked\\resources\\app', + resourcesPath: 'C:\\workspace\\personal\\repositories\\kopaya\\release\\win-unpacked\\resources', + platform: 'win32', + }), + ).toEqual({ + command: 'C:\\workspace\\personal\\repositories\\kopaya\\release\\win-unpacked\\resources\\sidecar\\Kopaya.AgentHost.exe', + args: ['--stdio'], + cwd: 'C:\\workspace\\personal\\repositories\\kopaya\\release\\win-unpacked\\resources\\sidecar', + }); + }); + + test('omits the .exe suffix for bundled non-Windows sidecar binaries', () => { + expect( + resolveSidecarProcess({ + isPackaged: true, + appPath: '/Applications/Kopaya.app/Contents/Resources/app', + resourcesPath: '/Applications/Kopaya.app/Contents/Resources', + platform: 'darwin', + }), + ).toEqual({ + command: '/Applications/Kopaya.app/Contents/Resources/sidecar/Kopaya.AgentHost', + args: ['--stdio'], + cwd: '/Applications/Kopaya.app/Contents/Resources/sidecar', + }); + }); +}); diff --git a/tests/shared/pattern.test.ts b/tests/shared/pattern.test.ts index add49dd..6eaf1f2 100644 --- a/tests/shared/pattern.test.ts +++ b/tests/shared/pattern.test.ts @@ -2,9 +2,11 @@ import { describe, expect, test } from 'bun:test'; import { createBuiltinPatterns, validatePatternDefinition } from '@shared/domain/pattern'; +const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z'; + describe('pattern validation', () => { test('builtin patterns are valid except explicitly unavailable modes', () => { - const patterns = createBuiltinPatterns('2026-03-22T00:00:00.000Z'); + const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP); const validPatterns = patterns.filter((pattern) => pattern.availability !== 'unavailable'); @@ -14,11 +16,69 @@ describe('pattern validation', () => { }); test('magentic pattern is marked unavailable', () => { - const magentic = createBuiltinPatterns('2026-03-22T00:00:00.000Z').find( + const magentic = createBuiltinPatterns(BUILTIN_TIMESTAMP).find( (pattern) => pattern.mode === 'magentic', ); expect(magentic).toBeDefined(); expect(validatePatternDefinition(magentic!)[0]?.message).toContain('unsupported'); }); + + test('single-agent mode reports agent count, warning, and model issues together', () => { + const singlePattern = createBuiltinPatterns(BUILTIN_TIMESTAMP).find( + (pattern) => pattern.mode === 'single', + ); + + expect(singlePattern).toBeDefined(); + + const issues = validatePatternDefinition({ + ...singlePattern!, + agents: [ + { + ...singlePattern!.agents[0], + instructions: ' ', + }, + { + ...singlePattern!.agents[0], + id: 'agent-reviewer', + name: 'Reviewer', + model: '', + }, + ], + }); + + expect(issues.find((issue) => issue.field === 'agents')?.message).toBe( + 'Single-agent chat requires exactly one agent.', + ); + expect(issues.find((issue) => issue.field === 'agents.instructions')?.level).toBe('warning'); + expect(issues.find((issue) => issue.field === 'agents.instructions')?.message).toBe( + 'Agent "Primary Agent" should have instructions.', + ); + expect(issues.find((issue) => issue.field === 'agents.model')?.message).toBe( + 'Agent "Reviewer" requires a model identifier.', + ); + }); + + test('multi-agent orchestration modes reject single-agent configurations', () => { + const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP); + const handoff = patterns.find((pattern) => pattern.mode === 'handoff'); + const groupChat = patterns.find((pattern) => pattern.mode === 'group-chat'); + + expect(handoff).toBeDefined(); + expect(groupChat).toBeDefined(); + + expect( + validatePatternDefinition({ + ...handoff!, + agents: handoff!.agents.slice(0, 1), + }).find((issue) => issue.field === 'agents')?.message, + ).toBe('Handoff orchestration requires at least two agents.'); + + expect( + validatePatternDefinition({ + ...groupChat!, + agents: groupChat!.agents.slice(0, 1), + }).find((issue) => issue.field === 'agents')?.message, + ).toBe('Group chat requires at least two agents.'); + }); }); diff --git a/tests/shared/workspace.test.ts b/tests/shared/workspace.test.ts new file mode 100644 index 0000000..21dc008 --- /dev/null +++ b/tests/shared/workspace.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; + +import { createWorkspaceSeed } from '@shared/domain/workspace'; + +describe('workspace seed', () => { + test('starts empty and seeds the built-in orchestration patterns with a shared timestamp', () => { + const workspace = createWorkspaceSeed(); + + expect(workspace.projects).toEqual([]); + expect(workspace.sessions).toEqual([]); + expect(workspace.selectedProjectId).toBeUndefined(); + expect(workspace.selectedPatternId).toBeUndefined(); + expect(workspace.selectedSessionId).toBeUndefined(); + + expect(workspace.patterns.map((pattern) => pattern.mode)).toEqual([ + 'single', + 'sequential', + 'concurrent', + 'handoff', + 'group-chat', + 'magentic', + ]); + + for (const pattern of workspace.patterns) { + expect(pattern.createdAt).toBe(workspace.lastUpdatedAt); + expect(pattern.updatedAt).toBe(workspace.lastUpdatedAt); + } + + const magentic = workspace.patterns.find((pattern) => pattern.mode === 'magentic'); + + expect(magentic?.availability).toBe('unavailable'); + expect(magentic?.unavailabilityReason).toContain('unsupported'); + }); +});