Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions apps/cli/__tests__/integration/draft-session-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import type { RuntimeSession } from "@acp-kit/core";
import { Result } from "better-result";
import type { AgentPool } from "@/core/acp/pool";
import { ThreadCoordinator } from "@/core/threads/coordinator";
import { CoordinatorAgentLockedError } from "@/errors/coordinator";

const threadState = {
id: "thread-1",
projectId: "project-1",
name: "Draft",
agentName: undefined as string | undefined,
sessionId: undefined as string | undefined,
agentLocked: undefined as true | undefined,
createdAt: "2026-07-12T00:00:00.000Z",
updatedAt: "2026-07-12T00:00:00.000Z",
};

function createMockSession(sessionId: string): RuntimeSession {
return {
sessionId,
transcript: {
session: {
models: {
availableModels: [{ modelId: "model-1", name: "Model 1" }],
},
modes: { availableModes: [] },
configOptions: [],
},
},
close: mock(async () => undefined),
setModel: mock(async () => undefined),
setMode: mock(async () => undefined),
on: () => () => undefined,
prompt: mock(async () => ({})),
cancel: mock(async () => undefined),
} as unknown as RuntimeSession;
}

const sessions: RuntimeSession[] = [];

mock.module("@cyrus/database/repositories/projects", () => ({
resolveProjectCwd: async () => Result.ok("/tmp/project"),
}));

mock.module("@cyrus/database/repositories/threads", () => ({
getThread: async () => Result.ok({ ...threadState }),
bindThreadAgent: (
_threadId: string,
_projectId: string,
data: { agentName: string; sessionId: string }
) => {
threadState.agentName = data.agentName;
threadState.sessionId = data.sessionId;
return Promise.resolve(Result.ok({ ...threadState }));
},
}));

function createCoordinator() {
const pool = {
getState: () => "ready",
getRuntime: async () => ({
newSession: () => {
const session = createMockSession(`session-${sessions.length + 1}`);
sessions.push(session);
return Promise.resolve(session);
},
agentCapabilities: { loadSession: true },
}),
getSdkConnection: () => undefined,
} as unknown as AgentPool;

return new ThreadCoordinator(pool);
}

describe("draft session lifecycle", () => {
beforeEach(() => {
sessions.length = 0;
threadState.agentName = undefined;
threadState.sessionId = undefined;
threadState.agentLocked = undefined;
});

test("bind then catalog then prompt reuses the same session id", async () => {
const coordinator = createCoordinator();

const bound = await coordinator.bindAgent(
"thread-1",
"project-1",
"mock-agent"
);
expect(bound.isOk()).toBe(true);
if (bound.isErr()) throw new Error("expected bind to succeed");
expect(bound.value.sessionId).toBe("session-1");

const models = await coordinator.getModels("thread-1");
expect(models.isOk()).toBe(true);
if (models.isErr()) throw new Error("expected models to succeed");
expect(models.value[0]?.id).toBe("model-1");

const prompt = await coordinator.prompt(
"mock-agent",
"thread-1",
"project-1",
"hello"
);
expect(prompt.isOk()).toBe(true);
if (prompt.isErr()) throw new Error("expected prompt to succeed");

const events: string[] = [];
for await (const event of prompt.value) {
events.push(event.type);
}

expect(sessions).toHaveLength(1);
expect(sessions[0]?.sessionId).toBe("session-1");
expect(sessions[0]?.prompt).toHaveBeenCalledWith("hello");
});

test("rejects agent switch when locked", async () => {
threadState.agentName = "mock-agent";
threadState.sessionId = "session-1";
threadState.agentLocked = true;

const coordinator = createCoordinator();

const result = await coordinator.bindAgent(
"thread-1",
"project-1",
"other-agent"
);
expect(result.isErr()).toBe(true);
if (result.isOk()) throw new Error("expected bind to fail");
expect(CoordinatorAgentLockedError.is(result.error)).toBe(true);
});
});
16 changes: 10 additions & 6 deletions apps/cli/__tests__/integration/wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentEvent } from "@cyrus/schemas/rtc/chat";
import { Result } from "better-result";
import { runTurn } from "../../src/utils/run-turn";
import { createMockPromptStream } from "../helpers/acp-runtime";

Expand Down Expand Up @@ -34,7 +35,8 @@ describe("acp mock runtime", () => {
emitTerminal: () => Promise.resolve(),
runtime: {
threadCoordinator: {
prompt: () => createMockPromptStream({ message: "pong" }),
prompt: async () =>
Result.ok(createMockPromptStream({ message: "pong" })),
},
} as never,
});
Expand Down Expand Up @@ -63,11 +65,13 @@ describe("acp mock runtime", () => {
},
runtime: {
threadCoordinator: {
prompt: () =>
createMockPromptStream({
message: "pong",
failAfterToken: true,
}),
prompt: async () =>
Result.ok(
createMockPromptStream({
message: "pong",
failAfterToken: true,
})
),
},
} as never,
});
Expand Down
24 changes: 8 additions & 16 deletions apps/cli/src/commands/agents/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,21 @@
import { pingAcpAgent } from "@/core/acp/ping";
import { checkAgentHealth } from "@/core/agents/health";
import { getAgent, listAgents } from "@/store/agents";
import { createSpinner } from "@/utils/spinner";
import { green, print, red } from "@/utils/style";
import type { AgentEntry } from "@/validators/agent";

type HealthResult = {
healthy: boolean;
error?: string;
};

async function checkAgent(
registryId: string,
entry: AgentEntry
): Promise<HealthResult> {
async function checkAgent(registryId: string, entry: AgentEntry) {
const spinner = createSpinner(`Checking ${registryId}…`);
spinner.start();
const result = await pingAcpAgent(registryId, entry);
const result = await checkAgentHealth(registryId, entry);
spinner.stop();
return result.match<HealthResult>({
ok: () => ({ healthy: true }),
err: (error) => ({ healthy: false, error }),
});
return result;
}

function printHealth(registryId: string, result: HealthResult): void {
function printHealth(
registryId: string,
result: Awaited<ReturnType<typeof checkAgentHealth>>
): void {
const status = result.healthy ? green("healthy") : red("unhealthy");
print.line`${registryId}: ${status}`;
if (result.error) print.line` ${result.error}`;
Expand Down
59 changes: 59 additions & 0 deletions apps/cli/src/core/agents/health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { Result } from "better-result";
import {
clearHealthCache,
listHealthyAgents,
setHealthCacheForTest,
} from "./health";

mock.module("@/core/acp/ping", () => ({
pingAcpAgent: mock(async (registryId: string) =>
registryId === "healthy-agent" ? Result.ok({}) : Result.err("unhealthy")
),
}));

mock.module("@/store/agents", () => ({
listAgents: async () =>
Result.ok({
"healthy-agent": {
registryId: "healthy-agent",
name: "Healthy",
icon: "https://example.com/healthy.png",
command: "echo",
args: [],
},
"sick-agent": {
registryId: "sick-agent",
name: "Sick",
icon: "https://example.com/sick.png",
command: "echo",
args: [],
},
}),
}));

describe("agent health", () => {
beforeEach(() => {
clearHealthCache();
});

test("listHealthyAgents omits unhealthy agents", async () => {
const agents = await listHealthyAgents();
expect(agents).toEqual([
{
id: "healthy-agent",
name: "Healthy",
icon: "https://example.com/healthy.png",
},
]);
});

test("reuses cached health results within ttl", async () => {
setHealthCacheForTest("sick-agent", true);
const agents = await listHealthyAgents();
expect(agents.map((agent) => agent.id).sort()).toEqual([
"healthy-agent",
"sick-agent",
]);
});
});
74 changes: 74 additions & 0 deletions apps/cli/src/core/agents/health.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { pingAcpAgent } from "@/core/acp/ping";
import { type EnabledAgent, listAgents } from "@/store/agents";
import type { AgentEntry } from "@/validators/agent";

const HEALTH_CACHE_TTL_MS = 60_000;

export type AgentHealthResult = {
healthy: boolean;
error?: string;
};

type HealthEntry = {
healthy: boolean;
error?: string;
checkedAt: number;
};

const healthCache = new Map<string, HealthEntry>();

export async function checkAgentHealth(
registryId: string,
entry: AgentEntry
): Promise<AgentHealthResult> {
const cached = healthCache.get(registryId);
if (cached && Date.now() - cached.checkedAt < HEALTH_CACHE_TTL_MS)
return { healthy: cached.healthy, error: cached.error };

const result = await pingAcpAgent(registryId, entry);
const health = result.match<AgentHealthResult>({
ok: () => ({ healthy: true }),
err: (error) => ({ healthy: false, error }),
});
healthCache.set(registryId, { ...health, checkedAt: Date.now() });
return health;
}

export async function listHealthyAgents(): Promise<EnabledAgent[]> {
const registry = await listAgents();
if (registry.isErr()) return [];

const entries = Object.entries(registry.value).sort(([a], [b]) =>
a.localeCompare(b)
);
const results = await Promise.all(
entries.map(async ([id, entry]) => ({
agent: {
id,
name: entry.name,
icon: entry.icon,
},
health: await checkAgentHealth(id, entry),
}))
);

return results
.filter(({ health }) => health.healthy)
.map(({ agent }) => agent);
}

export function clearHealthCache(): void {
healthCache.clear();
}

export function setHealthCacheForTest(
registryId: string,
healthy: boolean,
error?: string
): void {
healthCache.set(registryId, {
healthy,
error,
checkedAt: Date.now(),
});
}
Loading