mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 05:28:46 +02:00
feat: add packaging and validation coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-electron/
|
||||
dist-sidecar/
|
||||
release/
|
||||
|
||||
sidecar/**/bin/
|
||||
sidecar/**/obj/
|
||||
|
||||
@@ -31,7 +31,7 @@ export default defineConfig({
|
||||
renderer: {
|
||||
root: 'src/renderer',
|
||||
build: {
|
||||
outDir: '../../dist/renderer',
|
||||
outDir: 'dist/renderer',
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
@@ -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<JsonElement> 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<JsonElement> 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<IReadOnlyList<JsonElement>> RunHostAsync<TCommand>(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<JsonElement> ParseEvents(string output)
|
||||
{
|
||||
List<JsonElement> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<PatternValidationIssueDto> issues = _validator.Validate(pattern);
|
||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
||||
CreatePattern(
|
||||
"single",
|
||||
[CreateAgent()]));
|
||||
|
||||
Assert.Empty(issues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HandoffPattern_WithSingleAgent_IsReportedAsInvalid()
|
||||
{
|
||||
IReadOnlyList<PatternValidationIssueDto> 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<PatternValidationIssueDto> 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<PatternValidationIssueDto> 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<PatternAgentDefinitionDto> 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<PatternValidationIssueDto> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user